综合训练(多物种合并模型)全链路:合并打包/独立版本序列/App 覆盖互斥 + 管理端发布入口
- 后端:/admin/trainings/combined 发起(≥2 数据集、类别重映射、防重名、负样本单份); model_training/model_version 加 kind+dataset_ids(迁移 v14),综合任务 dataset_id=0、 文件基名 combined(_n)、版本序列独立;训练列表补 published 标记 - 管理端:数据训练页工具栏发起综合训练;横幅常驻进行中任务 + 每档最近一条已结束任务, 成功未发布给「发布模型」入口(可关闭收起) - App:目录解析 kind/datasetIds、激活覆盖互斥、自动更新退场改目标档待办横幅手动一键下载
This commit is contained in:
+5
-3
@@ -17,7 +17,7 @@
|
||||
| 后台管理端 | `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 文件,删历史版本仅删记录 |
|
||||
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程,**双 tab(2026-09-07)**:「数据集」tab = 数据集卡片列表(封面图/描述/图片数/已标注数/**训练状态徽标**),「负样本」tab = 负样本库图片网格(上传/删除,见技术设计.md「负样本库」——训练打包时统一混入全部物种数据集);**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片不自动标注(2026-09-04 自动标注退场)**:标注唯一入口 = 勾选图片顶栏「预标」(RF-DETR 四级漏斗检测,见技术设计.md「预标注四级漏斗」),进度条展示在页顶;**预标完成进「待审核」,人工审核通过才「已标注」**(`dataset_image.review_status` 0 未标注/1 待审核/2 已审核 三态,训练集只收已审核图);点击原图/标注图弹窗放大进入标注编辑器(画框/确认/清理,保存即视为已审核);封面(上传/生成统一 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` 节点,部署级配置);**双档位(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` 留作训练机人工深度调试 |
|
||||
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 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` 快照;**综合训练(2026-09-09)**:`POST /admin/trainings/combined` 勾选 ≥2 个数据集 + 档位,多物种合并训练出**一个综合模型**(全类一张 tflite:类别表 = 各物种名按数据集 id 升序 + 共享 suspect 置末位,打包时类别 id 重映射、负样本只混一份、图片名加 d<id>_ 前缀防跨数据集重名),产物/发布/目录下发走现有链路,文件基名 `combined`(combined.tflite / combined_n.tflite);单物种训练流程不变,两种模式并存(详见技术设计.md「综合训练」);**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 模型兜底 |
|
||||
| 标注 | **无自动标注(2026-09-04 退场,用户定案)**:上传/生成入库不触发任何检测,`localAi` 未配置不再阻断入库;标注唯一入口 = 管理端勾选图片顶栏「预标」→ `POST /admin/label-tasks`(RF-DETR **四级漏斗**:全图扫描→空检自动升级切片扫描→仍空 VLM 提议候选区+RF-DETR 精修;切片参数走 `localAi.tileSize`/`tileOverlap`/`tileThreshold`,见技术设计.md「预标注四级漏斗」;扫描结果 minIoU 重叠去重后直写 `dataset_image.labels_json`,空检出写 `[]` 且 review_status 保持未标注);**预标完成 →「待审核」(review_status=1),人工审核通过才「已标注」(=2)**,训练集打包只收已审核图(prepareYoloSet 质量闸门);工作台弹窗人工画框/确认后保存即视为已审核;管理端对待审核图批量「通过/拒绝」(拒绝 = 清标注回未标注池,并计入对应 App 用户的低质统计,见「标注众包赚时长」) |
|
||||
@@ -52,8 +52,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/**negative**=负样本库,2026-09-07:固定保留名 `__negative__`,训练打包时混入全部物种数据集当背景学习)、`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/''/'[]'=无框)、`review_status`(2026-09-04 审核三态:0 未标注/1 待审核/2 已审核;预标与 App 提交→1,人工保存与审核通过→2,拒绝清标注→0;训练集只收 2)、`clean_excluded`(0/1,2026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`annotate_task_id`(2026-09-07 众包下发的任务占用标记,0=未下发;下发即从「未标注」tab 消失,停用任务释放未领取图回 0)、`created_at` |
|
||||
| `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,前缀空回退数据集名),客户端固定下载,无存档回退) |
|
||||
| `model_training` | 训练任务 | `id`(PK)、`name`、`status`(queued/running/success/failed;queued=GPU 忙排队中,2026-09-03)、`dataset_id`(综合任务=0)、`variant`(s/n 档位,default s,2026-09-03)、`kind`(species/combined,2026-09-09 综合)、`dataset_ids`(JSON,综合任务覆盖的数据集列表)、`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`(按 (数据集,档位) 各记一条)、`kind`(species/combined,2026-09-09 综合,综合行 dataset_id=0)、`dataset_ids`(JSON,综合覆盖的数据集)、`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` |
|
||||
| `annotate_task` | 标注众包任务(2026-09-04;2026-09-07 图片粒度下发) | `id`(PK)、`dataset_id`、`name`、`status`(published/stopped)、`created_at`(任务图集 = `dataset_image.annotate_task_id` 占用本任务 id 的图,不落任务表) |
|
||||
@@ -211,6 +211,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
||||
| 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` | 发起训练 `{"datasetId":1,"name":"...","variants":["s","n"]}`:`variants` 限定档位(省略=双档 s+n 各建一条任务;只补跑高性能档传 `["n"]`;请求的档位 n 未配置时报错);校验数据集有标注 → 落任务返回 `{id}`(首条任务 id);**GPU 独占排队**:并发度 1 不变——已有 running 时不拒绝、新任务落 queued,running 结束后轮询自动按创建顺序晋级启动(一次一个);同数据集同档位已有任务(running/queued)时拒绝(防重复提交) |
|
||||
| POST | `/admin/trainings/combined` | 综合训练发起 `{"datasetIds":[1,2],"variants":["s","n"]}`:勾选 ≥2 个数据集合并训练一个多物种模型;类别表 = 各物种名(按数据集 id 升序,gen_species 回退数据集名)+ 共享 suspect 置末位;每档位一条 `kind=combined` 任务(dataset_id=0、dataset_ids 快照),与单物种任务同队列排队;产物基名 `combined`(combined.tflite / combined_n.tflite) |
|
||||
| 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;queued=无进程直接置 failed |
|
||||
@@ -264,6 +265,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
||||
```
|
||||
|
||||
- 只返回 `is_latest=1` 的模型:**每 (数据集, 档位) 至多一条**(每数据集 s/n 各一条,variant 标识档位;n 档文件名带 `_n` 后缀);无任何发布模型时 `list` 为空数组
|
||||
- **综合模型条目(2026-09-09)**:`kind:"combined"` + `datasetIds`(覆盖的数据集 id 列表)+ `datasetId:0`、`datasetName:"综合"`,下载地址 `/download/trainings/combined(_n).tflite`;单物种条目 `kind:"species"`(缺省视为 species,老 App 兼容);App 激活综合模型时自动停用其覆盖物种的单物种模型(反之亦然,覆盖互斥)
|
||||
- 下载地址由客户端拼 `apiBaseUrl` 访问;下载文件 sha256 校验,类别名数组 `labels` 用于多模型合并推理展示;条目带 `variant`(2026-09-03),App 按识别档位(s 高识别/n 高性能)筛选加载
|
||||
|
||||
## 使用说明
|
||||
|
||||
@@ -63,6 +63,13 @@ const (
|
||||
// n 档模型文件名后缀:trainings/<基名>_n.tflite(s 档无后缀 = 旧版唯一位,向后兼容)
|
||||
TrainingVariantNFileSuffix = "_n"
|
||||
|
||||
// 训练/模型类型(2026-09-09 综合训练):species=单物种(存量默认)| combined=多物种综合模型
|
||||
// (dataset_id=0、dataset_ids=覆盖数据集列表,文件基名 combined)
|
||||
TrainingKindSpecies = "species"
|
||||
TrainingKindCombined = "combined"
|
||||
// 综合模型文件基名与训练机目录名
|
||||
TrainingCombinedBase = "combined"
|
||||
|
||||
// 数据集状态 building → labeled → synced(synced = 已同步训练机)
|
||||
DatasetStatusBuilding = "building"
|
||||
DatasetStatusLabeled = "labeled"
|
||||
|
||||
@@ -262,6 +262,11 @@ func (c *cAdmin) UnfreezeAnnotate(ctx context.Context, req *dto.AdminAnnotateUnf
|
||||
return service.Annotate.AdminUnfreeze(ctx, req)
|
||||
}
|
||||
|
||||
// StartCombined 综合训练发起(多物种合并模型)
|
||||
func (c *cAdmin) StartCombined(ctx context.Context, req *dto.AdminTrainingCombinedStartReq) (*dto.AdminTrainingCombinedStartRes, error) {
|
||||
return service.Training.AdminStartCombined(ctx, req)
|
||||
}
|
||||
|
||||
// ListFalseTargets 假目标上报列表
|
||||
func (c *cAdmin) ListFalseTargets(ctx context.Context, req *dto.AdminFalseTargetListReq) (*dto.AdminFalseTargetListRes, error) {
|
||||
return service.FalseTarget.AdminList(ctx, req)
|
||||
|
||||
@@ -52,6 +52,8 @@ func (d *modelTrainingDao) Insert(ctx context.Context, m *entity.ModelTraining)
|
||||
"status": m.Status,
|
||||
"variant": m.Variant,
|
||||
"dataset_id": m.DatasetId,
|
||||
"kind": m.Kind,
|
||||
"dataset_ids": m.DatasetIds,
|
||||
"imgsz": m.Imgsz,
|
||||
"epochs": m.Epochs,
|
||||
"batch": m.Batch,
|
||||
|
||||
@@ -41,6 +41,8 @@ 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,
|
||||
"kind": m.Kind,
|
||||
"dataset_ids": m.DatasetIds,
|
||||
"variant": m.Variant,
|
||||
"version": m.Version,
|
||||
"training_id": m.TrainingId,
|
||||
|
||||
@@ -299,8 +299,10 @@ type AdminTrainingItem struct {
|
||||
Name string `json:"name"`
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Variant string `json:"variant"` // s(高识别) | n(高性能)
|
||||
Status string `json:"status"` // queued | running | success | failed
|
||||
Kind string `json:"kind"` // species 单物种 | combined 综合
|
||||
Variant string `json:"variant"` // s(高识别) | n(高性能)
|
||||
Status string `json:"status"` // queued | running | success | failed
|
||||
Published bool `json:"published"` // 该训练是否已发布过版本(发布入口状态展示)
|
||||
Imgsz int `json:"imgsz"`
|
||||
Epochs int `json:"epochs"`
|
||||
Batch int `json:"batch"`
|
||||
@@ -333,6 +335,20 @@ type AdminTrainingStartRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminTrainingCombinedStartReq 综合训练发起(2026-09-09 多物种合并模型):
|
||||
// 勾选 ≥2 个数据集,多物种合并训练一个全类 tflite(类别表 = 各物种名 + 共享 suspect);
|
||||
// 每档位一条 kind=combined 任务(dataset_id=0),与单物种任务同队列排队
|
||||
type AdminTrainingCombinedStartReq struct {
|
||||
g.Meta `path:"/trainings/combined" method:"post" summary:"发起综合训练" tags:"管理端"`
|
||||
DatasetIds []int64 `json:"datasetIds" v:"required" dc:"参与合并的数据集 id 列表(≥2,互不相同)"`
|
||||
Name string `json:"name" v:"length:0,50" dc:"任务名称,空自动生成"`
|
||||
Variants []string `json:"variants" dc:"限定档位 s/n(省略=双档各建一条任务;含未知档位/未配置档位报错)"`
|
||||
}
|
||||
|
||||
type AdminTrainingCombinedStartRes struct {
|
||||
FirstId int64 `json:"firstId"` // 首条任务 id(variants 多条时取最小)
|
||||
}
|
||||
|
||||
// AdminTrainingDetailReq 训练任务详情(含日志尾部)
|
||||
type AdminTrainingDetailReq struct {
|
||||
g.Meta `path:"/trainings/detail" method:"get" summary:"训练任务详情" tags:"管理端"`
|
||||
@@ -549,15 +565,17 @@ type ModelCatalogReq struct {
|
||||
type ModelCatalogItem struct {
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Variant string `json:"variant"` // s(高识别) | n(高性能)
|
||||
Variant string `json:"variant"` // s(高识别) | n(高性能)
|
||||
Kind string `json:"kind"` // species 单物种(缺省视为 species,老 App 忽略) | combined 综合
|
||||
DatasetIds []int64 `json:"datasetIds,omitempty"` // combined:覆盖的数据集 id 列表
|
||||
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"` // s 档 /download/trainings/<文件名前缀>.tflite;n 档 <文件名前缀>_n.tflite
|
||||
CoverUrl string `json:"coverUrl"` // /api/v1/app/cover?namePrefix=<前缀>(App 模型清单缩略图)
|
||||
DownloadUrl string `json:"downloadUrl"` // s 档 /download/trainings/<文件名前缀>.tflite;n 档 <文件名前缀>_n.tflite;combined 固定 combined(_n)
|
||||
CoverUrl string `json:"coverUrl"` // /api/v1/app/cover?namePrefix=<前缀>(App 模型清单缩略图);combined 为空
|
||||
}
|
||||
|
||||
type ModelCatalogRes struct {
|
||||
|
||||
@@ -10,7 +10,9 @@ type ModelTraining struct {
|
||||
Name string `json:"name" orm:"name" description:"任务名"`
|
||||
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:"来源数据集"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"来源数据集(综合任务=0)"`
|
||||
Kind string `json:"kind" orm:"kind" description:"species 单物种|combined 多物种综合"`
|
||||
DatasetIds string `json:"datasetIds" orm:"dataset_ids" description:"综合任务覆盖的数据集 id JSON 数组"`
|
||||
Imgsz int `json:"imgsz" orm:"imgsz" description:"训练分辨率"`
|
||||
Epochs int `json:"epochs" orm:"epochs" description:"目标轮数"`
|
||||
Batch int `json:"batch" orm:"batch" description:"batch size"`
|
||||
|
||||
@@ -8,7 +8,9 @@ import "github.com/gogf/gf/v2/os/gtime"
|
||||
// 前缀空回退数据集名,无存档回退机制);labels 为类别名数组 JSON(App 多模型合并推理依赖)。
|
||||
type ModelVersion struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集(综合模型=0)"`
|
||||
Kind string `json:"kind" orm:"kind" description:"species 单物种|combined 多物种综合"`
|
||||
DatasetIds string `json:"datasetIds" orm:"dataset_ids" description:"综合覆盖的数据集 id JSON 数组"`
|
||||
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:"来源训练任务"`
|
||||
|
||||
@@ -630,6 +630,123 @@ func (s *labelTaskService) AdminLabelSave(ctx context.Context, req *dto.AdminLab
|
||||
return &dto.AdminLabelSaveRes{LabeledCount: labeled}, nil
|
||||
}
|
||||
|
||||
// prepareCombinedYoloSet 综合训练打包(2026-09-09 多物种合并模型):多数据集合并为一个
|
||||
// 全类训练包。全局类别表 = 各数据集物种名按 datasetIds 传入顺序(gen_species 回退数据集名)
|
||||
// + 共享 suspect 置末位;各数据集标注 class 0(本物种)重映射到该数据集物种下标、
|
||||
// class 1(suspect)重映射到末位下标;图片/标签名加 d<datasetId>_ 前缀防跨数据集重名;
|
||||
// 负样本库只混一份(与单物种语义一致);80/20 拆分在合并后总池上做。
|
||||
// 返回训练包与类别名表(data.yaml names 由发起方用该表生成)。
|
||||
func (s *labelTaskService) prepareCombinedYoloSet(ctx context.Context, datasetIds []int64) (*common.YoloPackage, []string, error) {
|
||||
type src struct {
|
||||
dataset *entity.Dataset
|
||||
images []*entity.DatasetImage
|
||||
imgDir string
|
||||
}
|
||||
var names []string
|
||||
var sources []src
|
||||
for _, id := range datasetIds {
|
||||
d, err := dao.Dataset.GetById(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if d == nil {
|
||||
return nil, nil, gerror.Newf("数据集 %d 不存在", id)
|
||||
}
|
||||
if d.Source == consts.DatasetSourceNegative {
|
||||
return nil, nil, gerror.New("负样本库不参与综合训练(打包时自动混入)")
|
||||
}
|
||||
images, err := dao.DatasetImage.ListByDataset(ctx, d.Id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
species := strings.TrimSpace(d.GenSpecies)
|
||||
if species == "" {
|
||||
species = d.Name
|
||||
}
|
||||
names = append(names, species)
|
||||
sources = append(sources, src{dataset: d, images: images, imgDir: common.DatasetImagesDir(ctx, d.Name)})
|
||||
}
|
||||
names = append(names, "suspect")
|
||||
suspectIdx := len(names) - 1
|
||||
|
||||
type item struct {
|
||||
filename string
|
||||
lines string
|
||||
imgPath string
|
||||
}
|
||||
var kept []item
|
||||
for si, sc := range sources {
|
||||
valid := 0
|
||||
for _, img := range sc.images {
|
||||
if img.CleanExcluded == 1 {
|
||||
continue // 数据清洗排除图不进训练集
|
||||
}
|
||||
if img.ReviewStatus != consts.ReviewImageApproved {
|
||||
continue // 训练集只收人工审核通过的图
|
||||
}
|
||||
if img.LabelsJson == "" || img.LabelsJson == "[]" {
|
||||
continue // 空框(确认无目标)不进训练集
|
||||
}
|
||||
var boxes []*dto.AdminLabelBox
|
||||
if json.Unmarshal([]byte(img.LabelsJson), &boxes) != nil || len(boxes) == 0 {
|
||||
continue
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, box := range boxes {
|
||||
cls := suspectIdx
|
||||
if box.Class <= 0 {
|
||||
cls = si // class 0 = 本物种 → 该数据集物种下标;其余(suspect)→ 末位
|
||||
}
|
||||
fmt.Fprintf(&b, "%d %.6f %.6f %.6f %.6f\n", cls, box.Cx, box.Cy, box.W, box.H)
|
||||
}
|
||||
kept = append(kept, item{
|
||||
filename: fmt.Sprintf("d%d_%s", sc.dataset.Id, img.Filename),
|
||||
lines: strings.TrimSpace(b.String()),
|
||||
imgPath: filepath.Join(sc.imgDir, img.Filename),
|
||||
})
|
||||
valid++
|
||||
}
|
||||
if valid == 0 {
|
||||
return nil, nil, gerror.Newf("数据集 %s 无有效标注,请先完成标注审核", sc.dataset.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 固定随机种子 + 20% val(至少 1 张,语义同单物种 prepareYoloSet)
|
||||
idx := make([]int, len(kept))
|
||||
for i := range idx {
|
||||
idx[i] = i
|
||||
}
|
||||
randShuffle(idx)
|
||||
nVal := len(kept) / 5
|
||||
if nVal < 1 {
|
||||
nVal = 1
|
||||
}
|
||||
pkg := &common.YoloPackage{}
|
||||
addSplit := func(split string, items []item) {
|
||||
for _, it := range items {
|
||||
pkg.Files = append(pkg.Files,
|
||||
common.YoloFile{Name: filepath.Join("images", split, it.filename), ImagePath: it.imgPath},
|
||||
common.YoloFile{Name: filepath.Join("labels", split, strings.TrimSuffix(it.filename, filepath.Ext(it.filename))+".txt"), Content: []byte(it.lines + "\n")},
|
||||
)
|
||||
}
|
||||
}
|
||||
var trainItems, valItems []item
|
||||
for i, it := range kept {
|
||||
if i < nVal {
|
||||
valItems = append(valItems, it)
|
||||
} else {
|
||||
trainItems = append(trainItems, it)
|
||||
}
|
||||
}
|
||||
addSplit("train", trainItems)
|
||||
addSplit("val", valItems)
|
||||
// 负样本库只混一份(空标签 = 背景图)
|
||||
if err := s.appendNegatives(ctx, pkg); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pkg, names, nil
|
||||
}
|
||||
|
||||
// prepareYoloSet 训练前组装内存 YOLO 训练集包:已标注图(labels_json 非空)按 80/20 拆 train/val,
|
||||
// 标注 txt 内存生成、原图仅记源路径(由训练通道读取,不落本地暂存盘);无标注报错。
|
||||
// clean_excluded=1 的图跳过(数据清洗排除,见技术设计.md「数据清洗」)。
|
||||
|
||||
@@ -16,9 +16,11 @@ type modelVersionService struct{}
|
||||
|
||||
var ModelVersion = &modelVersionService{}
|
||||
|
||||
// ClientCatalog 客户端模型目录:全部数据集当前生效模型(App 多模型下载热更新)。
|
||||
// ClientCatalog 客户端模型目录:全部数据集当前生效模型 + 综合模型(App 多模型下载热更新)。
|
||||
// 双档位(2026-09-03):每数据集 s/n 两档 is_latest=1 各一条;downloadUrl 复用 /download 静态托管
|
||||
// (/download/trainings/<基名>.tflite,n 档带 _n 后缀,基名前缀空回退数据集名)。
|
||||
// 综合模型(2026-09-09):kind=combined 行 dataset_id=0,条目 datasetName=综合、fileBase=combined(+_n)、
|
||||
// 带 datasetIds 覆盖列表,无封面。
|
||||
func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCatalogRes, error) {
|
||||
list, err := dao.ModelVersion.ListAllLatest(ctx)
|
||||
if err != nil {
|
||||
@@ -28,20 +30,40 @@ func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCata
|
||||
modelNames := Training.datasetModelNameMap(ctx)
|
||||
items := make([]*dto.ModelCatalogItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
datasetName := names[v.DatasetId]
|
||||
if datasetName == "" {
|
||||
continue
|
||||
kind := v.Kind
|
||||
if kind == "" {
|
||||
kind = consts.TrainingKindSpecies
|
||||
}
|
||||
// 双档位:每数据集最多 2 条生效记录(is_latest 按 (数据集,档位) 各一),n 档 downloadUrl 带 _n 后缀;
|
||||
// cover 属数据集(两档同物种同图源),沿用基名前缀
|
||||
fileBase := modelNames[v.DatasetId]
|
||||
if v.Variant == consts.TrainingVariantN {
|
||||
fileBase += consts.TrainingVariantNFileSuffix
|
||||
var datasetName, fileBase, coverUrl string
|
||||
var datasetIds []int64
|
||||
if kind == consts.TrainingKindCombined {
|
||||
datasetName = "综合"
|
||||
fileBase = consts.TrainingCombinedBase
|
||||
if v.Variant == consts.TrainingVariantN {
|
||||
fileBase += consts.TrainingVariantNFileSuffix
|
||||
}
|
||||
if err := json.Unmarshal([]byte(v.DatasetIds), &datasetIds); err != nil || datasetIds == nil {
|
||||
datasetIds = []int64{}
|
||||
}
|
||||
} else {
|
||||
datasetName = names[v.DatasetId]
|
||||
if datasetName == "" {
|
||||
continue
|
||||
}
|
||||
// 双档位:每数据集最多 2 条生效记录(is_latest 按 (数据集,档位) 各一),n 档 downloadUrl 带 _n 后缀;
|
||||
// cover 属数据集(两档同物种同图源),沿用基名前缀
|
||||
fileBase = modelNames[v.DatasetId]
|
||||
if v.Variant == consts.TrainingVariantN {
|
||||
fileBase += consts.TrainingVariantNFileSuffix
|
||||
}
|
||||
coverUrl = "/api/v1/app/cover?namePrefix=" + url.PathEscape(modelNames[v.DatasetId])
|
||||
}
|
||||
items = append(items, &dto.ModelCatalogItem{
|
||||
DatasetId: v.DatasetId,
|
||||
DatasetName: datasetName,
|
||||
Variant: v.Variant,
|
||||
Kind: kind,
|
||||
DatasetIds: datasetIds,
|
||||
Version: v.Version,
|
||||
Labels: parseLabels(v.Labels),
|
||||
SizeBytes: v.SizeBytes,
|
||||
@@ -49,7 +71,7 @@ func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCata
|
||||
Notes: v.Notes,
|
||||
PublishedAt: v.CreatedAt,
|
||||
DownloadUrl: "/download/trainings/" + fileBase + ".tflite",
|
||||
CoverUrl: "/api/v1/app/cover?namePrefix=" + url.PathEscape(modelNames[v.DatasetId]),
|
||||
CoverUrl: coverUrl,
|
||||
})
|
||||
}
|
||||
return &dto.ModelCatalogRes{List: items}, nil
|
||||
|
||||
+211
-37
@@ -147,15 +147,24 @@ func (s *trainingService) handleResult(ctx context.Context, runner common.Traini
|
||||
}
|
||||
|
||||
// finishSuccess 训练成功:解析 result.json(最终指标 + 类别名)→ 拉取 tflite → 更新任务。
|
||||
// 综合任务(kind=combined)跳过数据集查找,产物基名 fixed combined。
|
||||
func (s *trainingService) finishSuccess(ctx context.Context, runner common.TrainingRunner, job *common.TrainingJob, t *entity.ModelTraining, result, tail string) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 查数据集失败: %+v", t.Id, err)
|
||||
return
|
||||
}
|
||||
if dataset == nil {
|
||||
_ = s.finishFailed(ctx, t, "数据集已删除")
|
||||
return
|
||||
base := consts.TrainingCombinedBase
|
||||
if t.Kind == consts.TrainingKindCombined {
|
||||
if t.Variant == consts.TrainingVariantN {
|
||||
base += consts.TrainingVariantNFileSuffix
|
||||
}
|
||||
} else {
|
||||
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
|
||||
}
|
||||
base = modelFileBaseName(dataset.Name, dataset.NamePrefix, t.Variant)
|
||||
}
|
||||
var res struct {
|
||||
Metrics map[string]float64 `json:"metrics"`
|
||||
@@ -189,7 +198,7 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
|
||||
}
|
||||
// tflite 直写 trainings/<文件名基名>.tflite(基名按档位:n 档带 _n 后缀;当前生效模型唯一位,
|
||||
// 无 per-task 存档、无 zip)。
|
||||
dest := common.TrainingModelPath(ctx, modelFileBaseName(dataset.Name, dataset.NamePrefix, t.Variant))
|
||||
dest := common.TrainingModelPath(ctx, base)
|
||||
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)
|
||||
@@ -261,6 +270,21 @@ func trainingEtaMinutes(t *entity.ModelTraining) int {
|
||||
return int((time.Duration(t.TotalEpochs-t.CurrentEpoch) * per).Minutes()) + 1
|
||||
}
|
||||
|
||||
// parseCombinedIds 解析综合任务覆盖的数据集 id JSON 数组(晋级打包时用)
|
||||
func parseCombinedIds(s string) ([]int64, error) {
|
||||
var ids []int64
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil, gerror.New("综合任务缺少覆盖数据集列表")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &ids); err != nil {
|
||||
return nil, gerror.Wrap(err, "覆盖数据集列表解析失败")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, gerror.New("综合任务覆盖数据集为空")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// parseEpochTail 从日志尾部解析最后一条 epoch 进度行({"epoch":N,"total":M,"metrics":{...}})
|
||||
func parseEpochTail(tail string) (epoch, total int, metrics string) {
|
||||
lines := strings.Split(tail, "\n")
|
||||
@@ -310,14 +334,32 @@ func (s *trainingService) AdminListTrainings(ctx context.Context, req *dto.Admin
|
||||
return nil, err
|
||||
}
|
||||
names := s.datasetNameMap(ctx)
|
||||
trainingIds := make([]int64, 0, len(list))
|
||||
for _, v := range list {
|
||||
trainingIds = append(trainingIds, v.Id)
|
||||
}
|
||||
published, err := dao.ModelVersion.PublishedByTrainingIds(ctx, trainingIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*dto.AdminTrainingItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
datasetName := names[v.DatasetId]
|
||||
kind := v.Kind
|
||||
if kind == "" {
|
||||
kind = consts.TrainingKindSpecies
|
||||
}
|
||||
if datasetName == "" && kind == consts.TrainingKindCombined {
|
||||
datasetName = "综合"
|
||||
}
|
||||
items = append(items, &dto.AdminTrainingItem{
|
||||
Id: v.Id,
|
||||
Name: v.Name,
|
||||
DatasetId: v.DatasetId,
|
||||
DatasetName: names[v.DatasetId],
|
||||
DatasetName: datasetName,
|
||||
Kind: kind,
|
||||
Status: v.Status,
|
||||
Published: published[v.Id],
|
||||
Variant: v.Variant,
|
||||
Imgsz: v.Imgsz,
|
||||
Epochs: v.Epochs,
|
||||
@@ -464,6 +506,103 @@ func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.Admin
|
||||
return &dto.AdminTrainingStartRes{Id: firstId}, nil
|
||||
}
|
||||
|
||||
// AdminStartCombined 综合训练发起(2026-09-09 多物种合并模型):勾选 ≥2 个数据集合并训练
|
||||
// 一个全类 tflite。任务 kind=combined、dataset_id=0、dataset_ids=覆盖列表快照,每档位一条,
|
||||
// 与单物种任务同队列排队;打包(类别重映射/负样本单份/防重名)在晋级时执行(prepareCombinedYoloSet)。
|
||||
func (s *trainingService) AdminStartCombined(ctx context.Context, req *dto.AdminTrainingCombinedStartReq) (*dto.AdminTrainingCombinedStartRes, error) {
|
||||
cfg, ok := common.TrainingConfigOf(ctx)
|
||||
if !ok {
|
||||
return nil, gerror.NewCode(common.CodeTrainingNotConfigured)
|
||||
}
|
||||
ids := req.DatasetIds
|
||||
// 去重 + 过滤非法值
|
||||
seen := map[int64]bool{}
|
||||
clean := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
clean = append(clean, id)
|
||||
}
|
||||
if len(clean) < 2 {
|
||||
return nil, gerror.New("综合训练至少选择 2 个数据集")
|
||||
}
|
||||
variants, err := normalizeVariants(req.Variants, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 校验数据集存在且非负样本库;有标注立即反馈(晋级时重新打包取新鲜数据)
|
||||
for _, id := range clean {
|
||||
d, err := dao.Dataset.GetById(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d == nil {
|
||||
return nil, gerror.Newf("数据集 %d 不存在", id)
|
||||
}
|
||||
if d.Source == consts.DatasetSourceNegative {
|
||||
return nil, gerror.New("负样本库不参与综合训练(打包时自动混入)")
|
||||
}
|
||||
}
|
||||
if _, _, err := LabelTask.prepareCombinedYoloSet(ctx, clean); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("综合训练 %s", gtime.Now().Format("01-02 15:04"))
|
||||
}
|
||||
idsJSON, err := json.Marshal(clean)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "覆盖列表序列化失败")
|
||||
}
|
||||
now := gtime.Now()
|
||||
var firstId int64
|
||||
// 任务参数为部署级配置快照(与单物种一致);防重按综合槽位 (dataset_id=0, 档位)
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
for _, v := range variants {
|
||||
active, err := dao.Training.ActiveByDatasetVariant(ctx, 0, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active != 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: 0,
|
||||
Kind: consts.TrainingKindCombined,
|
||||
DatasetIds: string(idsJSON),
|
||||
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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminTrainingCombinedStartRes{FirstId: firstId}, nil
|
||||
}
|
||||
|
||||
// normalizeVariants 归一化发起档位:空=双档 s+n(保序去重);n 档需 config 已配置 modelN/imgszN
|
||||
func normalizeVariants(req []string, cfg common.TrainingConfig) ([]string, error) {
|
||||
var out []string
|
||||
@@ -539,23 +678,45 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
|
||||
_ = 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
|
||||
// 综合任务(kind=combined):合并打包 + 类别重映射,训练机目录/文件基名 fixed combined;
|
||||
// 单物种任务走原数据集链路
|
||||
var pkg *common.YoloPackage
|
||||
var classNames []string
|
||||
var jobDsName string
|
||||
if t.Kind == consts.TrainingKindCombined {
|
||||
ids, err := parseCombinedIds(t.DatasetIds)
|
||||
if err != nil {
|
||||
_ = s.finishFailed(ctx, t, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
p, cls, err := LabelTask.prepareCombinedYoloSet(ctx, ids)
|
||||
if err != nil {
|
||||
_ = s.finishFailed(ctx, t, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
pkg, classNames, jobDsName = p, cls, consts.TrainingCombinedBase
|
||||
} else {
|
||||
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
|
||||
}
|
||||
p, err := LabelTask.prepareYoloSet(ctx, dataset)
|
||||
if err != nil {
|
||||
_ = s.finishFailed(ctx, t, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
pkg = p
|
||||
classNames = localAiClassNames(dataset)
|
||||
jobDsName = dataset.Name
|
||||
}
|
||||
job := &common.TrainingJob{
|
||||
TaskId: t.Id,
|
||||
DatasetName: dataset.Name,
|
||||
DatasetName: jobDsName,
|
||||
Python: cfg.Python,
|
||||
Workdir: cfg.Workdir,
|
||||
DatasetDir: cfg.DatasetDir,
|
||||
@@ -566,14 +727,14 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
|
||||
model = cfg.ModelN
|
||||
}
|
||||
// data.yaml 的 path 指向训练机路径,随包一起同步
|
||||
trainPath := filepath.Join(cfg.Workdir, cfg.DatasetDir, "yolo", dataset.Name)
|
||||
trainPath := filepath.Join(cfg.Workdir, cfg.DatasetDir, "yolo", jobDsName)
|
||||
pkg.Files = append(pkg.Files, common.YoloFile{
|
||||
Name: "dataset.yaml",
|
||||
Content: []byte(yoloYamlContent(trainPath, localAiClassNames(dataset))),
|
||||
Content: []byte(yoloYamlContent(trainPath, classNames)),
|
||||
})
|
||||
taskJSON, _ := json.Marshal(map[string]any{
|
||||
"workdir": cfg.Workdir,
|
||||
"yolo": filepath.ToSlash(filepath.Join(cfg.DatasetDir, "yolo", dataset.Name)),
|
||||
"yolo": filepath.ToSlash(filepath.Join(cfg.DatasetDir, "yolo", jobDsName)),
|
||||
"model": model,
|
||||
"imgsz": t.Imgsz,
|
||||
"epochs": t.Epochs,
|
||||
@@ -727,14 +888,25 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
|
||||
if t.Status != consts.TrainingStatusSuccess {
|
||||
return nil, gerror.NewCode(common.CodeTrainingNotSuccess)
|
||||
}
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// 综合任务(kind=combined):dataset_id=0、文件基名 combined,版本序列独立;跳过数据集查找
|
||||
base := consts.TrainingCombinedBase
|
||||
datasetId := t.DatasetId
|
||||
if t.Kind == consts.TrainingKindCombined {
|
||||
datasetId = 0
|
||||
if t.Variant == consts.TrainingVariantN {
|
||||
base += consts.TrainingVariantNFileSuffix
|
||||
}
|
||||
} else {
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
base = modelFileBaseName(dataset.Name, dataset.NamePrefix, t.Variant)
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
bestTflite := common.TrainingModelPath(ctx, modelFileBaseName(dataset.Name, dataset.NamePrefix, t.Variant))
|
||||
bestTflite := common.TrainingModelPath(ctx, base)
|
||||
data, err := os.ReadFile(bestTflite)
|
||||
if err != nil {
|
||||
return nil, gerror.New("训练产物 tflite 缺失,无法发布")
|
||||
@@ -744,18 +916,20 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version, err := s.nextVersion(ctx, t.DatasetId)
|
||||
version, err := s.nextVersion(ctx, datasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := gtime.Now()
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
// 同 (数据集,档位) 旧版置 0(s/n 两档互不影响,各记各的 is_latest),再插新版本(is_latest=1)
|
||||
if err := dao.ModelVersion.ClearLatest(ctx, t.DatasetId, t.Variant); err != nil {
|
||||
if err := dao.ModelVersion.ClearLatest(ctx, datasetId, t.Variant); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := dao.ModelVersion.Insert(ctx, &entity.ModelVersion{
|
||||
DatasetId: t.DatasetId,
|
||||
DatasetId: datasetId,
|
||||
Kind: t.Kind,
|
||||
DatasetIds: t.DatasetIds,
|
||||
Variant: t.Variant,
|
||||
Version: version,
|
||||
TrainingId: t.Id,
|
||||
|
||||
Binary file not shown.
@@ -273,6 +273,40 @@ func initDatabase(ctx context.Context) {
|
||||
}
|
||||
g.Log().Infof(ctx, "数据库初始化完成(version=13)")
|
||||
}
|
||||
if version < 14 {
|
||||
// v14:综合训练(2026-09-09 多物种合并模型)——model_training / model_version
|
||||
// 加 kind(species/combined)+ dataset_ids(JSON 覆盖数据集列表)。
|
||||
// 存量行默认 species 零回填;全新库建表自带全列直接跳过
|
||||
addCols := []struct{ table, col, ddl string }{
|
||||
{"model_training", "kind", "TEXT NOT NULL DEFAULT 'species'"},
|
||||
{"model_training", "dataset_ids", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"model_version", "kind", "TEXT NOT NULL DEFAULT 'species'"},
|
||||
{"model_version", "dataset_ids", "TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
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+" "+c.ddl); err != nil {
|
||||
g.Log().Fatalf(ctx, "给 %s 加列 %s 失败: %+v", c.table, c.col, err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 14"); err != nil {
|
||||
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
|
||||
}
|
||||
g.Log().Infof(ctx, "数据库初始化完成(version=14)")
|
||||
}
|
||||
// 死表清理: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)
|
||||
|
||||
@@ -473,6 +473,32 @@ CREATE TABLE IF NOT EXISTS false_target_report (
|
||||
|
||||
**训练用法**:审核通过图入负样本库(`__negative__`,固定保留名),`prepare_yolo` 打包时统一混入全部物种数据集当背景图(无标注),压制同类误报。误报裁剪块含「像目标但不是」的纹理,正是背景图最需要的难例。
|
||||
|
||||
## 综合训练(多物种合并模型,2026-09-09)
|
||||
|
||||
**动机**:多物种分别训模型在端上要 N 模型并行推理 + 跨模型 NMS 合并,内存/算力开销随物种数线性涨;综合训练把多个物种合并成**一张全类 tflite**,端上单模型单次推理,App 按类别名过滤展示/上报即可控制"识别哪些动物"。单物种训练链路完整保留,两种模式并存。
|
||||
|
||||
**迁移(PRAGMA user_version = 14)**:`model_training` / `model_version` 各加两列(EnsureColumn):
|
||||
- `kind TEXT NOT NULL DEFAULT 'species'`——species 单物种(存量行默认)/ combined 综合
|
||||
- `dataset_ids TEXT NOT NULL DEFAULT ''`——JSON,综合任务/模型覆盖的数据集 id 列表
|
||||
|
||||
**综合任务**:`POST /admin/trainings/combined`(datasetIds ≥2、variants 每档一条任务):`dataset_id=0`、`kind=combined`、`dataset_ids` 快照,与单物种任务同队列排队;晋级时走合并打包。
|
||||
|
||||
**合并打包**(prepareCombinedYoloSet,与单物种 prepareYoloSet 并列):
|
||||
- **全局类别表**:各数据集物种名按 `dataset_id` 升序(`gen_species` 回退数据集名)+ 共享 `suspect` 置末位
|
||||
- **类别 id 重映射**:各数据集标注里 class 0(本物种)→ 该数据集物种下标、class 1(suspect)→ 末位下标
|
||||
- **防重名**:合并目录图片/标签名加 `d<datasetId>_` 前缀
|
||||
- **负样本只混一份**(appendNegatives 对合并包调用一次,与单物种语义一致)
|
||||
- 80/20 拆分在合并后总池上做;训练机目录 `datasets/yolo/combined/`,data.yaml names = 全局类别表
|
||||
|
||||
**产物与发布**:文件基名 `combined`(`combined.tflite` / `combined_n.tflite`),finishSuccess/Publish 对 `kind=combined` 分支:跳过数据集查找(dataset_id=0)、版本序列走 dataset_id=0 独立递增、`model_version` 落 `kind` + `dataset_ids`,`is_latest` 按 (dataset_id=0, variant) 一条。
|
||||
|
||||
**目录协议**:`/api/v1/models` 与 `app/update` models[] 的综合条目:`datasetId:0`、`datasetName:"综合"`、`kind:"combined"`、`datasetIds:[...]`、labels = 全局类别表;单物种条目带 `kind:"species"`。**老 App 兼容**:不识别的 kind 字段被忽略,综合条目按普通条目展示(可下载可用,无覆盖互斥)。
|
||||
|
||||
**App 端**(新 App):
|
||||
- 综合模型自成一张卡片(s/n 双档、分别下载,沿用 2026-09-09 切档检测横幅机制)
|
||||
- **覆盖互斥**:激活综合模型 → 自动停用其 `datasetIds` 覆盖物种的单物种激活;激活某单物种 → 自动停用覆盖它的综合模型(交互语义 = 现有"同一动物只跑一档"的自然扩展);兜底:即使并存,跨模型 NMS 按类别名合并不会重复框
|
||||
- 类别过滤展示/上报按模型 labels 进行,端上参数化"识别哪些动物"= 过滤类名,模型本身全类检测
|
||||
|
||||
## 待办/风险
|
||||
|
||||
- 微信支付需商户号(APP 支付权限)、APIv3 密钥与平台证书;支付宝需商户应用与密钥 —— 当前均未配置,接口按真实 SDK 契约实现,配置走 `config.yml` 占位
|
||||
|
||||
Reference in New Issue
Block a user