综合训练(多物种合并模型)全链路:合并打包/独立版本序列/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:
2026-09-09 15:45:51 +08:00
parent d8853ee2ab
commit 87ab06221c
17 changed files with 796 additions and 104 deletions
@@ -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);
},
+87 -32
View File
@@ -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<int> datasetIds; // combined:覆盖的数据集 id 列表
final String version;
final List<String> 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<String, dynamic> 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 字段 → species2026-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<void>? _inFlight;
/// 目标档待办清单(2026-09-09):当前 [mode] 下「未下载」或「本地版本落后
/// 服务器」的模型条目,切档/目录刷新时重算,UI 出横幅由用户手动一键下载/更新
List<ModelCatalogItem> _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<ModelCatalogItem> 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<void> setMode(String variant) async {
if (variant != kVariantS && variant != kVariantN) return;
if (_mode == variant) return;
_mode = variant;
await _saveMode();
await refreshModePending();
notifyListeners();
}
/// 重算目标档待办:未下载(无文件)或本地版本/sha 与服务器不符(有新版本)。
/// 下载中的条目跳过(落地后自然移出)
Future<void> refreshModePending() async {
final out = <ModelCatalogItem>[];
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<void> 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<void> 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 = <ModelKey>{
..._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<void> 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<void> _saveMode() async {
try {
final root = await _rootDir();
+5 -3
View File
@@ -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/12026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`annotate_task_id`(2026-09-07 众包下发的任务占用标记,0=未下发;下发即从「未标注」tab 消失,停用任务释放未领取图回 0)、`created_at` |
| `model_training` | 训练任务 | `id`(PK)、`name``status`(queued/running/success/failedqueued=GPU 忙排队中,2026-09-03)、`dataset_id``variant`(s/n 档位,default s2026-09-03)、`imgsz`/`epochs`/`batch`/`device`(参数快照)、`current_epoch`/`total_epochs``metrics`(JSON)、`log_tail``pid``error``started_at`/`finished_at``created_at` |
| `model_version` | 模型版本(每数据集全局共用序列) | `id`(PK)、`dataset_id``variant`(s/ndefault s;存量行迁移为 s)、`version`(m1.0.0 递增, 同数据集 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/failedqueued=GPU 忙排队中,2026-09-03)、`dataset_id`(综合任务=0)`variant`(s/n 档位,default s2026-09-03)、`kind`(species/combined2026-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/ndefault s;存量行迁移为 s)、`version`(m1.0.0 递增, 同数据集 UNIQUE,s/n 交替发布共用序列)、`training_id``metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256``size_bytes``is_latest`(按 (数据集,档位) 各记一条)、`kind`(species/combined2026-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-042026-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=杀训练进程置 failedqueued=无进程直接置 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 高性能)筛选加载
## 使用说明
+7
View File
@@ -63,6 +63,13 @@ const (
// n 档模型文件名后缀:trainings/<基名>_n.tflites 档无后缀 = 旧版唯一位,向后兼容)
TrainingVariantNFileSuffix = "_n"
// 训练/模型类型(2026-09-09 综合训练):species=单物种(存量默认)| combined=多物种综合模型
// dataset_id=0、dataset_ids=覆盖数据集列表,文件基名 combined)
TrainingKindSpecies = "species"
TrainingKindCombined = "combined"
// 综合模型文件基名与训练机目录名
TrainingCombinedBase = "combined"
// 数据集状态 building → labeled → syncedsynced = 已同步训练机)
DatasetStatusBuilding = "building"
DatasetStatusLabeled = "labeled"
+5
View File
@@ -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)
+2
View File
@@ -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,
+2
View File
@@ -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,
+23 -5
View File
@@ -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/<文件名前缀>.tfliten 档 <文件名前缀>_n.tflite
CoverUrl string `json:"coverUrl"` // /api/v1/app/cover?namePrefix=<前缀>App 模型清单缩略图)
DownloadUrl string `json:"downloadUrl"` // s 档 /download/trainings/<文件名前缀>.tfliten 档 <文件名前缀>_n.tflitecombined 固定 combined(_n)
CoverUrl string `json:"coverUrl"` // /api/v1/app/cover?namePrefix=<前缀>App 模型清单缩略图)combined 为空
}
type ModelCatalogRes struct {
+3 -1
View File
@@ -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"`
+3 -1
View File
@@ -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:"来源训练任务"`
+117
View File
@@ -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 1suspect)重映射到末位下标;图片/标签名加 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「数据清洗」)。
+32 -10
View File
@@ -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/<基名>.tfliten 档带 _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
View File
@@ -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.
+34
View File
@@ -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
// 加 kindspecies/combined+ dataset_idsJSON 覆盖数据集列表)。
// 存量行默认 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)
+26
View File
@@ -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` 占位
+183 -1
View File
@@ -1,7 +1,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { EditPen, Loading, Picture, Plus, VideoPlay } from '@element-plus/icons-vue'
import { EditPen, Loading, Picture, Plus, Share, VideoPlay } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import request from '../api/request'
import FalseTargets from './FalseTargets.vue'
@@ -267,6 +267,101 @@ function resetCoverState() {
coverDeleted.value = false
}
// ---------- 2026-09-09 ----------
const combinedDlg = ref(false)
const combinedIds = ref([]) // id2
const combinedVariants = ref(['s', 'n']) // s= n=
const combinedSubmitting = ref(false)
const combinedAll = ref([]) // /trainings 50
const combinedDismissed = ref([]) // id/
// = queued/running+ =
// /
const combinedRows = computed(() => {
const all = combinedAll.value.filter((t) => !combinedDismissed.value.includes(t.id))
const active = all.filter((t) => t.status === 'running' || t.status === 'queued')
const latestDone = {}
for (const t of all) {
if (t.status !== 'success' && t.status !== 'failed') continue
if (!latestDone[t.variant] || t.id > latestDone[t.variant].id) latestDone[t.variant] = t
}
return [...active, ...Object.values(latestDone)]
})
const variantLabels = { s: '高精度', n: '高性能' }
function openCombined() {
if (list.value.length < 2) {
ElMessage.warning('综合训练至少需要 2 个数据集')
return
}
combinedIds.value = []
combinedVariants.value = ['s', 'n']
combinedDlg.value = true
}
function combinedSubmit() {
if (combinedIds.value.length < 2) {
ElMessage.warning('请至少勾选 2 个数据集')
return
}
if (!combinedVariants.value.length) {
ElMessage.warning('请选择训练档位')
return
}
combinedSubmitting.value = true
request
.post('/trainings/combined', {
datasetIds: combinedIds.value,
variants: combinedVariants.value,
})
.then(() => {
ElMessage.success('综合训练已发起,进入队列')
combinedDlg.value = false
loadCombined()
})
.finally(() => {
combinedSubmitting.value = false
})
}
function loadCombined() {
request
.get('/trainings', { params: { page: 1, size: 50 } })
.then((data) => {
combinedAll.value = (data.list || []).filter((t) => t.kind === 'combined')
})
.catch(() => {})
}
function cancelCombined(t) {
ElMessageBox.confirm(`终止${t.status === 'queued' ? '排队中的' : '运行中的'}综合训练(${variantLabels[t.variant]})?`, '取消综合训练', { type: 'warning' })
.then(() => request.post('/trainings/cancel', { id: t.id }).then(() => {
ElMessage.success('已取消')
loadCombined()
}))
.catch(() => {})
}
// trainings/combined(_n).tflite
function publishCombined(t) {
ElMessageBox.confirm(
`确定将综合模型 ${variantLabels[t.variant]}档(训练 #${t.id}${t.name || ''}」)结果发布为模型版本?版本号综合序列独立自增,App 按识别档位热更新下载。`,
'发布模型版本',
{ confirmButtonText: '发布', cancelButtonText: '取消', type: 'warning' },
)
.then(() => request.post('/trainings/publish', { id: t.id }))
.then((data) => {
ElMessage.success(`已发布版本 ${data.version}`)
loadCombined()
})
.catch(() => {})
}
//
function dismissCombined(t) {
combinedDismissed.value.push(t.id)
}
function openCreate() {
Object.assign(dlgForm, {
mode: 'create',
@@ -583,8 +678,10 @@ let trainPoll = null
onMounted(() => {
load()
loadFtPendingCount()
loadCombined()
trainPoll = setInterval(() => {
if (list.value.some((r) => trList(r).some((t) => t.status === 'running'))) load()
if (combinedRows.value.some((t) => t.status === 'running' || t.status === 'queued')) loadCombined()
}, 10000)
})
@@ -620,10 +717,58 @@ onBeforeUnmount(() => {
<el-button @click="search">搜索</el-button>
</div>
<div class="toolbar-right">
<el-button :icon="Share" @click="openCombined">综合训练</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新建数据集</el-button>
</div>
</div>
<!-- 综合训练横幅进行中任务进度 + 每档位最近一条已结束任务综合任务 dataset_id=0
不归属任何数据集卡片成功未发布在此给发布入口失败/已发布可关闭收起 -->
<div v-if="combinedRows.length" class="combined-strip">
<div v-for="t in combinedRows" :key="t.id" class="combined-line">
<el-tag type="warning" size="small">综合训练</el-tag>
<span class="combined-name">{{ variantLabels[t.variant] }}</span>
<template v-if="t.status === 'running'">
<el-progress
class="combined-bar"
:percentage="trainPercent(t)"
:stroke-width="6"
:show-text="false"
/>
<span class="combined-text">{{ t.currentEpoch || 0 }}/{{ t.totalEpochs || '-' }} </span>
<span v-if="t.etaMinutes" class="combined-text">· 剩余约 {{ etaText(t.etaMinutes) }}</span>
</template>
<el-tag v-else-if="t.status === 'queued'" size="small" type="info">排队中</el-tag>
<template v-else-if="t.status === 'success'">
<el-tag type="success" size="small">已完成</el-tag>
<el-button
v-if="!t.published"
size="small"
type="success"
class="ds-pub"
@click="publishCombined(t)"
>
发布模型
</el-button>
<span v-else class="combined-text">已发布</span>
</template>
<el-tooltip v-else-if="t.status === 'failed' && t.error" :content="t.error" placement="top">
<el-tag type="danger" size="small">失败</el-tag>
</el-tooltip>
<el-tag v-else type="danger" size="small">失败</el-tag>
<el-button
v-if="t.status === 'running' || t.status === 'queued'"
text
type="danger"
size="small"
@click="cancelCombined(t)"
>
{{ t.status === 'queued' ? '取消排队' : '终止' }}
</el-button>
<el-button v-else text size="small" @click="dismissCombined(t)">关闭</el-button>
</div>
</div>
<div v-loading="loading" class="ds-grid">
<div v-for="row in list" :key="row.id" class="ds-card" @click="router.push(`/datasets/${row.id}`)">
<div class="ds-cover">
@@ -768,6 +913,43 @@ onBeforeUnmount(() => {
<FalseTargets v-if="pageTab === 'falseTargets'" ref="ftRef" />
<!-- 负样本批量生成内置场景池 + RF-DETR 空检自动剔除进度在负样本 tab 内展示 -->
<!-- 综合训练发起多选数据集2+ 档位合并训练一个全类模型 -->
<el-dialog v-model="combinedDlg" title="综合训练" width="min(480px, 94vw)">
<el-alert
type="info"
:closable="false"
show-icon
title="多个数据集合并训练出一个全类模型,App 下载一个模型即可识别多种动物;任一物种补数据后需重新发起综合训练"
style="margin-bottom: 14px"
/>
<el-form label-width="70px">
<el-form-item label="数据集">
<el-checkbox-group v-model="combinedIds">
<el-checkbox v-for="d in list" :key="d.id" :value="d.id">
{{ d.name }}{{ d.imageCount }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="档位">
<el-checkbox-group v-model="combinedVariants">
<el-checkbox value="s">高精度s@1280</el-checkbox>
<el-checkbox value="n">高性能n@704</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="combinedDlg = false">取消</el-button>
<el-button
type="primary"
:loading="combinedSubmitting"
:disabled="combinedIds.length < 2"
@click="combinedSubmit"
>
发起训练{{ combinedIds.length }} 个数据集
</el-button>
</template>
</el-dialog>
<el-dialog v-model="negGenVisible" title="AI 生成负样本" width="min(480px, 94vw)">
<el-form label-width="90px">
<el-form-item label="生成张数">