From 87ab06221ce6ac5b405e2d3409c58f64e319056a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Wed, 9 Sep 2026 15:45:51 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=BC=E5=90=88=E8=AE=AD=E7=BB=83=EF=BC=88?= =?UTF-8?q?=E5=A4=9A=E7=89=A9=E7=A7=8D=E5=90=88=E5=B9=B6=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=EF=BC=89=E5=85=A8=E9=93=BE=E8=B7=AF=EF=BC=9A=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=89=93=E5=8C=85/=E7=8B=AC=E7=AB=8B=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=BA=8F=E5=88=97/App=20=E8=A6=86=E7=9B=96=E4=BA=92=E6=96=A5?= =?UTF-8?q?=20+=20=E7=AE=A1=E7=90=86=E7=AB=AF=E5=8F=91=E5=B8=83=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:/admin/trainings/combined 发起(≥2 数据集、类别重映射、防重名、负样本单份); model_training/model_version 加 kind+dataset_ids(迁移 v14),综合任务 dataset_id=0、 文件基名 combined(_n)、版本序列独立;训练列表补 published 标记 - 管理端:数据训练页工具栏发起综合训练;横幅常驻进行中任务 + 每档最近一条已结束任务, 成功未发布给「发布模型」入口(可关闭收起) - App:目录解析 kind/datasetIds、激活覆盖互斥、自动更新退场改目标档待办横幅手动一键下载 --- .../lib/camera/model_catalog_section.dart | 70 ++++- flutter_app/lib/models/model_manager.dart | 119 ++++++--- server/README.md | 8 +- server/biz/consts/consts.go | 7 + server/biz/controller/admin.go | 5 + server/biz/dao/model_training.go | 2 + server/biz/dao/model_version.go | 2 + server/biz/model/dto/training.go | 28 +- server/biz/model/entity/model_training.go | 4 +- server/biz/model/entity/model_version.go | 4 +- server/biz/service/label_task.go | 117 +++++++++ server/biz/service/model_version.go | 42 ++- server/biz/service/training.go | 248 +++++++++++++++--- server/data/observer.db | Bin 2461696 -> 2461696 bytes server/main.go | 34 +++ server/技术设计.md | 26 ++ server_admin/src/views/Datasets.vue | 184 ++++++++++++- 17 files changed, 796 insertions(+), 104 deletions(-) diff --git a/flutter_app/lib/camera/model_catalog_section.dart b/flutter_app/lib/camera/model_catalog_section.dart index 3cc5139..d22e898 100644 --- a/flutter_app/lib/camera/model_catalog_section.dart +++ b/flutter_app/lib/camera/model_catalog_section.dart @@ -18,8 +18,9 @@ String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度'; /// - 任一档失败 → 错误提示 + 重试(只补下未成功的档); /// - 目标档已激活 →「使用中」点击取消使用; /// - 目标档已下载未激活 →「使用」直接启用(另一档在运行会被自动停用); -/// - 目标档未下载 →「下载」取回缺失档(目标档落地自动启用、伴档备好; -/// 另一档在使用时补下目标档后自动切换过去)。 +/// - 目标档未下载 →「下载」只取回目标档(2026-09-09 双档分别下载,伴档不随下); +/// 另一档在使用时补下目标档后自动切换过去。切档/刷新检测目标档「未下载/ +/// 待更新」出横幅,用户手动一键下载/更新(自动更新已退场)。 class ModelCatalogSection extends StatelessWidget { final ModelManager manager; @@ -61,6 +62,50 @@ class ModelCatalogSection extends StatelessWidget { '卡片操作面向所选档位;同一动物一次只运行一档,切换会自动停用另一档', style: TextStyle(color: Colors.white38, fontSize: 11), ), + // 目标档待办横幅(切档检测):未下载/版本落后的模型,用户手动一键触发 + if (manager.modePending.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.system_update_alt, + color: Colors.orangeAccent, size: 15), + const SizedBox(width: 6), + Expanded( + child: Text( + '${_variantLabel(manager.mode)}模式:' + '${manager.modePending.length} 个模型待下载/更新', + style: const TextStyle( + color: Colors.orangeAccent, fontSize: 11.5), + ), + ), + TextButton.icon( + onPressed: () { + for (final i in manager.modePending.toList()) { + manager.downloadModel(i); + } + }, + icon: const Icon(Icons.download, size: 14), + label: const Text('一键下载', + style: TextStyle(fontSize: 11.5)), + style: TextButton.styleFrom( + foregroundColor: Colors.orangeAccent, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 6), + minimumSize: const Size(0, 26), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ), + ], const SizedBox(height: 12), Row( children: [ @@ -414,24 +459,21 @@ class _SpeciesCard extends StatelessWidget { ), ); } else { - // 目标档未下载:下载全部缺失档(目标档落地自动启用、伴档备好待切); - // 另一档在使用时按钮同样为「下载」:补下目标档后自动切换(停用另一档) + // 目标档未下载:只下载目标档(2026-09-09 双档分别下载——伴档不随下, + // 缺失/待更新由切档横幅提示手动补);另一档在使用时补下目标档后自动切换 final use = activeOther.isNotEmpty; mainBtn = SizedBox( height: 30, child: FilledButton( onPressed: () async { - if (!use) { - for (final i in items) { - if (!manager.isDownloaded(i.datasetId, i.variant)) { - manager.downloadModel(i); - } - } - return; - } if (!manager.isDownloaded(target.datasetId, target.variant)) { - final ok = await manager.downloadModel(target); - if (!ok) return; // 下载失败/取消:错误分支展示,保持现状 + if (use) { + final ok = await manager.downloadModel(target); + if (!ok) return; // 下载失败/取消:错误分支展示,保持现状 + } else { + manager.downloadModel(target); + return; + } } await manager.setActive(target.datasetId, target.variant, true); }, diff --git a/flutter_app/lib/models/model_manager.dart b/flutter_app/lib/models/model_manager.dart index a8fd16c..9795e30 100644 --- a/flutter_app/lib/models/model_manager.dart +++ b/flutter_app/lib/models/model_manager.dart @@ -13,6 +13,11 @@ import '../config/app_config.dart'; const String kVariantS = 's'; const String kVariantN = 'n'; +/// 模型类型(2026-09-09 综合训练):species = 单物种(缺省,老目录兼容),combined = 多物种综合 +/// (datasetId=0、datasetIds=覆盖物种列表;与其覆盖的单物种模型激活互斥) +const String kKindSpecies = 'species'; +const String kKindCombined = 'combined'; + /// 模型身份键:同一数据集不同档位是两个独立条目(下载/激活/记账互不影响) typedef ModelKey = ({int datasetId, String variant}); @@ -23,6 +28,8 @@ class ModelCatalogItem { final int datasetId; final String datasetName; final String variant; + final String kind; // species 单物种(缺省)| combined 多物种综合 + final List datasetIds; // combined:覆盖的数据集 id 列表 final String version; final List labels; final int sizeBytes; @@ -34,6 +41,8 @@ class ModelCatalogItem { required this.datasetId, required this.datasetName, this.variant = kVariantS, + this.kind = kKindSpecies, + this.datasetIds = const [], required this.version, required this.labels, required this.sizeBytes, @@ -42,12 +51,19 @@ class ModelCatalogItem { this.coverUrl = '', }); + bool get isCombined => kind == kKindCombined; + factory ModelCatalogItem.fromJson(Map j) => ModelCatalogItem( datasetId: (j['datasetId'] as num?)?.toInt() ?? 0, datasetName: j['datasetName'] as String? ?? '', // 旧目录无 variant 字段(2026-09-03 前发布的单档 s)→ 归为 s variant: j['variant'] as String? ?? kVariantS, + // 老目录无 kind 字段 → species(2026-09-09 综合训练) + kind: j['kind'] as String? ?? kKindSpecies, + datasetIds: (j['datasetIds'] as List? ?? const []) + .map((e) => (e as num).toInt()) + .toList(), version: j['version'] as String? ?? '', labels: (j['labels'] as List? ?? const []) .map((e) => e.toString()) @@ -94,8 +110,10 @@ class ModelBundle { /// 界面并自行调整。 /// 目录**缓存优先**(2026-09-03):最近一次成功拉取的 models 目录落盘 /// catalog.json,[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络 -/// 成功后再以权威目录覆盖并落盘;清理/激活同步/自动更新只在网络成功(fetched) +/// 成功后再以权威目录覆盖并落盘;清理/激活同步/待办检测只在网络成功(fetched) /// 后执行——缓存降级时不清文件不下载,离线首启不误删已下载模型。 +/// 版本更新**手动制**(2026-09-09):静默后台自动更新退场,切档/目录刷新仅 +/// 检测目标档「未下载/待更新」置入 [modePending],由用户在横幅手动一键触发。 class ModelManager extends ChangeNotifier { static final ModelManager instance = ModelManager._(); @@ -117,6 +135,10 @@ class ModelManager extends ChangeNotifier { String? _error; Future? _inFlight; + /// 目标档待办清单(2026-09-09):当前 [mode] 下「未下载」或「本地版本落后 + /// 服务器」的模型条目,切档/目录刷新时重算,UI 出横幅由用户手动一键下载/更新 + List _modePending = const []; + /// 模型文件/激活集变更版本戳:下载完成或激活变化 +1, /// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入) int _revision = 0; @@ -129,6 +151,9 @@ class ModelManager extends ChangeNotifier { /// 首次下载的自动激活档,不直接切换已在运行的模型(运行看激活集) String get mode => _mode; + /// 目标档待办清单(未下载 / 本地版本落后),切档时检测、UI 出横幅手动触发 + List get modePending => _modePending; + bool isActive(int datasetId, String variant) => _active.contains((datasetId: datasetId, variant: variant)); @@ -180,16 +205,33 @@ class ModelManager extends ChangeNotifier { return _models.map((m) => m.datasetName).join(','); } - /// 切换识别目标档位:只改偏好并持久化(不切换已在运行的模型—— - /// 每个动物的实际档位由激活集决定,卡片按钮会面向新目标档给出「改用」操作) + /// 切换识别目标档位:改偏好并持久化,同时**检测目标档待办**(未下载 / + /// 版本落后),结果置入 [modePending] 供 UI 横幅手动触发下载/更新 Future setMode(String variant) async { if (variant != kVariantS && variant != kVariantN) return; if (_mode == variant) return; _mode = variant; await _saveMode(); + await refreshModePending(); notifyListeners(); } + /// 重算目标档待办:未下载(无文件)或本地版本/sha 与服务器不符(有新版本)。 + /// 下载中的条目跳过(落地后自然移出) + Future refreshModePending() async { + final out = []; + for (final item in _catalog.where((c) => c.variant == _mode)) { + final key = (datasetId: item.datasetId, variant: item.variant); + if (_progress.containsKey(key)) continue; + if (!await _hasFile(item)) { + out.add(item); // 未下载 + continue; + } + if (!await _isLocal(item)) out.add(item); // 有新版本 + } + _modePending = out; + } + /// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。 Future refresh() { if (_refreshing) return _inFlight ?? Future.value(); @@ -271,8 +313,9 @@ class ModelManager extends ChangeNotifier { _models = await _loadBundles(_catalog); _ready = true; _error = null; - // 自动更新:已下载/已激活模型发现新版本后台重下(不阻塞目录刷新) - unawaited(autoUpdate()); + // 目标档待办重算(2026-09-09:自动更新退场,改为切档检测 + 手动触发—— + // 静默后台下载不区分档位流量开销大,且会掩盖「待更新」状态) + await refreshModePending(); } catch (e) { if (!_ready) _error = '模型目录拉取失败:$e'; // 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示) @@ -339,6 +382,7 @@ class ModelManager extends ChangeNotifier { _models = await _loadBundles(_catalog); } } + unawaited(refreshModePending()); // 落地后移出待办横幅 notifyListeners(); return true; } @@ -474,18 +518,50 @@ class ModelManager extends ChangeNotifier { /// 设置激活状态(true=使用,false=取消;仅本次会话内生效,不持久化)。 /// 同一数据集至多一个档位在使用:激活某档时若同数据集另一档在使用则先停用 /// (2026-09-03:不同动物可跑不同档位,同一种动物一次只跑一档)。 - /// 变化即重建推理 worker。未下载的模型不可激活(下载完成按目标档自动激活)。 + /// **覆盖互斥(2026-09-09 综合模型)**:激活综合模型自动停用其 datasetIds 覆盖 + /// 物种的单物种模型;激活某单物种自动停用覆盖它的综合模型(兜底:并存时跨模型 + /// NMS 按类别名合并不会重复框,互斥只为省算力)。变化即重建推理 worker。 Future setActive(int datasetId, String variant, bool active) async { final key = (datasetId: datasetId, variant: variant); if (!active) { if (!_active.remove(key)) return; } else { final keyActive = _active.contains(key); - final sameDsOthers = _active - .where((k) => k.datasetId == datasetId && k.variant != variant) - .toList(); - if (keyActive && sameDsOthers.isEmpty) return; // 状态未变化 - _active.removeAll(sameDsOthers); + final conflicts = { + ..._active.where( + (k) => k.datasetId == datasetId && k.variant != variant), + }; + // 覆盖互斥:找到本条目目录信息,按 kind 判定冲突集 + ModelCatalogItem? catItem; + for (final c in _catalog) { + if (c.datasetId == datasetId && c.variant == variant) { + catItem = c; + break; + } + } + if (catItem != null && catItem.isCombined) { + // 综合:停用其覆盖物种的全部单物种激活 + for (final k in _active) { + if (k.datasetId != 0 && catItem.datasetIds.contains(k.datasetId)) { + conflicts.add(k); + } + } + } else if (catItem != null) { + // 单物种:停用覆盖本物种的综合模型(同档位才冲突) + for (final k in _active) { + if (k.datasetId != 0) continue; + for (final c in _catalog) { + if (c.datasetId == 0 && + c.variant == k.variant && + c.isCombined && + c.datasetIds.contains(datasetId)) { + conflicts.add(k); + } + } + } + } + if (keyActive && conflicts.isEmpty) return; // 状态未变化 + _active.removeAll(conflicts); _active.add(key); } _revision++; @@ -493,27 +569,6 @@ class ModelManager extends ChangeNotifier { notifyListeners(); } - /// 自动更新:已下载的模型目录出现新版本时自动重下(不改变激活状态—— - /// 激活只在用户显式下载/启用时发生,2026-09-03 会话制修订)。 - /// 使用中的模型原地更新则立即生效(重建推理 worker)。未下载的模型不自动拉取, - /// 避免无谓流量。下载进度经 downloadModel 通知。 - /// 2026-09-01 用户需求:发布新模型后 App 端自动更新,无需手动触发。 - Future autoUpdate() async { - if (_catalog.isEmpty) return; - for (final item in _catalog) { - final key = (datasetId: item.datasetId, variant: item.variant); - if (!_downloaded.contains(key)) continue; - try { - // 后台 fire-and-forget:本地检查/下载都可能撞上存储变动(如清理), - // 不得向外抛未处理异步异常 - if (await _isLocal(item)) continue; - await downloadModel(item, autoActivate: false); - } catch (e) { - debugPrint('[ModelManager] 自动更新失败: ${item.datasetName} $e'); - } - } - } - Future _saveMode() async { try { final root = await _rootDir(); diff --git a/server/README.md b/server/README.md index d5f5805..1875334 100644 --- a/server/README.md +++ b/server/README.md @@ -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_ 前缀防跨数据集重名),产物/发布/目录下发走现有链路,文件基名 `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//`,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 高性能)筛选加载 ## 使用说明 diff --git a/server/biz/consts/consts.go b/server/biz/consts/consts.go index 3dbe4b4..bacf8c0 100644 --- a/server/biz/consts/consts.go +++ b/server/biz/consts/consts.go @@ -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" diff --git a/server/biz/controller/admin.go b/server/biz/controller/admin.go index fbd9c71..be72f2c 100644 --- a/server/biz/controller/admin.go +++ b/server/biz/controller/admin.go @@ -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) diff --git a/server/biz/dao/model_training.go b/server/biz/dao/model_training.go index d1b23fb..604b3a7 100644 --- a/server/biz/dao/model_training.go +++ b/server/biz/dao/model_training.go @@ -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, diff --git a/server/biz/dao/model_version.go b/server/biz/dao/model_version.go index 66c2766..fcf2cae 100644 --- a/server/biz/dao/model_version.go +++ b/server/biz/dao/model_version.go @@ -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, diff --git a/server/biz/model/dto/training.go b/server/biz/model/dto/training.go index feface8..3186147 100644 --- a/server/biz/model/dto/training.go +++ b/server/biz/model/dto/training.go @@ -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 { diff --git a/server/biz/model/entity/model_training.go b/server/biz/model/entity/model_training.go index 9b4f397..18a8b75 100644 --- a/server/biz/model/entity/model_training.go +++ b/server/biz/model/entity/model_training.go @@ -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"` diff --git a/server/biz/model/entity/model_version.go b/server/biz/model/entity/model_version.go index 85d2a2b..f5c906b 100644 --- a/server/biz/model/entity/model_version.go +++ b/server/biz/model/entity/model_version.go @@ -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:"来源训练任务"` diff --git a/server/biz/service/label_task.go b/server/biz/service/label_task.go index 5358486..f8403eb 100644 --- a/server/biz/service/label_task.go +++ b/server/biz/service/label_task.go @@ -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_ 前缀防跨数据集重名; +// 负样本库只混一份(与单物种语义一致);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「数据清洗」)。 diff --git a/server/biz/service/model_version.go b/server/biz/service/model_version.go index 5960059..0d6c577 100644 --- a/server/biz/service/model_version.go +++ b/server/biz/service/model_version.go @@ -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 diff --git a/server/biz/service/training.go b/server/biz/service/training.go index 22340bc..d1a9216 100644 --- a/server/biz/service/training.go +++ b/server/biz/service/training.go @@ -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, diff --git a/server/data/observer.db b/server/data/observer.db index b0eeed8254862f0991bf1a4661f9b46bec1a952b..9cb96ca6550d333de0ca2e618bafc120a3f71c21 100644 GIT binary patch delta 13650 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 CNFOx- diff --git a/server/main.go b/server/main.go index 75f4ea5..3d99dbc 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/server/技术设计.md b/server/技术设计.md index 68c0085..dcb1e53 100644 --- a/server/技术设计.md +++ b/server/技术设计.md @@ -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_` 前缀 +- **负样本只混一份**(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` 占位 diff --git a/server_admin/src/views/Datasets.vue b/server_admin/src/views/Datasets.vue index a2fb301..527bc5d 100644 --- a/server_admin/src/views/Datasets.vue +++ b/server_admin/src/views/Datasets.vue @@ -1,7 +1,7 @@