综合训练(多物种合并模型)全链路:合并打包/独立版本序列/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();