双档位训练体系(s高识别/n高性能 串行排队、按档发布)与 TFLite 硬件加速补丁
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+42
-14
@@ -38,30 +38,58 @@ xcrun devicectl device process launch --console --terminate-existing \
|
||||
"In iOS 14+, debug mode Flutter apps can only be launched from Flutter tooling"。
|
||||
debug 调试必须用 `flutter run -d <设备ID>` 或 Xcode IDE 启动(`flutter devices` 查设备ID);
|
||||
从图标启动只对 release 构建有效。
|
||||
- **`flutter run` 真机 debug 附接失败(errno 49,多次复现)**:Xcode 构建、安装、
|
||||
启动都成功,attach 阶段报 `OS Error: Can't assign requested address, errno = 49`
|
||||
工具即退出——与本机 VPN(utun 隧道)环境相关,断 VPN 后可恢复。
|
||||
需要真机验证时一律用上方 release + devicectl 流程(不依赖 attach);只有需要
|
||||
热重载/看 debugPrint 才用 `flutter run`,遇 errno 49 先断 VPN 重试。
|
||||
## 端侧推理加速(GPU / CoreML)
|
||||
|
||||
识别慢的根因是 yolov8s@1280 推理量大(CPU 4 线程约每秒不到 1 帧),
|
||||
`TfliteDetector.fromBuffer` 加载模型时按平台挂加速 delegate,均为**浮点计算不降精度**
|
||||
(区别于 int8 量化掉点):
|
||||
|
||||
| 平台 | delegate | 说明 |
|
||||
|---|---|---|
|
||||
| Android | `GpuDelegateV2` | TFLite GPU delegate;依赖 `libtensorflowlite_gpu_jni.so`,已 vendor 到 `android/app/src/main/jniLibs/arm64-v8a/`(AAR 因 AGP 9 namespace 冲突保持排除,升级 tflite_flutter 时需同步换 .so,版本对齐 base 2.11.0) |
|
||||
| iOS | `CoreMlDelegate` | Core ML(苹果 ANE/GPU,插件 pod 自带 TensorFlowLiteSwift/CoreML,无需额外依赖) |
|
||||
|
||||
delegate 初始化失败(老设备/驱动/符号缺失)**自动回退纯 CPU 4 线程**,最后才返回 null
|
||||
(仅预览不识别)。生效与否看日志:加载模型时输出
|
||||
`[TfliteDetector] 加速生效 model=xxx (CoreML|GPU)`,回退输出 `回退 CPU` 及原因。
|
||||
GPU delegate 默认允许 FP16 计算(YOLO 类精度损失可忽略);如需全精度改为传
|
||||
`GpuDelegateOptionsV2(isPrecisionLossAllowed: false)`。
|
||||
|
||||
## 模型热更新(多数据集模型)
|
||||
|
||||
模型与 APK 更新走**独立通道**:启动时拉取 `GET /api/v1/app/update` 随附的
|
||||
`models` 目录(公开接口,无需登录),与 `UpdateChecker` 的 APK 检查并行。
|
||||
|
||||
- **目录条目**:`{datasetId, datasetName, version, labels[], sizeBytes, sha256,
|
||||
downloadUrl, coverUrl}`;服务器未发布模型时不返回 `models` 字段,App 无模型可用,
|
||||
- **目录条目**:`{datasetId, datasetName, variant, version, labels[], sizeBytes, sha256,
|
||||
downloadUrl, coverUrl}`——**双档位(2026-09-03)**:每数据集至多 2 条 = 高识别 s
|
||||
(@1280 精度优先,默认)+ 高性能 n(@704 速度优先)各自的当前版本,条目带 `variant`
|
||||
(s/n);服务器未发布模型时不返回 `models` 字段,App 无模型可用,
|
||||
相机页仅预览不识别。
|
||||
- **下载入口**:相机页设置弹层「模型清单」按需下载/使用(封面缩略图 2 列网格,
|
||||
未下载点击「使用」显示进度,完成自动激活;已激活再次点击取消;下载中可取消)。
|
||||
- **存储**:应用私有目录 `models/<datasetId>/`,含 `model.tflite`、
|
||||
`labels.json`、`meta.json`(meta 记录 `{version, sha256}`)。版本与摘要都
|
||||
未变化时跳过下载;变化则下载到 `.part` 临时文件、sha256 校验通过后
|
||||
原子 rename 替换,失败重试一次并保留旧模型,下次启动再试。
|
||||
- **清理**:服务器下线的数据集下次同步时删除本地对应目录。
|
||||
- **并行推理合并**:识别时加载全部已激活模型(`DetectorWorker` isolate 内
|
||||
逐模型加载,单个失败不影响其他),同帧各模型独立推理后按类别分组做
|
||||
每数据集两档各占一格并带档位角标;未下载点击「使用」显示进度,完成自动激活;
|
||||
已激活再次点击取消;下载中可取消)。**档位切换**:弹层顶部「识别模式」分段控件
|
||||
(s 高识别 / n 高性能,默认 s)持久化本地,切换即热加载新档位已激活模型。
|
||||
- **存储**:应用私有目录 `models/<datasetId>/<variant>/`(双档位 2026-09-03,
|
||||
原无 variant 目录与存量 s 档一致——s 档复用 `models/<datasetId>/` 同级读取,
|
||||
目录键 = 档位标识符),含 `model.tflite`、`labels.json`、`meta.json`
|
||||
(meta 记录 `{version, sha256}`)。版本与摘要都未变化时跳过下载;变化则下载到
|
||||
`.part` 临时文件、sha256 校验通过后原子 rename 替换,失败重试一次并保留旧模型,
|
||||
下次启动再试——检查记账按 `(datasetId, variant)` 独立。
|
||||
- **清理**:服务器下线的数据集下次同步时删除本地对应目录(两档都无条目时才删)。
|
||||
- **并行推理合并**:识别时加载**当前识别档位**下全部已激活模型(`DetectorWorker`
|
||||
isolate 内逐模型加载,单个失败不影响其他),同帧各模型独立推理后按类别分组做
|
||||
**跨模型 NMS**(同类别不同模型检出同一目标取高分去重,不同类别互不压制),
|
||||
结果叠加 `modelName` 标注来源。
|
||||
结果叠加 `modelName`(数据集名+档位)标注来源。
|
||||
|
||||
实现:`lib/models/model_manager.dart`(下载/校验/持久化,`ModelManager`
|
||||
单例 + ChangeNotifier)、`lib/detection/detector_worker.dart`(多模型并行
|
||||
推理与 `mergeAcrossModels`)、`lib/camera/camera_screen.dart`(启动同步 +
|
||||
诊断行展示模型列表)。
|
||||
单例 + ChangeNotifier,条目身份含档位)、`lib/detection/detector_worker.dart`
|
||||
(多模型并行推理与 `mergeAcrossModels`)、`lib/camera/camera_screen.dart`
|
||||
(启动同步 + 设置弹层「识别模式」切换 + 诊断行展示模型列表)。
|
||||
|
||||
- **模型输入是 NHWC**:训练导出的模型需做字节级手术(开头 TRANSPOSE→RESHAPE,
|
||||
输入 [1,320,320,3])再发布给 App,否则 iOS 报
|
||||
|
||||
@@ -45,8 +45,10 @@ kotlin {
|
||||
// 不依赖任何相机三方库(含 CameraX)。
|
||||
|
||||
// tflite_flutter 依赖的 tensorflow-lite / tensorflow-lite-gpu / tensorflow-lite-api 三个 AAR
|
||||
// 声明了相同 namespace(org.tensorflow.lite),新 AGP 视作冲突直接报错;
|
||||
// 本项目仅用 CPU 推理,GPU delegate 未使用,排除 gpu 及其传递依赖的 api 即可。
|
||||
// 声明了相同 namespace(org.tensorflow.lite),新 AGP 视作冲突直接报错,故仍整体排除;
|
||||
// GPU delegate 所需 libtensorflowlite_gpu_jni.so 已手工抽取 vendor 到
|
||||
// src/main/jniLibs/arm64-v8a/(tflite_flutter 经 FFI 直调 .so,不用 AAR 内 Java 类),
|
||||
// 与 base tensorflow-lite 同为 2.11.0 版本;升级 tflite_flutter 时需同步更新 .so。
|
||||
configurations.all {
|
||||
exclude(group = "org.tensorflow", module = "tensorflow-lite-gpu")
|
||||
exclude(group = "org.tensorflow", module = "tensorflow-lite-api")
|
||||
|
||||
Binary file not shown.
@@ -1,3 +1,4 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
@@ -44,12 +45,28 @@ class TfliteDetector {
|
||||
|
||||
/// 模型缺失或加载失败返回 null(App 降级为仅预览)。
|
||||
/// 在后台 isolate 内调用(模型字节由主 isolate 读取后传入)。
|
||||
/// 加速:Android 挂 TFLite GPU delegate、iOS 挂 CoreML delegate(ANE/GPU),
|
||||
/// 均为浮点计算不降精度(对比 int8 量化);delegate 初始化失败自动回退纯 CPU 4 线程。
|
||||
static Future<TfliteDetector?> fromBuffer(
|
||||
Uint8List bytes,
|
||||
List<String> labels, {
|
||||
int modelId = -1,
|
||||
String modelName = '',
|
||||
}) async {
|
||||
final delegate = _createAccelDelegate();
|
||||
if (delegate != null) {
|
||||
try {
|
||||
final options = InterpreterOptions()..threads = 4;
|
||||
options.addDelegate(delegate);
|
||||
final interpreter = Interpreter.fromBuffer(bytes, options: options);
|
||||
debugPrint('[TfliteDetector] 加速生效 model=$modelName '
|
||||
'(${Platform.isIOS ? 'CoreML' : 'GPU'})');
|
||||
return TfliteDetector._fromModel(
|
||||
interpreter, labels, modelId, modelName);
|
||||
} catch (e) {
|
||||
debugPrint('[TfliteDetector] 加速 delegate 初始化失败,回退 CPU: $e');
|
||||
}
|
||||
}
|
||||
try {
|
||||
final interpreter = Interpreter.fromBuffer(
|
||||
bytes,
|
||||
@@ -62,6 +79,26 @@ class TfliteDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台加速 delegate:Android=TFLite GPU(libtensorflowlite_gpu_jni.so 已 vendor 到
|
||||
/// android/app/src/main/jniLibs,AAR 因 AGP namespace 冲突保持排除)、iOS=CoreML。
|
||||
/// 老设备/驱动/符号缺失时创建失败返回 null,走 CPU。
|
||||
static Delegate? _createAccelDelegate() {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
return CoreMlDelegate();
|
||||
} catch (e) {
|
||||
debugPrint('[TfliteDetector] CoreML delegate 创建失败: $e');
|
||||
}
|
||||
} else if (Platform.isAndroid) {
|
||||
try {
|
||||
return GpuDelegateV2();
|
||||
} catch (e) {
|
||||
debugPrint('[TfliteDetector] GPU delegate 创建失败: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 输出布局 [1, 4+nc, anchors] 取自模型本身,类别数不与 labels 文件长度耦合。
|
||||
factory TfliteDetector._fromModel(Interpreter interpreter,
|
||||
List<String> labels, int modelId, String modelName) {
|
||||
|
||||
+17
-14
@@ -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` 节点),并发度 1(GPU 独占);训练脚本 `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/1,2026-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/failed;queued=GPU 忙排队中,2026-09-03)、`dataset_id`、`variant`(s/n 档位,default s,2026-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/n,default s;存量行迁移为 s)、`version`(m1.0.0 递增, 同数据集 UNIQUE,s/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=杀训练进程置 failed;queued=无进程直接置 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-ai(baseUrl + 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-ai(baseUrl + 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
+1
-1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -30,11 +30,18 @@ const (
|
||||
// 预标注(逐张调 RF-DETR)池默认并发度(被 config.yml labelTask.poolSize 覆盖)
|
||||
LabelPoolDefaultSize = 4
|
||||
|
||||
// 训练任务状态机 running → success/failed
|
||||
// 训练任务状态机 queued → running → success/failed(queued=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.tflite(s 档无后缀 = 旧版唯一位,向后兼容)
|
||||
TrainingVariantNFileSuffix = "_n"
|
||||
|
||||
// 数据集状态 building → labeled → synced(synced = 已同步训练机)
|
||||
DatasetStatusBuilding = "building"
|
||||
DatasetStatusLabeled = "labeled"
|
||||
|
||||
@@ -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 晋级排队任务为 running(CAS:仅 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<文件名前缀>.tflite;n 档 <文件名前缀>_n.tflite
|
||||
CoverUrl string `json:"coverUrl"` // /api/v1/app/cover?namePrefix=<前缀>(App 模型清单缩略图)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// ModelTraining 训练任务:runner 启动训练进程,轮询解析 epoch 日志更新进度/指标,
|
||||
// 日志尾部截断存 log_tail;pid 用于取消与存活探测。
|
||||
// 状态机 queued → running → success/failed(queued=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:"目标轮数"`
|
||||
|
||||
@@ -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/<文件名前缀>.tflite(s 档)/<文件名前缀>_n.tflite(n 档,
|
||||
// 前缀空回退数据集名,无存档回退机制);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:"发布时间"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/<基名>.tflite,n 档带 _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
@@ -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 → running(CAS 防与
|
||||
// 取消/删除竞态,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 # 训练机数据集根目录(相对 workdir,yolo/<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)
|
||||
|
||||
@@ -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 COLUMN(NOT 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
@@ -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 | failed(queued=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/imgsz(train_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 pid;queued 无进程直接置 failed);超时无心跳判死;**Go 服务重启后启动扫描** running 任务按 pid 存活探测(subprocess 本机、ssh 远程 `kill -0`),进程已死则置 failed(queued 任务落库即持久,重启后由轮询继续晋级,无需恢复处理)
|
||||
- **发起训练异步化(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 布局重构)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user