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