双档位训练体系(s高识别/n高性能 串行排队、按档发布)与 TFLite 硬件加速补丁

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:20:46 +08:00
co-authored by Claude Opus 4.7
parent bb75c69d91
commit 6f8c54c31d
23 changed files with 651 additions and 269 deletions
+17 -14
View File
@@ -15,9 +15,9 @@
| 后台管理端 | `server_admin/`Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token` |
| 版本管理 | 后台管理端上传 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 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗为**审核视图(不做手动画框)**:点击框选中,列表可确认疑似框/删除误检框/清空并保存——AI 自动标注结果直接作为标注,人工仅审核确认;封面(上传/生成统一 1248x704 转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 |
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置)进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
| 模型版本与热更新 | **每数据集一个模型**:训练成后一键「发布」(训练任务操作列)——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 模型兜底 |
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置)**双档位(2026-09-03)**:一次发起按档位各建一条任务——高识别档 s(基座 `training.model`、imgsz `training.imgsz`=1280/ 高性能档 n(基座 `training.modelN`=yolov8n.pt、imgsz `training.imgszN`=704),请求传 `variants:["s","n"]` 限定(省略=双档;n 档配置缺失时请求报错),epochs/batch/device 双档共用,任务带 `variant` 快照;**GPU 独占排队(2026-09-03**:并发度 1 不变——已有 running 时新任务落 `queued` 排队(不再拒绝),10s 轮询在 running 结束后自动按创建顺序晋级启动、一次一个(训练机单 GPU 串行跑多档/多数据集),取消 running=杀进程、queued=直接置失败;进度/日志/指标监控(每 epoch 粒度);训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化task.json 传 model/imgsz,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
| 模型版本与热更新 | **每数据集每档位一个模型**2026-09-03 双档位):训练成后一键「发布」(训练任务操作列)——tflite 已由训练成功直写最终位置:s 档 `workspace/trainings/<文件名前缀>.tflite`、n 档 `<文件名前缀>_n.tflite`(前缀空回退数据集名),发布仅落 `model_version` 记录(sha256/大小/指标/类别名,带 `variant` 档位列);版本序列每数据集全局共用 m1.0.0 递增(s/n 交替发布走同一序列,无档位独立序列),`is_latest` 按 (数据集, 档位) 各记一条——发布只清同档位旧记录,s/n 两档互不影响,目录可分别发布、分别下发。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/档位 `variant` s|n/版本/类别/大小/sha256/下载地址;**每数据集最多 2 条 = s/n 两档各自的 is_latest**),下载 URL s 档 `/download/trainings/<文件名前缀>.tflite`、n 档 `/download/trainings/<文件名前缀>_n.tflite`(前缀空回退数据集名);**App 模型管理页**用户自由下载/删除/启用模型,识别时**按当前识别档位(s 高识别 / n 高性能,全局切换)加载该档位已启用模型**并行推理 + 跨模型 NMS 合并(按类别名),内置 assets 模型兜底 |
| 标注 | **图片入库自动触发**:手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 全图扫描(`label_task` 记录进度,页顶进度条展示;**localAi 未配置 → 上传/生成接口直接报错;已有标注任务在跑(忙)→ 不报错**,当前任务成功完成后自动补标未标注图)→ 扫描结果(**重叠去重**:NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除——重叠比 = 交叠面积/两框较小面积,RF-DETR 同目标常输出一大一小两框,此判据能命中,同目标只留置信度最高者)**直接写 `dataset_image.labels_json`**(覆盖该图已有标注,即重标语义);点击弹窗放大进入**审核视图(不做手动画框)**:点击框选中,列表确认疑似框/删除误检框/清空 → 保存即整体覆写 `dataset_image.labels_json`YOLO 归一化 JSON 数组,AI 标注直写、人工仅审核确认);`POST /admin/label-tasks` 详情页「全量标注」按钮入口(另有自动触发),可发起全量/指定图重标;自动/手动/混合并存,训练前自动整理(prepare_yolo 逻辑在服务端) |
## 架构与数据流
@@ -50,8 +50,8 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| `app_version` | App 版本管理 | `id`(PK)、`version`(x.y.z, UNIQUE)、`notes`(更新说明)、`created_at``updated_at`(下载地址不落表:APK 固定文件 `app.apkDir`/`observer-latest.apk`,默认 `./workspace/` |
| `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` 节点);**生成参数池(创建时 VLM 自动生成,界面不维护,可 `POST /datasets/gen-pools` 重新生成)**`gen_species`(单值=数据集物种)、`gen_tone`(单值 轮廓色词 深色/浅色)、`gen_heights`(数值 站高cm,距离感公式用)、`gen_scenes`/`gen_actions`/`gen_occlusions`(JSON 数组 各≥3条)、`gen_classes`(单值 第二标注类别名="suspect",第一类别=gen_species,训练 data.yaml names);**单物种规则:每数据集只对应一个物种(生成图片固定按数据集名),不同物种拆到不同数据集** |
| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id``filename``source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 自动标注与人工标注同存、人工可修改/清理,null/''/'[]'=未标注)、`clean_excluded`(0/12026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`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``metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256``size_bytes``is_latest``notes``created_at`(模型文件不落表:发布即写 `trainings/<文件名前缀>.tflite`(前缀空回退数据集名),客户端固定下载,无存档回退) |
| `model_training` | 训练任务 | `id`(PK)、`name``status`(queued/running/success/failedqueued=GPU 忙排队中,2026-09-03)、`dataset_id``variant`(s/n 档位,default s2026-09-03)、`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``variant`(s/ndefault s;存量行迁移为 s)、`version`(m1.0.0 递增, 同数据集 UNIQUEs/n 交替发布共用序列)、`training_id``metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256``size_bytes``is_latest`(按 (数据集,档位) 各记一条)`notes``created_at`(模型文件不落表:发布即写 `trainings/<文件名前缀>.tflite`s/`<文件名前缀>_n.tflite`n前缀空回退数据集名),客户端固定下载,无存档回退) |
| `label_task` | 标注任务 | `id`(PK)、`dataset_id``filenames`(JSON 选中图片列表,NULL=全量)、`status`(running/done)、`total`/`done``created_at``finished_at` |
| `gen_task` | AI 生成任务(异步批量) | `id`(PK)、`dataset_id``status`(running/done/failed)、`total`/`done``error``created_at``finished_at` |
@@ -202,11 +202,11 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
| GET | `/admin/label-tasks` | 标注任务列表:`page/size` 分页(含 status/total/done |
| GET | `/admin/label-tasks/detail` | 标注任务详情:返回数据集全部图片 + 每张标注框(`boxes`YOLO 归一化 xywh + 置信度 + 类别) |
| POST | `/admin/label-tasks/save` | 保存单张标注 `{"datasetId":1,"filename":"a.jpg","boxes":[{"class":0,"cx":0.5,"cy":0.4,"w":0.1,"h":0.2}]}`:整体覆写该图 `labels_json`(空 boxes=清空标注),返回该数据集当前 `labeledCount` |
| POST | `/admin/trainings` | 发起训练 `{"dataset":"yolo","imgsz":1280,"epochs":150,"batch":16,"device":"0","name":"..."}`imgsz/model 实际以 `config.yml` training 节点为准):先同步数据集到训练机 → 校验目录存在 → runner 启动训练;**并发度 1**已有 running 任务时返回错误 |
| GET | `/admin/trainings` | 训练任务列表:`page/size` 分页,按下发时间倒序,含 status/进度/指标 |
| POST | `/admin/trainings` | 发起训练 `{"datasetId":1,"name":"...","variants":["s","n"]}``variants` 限定档位(省略=双档 s+n 各建一条任务;只补跑高性能档传 `["n"]`;请求的档位 n 未配置时报错);校验数据集有标注 → 落任务返回 `{id}`(首条任务 id);**GPU 独占排队**并发度 1 不变——已有 running 时不拒绝、新任务落 queued,running 结束后轮询自动按创建顺序晋级启动(一次一个);同数据集同档位已有任务(running/queued)时拒绝(防重复提交) |
| GET | `/admin/trainings` | 训练任务列表:`page/size` 分页,按下发时间倒序,含 status(queued/running/success/failed)/variant/进度/指标;`status` 过滤参数支持 queued |
| GET | `/admin/trainings/detail` | 任务详情 `{"id":1}`:参数快照 + 进度 + 指标 + 日志尾部 |
| POST | `/admin/trainings/cancel` | 取消训练 `{"id":1}`(仅 running):杀训练进程,状态置 failed(记录 error |
| POST | `/admin/trainings/publish` | 发布为最新模型 `{"id":1,"notes":"..."}`(仅 success):tflite 拷为 `workspace/model-latest.tflite`(原子覆盖)+ sha256/大小 → 插入 `model_version`(版本号递增 m1.0.0 → m1.0.1+ 旧版 `is_latest=0` |
| POST | `/admin/trainings/cancel` | 取消训练 `{"id":1}`running=杀训练进程置 failedqueued=无进程直接置 failed |
| POST | `/admin/trainings/publish` | 发布为最新模型 `{"id":1,"notes":"..."}`(仅 success):读训练成功已直写的 tflite(s 档 `<文件名前缀>.tflite` / n 档 `<文件名前缀>_n.tflite`)算 sha256/大小 → 插入 `model_version`(版本号数据集内递增 m1.0.0 → m1.0.1+ 同档位旧版 `is_latest=0`s/n 互不影响) |
| GET | `/admin/label-workbench` | 标注工作台数据 `{"datasetId":1}`:数据集全部图片 + 每张标注框(`boxes`labels_json 全量)——无历史标注任务时详情页工作台的数据源 |
管理页面由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
@@ -218,19 +218,22 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
```json
{
"list": [
{"datasetId": 1, "datasetName": "pheasant", "version": "m1.2.0", "labels": ["pheasant", "suspect"],
{"datasetId": 1, "datasetName": "pheasant", "variant": "s", "version": "m1.2.0", "labels": ["pheasant", "suspect"],
"sizeBytes": 6400000, "sha256": "ab12...", "notes": "修复小目标漏检", "publishedAt": "2026-08-26T10:00:00+08:00",
"downloadUrl": "/download/models/pheasant/latest.tflite"}
"downloadUrl": "/download/trainings/pheasant.tflite"},
{"datasetId": 1, "datasetName": "pheasant", "variant": "n", "version": "m1.3.0", "labels": ["pheasant", "suspect"],
"sizeBytes": 3500000, "sha256": "cd34...", "notes": "", "publishedAt": "2026-09-03T10:00:00+08:00",
"downloadUrl": "/download/trainings/pheasant_n.tflite"}
]
}
```
- 只返回 `is_latest=1` 的模型(每数据集至多一条);无任何发布模型时 `list` 为空数组
- 下载地址由客户端拼 `apiBaseUrl` 访问;下载文件 sha256 校验,类别名数组 `labels` 用于多模型合并推理展示
- 只返回 `is_latest=1` 的模型:**每 (数据集, 档位) 至多一条**(每数据集 s/n 各一条,variant 标识档位;n 档文件名带 `_n` 后缀);无任何发布模型时 `list` 为空数组
- 下载地址由客户端拼 `apiBaseUrl` 访问;下载文件 sha256 校验,类别名数组 `labels` 用于多模型合并推理展示;条目带 `variant`2026-09-03),App 按识别档位(s 高识别/n 高性能)筛选加载
## 使用说明
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时)、`imageGen`AI 生成图片 provider`dashscope` 通义万相(apiKey + model qwen-image-3.0/ `localai` 本地 local-aibaseUrl + model 如 qwen-image;每张不设调用超时,失败由 provider 真实返回判定))、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时**双档位 2026-09-03**s 档基座/分辨率 `model`/`imgsz`、n 档 `modelN`/`imgszN`——n 档未配置时发起 n 档训练报错,epochs/batch/device 双档共用)、`imageGen`AI 生成图片 provider`dashscope` 通义万相(apiKey + model qwen-image-3.0/ `localai` 本地 local-aibaseUrl + model 如 qwen-image;每张不设调用超时,失败由 provider 真实返回判定))、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化
2. `go build ./...` 编译验证
3. 本地运行 `go run main.go`;服务层白盒测试:`GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/`(独立测试库,见 `biz/service/testdata/`
4. 后台管理端:`cd server_admin && npm run build`(构建产物输出到 `server/admin_dist/`,由后端 `/admin/` 托管);开发联调 `npm run dev`Vite 代理 `/api``:8080`)。首次访问 `/admin/` 进入登录页,输入 `config.yml admin.token` 对应的管理 token(存浏览器 localStorage,随请求携带;token 不内嵌构建产物)。**硬性要求:只要修改了 `server_admin/` 源码,必须同步重新构建 `admin_dist/` 并提交产物**(后端托管的是构建产物,不重新构建则线上/部署版本不生效;禁止只改源码不构建)
File diff suppressed because one or more lines are too long
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-BMgzRXh7.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-Cp_kU-Al.css">
<script type="module" crossorigin src="/admin/assets/index-4BRivNEp.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-qWtNZdSB.css">
</head>
<body>
<div id="app"></div>
+8 -1
View File
@@ -30,11 +30,18 @@ const (
// 预标注(逐张调 RF-DETR)池默认并发度(被 config.yml labelTask.poolSize 覆盖)
LabelPoolDefaultSize = 4
// 训练任务状态机 running → success/failed
// 训练任务状态机 queued → running → success/failedqueued=GPU 忙排队,2026-09-03 双档位串行)
TrainingStatusQueued = "queued"
TrainingStatusRunning = "running"
TrainingStatusSuccess = "success"
TrainingStatusFailed = "failed"
// 训练档位:s=高识别(yolov8s@1280,精度优先,默认) | n=高性能(yolov8n@704,速度优先)
TrainingVariantS = "s"
TrainingVariantN = "n"
// n 档模型文件名后缀:trainings/<基名>_n.tflites 档无后缀 = 旧版唯一位,向后兼容)
TrainingVariantNFileSuffix = "_n"
// 数据集状态 building → labeled → syncedsynced = 已同步训练机)
DatasetStatusBuilding = "building"
DatasetStatusLabeled = "labeled"
+71 -6
View File
@@ -2,6 +2,7 @@ package dao
import (
"context"
"sort"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
@@ -23,6 +24,7 @@ func init() {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
variant TEXT NOT NULL DEFAULT 's',
dataset_id INTEGER NOT NULL,
imgsz INTEGER NOT NULL DEFAULT 1280,
epochs INTEGER NOT NULL DEFAULT 150,
@@ -48,6 +50,7 @@ func (d *modelTrainingDao) Insert(ctx context.Context, m *entity.ModelTraining)
res, err := g.DB().Model(consts.TableTraining).Ctx(ctx).Data(g.Map{
"name": m.Name,
"status": m.Status,
"variant": m.Variant,
"dataset_id": m.DatasetId,
"imgsz": m.Imgsz,
"epochs": m.Epochs,
@@ -170,6 +173,57 @@ func (d *modelTrainingDao) RunningByDataset(ctx context.Context, datasetId int64
return &e, nil
}
// ActiveByDatasetVariant 某 (数据集,档位) 未终态任务(running/queued;发起训练防重检查用)
func (d *modelTrainingDao) ActiveByDatasetVariant(ctx context.Context, datasetId int64, variant string) (*entity.ModelTraining, error) {
var e entity.ModelTraining
err := g.DB().Model(consts.TableTraining).Ctx(ctx).
Where("dataset_id", datasetId).Where("variant", variant).
WhereIn("status", []string{consts.TrainingStatusRunning, consts.TrainingStatusQueued}).
OrderAsc("id").Limit(1).Scan(&e)
if err != nil {
if common.IsNoRows(err) {
return nil, nil
}
return nil, err
}
return &e, nil
}
// PeekQueued 最老 queued 任务(串行晋级调度:无 running 时取一条)
func (d *modelTrainingDao) PeekQueued(ctx context.Context) (*entity.ModelTraining, error) {
var e entity.ModelTraining
err := g.DB().Model(consts.TableTraining).Ctx(ctx).
Where("status", consts.TrainingStatusQueued).OrderAsc("id").Limit(1).Scan(&e)
if err != nil {
if common.IsNoRows(err) {
return nil, nil
}
return nil, err
}
return &e, nil
}
// Promote 晋级排队任务为 runningCAS:仅 queued 可晋级,防与取消/删除竞态;开始时间取晋级时刻)
func (d *modelTrainingDao) Promote(ctx context.Context, id int64, startedAt *gtime.Time) (bool, error) {
res, err := g.DB().Model(consts.TableTraining).Ctx(ctx).
Where("id", id).Where("status", consts.TrainingStatusQueued).
Data(g.Map{"status": consts.TrainingStatusRunning, "started_at": startedAt}).Update()
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// FailQueuedByDataset 某数据集全部排队任务置 failed(删数据集时调用:排队任务引用已删目录,
// 晋级必失败,直接失败并带出原因,避免列表残留「排队中」)
func (d *modelTrainingDao) FailQueuedByDataset(ctx context.Context, datasetId int64, reason string) error {
_, err := g.DB().Model(consts.TableTraining).Ctx(ctx).
Where("dataset_id", datasetId).Where("status", consts.TrainingStatusQueued).
Data(g.Map{"status": consts.TrainingStatusFailed, "error": reason, "finished_at": gtime.Now()}).Update()
return err
}
// ListRunning 全部 running 任务(Go 重启后恢复扫描用)
func (d *modelTrainingDao) ListRunning(ctx context.Context) ([]*entity.ModelTraining, error) {
var list []*entity.ModelTraining
@@ -196,10 +250,11 @@ func (d *modelTrainingDao) FailUnstarted(ctx context.Context) error {
return err
}
// LatestByDatasets 批量取各数据集最新一条训练记录(列表卡片训练状态用;
// IN 一次取回按 id 倒序,应用层按 dataset_id 去重;数据集表小、记录少,单次查询足够)
func (d *modelTrainingDao) LatestByDatasets(ctx context.Context, datasetIds []int64) (map[int64]*entity.ModelTraining, error) {
out := make(map[int64]*entity.ModelTraining)
// LatestByDatasets 批量取各数据集各档位(s/n最新一条训练记录(列表卡片训练状态用;
// IN 一次取回按 id 倒序,应用层按 (dataset_id, variant) 去重;数据集表小、记录少,单次查询足够)
// 返回 map[dataset_id][]train,每数据集 ≤2 条(s 在前 n 在后)。
func (d *modelTrainingDao) LatestByDatasets(ctx context.Context, datasetIds []int64) (map[int64][]*entity.ModelTraining, error) {
out := make(map[int64][]*entity.ModelTraining)
if len(datasetIds) == 0 {
return out, nil
}
@@ -217,12 +272,22 @@ func (d *modelTrainingDao) LatestByDatasets(ctx context.Context, datasetIds []in
}
return nil, err
}
seen := make(map[int64]map[string]bool) // dataset_id → 已收档位
for _, t := range list {
if _, ok := out[t.DatasetId]; !ok {
out[t.DatasetId] = t
if seen[t.DatasetId] == nil {
seen[t.DatasetId] = map[string]bool{}
}
if seen[t.DatasetId][t.Variant] {
continue
}
seen[t.DatasetId][t.Variant] = true
out[t.DatasetId] = append(out[t.DatasetId], t)
}
}
// 同数据集内固定 s 前 n 后(乱序展示无意义)
for ds := range out {
sort.Slice(out[ds], func(i, j int) bool { return out[ds][i].Variant < out[ds][j].Variant })
}
return out, nil
}
+5 -3
View File
@@ -20,6 +20,7 @@ func init() {
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS model_version (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL,
variant TEXT NOT NULL DEFAULT 's',
version TEXT NOT NULL,
training_id INTEGER,
metrics TEXT,
@@ -40,6 +41,7 @@ func init() {
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,
"variant": m.Variant,
"version": m.Version,
"training_id": m.TrainingId,
"metrics": m.Metrics,
@@ -56,10 +58,10 @@ func (d *modelVersionDao) Insert(ctx context.Context, m *entity.ModelVersion) (i
return res.LastInsertId()
}
// ClearLatest 某数据集所有版本置 is_latest=0(发布前调用)
func (d *modelVersionDao) ClearLatest(ctx context.Context, datasetId int64) error {
// ClearLatest 某 (数据集,档位) 所有版本置 is_latest=0(发布前调用s/n 两档互不影响
func (d *modelVersionDao) ClearLatest(ctx context.Context, datasetId int64, variant string) error {
_, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).
Where("dataset_id", datasetId).Data(g.Map{"is_latest": 0}).Update()
Where("dataset_id", datasetId).Where("variant", variant).Data(g.Map{"is_latest": 0}).Update()
return err
}
+48 -35
View File
@@ -21,33 +21,40 @@ type AdminDatasetListReq struct {
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
// AdminDatasetItem 数据集条目(卡片展示用;AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh))
// AdminDatasetTrainBrief 数据集卡片训练状态:各档位(s/n)最新一条训练记录
type AdminDatasetTrainBrief struct {
TrainingId int64 `json:"trainingId"`
Variant string `json:"variant"` // s(高识别) | n(高性能)
Status string `json:"status"` // queued | running | success | failed
Error string `json:"error"`
Published bool `json:"published"` // 该训练是否已发布过版本(已发布不再显示发布按钮)
CurrentEpoch int `json:"currentEpoch"`
TotalEpochs int `json:"totalEpochs"`
}
// AdminDatasetItem 数据集条目(卡片展示用;AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh);
// 双档位训练后每数据集最多两条训练状态(Trains,s 前 n 后),无记录档位不在列)
type AdminDatasetItem struct {
Id int64 `json:"id"`
Name string `json:"name"`
Source string `json:"source"` // manual | ai
ImageCount int64 `json:"imageCount"`
LabeledCount int64 `json:"labeledCount"`
Status string `json:"status"` // building | labeled | synced
Cover string `json:"cover"` // 封面文件名
Description string `json:"description"` // 描述
NamePrefix string `json:"namePrefix"` // AI 生成图文件名前缀
GenSpecies string `json:"genSpecies"` // 生成参数池:物种(单值)
GenTone string `json:"genTone"` // 轮廓色词(单值)
GenHeights float64 `json:"genHeights"` // 站高cm(数值)
GenScenes string `json:"genScenes"` // 场景池(JSON 数组)
GenActions string `json:"genActions"` // 动作池(JSON 数组)
GenOcclusions string `json:"genOcclusions"` // 遮挡池(JSON 数组)
GenClasses string `json:"genClasses"` // 第二标注类别名(单值)
SortOrder int64 `json:"sortOrder"` // 序号(列表排序主键,升序)
TrainingId int64 `json:"trainingId"` // 最新训练记录 id(发布/详情用
TrainingStatus string `json:"trainingStatus"` // 最新训练记录状态 running|success|failed|空
TrainingError string `json:"trainingError"` // 最新训练记录失败原因(failed 时展示用)
TrainingPublished bool `json:"trainingPublished"` // 该训练是否已发布过版本(已发布不再显示发布按钮)
TrainingCurrentEpoch int `json:"trainingCurrentEpoch"`
TrainingTotalEpochs int `json:"trainingTotalEpochs"`
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
Id int64 `json:"id"`
Name string `json:"name"`
Source string `json:"source"` // manual | ai
ImageCount int64 `json:"imageCount"`
LabeledCount int64 `json:"labeledCount"`
Status string `json:"status"` // building | labeled | synced
Cover string `json:"cover"` // 封面文件名
Description string `json:"description"`
NamePrefix string `json:"namePrefix"` // AI 生成图文件名前缀
GenSpecies string `json:"genSpecies"` // 生成参数池:物种(单值)
GenTone string `json:"genTone"` // 轮廓色词(单值)
GenHeights float64 `json:"genHeights"` // 站高cm(数值)
GenScenes string `json:"genScenes"` // 场景池(JSON 数组)
GenActions string `json:"genActions"` // 动作池(JSON 数组)
GenOcclusions string `json:"genOcclusions"`
GenClasses string `json:"genClasses"` // 第二标注类别名(单值)
SortOrder int64 `json:"sortOrder"` // 序号(列表排序主键,升序)
Trains []*AdminDatasetTrainBrief `json:"trains"` // 各档位最新训练(无记录为空数组
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
}
type AdminDatasetListRes struct {
@@ -253,7 +260,7 @@ type AdminGenCoverRes struct {
// AdminTrainingListReq 训练任务列表(创建时间倒序)
type AdminTrainingListReq struct {
g.Meta `path:"/trainings" method:"get" summary:"训练任务列表" tags:"管理端"`
Status string `json:"status" v:"in:running,success,failed" dc:"状态筛选"`
Status string `json:"status" v:"in:queued,running,success,failed" dc:"状态筛选"`
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
@@ -264,7 +271,8 @@ type AdminTrainingItem struct {
Name string `json:"name"`
DatasetId int64 `json:"datasetId"`
DatasetName string `json:"datasetName"`
Status string `json:"status"` // running | success | failed
Variant string `json:"variant"` // s(高识别) | n(高性能)
Status string `json:"status"` // queued | running | success | failed
Imgsz int `json:"imgsz"`
Epochs int `json:"epochs"`
Batch int `json:"batch"`
@@ -283,11 +291,13 @@ type AdminTrainingListRes struct {
List []*AdminTrainingItem `json:"list"`
}
// AdminTrainingStartReq 发起训练(并发度 1:已有 running 任务时拒绝;训练参数走 config.yml training 节点,name 空则自动生成)
// AdminTrainingStartReq 发起训练(GPU 独占排队:忙时新任务落 queued,running 结束自动晋级;
// 同 (数据集,档位) 已有 running/queued 任务时拒绝;训练参数走 config.yml training 节点,name 空则自动生成)
type AdminTrainingStartReq struct {
g.Meta `path:"/trainings" method:"post" summary:"发起训练" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
Name string `json:"name" v:"length:0,50" dc:"任务名称,空自动生成"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
Name string `json:"name" v:"length:0,50" dc:"任务名称,空自动生成"`
Variants []string `json:"variants" dc:"限定档位 s/n(省略=双档各建一条任务;只补跑高性能档传 [\"n\"];含未知档位/未配置档位报错)"`
}
type AdminTrainingStartRes struct {
@@ -306,7 +316,7 @@ type AdminTrainingDetailRes struct {
LogTail string `json:"logTail"`
}
// AdminTrainingCancelReq 取消训练(杀进程,任务置 failed
// AdminTrainingCancelReq 取消训练(running=杀进程,queued=无进程直接置 failed
type AdminTrainingCancelReq struct {
g.Meta `path:"/trainings/cancel" method:"post" summary:"取消训练" tags:"管理端"`
Id int64 `json:"id" v:"required|min:1" dc:"训练任务 id"`
@@ -314,7 +324,8 @@ type AdminTrainingCancelReq struct {
type AdminTrainingCancelRes struct{}
// AdminTrainingPublishReq 发布模型版本(仅 success 任务;按数据集版本号 m<major>.<minor>.<patch> 自增)
// AdminTrainingPublishReq 发布模型版本(仅 success 任务;按任务档位发布——s/n 各自的
// is_latest 互不影响,版本号数据集内共用 m<major>.<minor>.<patch> 自增)
type AdminTrainingPublishReq struct {
g.Meta `path:"/trainings/publish" method:"post" summary:"发布模型版本" tags:"管理端"`
Id int64 `json:"id" v:"required|min:1" dc:"训练任务 id"`
@@ -502,17 +513,19 @@ type ModelCatalogReq struct {
g.Meta `path:"/models" method:"get" summary:"模型目录" tags:"客户端"`
}
// ModelCatalogItem 客户端模型条目(App 按需下载,多模型并行推理合并
// ModelCatalogItem 客户端模型条目(App 按需下载,多模型并行推理合并variant 区分档位,
// s/n 两档同数据集各一条 is_latest,App 按识别档位筛选加载)
type ModelCatalogItem struct {
DatasetId int64 `json:"datasetId"`
DatasetName string `json:"datasetName"`
Variant string `json:"variant"` // s(高识别) | n(高性能)
Version string `json:"version"`
Labels []string `json:"labels"` // 类别名,App 推理结果展示用
SizeBytes int64 `json:"sizeBytes"`
Sha256 string `json:"sha256"`
Notes string `json:"notes"`
PublishedAt *gtime.Time `json:"publishedAt"`
DownloadUrl string `json:"downloadUrl"` // /download/trainings/<文件名前缀>.tflite(前缀空回退数据集名)
DownloadUrl string `json:"downloadUrl"` // s 档 /download/trainings/<文件名前缀>.tfliten 档 <文件名前缀>_n.tflite
CoverUrl string `json:"coverUrl"` // /api/v1/app/cover?namePrefix=<前缀>App 模型清单缩略图)
}
+3 -1
View File
@@ -4,10 +4,12 @@ import "github.com/gogf/gf/v2/os/gtime"
// ModelTraining 训练任务:runner 启动训练进程,轮询解析 epoch 日志更新进度/指标,
// 日志尾部截断存 log_tail;pid 用于取消与存活探测。
// 状态机 queued → running → success/failedqueued=GPU 忙排队,轮询晋级启动)。
type ModelTraining struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
Name string `json:"name" orm:"name" description:"任务名"`
Status string `json:"status" orm:"status" description:"running|success|failed"`
Status string `json:"status" orm:"status" description:"queued|running|success|failed"`
Variant string `json:"variant" orm:"variant" description:"档位 s(高识别)|n(高性能)"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"来源数据集"`
Imgsz int `json:"imgsz" orm:"imgsz" description:"训练分辨率"`
Epochs int `json:"epochs" orm:"epochs" description:"目标轮数"`
+6 -4
View File
@@ -2,19 +2,21 @@ package entity
import "github.com/gogf/gf/v2/os/gtime"
// ModelVersion 模型版本:每数据集独立版本序列(m1.0.0 递增UNIQUE(dataset_id, version)
// 模型文件不落表:训练成功即直写 trainings/<文件名前缀>.tflite(前缀空回退数据集名,无存档回退机制);
// labels 为类别名数组 JSON(App 多模型合并推理依赖)。
// ModelVersion 模型版本:每数据集全局共用版本序列(s/n 交替发布同一序列UNIQUE(dataset_id, version)
// is_latest 按 (数据集, 档位) 各记一条。
// 模型文件不落表:训练成功即直写 trainings/<文件名前缀>.tflites 档)/<文件名前缀>_n.tfliten 档,
// 前缀空回退数据集名,无存档回退机制);labels 为类别名数组 JSON(App 多模型合并推理依赖)。
type ModelVersion struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
Variant string `json:"variant" orm:"variant" description:"档位 s|n"`
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=该数据集当前生效"`
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:"发布时间"`
}
+35 -14
View File
@@ -77,8 +77,10 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
}
// 已发布过的训练不再返回发布按钮(model_version.training_id 反查)
trainingIds := make([]int64, 0, len(latest))
for _, t := range latest {
trainingIds = append(trainingIds, t.Id)
for _, ts := range latest {
for _, t := range ts {
trainingIds = append(trainingIds, t.Id)
}
}
published, err := dao.ModelVersion.PublishedByTrainingIds(ctx, trainingIds)
if err != nil {
@@ -105,16 +107,20 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
GenOcclusions: v.GenOcclusions,
GenClasses: v.GenClasses,
SortOrder: v.SortOrder,
Trains: []*dto.AdminDatasetTrainBrief{},
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}
if t, ok := latest[v.Id]; ok {
item.TrainingId = t.Id
item.TrainingStatus = t.Status
item.TrainingError = t.Error
item.TrainingPublished = published[t.Id]
item.TrainingCurrentEpoch = t.CurrentEpoch
item.TrainingTotalEpochs = t.TotalEpochs
for _, t := range latest[v.Id] {
item.Trains = append(item.Trains, &dto.AdminDatasetTrainBrief{
TrainingId: t.Id,
Variant: t.Variant,
Status: t.Status,
Error: t.Error,
Published: published[t.Id],
CurrentEpoch: t.CurrentEpoch,
TotalEpochs: t.TotalEpochs,
})
}
items = append(items, item)
}
@@ -234,11 +240,18 @@ func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, n
}
// migrateModelFile 模型文件随命名变更迁移(基名 = 文件名前缀,空回退数据集名):
// 双档位(2026-09-03)s/n 两文件一并迁移(n 档文件名带 _n 后缀);
// 旧文件不存在或新旧路径相同(前缀未变且非空)直接跳过;失败仅记日志不阻断业务——
// 模型文件为付费训练产物,保留旧名总比删除好(下次训练直写新名覆盖)。
func migrateModelFile(ctx context.Context, oldName, oldPrefix, newName, newPrefix string) {
oldPath := common.TrainingModelPath(ctx, modelFileName(oldName, oldPrefix))
newPath := common.TrainingModelPath(ctx, modelFileName(newName, newPrefix))
migrateModelFileVariant(ctx, oldName, oldPrefix, newName, newPrefix, "")
migrateModelFileVariant(ctx, oldName, oldPrefix, newName, newPrefix, consts.TrainingVariantNFileSuffix)
}
// migrateModelFileVariant 迁移单档位文件(variantSuffix 空=s 档,_n=n 档)
func migrateModelFileVariant(ctx context.Context, oldName, oldPrefix, newName, newPrefix, variantSuffix string) {
oldPath := common.TrainingModelPath(ctx, modelFileName(oldName, oldPrefix)+variantSuffix)
newPath := common.TrainingModelPath(ctx, modelFileName(newName, newPrefix)+variantSuffix)
if oldPath == newPath {
return
}
@@ -794,7 +807,7 @@ func (s *datasetService) parseGenPoolsJSON(content string, species string) (*ent
}
// AdminDeleteDataset 删除数据集:有 running 标注任务 / 该数据集训练进行中 / 已发布模型版本时拒绝
// (训练产物与模型为付费资产,需先删除模型版本再删数据集)。
// (训练产物与模型为付费资产,需先删除模型版本再删数据集);排队训练任务直接置 failed
// 删除 = 删图片/模型目录 + 删记录(标注随图片行删除,Serial 单写者串行)。
func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteReq) (*dto.AdminDatasetDeleteRes, error) {
var name, modelName string
@@ -820,6 +833,10 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD
} else if t != nil {
return gerror.New("该数据集有训练任务进行中,无法删除")
}
// 排队任务引用将删的目录,晋级必失败:直接置 failed 带出原因(双档位串行队列 2026-09-03 起支持排队)
if err := dao.Training.FailQueuedByDataset(ctx, d.Id, "数据集已删除,排队训练取消"); err != nil {
return err
}
// 模型版本记录随数据集级联删除(管理端无模型管理界面,2026-08-26 决策;
// 若需保留已下发模型,删除数据集前先确认客户端不再需要)
if err := dao.ModelVersion.DeleteByDataset(ctx, d.Id); err != nil {
@@ -833,8 +850,12 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD
if err != nil {
return nil, err
}
// 文件清理(图片目录 + 当前生效模型文件;删除失败仅记日志,记录已删)
paths := []string{common.DatasetImagesDir(ctx, name), common.TrainingModelPath(ctx, modelName)}
// 文件清理(图片目录 + 当前生效模型文件s/n 双档位文件一并清理;删除失败仅记日志,记录已删)
paths := []string{
common.DatasetImagesDir(ctx, name),
common.TrainingModelPath(ctx, modelName),
common.TrainingModelPath(ctx, modelName+consts.TrainingVariantNFileSuffix),
}
for _, p := range paths {
if err := os.RemoveAll(p); err != nil {
g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", p, err)
+11 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/url"
"observer-server/biz/consts"
"observer-server/biz/dao"
"observer-server/biz/model/dto"
)
@@ -16,7 +17,8 @@ type modelVersionService struct{}
var ModelVersion = &modelVersionService{}
// ClientCatalog 客户端模型目录:全部数据集当前生效模型(App 多模型下载热更新)。
// downloadUrl 复用 /download 静态托管/download/trainings/<文件名前缀>.tflite,前缀空回退数据集名)。
// 双档位(2026-09-03):每数据集 s/n 两档 is_latest=1 各一条;downloadUrl 复用 /download 静态托管
// /download/trainings/<基名>.tfliten 档带 _n 后缀,基名前缀空回退数据集名)。
func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCatalogRes, error) {
list, err := dao.ModelVersion.ListAllLatest(ctx)
if err != nil {
@@ -30,16 +32,23 @@ func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCata
if datasetName == "" {
continue
}
// 双档位:每数据集最多 2 条生效记录(is_latest 按 (数据集,档位) 各一),n 档 downloadUrl 带 _n 后缀;
// cover 属数据集(两档同物种同图源),沿用基名前缀
fileBase := modelNames[v.DatasetId]
if v.Variant == consts.TrainingVariantN {
fileBase += consts.TrainingVariantNFileSuffix
}
items = append(items, &dto.ModelCatalogItem{
DatasetId: v.DatasetId,
DatasetName: datasetName,
Variant: v.Variant,
Version: v.Version,
Labels: parseLabels(v.Labels),
SizeBytes: v.SizeBytes,
Sha256: v.Sha256,
Notes: v.Notes,
PublishedAt: v.CreatedAt,
DownloadUrl: "/download/trainings/" + modelNames[v.DatasetId] + ".tflite",
DownloadUrl: "/download/trainings/" + fileBase + ".tflite",
CoverUrl: "/api/v1/app/cover?namePrefix=" + url.PathEscape(modelNames[v.DatasetId]),
})
}
+229 -84
View File
@@ -70,6 +70,10 @@ func (s *trainingService) pollTrainings(ctx context.Context) {
}
s.pollOne(ctx, runner, job, t)
}
// GPU 空闲 → 晋级最老排队任务(串行执行,一次一个)
if len(running) == 0 {
s.promoteQueued(ctx)
}
}
}
@@ -140,7 +144,6 @@ func (s *trainingService) handleResult(ctx context.Context, runner common.Traini
}
// 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 {
@@ -181,7 +184,9 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
_ = s.finishFailed(ctx, t, "训练完成但 result.json 缺少 best_tflite")
return
}
dest := common.TrainingModelPath(ctx, modelFileName(dataset.Name, dataset.NamePrefix))
// tflite 直写 trainings/<文件名基名>.tflite(基名按档位:n 档带 _n 后缀;当前生效模型唯一位,
// 无 per-task 存档、无 zip)。
dest := common.TrainingModelPath(ctx, modelFileBaseName(dataset.Name, dataset.NamePrefix, t.Variant))
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)
@@ -296,6 +301,7 @@ func (s *trainingService) AdminListTrainings(ctx context.Context, req *dto.Admin
DatasetId: v.DatasetId,
DatasetName: names[v.DatasetId],
Status: v.Status,
Variant: v.Variant,
Imgsz: v.Imgsz,
Epochs: v.Epochs,
Batch: v.Batch,
@@ -346,11 +352,22 @@ func modelFileName(name, prefix string) string {
return name
}
// AdminStartTraining 发起训练:并发度 1(已有 running 拒绝);先本地整理 yolo 训练集
// 80/20 拆 train/val,有标注才可训练)落 running 记录,请求毫秒级返回。
// 训练机侧准备(写任务参数 → 同步数据集 → 启动进程)耗时可达分钟级(ssh 同步整包),
// 脱离请求 ctx 在后台协程执行(与预标注 runDetection 同模式),任何一步失败置任务 failed
// 由列表/轮询呈现;并发检查在 Serial 内,双击/并发点发只落一条任务。
// modelFileBaseName 模型文件基名按档位区分:s 档 = 基名(旧版唯一位,向后兼容);
// n 档(高性能) = 基名_n(两档文件互不覆盖,同数据集可并存)
func modelFileBaseName(name, prefix, variant string) string {
base := modelFileName(name, prefix)
if variant == consts.TrainingVariantN {
return base + consts.TrainingVariantNFileSuffix
}
return base
}
// AdminStartTraining 发起训练:双档位(2026-09-03)——variants 限定档位(空=双档 s+n 各建一条任务),
// 每任务独立排队(queued):GPU 独占并发度 1 不变,已有 running 时不再拒绝,由 pollTrainings 在
// running 结束后按创建顺序晋级启动(一次一个)。请求仅做校验(训练通道配置 / 数据集存在 /
// variants 合法 / 数据集有标注)+ Serial 内全档防重检查后落 queued 记录即返回(毫秒级);
// 训练机侧准备(写任务参数 → 同步数据集 → 启动进程,耗时可达分钟级)在晋级后的后台协程执行,
// 任何一步失败经 finishFailed 置任务 failed 由列表/轮询呈现。
func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.AdminTrainingStartReq) (*dto.AdminTrainingStartRes, error) {
runner := common.Runner(ctx)
if runner == nil {
@@ -367,91 +384,210 @@ func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.Admin
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
// 组装 yolo 训练集包(有标注才可训练;内存组装,不落本地暂存盘)
pkg, err := LabelTask.prepareYoloSet(ctx, dataset)
variants, err := normalizeVariants(req.Variants, cfg)
if err != nil {
return nil, err
}
// 训练参数为部署级配置(config.yml training 节点,界面不传):device 随训练机硬件、
// imgsz 须与 App 端推理对齐、epochs 随算力预期
imgsz, epochs, batch, device := cfg.Imgsz, cfg.Epochs, cfg.Batch, cfg.Device
// 校验有标注(立即反馈;晋级时重新打包取发起后的新鲜数据,此处仅作门槛)
if _, err := LabelTask.prepareYoloSet(ctx, dataset); err != nil {
return nil, err
}
name := req.Name
if name == "" {
name = fmt.Sprintf("%s 训练 %s", dataset.Name, gtime.Now().Format("01-02 15:04"))
}
now := gtime.Now()
var taskId int64
var firstId int64
// 训练参数为部署级配置(config.yml training 节点,界面不传):device 随训练机硬件、
// imgsz 按档位、epochs 随算力预期;请求时快照进任务记录(列表展示与实际运行一致)
err = common.Serial().Submit(ctx, func() error {
running, err := dao.Training.Running(ctx)
if err != nil {
return err
// 防重:同 (数据集,档位) 已有 running/queued 任务则整请求拒绝(防重复提交双档各白跑一轮)
for _, v := range variants {
active, err := dao.Training.ActiveByDatasetVariant(ctx, dataset.Id, v)
if err != nil {
return err
}
if active != nil {
return gerror.NewCode(common.CodeTrainingRunning)
}
}
if running != nil {
return gerror.NewCode(common.CodeTrainingRunning)
for _, v := range variants {
imgsz := cfg.Imgsz
if v == consts.TrainingVariantN {
imgsz = cfg.ImgszN
}
taskId, err := dao.Training.Insert(ctx, &entity.ModelTraining{
Name: name,
Status: consts.TrainingStatusQueued,
Variant: v,
DatasetId: dataset.Id,
Imgsz: imgsz,
Epochs: cfg.Epochs,
Batch: cfg.Batch,
Device: cfg.Device,
StartedAt: now,
CreatedAt: now,
})
if err != nil {
return err
}
if firstId == 0 {
firstId = taskId
}
}
taskId, err = dao.Training.Insert(ctx, &entity.ModelTraining{
Name: name,
Status: consts.TrainingStatusRunning,
DatasetId: dataset.Id,
Imgsz: imgsz,
Epochs: epochs,
Batch: batch,
Device: device,
StartedAt: now,
CreatedAt: now,
})
return err
return nil
})
if err != nil {
return nil, err
}
// 训练机侧准备(写任务参数/同步数据集/启动进程)为生命周期任务,脱离请求 ctx 后台执行;
// 失败置任务 failed(记录保留便于排查),请求本身不等待
return &dto.AdminTrainingStartRes{Id: firstId}, nil
}
// normalizeVariants 归一化发起档位:空=双档 s+n(保序去重);n 档需 config 已配置 modelN/imgszN
func normalizeVariants(req []string, cfg common.TrainingConfig) ([]string, error) {
var out []string
add := func(v string) {
for _, x := range out {
if x == v {
return
}
}
out = append(out, v)
}
if len(req) == 0 {
add(consts.TrainingVariantS)
add(consts.TrainingVariantN)
} else {
for _, v := range req {
if v != consts.TrainingVariantS && v != consts.TrainingVariantN {
return nil, gerror.Newf("未知训练档位: %s(仅支持 s/n", v)
}
add(v)
}
}
for _, v := range out {
if v == consts.TrainingVariantN && (cfg.ModelN == "" || cfg.ImgszN <= 0) {
return nil, gerror.New("高性能档(n)未配置(config.yml training.modelN/imgszN")
}
}
return out, nil
}
// promoteQueued 串行晋级(pollTrainings 无 running 时调用):最老 queued → runningCAS 防与
// 取消/删除竞态,started_at 取晋级时刻——超时判死自此刻起算),晋级成功起后台协程做训练机准备。
func (s *trainingService) promoteQueued(ctx context.Context) {
queued, err := dao.Training.PeekQueued(ctx)
if err != nil {
g.Log().Errorf(ctx, "读取排队训练任务失败: %+v", err)
return
}
if queued == nil {
return
}
ok, err := dao.Training.Promote(ctx, queued.Id, gtime.Now())
if err != nil {
g.Log().Errorf(ctx, "晋级训练 %d 失败: %+v", queued.Id, err)
return
}
if !ok {
return // 已被取消/删除抢先,跳过
}
g.Log().Infof(ctx, "训练 %d 晋级启动(dataset_id=%d variant=%s", queued.Id, queued.DatasetId, queued.Variant)
bgCtx := context.Background()
go func() {
s.prepareAndLaunch(bgCtx, queued.Id)
}()
}
// prepareAndLaunch 训练机侧准备(晋级后的后台任务):取数据集 → 重新组装 yolo 训练集包
// (发起后到晋级间标注可能变化,取晋级时刻新鲜数据;无标注/数据集已删除 → 置失败)→
// data.yaml 随包 → 写任务参数(model 按档位取 config 当前值;imgsz/epochs/batch/device 用
// 任务快照)→ 同步数据集 → 启动进程 → 记 pid。任何一步失败置任务 failed(记录保留便于排查)。
func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
t, err := dao.Training.GetById(ctx, taskId)
if err != nil {
g.Log().Errorf(ctx, "训练 %d 查询失败: %+v", taskId, err)
return
}
if t == nil || t.Status != consts.TrainingStatusRunning {
return
}
runner := common.Runner(ctx)
cfg, ok := common.TrainingConfigOf(ctx)
if !ok || runner == nil {
_ = s.finishFailed(ctx, t, "训练通道未配置")
return
}
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
}
pkg, err := LabelTask.prepareYoloSet(ctx, dataset)
if err != nil {
_ = s.finishFailed(ctx, t, "%s", err.Error())
return
}
job := &common.TrainingJob{
TaskId: taskId,
TaskId: t.Id,
DatasetName: dataset.Name,
Python: cfg.Python,
Workdir: cfg.Workdir,
DatasetDir: cfg.DatasetDir,
}
go func() {
// data.yaml 的 path 指向训练机路径,随包一起同步
trainPath := filepath.Join(cfg.Workdir, cfg.DatasetDir, "yolo", dataset.Name)
pkg.Files = append(pkg.Files, common.YoloFile{
Name: "dataset.yaml",
Content: []byte(yoloYamlContent(trainPath, localAiClassNames(dataset))),
})
taskJSON, _ := json.Marshal(map[string]any{
"workdir": cfg.Workdir,
"yolo": filepath.ToSlash(filepath.Join(cfg.DatasetDir, "yolo", dataset.Name)),
"model": cfg.Model,
"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)
return
}
if err := runner.SyncYoloDataset(bgCtx, job, pkg); err != nil {
_ = s.finishFailed(bgCtx, &entity.ModelTraining{Id: taskId}, "同步数据集失败: %v", err)
return
}
pid, err := runner.Start(bgCtx, job)
if err != nil {
_ = s.finishFailed(bgCtx, &entity.ModelTraining{Id: taskId}, "启动训练失败: %v", err)
return
}
if err := dao.Training.UpdatePid(bgCtx, taskId, pid); err != nil {
g.Log().Errorf(bgCtx, "训练 %d 记录 pid 失败: %+v", taskId, err)
}
}()
return &dto.AdminTrainingStartRes{Id: taskId}, nil
model := cfg.Model
if t.Variant == consts.TrainingVariantN {
model = cfg.ModelN
}
// data.yaml 的 path 指向训练机路径,随包一起同步
trainPath := filepath.Join(cfg.Workdir, cfg.DatasetDir, "yolo", dataset.Name)
pkg.Files = append(pkg.Files, common.YoloFile{
Name: "dataset.yaml",
Content: []byte(yoloYamlContent(trainPath, localAiClassNames(dataset))),
})
taskJSON, _ := json.Marshal(map[string]any{
"workdir": cfg.Workdir,
"yolo": filepath.ToSlash(filepath.Join(cfg.DatasetDir, "yolo", dataset.Name)),
"model": model,
"imgsz": t.Imgsz,
"epochs": t.Epochs,
"batch": t.Batch,
"device": t.Device,
"project": filepath.ToSlash(filepath.Join("runs", "tasks", strconv.FormatInt(t.Id, 10))),
"log_file": filepath.ToSlash(filepath.Join("logs", strconv.FormatInt(t.Id, 10)+".jsonl")),
"result_file": filepath.ToSlash(filepath.Join("results", strconv.FormatInt(t.Id, 10)+".json")),
})
if err := runner.WriteTaskJson(ctx, job, string(taskJSON)); err != nil {
_ = s.finishFailed(ctx, t, "写任务参数失败: %v", err)
return
}
if err := runner.SyncYoloDataset(ctx, job, pkg); err != nil {
_ = s.finishFailed(ctx, t, "同步数据集失败: %v", err)
return
}
// 取消竞态:同步期间/之前被取消 → 任务已 failed,不再启动进程(进程一旦启动难以回收,
// 启动前最终复查一次,缩小竞态窗口到毫秒级)
if !s.isTaskRunning(ctx, t.Id) {
return
}
pid, err := runner.Start(ctx, job)
if err != nil {
_ = s.finishFailed(ctx, t, "启动训练失败: %v", err)
return
}
if err := dao.Training.UpdatePid(ctx, t.Id, pid); err != nil {
g.Log().Errorf(ctx, "训练 %d 记录 pid 失败: %+v", t.Id, err)
}
}
// isTaskRunning 任务是否仍为 running(取消竞态复查用)
func (s *trainingService) isTaskRunning(ctx context.Context, id int64) bool {
t, err := dao.Training.GetById(ctx, id)
return err == nil && t != nil && t.Status == consts.TrainingStatusRunning
}
// yoloYamlContent 生成训练集 data.yaml 内容(path 为训练机绝对路径)
@@ -496,6 +632,7 @@ func (s *trainingService) AdminTrainingDetail(ctx context.Context, req *dto.Admi
DatasetId: t.DatasetId,
DatasetName: names[t.DatasetId],
Status: t.Status,
Variant: t.Variant,
Imgsz: t.Imgsz,
Epochs: t.Epochs,
Batch: t.Batch,
@@ -512,7 +649,10 @@ func (s *trainingService) AdminTrainingDetail(ctx context.Context, req *dto.Admi
}, nil
}
// AdminCancelTraining 取消训练:杀进程 + 置 failed
// AdminCancelTraining 取消训练:运行中 → 杀进程 + 置 failed;排队中 → 直接置 failed(进程未起)。
// 排队任务在检查后可能被 pollTrainings 晋级(promote 不在 Serial 内),取消前按最新状态重读,
// 已 running 则先杀进程;置 failed 后 prepareAndLaunch 的启动前 isTaskRunning 复查会终止
// 准备阶段的后续启动(进程取消对未注册进程为 no-op)。
func (s *trainingService) AdminCancelTraining(ctx context.Context, req *dto.AdminTrainingCancelReq) (*dto.AdminTrainingCancelRes, error) {
var t *entity.ModelTraining
err := common.Serial().Submit(ctx, func() error {
@@ -524,19 +664,22 @@ func (s *trainingService) AdminCancelTraining(ctx context.Context, req *dto.Admi
if t == nil {
return gerror.NewCode(common.CodeTrainingNotFound)
}
if t.Status != consts.TrainingStatusRunning {
return gerror.New("仅运行中的训练任务可取消")
if t.Status != consts.TrainingStatusRunning && t.Status != consts.TrainingStatusQueued {
return gerror.New("仅运行中/排队中的训练任务可取消")
}
return nil
})
if err != nil {
return nil, err
}
runner := common.Runner(ctx)
if runner != nil {
if cfg, ok := common.TrainingConfigOf(ctx); ok {
if job, jErr := s.buildJob(ctx, t, cfg); jErr == nil {
_ = runner.Cancel(ctx, job)
// 排队任务若已被晋级且进程已起(毫秒级竞态窗口),重读后一并杀进程,防孤儿训练占 GPU
if cur, gErr := dao.Training.GetById(ctx, t.Id); gErr == nil && cur != nil && cur.Status == consts.TrainingStatusRunning {
runner := common.Runner(ctx)
if runner != nil {
if cfg, ok := common.TrainingConfigOf(ctx); ok {
if job, jErr := s.buildJob(ctx, cur, cfg); jErr == nil {
_ = runner.Cancel(ctx, job)
}
}
}
}
@@ -546,8 +689,9 @@ func (s *trainingService) AdminCancelTraining(ctx context.Context, req *dto.Admi
return &dto.AdminTrainingCancelRes{}, nil
}
// AdminPublish 发布模型版本:仅 success 任务 + trainings/<文件名前缀>.tflite 存在(前缀空回退数据集名);
// 版本号同数据集内 m<major>.<minor>.<patch> 自增(无记录从 m1.0.0 起)。
// AdminPublish 发布模型版本:仅 success 任务 + trainings/<档位文件名基名>.tflite 存在(s 档=基名,
// n 档=基名_n,基名前缀空回退数据集名);is_latest 按 (数据集,档位) 各记一条(ClearLatest 带档位);
// 版本号同数据集内 s/n 共用序列 m<major>.<minor>.<patch> 自增(无记录从 m1.0.0 起)。
// 文件在训练成功时已直写最终位置(无额外副本),发布仅落版本记录(sha256/size 取自现有文件)。
func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTrainingPublishReq) (*dto.AdminTrainingPublishRes, error) {
t, err := dao.Training.GetById(ctx, req.Id)
@@ -567,7 +711,7 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
bestTflite := common.TrainingModelPath(ctx, modelFileName(dataset.Name, dataset.NamePrefix))
bestTflite := common.TrainingModelPath(ctx, modelFileBaseName(dataset.Name, dataset.NamePrefix, t.Variant))
data, err := os.ReadFile(bestTflite)
if err != nil {
return nil, gerror.New("训练产物 tflite 缺失,无法发布")
@@ -583,12 +727,13 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
}
now := gtime.Now()
err = common.Serial().Submit(ctx, func() error {
// 该数据集旧版全部置 0,再插新版本(is_latest=1
if err := dao.ModelVersion.ClearLatest(ctx, t.DatasetId); err != nil {
// 同 (数据集,档位) 旧版置 0(s/n 两档互不影响,各记各的 is_latest),再插新版本(is_latest=1
if err := dao.ModelVersion.ClearLatest(ctx, t.DatasetId, t.Variant); err != nil {
return err
}
_, err := dao.ModelVersion.Insert(ctx, &entity.ModelVersion{
DatasetId: t.DatasetId,
Variant: t.Variant,
Version: version,
TrainingId: t.Id,
Metrics: t.Metrics,
@@ -604,7 +749,7 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
if err != nil {
return nil, err
}
// 文件已由训练成功直写 trainings/<文件名前缀>.tflite,发布仅落版本记录,无额外副本
// 文件已由训练成功直写 trainings/<档位文件名>.tflite,发布仅落版本记录,无额外副本
return &dto.AdminTrainingPublishRes{Version: version}, nil
}
+1 -1
View File
@@ -19,7 +19,7 @@ var (
CodeImageGenNotConfigured = gcode.New(1012, "图像生成服务未配置(imageGen 节点)", nil)
CodeImageGenFailed = gcode.New(1013, "图像生成失败", nil)
CodeTrainingNotConfigured = gcode.New(1014, "训练通道未配置(training 节点)", nil)
CodeTrainingRunning = gcode.New(1015, "已有训练任务进行中(并发度 1", nil)
CodeTrainingRunning = gcode.New(1015, "该数据集同档位已有训练任务(运行中或排队中", nil)
CodeTrainingNotFound = gcode.New(1016, "训练任务不存在", nil)
CodeTrainingNotSuccess = gcode.New(1017, "仅训练成功的任务可发布", nil)
CodeLabelTaskRunning = gcode.New(1020, "该数据集已有预标注任务进行中", nil)
+6 -2
View File
@@ -87,8 +87,10 @@ type TrainingConfig struct {
DatasetDir string
Python string
TimeoutMins int
Model string // 训练基座权重(训练机 workdir 下,如 yolov8s.pt
Imgsz int
Model string // s 档(高识别)训练基座权重(训练机 workdir 下,如 yolov8s.pt
Imgsz int // s 档训练/导出分辨率
ModelN string // n 档(高性能)训练基座权重(如 yolov8n.pt
ImgszN int // n 档训练/导出分辨率
Epochs int
Batch int
Device string
@@ -103,6 +105,8 @@ func TrainingConfigOf(ctx context.Context) (TrainingConfig, bool) {
TimeoutMins: g.Cfg().MustGet(ctx, "training.timeoutMinutes", 600).Int(),
Model: g.Cfg().MustGet(ctx, "training.model", "yolov8s.pt").String(),
Imgsz: g.Cfg().MustGet(ctx, "training.imgsz", 1280).Int(),
ModelN: g.Cfg().MustGet(ctx, "training.modelN").String(),
ImgszN: g.Cfg().MustGet(ctx, "training.imgszN").Int(),
Epochs: g.Cfg().MustGet(ctx, "training.epochs", 150).Int(),
Batch: g.Cfg().MustGet(ctx, "training.batch", 16).Int(),
Device: g.Cfg().MustGet(ctx, "training.device", "0").String(),
+10 -4
View File
@@ -59,7 +59,8 @@ imageGen:
assumedVfovDeg: 52 # 镜头垂直视场角(度),固定为手机默认主摄(2026-08-28 用户定案;
# 曾随机 [15,90]、曾试超广角 90,均已否决)
# 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务):
# 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务——2026-09-03 起忙时新任务落 queued 排队,
# 轮询在 running 结束后自动按创建顺序晋级启动,一次一个):
# mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步)
training:
mode: ssh # subprocess | ssh
@@ -70,12 +71,17 @@ training:
port: 22
privateKeyPath: "" # 私钥路径与 password 二选一
password: "123"
workdir: /opt/pheasant_data # 训练机工作目录(train_server.py / yolov8s.pt 所在)
workdir: /opt/pheasant_data # 训练机工作目录(train_server.py / yolov8s.pt / yolov8n.pt 所在)
venvPython: /opt/pheasant_data/venv/bin/python
datasetDir: datasets # 训练机数据集根目录(相对 workdiryolo/<name> 为子目录)
timeoutMinutes: 240 # 训练超时判死(started_at 起算;实测 100 张约 3 分钟,千图级约 1 小时)
model: yolov8s.pt # 训练基座权重(须已同步到训练机 workdir;s 容量适配小目标+遮挡,详见技术设计)
imgsz: 1280 # 训练/导出分辨率(须与 App 端推理输入对齐;1280 提升远距离小目标保留像素)
# 双档位(2026-09-03 用户定案):s 档(高识别,默认)与 n 档(高性能)各自基座/分辨率;
# 一次「开始训练」按档位各建一条任务(请求 variants 可限定单档);epochs/batch/device 双档共用。
# n 档未配置(modelN/imgszN 缺一)时发起含 n 档的请求报错
model: yolov8s.pt # s 档训练基座权重(须已同步到训练机 workdir;s 容量适配小目标+遮挡,详见技术设计)
imgsz: 1280 # s 档训练/导出分辨率(须与 App 端推理输入对齐;1280 提升远距离小目标保留像素)
modelN: yolov8n.pt # n 档(高性能)训练基座权重(须已同步到训练机 workdir)
imgszN: 704 # n 档训练/导出分辨率(704² 端侧计算量约 s@1280 的 1/8,速度优先)
epochs: 150 # 训练轮数(patience 30 早停,设大可自动停)
batch: 16 # 批大小(按训练机显存调整)
device: "0" # GPU 编号(cpu 用 cpu
+32
View File
@@ -241,6 +241,38 @@ func initDatabase(ctx context.Context) {
}
g.Log().Infof(ctx, "数据库初始化完成(version=12)")
}
if version < 13 {
// v13:双档位训练 —— model_training + model_version 增 variant 列(s|n,默认 s 存量归 s 档)。
// 全新库建表自带该列;存量库 ADD COLUMNNOT NULL DEFAULT 's' 免回填)。
// PRAGMA table_info 逐列检测缺失才 ADD COLUMN
addCols := []struct{ table, col string }{
{"model_training", "variant"},
{"model_version", "variant"},
}
for _, c := range addCols {
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+" ADD COLUMN "+c.col+" TEXT NOT NULL DEFAULT 's'"); err != nil {
g.Log().Fatalf(ctx, "增加 %s.%s 列失败: %+v", c.table, c.col, err)
}
}
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 13"); err != nil {
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
}
g.Log().Infof(ctx, "数据库初始化完成(version=13)")
}
// 死表清理: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)
+24 -20
View File
@@ -94,8 +94,9 @@ CREATE TABLE IF NOT EXISTS dataset_image (
CREATE TABLE IF NOT EXISTS model_training (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- 任务名(默认「数据集+时间」)
status TEXT NOT NULL DEFAULT 'running', -- running | success | failed
dataset TEXT NOT NULL, -- 训练机数据集名(datasetDir 下子目录名)
status TEXT NOT NULL DEFAULT 'running', -- queued | running | success | failedqueued=GPU 忙排队,2026-09-03 双档位串行)
dataset_id INTEGER NOT NULL, -- → dataset.id
variant TEXT NOT NULL DEFAULT 's', -- s(高识别 1280) | n(高性能 704)2026-09-03 双档位
imgsz INTEGER NOT NULL DEFAULT 1280,
epochs INTEGER NOT NULL DEFAULT 150,
batch INTEGER NOT NULL DEFAULT 16,
@@ -108,20 +109,20 @@ CREATE TABLE IF NOT EXISTS model_training (
error TEXT, -- 失败原因
started_at TEXT NOT NULL,
finished_at TEXT,
created_at TEXT NOT NULL,
UNIQUE (dataset, imgsz, epochs, batch, started_at) -- 防止重复提交同参任务(宽松防呆)
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS model_version (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL, -- → dataset.id**每个数据集独立模型版本序列**
version TEXT NOT NULL, -- m1.0.0 递增(每次发布 patch+1,同数据集内唯一
dataset_id INTEGER NOT NULL, -- → dataset.id**版本序列每数据集全局共用(s/n 交替发布同一序列,2026-09-03)**
variant TEXT NOT NULL DEFAULT 's', -- s | n 档位(发布来源任务档位;存量行迁移默认 s
version TEXT NOT NULL, -- m1.0.0 递增(每次发布 patch+1,同数据集内唯一,两档共用不冲突)
training_id INTEGER, -- 来源训练任务 → model_training.id
metrics TEXT, -- JSON,与来源任务一致
labels TEXT NOT NULL, -- JSON 类别名数组(随模型下发,App 合并/展示用)
sha256 TEXT NOT NULL, -- tflite 文件校验
size_bytes INTEGER NOT NULL,
is_latest INTEGER NOT NULL DEFAULT 0, -- 1=该数据集当前生效(客户端拉取对象),每数据集至多一条
is_latest INTEGER NOT NULL DEFAULT 0, -- 1=该 (数据集,档位) 当前生效(客户端拉取对象),每档位至多一条
notes TEXT,
created_at TEXT NOT NULL,
UNIQUE (dataset_id, version)
@@ -157,6 +158,7 @@ CREATE INDEX IF NOT EXISTS idx_model_training_status ON model_training(status);
- v10 = 标注流程简化(撤销候选确认两阶段):`ALTER TABLE dataset_image DROP COLUMN candidates_json``PRAGMA table_info` 检测列存在才 DROP,新库建表已无此列直接跳过;存量候选数据为空直接删)——AI 预标注结果直写 `labels_json`,人工仅审核确认(2026-08-28 定案:疑似框确认/误检删除,不手动画框)
- 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`,新库建表已无此列自动跳过
- v13 = 双档位训练:`model_training``variant` 列 + 允许 `queued` 状态、`model_version``variant` 列(存量行默认 `s``PRAGMA table_info` 检测缺失才 `ALTER TABLE ... ADD COLUMN variant TEXT NOT NULL DEFAULT 's'`,新库建表自带跳过);`model_version``UNIQUE(dataset_id, version)` 与版本递增逻辑不变——**s/n 共用每数据集版本序列,is_latest 按 (数据集, 档位) 各记一条**(免去 SQLite 约束重建迁移)
## 全局训练配置(config.yml 直读)
@@ -358,10 +360,11 @@ training:
workdir: /opt/pheasant_data # 训练机工作目录
venvPython: /opt/pheasant_data/venv/bin/python
datasetDir: datasets # 训练机数据集根目录(相对 workdir,数据集为子目录)
concurrency: 1 # GPU 独占:同时仅一个 running,新任务排队
timeoutMinutes: 240 # 超时判死(started_at 起算;100 张实测约 3 分钟)
model: yolov8s.pt # 训练基座权重(须已同步到训练机 workdir)
imgsz: 1280 # 训练/导出分辨率(须与 App 端推理输入对齐)
model: yolov8s.pt # s 档(高识别)基座权重(须已同步到训练机 workdir)
imgsz: 1280 # s 档训练/导出分辨率(须与 App 端推理输入对齐)
modelN: yolov8n.pt # n 档(高性能)基座权重(2026-09-03 双档位;须已同步到训练机 workdir)
imgszN: 704 # n 档训练/导出分辨率(704² 端侧计算量约 s@1280 的 1/8,速度优先)
epochs: 150 # 训练轮数(patience 30 早停,设大可自动停)
batch: 16 # 批大小(按训练机显存调整)
device: "0" # GPU 编号(cpu 用 cpu
@@ -376,22 +379,23 @@ training:
- 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`(最终指标);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 任务返回错误「训练进行中」;训练任务不排队(简化,管理端人工再点一次)
- 产物拉取(2026-08-27 重构;命名 2026-08-28 改):成功后只拉 `best.tflite` 直写服务器 `workspace/trainings/<文件名前缀>.tflite`(前缀空回退数据集名;原子覆盖,无 per-task 存档、不再打包 zip
- **双档位训练(2026-09-03 用户定案)**:每数据集并行维护两个档位模型——**s 档(高识别)**:s@1280(基座 `training.model`/`imgsz`,见 2026-09-01 升级条),精度优先;**n 档(高性能)**:n@704(基座 `training.modelN`/`imgszN`),速度优先(中端机每帧 ~10ms 量级,远小于 s 的 70-120ms)。一次「开始训练」按档位各建一条任务:请求 `variants:["s","n"]` 限定(省略=双档;**只补跑高性能档传 `["n"]`**——存量 s 训练不重复发起);epochs/batch/device 双档共用 `training` 节点。任务表带 `variant` 列快照,训练机侧 `task.json` 按档位传 model/imgsztrain_server.py 本就参数化,无需改)。n 档权重须已同步到训练机 workdir(yolov8n.pt);n 档配置缺失(modelN/imgszN 未配)时发起含 n 档的请求报错。**选型背景**:s@1280 是当前最优精度形态,n@704 是端侧实时性的兜底形态(曾同规格 @704 训练:识别快、小目标误漏多),双档并存让用户在精度/速度间切换(App 端全局识别档位切换,见 flutter_app/README
- **任务生命周期**`queued → running → success/failed`;取消 = 杀进程(ssh 模式远程 kill pidqueued 无进程直接置 failed);超时无心跳判死;**Go 服务重启后启动扫描** running 任务按 pid 存活探测(subprocess 本机、ssh 远程 `kill -0`),进程已死则置 failedqueued 任务落库即持久,重启后由轮询继续晋级,无需恢复处理)
- **发起训练异步化(2026-08-27)**:发起请求仅做校验(数据集存在 / `prepareYoloSet` 有标注 / Serial 内并发检查)+ 落任务记录即返回(毫秒级);训练机侧准备(写任务参数 → ssh tar 同步数据集 → 启动进程,耗时可达分钟级)在后台协程执行(`context.Background()`,与预标注 `runDetection` 同模式),任何一步失败经 `finishFailed` 置任务 failed 由列表/轮询呈现——此前同步执行超过管理端 axios 10s 超时,出现「任务已落库但前端报 timeout」的不一致
- **GPU 独占排队(2026-09-03,替代「并发度 1 拒绝」)**:训练机单 GPU 无法并行两任务(显存),并发度 1 语义不变——已有 running 时新任务**不再拒绝**,落 `queued` 排队;10s 轮询在 running 结束后自动晋级最老 queued 为 running(CAS 防竞态后起后台协程做训练机准备);双档/多数据集可一次发起一串,训练机串行逐个执行。**同数据集同档位防重**:发起时检查该 (数据集,档位) 是否已有 running/queued 任务,有则拒绝(防双击/重复请求——排队不再拒绝后双档各自独立排队,同档重复提交会白跑两轮)。**发起校验与晋级都重新 `prepareYoloSet`**:请求时校验有标注即可(立即报错),晋级时重新打包(取发起后新标注,拆分 80/20 随任务时刻新鲜);数据集改名/删图期间排队任务晋级失败即置 failed 由列表呈现
- 产物拉取(2026-08-27 重构;命名 2026-08-28 改;**双档位 2026-09-03**):成功后只拉 `best.tflite` 直写服务器 `workspace/trainings/<文件名前缀>.tflite`s 档,前缀空回退数据集名)或 `<文件名前缀>_n.tflite`(n 档);原子覆盖,无 per-task 存档、不再打包 zip。s 档文件名与存量一致(存量已发布文件/旧 App 下载地址不变),n 档 `_n` 后缀区分
- 写操作走 `common.Serial()` 单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务
### 模型版本(每数据集一个模型,多模型体系)
### 模型版本(每数据集每档位一个模型,多模型体系)
**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注。**合并去重(2026-09-01 用户实测修订)**:单模型 NMS 与跨模型合并统一按 **minIoU**(交叠/较小框面积,阈值 0.45)**全局去重**(不分标签)——实测多模型会对同一目标检出不同类别、单模型会输出一大一小两框(标准 IoU=小/大 会漏判),重叠一律取高分框;与 server 标注端 `localAi.overlapThreshold`minIoU 风格,取值以 config.yml 为准)同思路。远处真实多目标互不重叠,正常保留。
**核心决策:每个数据集按档位各训一个模型(s/n 两档 2026-09-03),模型按数据集共用版本序列,App 按识别档位加载多模型并行推理合并**——用户按需下载若干数据集的模型,加载当前档位全部已下载模型共同推理标注。**合并去重(2026-09-01 用户实测修订)**:单模型 NMS 与跨模型合并统一按 **minIoU**(交叠/较小框面积,阈值 0.45)**全局去重**(不分标签)——实测多模型会对同一目标检出不同类别、单模型会输出一大一小两框(标准 IoU=小/大 会漏判),重叠一律取高分框;与 server 标注端 `localAi.overlapThreshold`minIoU 风格,取值以 config.yml 为准)同思路。远处真实多目标互不重叠,正常保留。
- 版本号规则:`m<major>.<minor>.<patch>`,**同一数据集内**每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);`UNIQUE(dataset_id, version)` 防重复
- 文件布局(2026-08-27 重构;命名 2026-08-28 改):`workspace/trainings/<文件名前缀>.tflite` 即当前生效模型唯一位(前缀空回退数据集名——存量数据集无前缀;改名/改前缀时模型文件随命名迁移)——训练成功时从训练机直写(原子覆盖),客户端固定下载该文件;**无 `<version>.tflite` 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚)**;每数据集一个文件互不影响
- 版本号规则:`m<major>.<minor>.<patch>`,**同一数据集内**每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);`UNIQUE(dataset_id, version)` 防重复——**s/n 双档共用序列**(不按档位分序列:避免改 UNIQUE 约束触发 SQLite 表重建,版本号对客户端仅同档内比较单调,跨档无比较需求;发布 n 后版本号继续在数据集全局递增)
- 文件布局(2026-08-27 重构;命名 2026-08-28 改**双档位 2026-09-03**):`workspace/trainings/<文件名前缀>.tflite`s 档)与 `<文件名前缀>_n.tflite`n 档)即当前生效模型唯一位(前缀空回退数据集名——存量数据集无前缀;改名/改前缀时模型文件随命名迁移)——训练成功时从训练机直写(原子覆盖),客户端固定下载该文件;**无 `<version>.tflite` 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚)**;每 (数据集,档位) 一个文件互不影响,s 档文件名与存量一致(旧 App/旧下载地址不变)
- 类别名:发布时从训练任务/数据集记录类别(训练脚本 result.json 输出 `names`),存 `model_version.labels`(JSON 数组),**App 合并推理依赖它**
- **发布**`POST /admin/trainings/publish`):校验任务 success + `trainings/<文件名前缀>.tflite` 存在(前缀空回退数据集名)→ 读文件算 sha256/size → 插 `model_version` + 该数据集旧版 `is_latest=0`;文件已在训练成功时就位,发布仅落版本记录
- **发布**`POST /admin/trainings/publish`):校验任务 success + 对应档位 `trainings/<文件名前缀>[_n].tflite` 存在(前缀空回退数据集名)→ 读文件算 sha256/size → 插 `model_version`(带任务档位 variant)+ **同档位**旧版 `is_latest=0`(s/n 两档各自独立,发布 n 不影响 s 生效状态);文件已在训练成功时就位,发布仅落版本记录。存量行 variant 迁移默认 s,与既有 s 档文件布局一致,旧记录照常可用
- **管理端无模型管理界面**(2026-08-26 决策):删 `AdminListModels`/`AdminActivateModel`/`AdminDeleteModel` 三个管理接口,`model_version` 表保留——仅支撑客户端下发目录;版本只增不删不回滚(发布即最新)
- **模型目录(客户端拉取)**`GET /api/v1/models`(公开,登录态即可)返回所有数据集当前生效模型:`{datasetId, datasetName, version, labels, sizeBytes, sha256, notes, publishedAt, downloadUrl}`;下载 URL `/download/trainings/<文件名前缀>.tflite`(前缀空回退数据集名;复用 `/download` 静态托管,文件名含中文需 URL 编码)
- **模型目录(客户端拉取)**`GET /api/v1/models`(公开,登录态即可)返回所有数据集当前生效模型:`{datasetId, datasetName, variant(s|n), version, labels, sizeBytes, sha256, notes, publishedAt, downloadUrl}`——**每数据集最多 2 条 = s/n 两档各自的 is_latest**ListAllLatest 取 is_latest=1 自然返回两档);下载 URL s 档 `/download/trainings/<文件名前缀>.tflite`、n 档 `<文件名前缀>_n.tflite`(前缀空回退数据集名;复用 `/download` 静态托管,文件名含中文需 URL 编码)。**客户端兼容**:条目新增 variant 字段,旧 App 忽略未知字段但按 datasetId 存储会与另一档条目互踩——双档目录与新 App 需同步上线(先发 App 再发布 n 档模型)
### 标注工作台(依赖 local-ai 可达;2026-08-26 布局重构)