- 后端:/admin/trainings/combined 发起(≥2 数据集、类别重映射、防重名、负样本单份); model_training/model_version 加 kind+dataset_ids(迁移 v14),综合任务 dataset_id=0、 文件基名 combined(_n)、版本序列独立;训练列表补 published 标记 - 管理端:数据训练页工具栏发起综合训练;横幅常驻进行中任务 + 每档最近一条已结束任务, 成功未发布给「发布模型」入口(可关闭收起) - App:目录解析 kind/datasetIds、激活覆盖互斥、自动更新退场改目标档待办横幅手动一键下载
699 lines
28 KiB
Dart
699 lines
28 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:crypto/crypto.dart' show sha256;
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:path_provider/path_provider.dart';
|
||
|
||
import '../config/app_config.dart';
|
||
|
||
/// 识别档位标识:s = 高精度(@1280 精度优先,默认),n = 高性能(@704 速度优先)
|
||
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});
|
||
|
||
/// 模型目录条目(GET /api/v1/app/update 响应 data.models[])。
|
||
/// 服务器发布模型后随版本检查一同下发,App 按目录逐数据集下载/更新。
|
||
/// 双档位(2026-09-03):每数据集至多 2 条(s/n 各一),[variant] 标识档位。
|
||
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;
|
||
final String sha256;
|
||
final String downloadUrl;
|
||
final String coverUrl;
|
||
|
||
const 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,
|
||
required this.sha256,
|
||
required this.downloadUrl,
|
||
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())
|
||
.toList(),
|
||
sizeBytes: (j['sizeBytes'] as num?)?.toInt() ?? 0,
|
||
sha256: j['sha256'] as String? ?? '',
|
||
downloadUrl: j['downloadUrl'] as String? ?? '',
|
||
coverUrl: j['coverUrl'] as String? ?? '',
|
||
);
|
||
}
|
||
|
||
/// 已就绪模型(字节 + 标签,供推理 worker 加载)
|
||
class ModelBundle {
|
||
final int datasetId;
|
||
final String datasetName;
|
||
final String variant;
|
||
final String version;
|
||
final List<String> labels;
|
||
final Uint8List bytes;
|
||
|
||
const ModelBundle({
|
||
required this.datasetId,
|
||
required this.datasetName,
|
||
required this.variant,
|
||
required this.version,
|
||
required this.labels,
|
||
required this.bytes,
|
||
});
|
||
}
|
||
|
||
/// 模型热更新管理:启动时拉取模型目录(随 /app/update 公开接口下发,无需登录态),
|
||
/// 按需下载/校验/持久化各数据集模型,供相机页多模型并行推理。
|
||
///
|
||
/// 双档位存储(2026-09-03):`models/<datasetId>/` 存放 s 档(legacy 布局,目录键 =
|
||
/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models/<datasetId>/n/`;
|
||
/// 各目录含 model.tflite + labels.json + meta.json,meta 记录 {version, sha256},
|
||
/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。
|
||
/// 识别目标档位 [mode] 只是用户偏好(持久化):设置弹层卡片按钮面向该档位。
|
||
/// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用
|
||
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03 修订)。
|
||
/// 激活集是**会话态**(2026-09-03 修订):每次进入视野页 [resetForSession] 清空、
|
||
/// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档
|
||
/// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览
|
||
/// 界面并自行调整。
|
||
/// 目录**缓存优先**(2026-09-03):最近一次成功拉取的 models 目录落盘
|
||
/// catalog.json,[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络
|
||
/// 成功后再以权威目录覆盖并落盘;清理/激活同步/待办检测只在网络成功(fetched)
|
||
/// 后执行——缓存降级时不清文件不下载,离线首启不误删已下载模型。
|
||
/// 版本更新**手动制**(2026-09-09):静默后台自动更新退场,切档/目录刷新仅
|
||
/// 检测目标档「未下载/待更新」置入 [modePending],由用户在横幅手动一键触发。
|
||
class ModelManager extends ChangeNotifier {
|
||
static final ModelManager instance = ModelManager._();
|
||
|
||
final String baseUrl;
|
||
final http.Client _client;
|
||
final Future<Directory> Function()? _rootDirOverride;
|
||
|
||
List<ModelBundle> _models = const [];
|
||
List<ModelCatalogItem> _catalog = const [];
|
||
final Set<ModelKey> _active = {};
|
||
final Set<ModelKey> _downloaded = {};
|
||
final Map<ModelKey, double> _progress = {};
|
||
final Map<ModelKey, String> _errors = {};
|
||
final Set<ModelKey> _cancelRequested = {};
|
||
String _mode = kVariantS;
|
||
bool _modeLoaded = false;
|
||
bool _ready = false;
|
||
bool _refreshing = false;
|
||
String? _error;
|
||
Future<void>? _inFlight;
|
||
|
||
/// 目标档待办清单(2026-09-09):当前 [mode] 下「未下载」或「本地版本落后
|
||
/// 服务器」的模型条目,切档/目录刷新时重算,UI 出横幅由用户手动一键下载/更新
|
||
List<ModelCatalogItem> _modePending = const [];
|
||
|
||
/// 模型文件/激活集变更版本戳:下载完成或激活变化 +1,
|
||
/// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入)
|
||
int _revision = 0;
|
||
int get revision => _revision;
|
||
|
||
/// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行)
|
||
List<ModelCatalogItem> get catalog => _catalog;
|
||
|
||
/// 识别目标档位(默认 s 高精度):用户偏好,持久化;只决定卡片按钮与
|
||
/// 首次下载的自动激活档,不直接切换已在运行的模型(运行看激活集)
|
||
String get mode => _mode;
|
||
|
||
/// 目标档待办清单(未下载 / 本地版本落后),切档时检测、UI 出横幅手动触发
|
||
List<ModelCatalogItem> get modePending => _modePending;
|
||
|
||
bool isActive(int datasetId, String variant) =>
|
||
_active.contains((datasetId: datasetId, variant: variant));
|
||
|
||
/// 该 (数据集, 档位) 模型文件是否已下载到本地(同步判断,内存态)
|
||
bool isDownloaded(int datasetId, String variant) =>
|
||
_downloaded.contains((datasetId: datasetId, variant: variant));
|
||
|
||
/// 下载进度 0..1(无下载/已完成为 null)
|
||
double? progressOf(int datasetId, String variant) =>
|
||
_progress[(datasetId: datasetId, variant: variant)];
|
||
|
||
/// 下载失败原因(失败后可重试)
|
||
String? errorOf(int datasetId, String variant) =>
|
||
_errors[(datasetId: datasetId, variant: variant)];
|
||
|
||
/// 中断进行中的下载:下一个数据块到达时终止(丢弃 .part),卡片恢复「使用」。
|
||
/// 取消不记错误,可再次下载。
|
||
void cancelDownload(int datasetId, String variant) {
|
||
_cancelRequested.add((datasetId: datasetId, variant: variant));
|
||
}
|
||
|
||
ModelManager._({String? baseUrl, http.Client? client})
|
||
: this(baseUrl: baseUrl, client: client);
|
||
|
||
/// 可注入 baseUrl / client / 存储根目录(单测用)
|
||
@visibleForTesting
|
||
ModelManager({
|
||
String? baseUrl,
|
||
http.Client? client,
|
||
Future<Directory> Function()? rootDir,
|
||
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
|
||
_client = client ?? http.Client(),
|
||
_rootDirOverride = rootDir;
|
||
|
||
/// 已激活且已下载的模型列表(每个数据集至多一个档位;空 = 未加载任何模型,仅预览)
|
||
List<ModelBundle> get models => _models;
|
||
|
||
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空")
|
||
bool get ready => _ready;
|
||
|
||
/// 模型目录拉取失败的错误信息(仅目录级;下载/校验失败见 errorOf)
|
||
String? get error => _error;
|
||
|
||
bool get refreshing => _refreshing;
|
||
|
||
/// 模型名摘要(诊断行展示):数据集名(同数据集的档位码不外显)
|
||
String get modelsLabel {
|
||
if (_models.isEmpty) return '未下载';
|
||
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();
|
||
_refreshing = true;
|
||
_inFlight = _doRefresh().whenComplete(() {
|
||
_refreshing = false;
|
||
_inFlight = null;
|
||
notifyListeners();
|
||
});
|
||
return _inFlight!;
|
||
}
|
||
|
||
/// 开始新识别会话(进入视野页时调用):清空激活集与已加载模型。
|
||
/// 激活集为会话态、不做跨会话持久化——上次使用的模型不自动恢复,识别需
|
||
/// 用户在模型清单手动启用(2026-09-03 会话制修订)。
|
||
void resetForSession() {
|
||
if (_active.isEmpty) return;
|
||
_active.clear();
|
||
_models = const [];
|
||
_revision++;
|
||
notifyListeners();
|
||
}
|
||
|
||
Future<void> _doRefresh() async {
|
||
try {
|
||
await _loadMode();
|
||
// 缓存优先(2026-09-03):网络返回前先载入上次成功拉取的目录并提前 notify
|
||
// ——设置弹层打开即有内容展示,不依赖网络请求;网络成功后再以权威目录覆盖
|
||
if (_catalog.isEmpty) {
|
||
await _loadCatalogCache();
|
||
if (_catalog.isNotEmpty) notifyListeners();
|
||
}
|
||
var fetched = false;
|
||
try {
|
||
final res = await _client
|
||
.get(Uri.parse('$baseUrl/api/v1/app/update'))
|
||
.timeout(const Duration(seconds: 30));
|
||
// 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8
|
||
final body =
|
||
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||
final data = body['data'] as Map<String, dynamic>? ?? const {};
|
||
final list = data['models'] as List? ?? const [];
|
||
_catalog = list
|
||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||
.toList();
|
||
await _saveCatalogCache(list);
|
||
fetched = true;
|
||
} catch (e) {
|
||
// 拉取失败:保留缓存/旧目录继续展示;本实例从未拉取成功过才记错误
|
||
// (有缓存兜底时同样提示,说明当前展示的目录未经最新网络确认)
|
||
if (!_ready) _error = '模型目录拉取失败:$e';
|
||
}
|
||
// 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新
|
||
if (_catalog.isEmpty && !fetched) return;
|
||
|
||
// 只拉目录不下载;扫描本地已有模型文件供清单展示。版本新旧都算已下载:
|
||
// 旧版本文件由 autoUpdate 静默补齐,无需用户看到「下载」按钮再下。
|
||
// 缓存目录同样扫描:离线重开也能正确标出已下载档位
|
||
final downloaded = <ModelKey>{};
|
||
for (final item in _catalog) {
|
||
if (await _hasFile(item)) {
|
||
downloaded.add((datasetId: item.datasetId, variant: item.variant));
|
||
}
|
||
}
|
||
_downloaded
|
||
..clear()
|
||
..addAll(downloaded);
|
||
|
||
// 清理/激活同步/自动更新只认网络拉到的权威目录:缓存降级时不清文件、
|
||
// 不触发下载——离线首启不会误删已下载模型(2026-09-03)
|
||
if (!fetched) return;
|
||
await _prune(_catalog);
|
||
// 服务器已下线的 (数据集, 档位) 移出激活集
|
||
final catalogKeys = _catalog
|
||
.map((c) => (datasetId: c.datasetId, variant: c.variant))
|
||
.toSet();
|
||
_active.removeWhere((k) => !catalogKeys.contains(k));
|
||
|
||
_models = await _loadBundles(_catalog);
|
||
_ready = true;
|
||
_error = null;
|
||
// 目标档待办重算(2026-09-09:自动更新退场,改为切档检测 + 手动触发——
|
||
// 静默后台下载不区分档位流量开销大,且会掩盖「待更新」状态)
|
||
await refreshModePending();
|
||
} catch (e) {
|
||
if (!_ready) _error = '模型目录拉取失败:$e';
|
||
// 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示)
|
||
}
|
||
}
|
||
|
||
/// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在)
|
||
Future<bool> _isLocal(ModelCatalogItem item) async {
|
||
final dir = await _modelDir(item.datasetId, item.variant);
|
||
try {
|
||
final meta = await _readMeta(dir);
|
||
final file = File('${dir.path}/model.tflite');
|
||
return meta != null &&
|
||
meta['version'] == item.version &&
|
||
meta['sha256'] == item.sha256 &&
|
||
await file.exists();
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta)。
|
||
/// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且
|
||
/// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别);
|
||
/// autoUpdate 等后台补档传 false:只更新文件不改变激活状态。
|
||
/// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。
|
||
Future<bool> downloadModel(ModelCatalogItem item,
|
||
{bool autoActivate = true,
|
||
void Function(int received, int total)? onProgress}) async {
|
||
final key = (datasetId: item.datasetId, variant: item.variant);
|
||
// 并发保护:同一 (数据集, 档位) 已有进行中的下载则直接短路
|
||
if (_progress.containsKey(key)) return false;
|
||
_cancelRequested.remove(key);
|
||
_progress[key] = 0;
|
||
final dir = await _modelDir(item.datasetId, item.variant);
|
||
final file = File('${dir.path}/model.tflite');
|
||
try {
|
||
for (var attempt = 0; attempt < 2; attempt++) {
|
||
if (_cancelRequested.contains(key)) break;
|
||
final ok = await _downloadAndVerify(item, dir, file,
|
||
onProgress: (r, t) {
|
||
_progress[key] = t == 0 ? 0 : r / t;
|
||
onProgress?.call(r, t);
|
||
notifyListeners();
|
||
});
|
||
if (ok) {
|
||
_progress.remove(key);
|
||
_errors.remove(key);
|
||
final wasActive = _active.contains(key);
|
||
_downloaded.add(key);
|
||
if (wasActive) {
|
||
// 使用中的模型原地更新:字节已替换,重建 worker 读新文件
|
||
_revision++;
|
||
_models = await _loadBundles(_catalog);
|
||
} else if (autoActivate &&
|
||
!_active.any((k) => k.datasetId == item.datasetId)) {
|
||
// 用户显式下载且该数据集尚无档位在使用:自动激活目标档条目;
|
||
// 目录没有目标档(存量单档物种)时激活本条,保证下载即有识别
|
||
final hasTarget = _catalog.any((c) =>
|
||
c.datasetId == item.datasetId && c.variant == _mode);
|
||
if (item.variant == _mode || !hasTarget) {
|
||
_active.add(key);
|
||
_revision++;
|
||
_models = await _loadBundles(_catalog);
|
||
}
|
||
}
|
||
unawaited(refreshModePending()); // 落地后移出待办横幅
|
||
notifyListeners();
|
||
return true;
|
||
}
|
||
if (_cancelRequested.contains(key)) break;
|
||
await file.delete().catchError((_) => file);
|
||
await File('${dir.path}/model.tflite.part')
|
||
.delete()
|
||
.catchError((_) => file);
|
||
}
|
||
if (_cancelRequested.contains(key)) {
|
||
// 用户取消:清理残留,不记错误
|
||
await file.delete().catchError((_) => file);
|
||
await File('${dir.path}/model.tflite.part')
|
||
.delete()
|
||
.catchError((_) => file);
|
||
_progress.remove(key);
|
||
notifyListeners();
|
||
debugPrint('[ModelManager] 下载已取消: ${item.datasetName}');
|
||
return false;
|
||
}
|
||
_progress.remove(key);
|
||
_errors[key] = '下载失败,请重试';
|
||
notifyListeners();
|
||
debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}');
|
||
return false;
|
||
} catch (e) {
|
||
if (_cancelRequested.contains(key)) {
|
||
await file.delete().catchError((_) => file);
|
||
await File('${dir.path}/model.tflite.part')
|
||
.delete()
|
||
.catchError((_) => file);
|
||
_progress.remove(key);
|
||
notifyListeners();
|
||
debugPrint('[ModelManager] 下载已取消: ${item.datasetName}');
|
||
return false;
|
||
}
|
||
_progress.remove(key);
|
||
_errors[key] = '下载异常:$e';
|
||
notifyListeners();
|
||
debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
Future<bool> _downloadAndVerify(
|
||
ModelCatalogItem item, Directory dir, File file,
|
||
{void Function(int received, int total)? onProgress}) async {
|
||
final part = File('${file.path}.part');
|
||
final sink = part.openWrite();
|
||
var received = 0;
|
||
try {
|
||
// 下载无总时长上限(大模型慢网可能数分钟);连接/响应头与数据流
|
||
// 分别做 30s 停滞判定,避免断流黑洞永久卡死
|
||
final res = await _client
|
||
.send(http.Request('GET', Uri.parse('$baseUrl${item.downloadUrl}')))
|
||
.timeout(const Duration(seconds: 30));
|
||
if (res.statusCode != 200) {
|
||
await sink.close();
|
||
return false;
|
||
}
|
||
final total = res.contentLength ?? item.sizeBytes;
|
||
await for (final chunk
|
||
in res.stream.timeout(const Duration(seconds: 30))) {
|
||
if (_cancelRequested.contains(
|
||
(datasetId: item.datasetId, variant: item.variant))) {
|
||
break; // 用户取消
|
||
}
|
||
sink.add(chunk);
|
||
received += chunk.length;
|
||
onProgress?.call(received, total);
|
||
}
|
||
if (_cancelRequested.contains(
|
||
(datasetId: item.datasetId, variant: item.variant))) {
|
||
await sink.close();
|
||
return false;
|
||
}
|
||
await sink.close();
|
||
final bytes = await part.readAsBytes();
|
||
final hex = sha256.convert(bytes).toString();
|
||
if (item.sha256.isNotEmpty && hex != item.sha256) {
|
||
debugPrint('[ModelManager] sha256 不匹配: ${item.datasetName} '
|
||
'want=${item.sha256} got=$hex');
|
||
return false;
|
||
}
|
||
await part.rename(file.path);
|
||
await dir.create(recursive: true);
|
||
await File('${dir.path}/labels.json')
|
||
.writeAsString(jsonEncode(item.labels));
|
||
await File('${dir.path}/meta.json').writeAsString(jsonEncode({
|
||
'version': item.version,
|
||
'sha256': item.sha256,
|
||
}));
|
||
debugPrint('[ModelManager] 已下载 ${item.datasetName}(${item.variant}) '
|
||
'${bytes.length}B -> ${file.path}');
|
||
return true;
|
||
} catch (e) {
|
||
await sink.close().catchError((_) {});
|
||
debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 清理本地目录:数据集整体下线(s/n 两档都无目录条目)删整目录;
|
||
/// 数据集仍在但某档已下线时清该档子目录(s 档为同级文件,无独立目录,
|
||
/// 残留文件不再被引用,仅占用磁盘,不做细粒度清除)。
|
||
Future<void> _prune(List<ModelCatalogItem> catalog) async {
|
||
final root = await _rootDir();
|
||
if (!await root.exists()) return;
|
||
final dsIds = catalog.map((c) => c.datasetId).toSet();
|
||
final nDsIds = catalog
|
||
.where((c) => c.variant == kVariantN)
|
||
.map((c) => c.datasetId)
|
||
.toSet();
|
||
await for (final e in root.list()) {
|
||
if (e is! Directory) continue;
|
||
// 目录 URI 末尾带 '/',pathSegments 末位为空串 → 过滤后取目录名
|
||
final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last;
|
||
final dsId = int.tryParse(name);
|
||
if (dsId == null) continue;
|
||
if (!dsIds.contains(dsId)) {
|
||
await e.delete(recursive: true).catchError((_) => e);
|
||
continue;
|
||
}
|
||
if (!nDsIds.contains(dsId)) {
|
||
final sub = Directory('${e.path}/$kVariantN');
|
||
if (await sub.exists()) {
|
||
await sub.delete(recursive: true).catchError((_) => e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 设置激活状态(true=使用,false=取消;仅本次会话内生效,不持久化)。
|
||
/// 同一数据集至多一个档位在使用:激活某档时若同数据集另一档在使用则先停用
|
||
/// (2026-09-03:不同动物可跑不同档位,同一种动物一次只跑一档)。
|
||
/// **覆盖互斥(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 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++;
|
||
_models = await _loadBundles(_catalog);
|
||
notifyListeners();
|
||
}
|
||
|
||
Future<void> _saveMode() async {
|
||
try {
|
||
final root = await _rootDir();
|
||
await root.create(recursive: true);
|
||
await File('${root.path}/mode.json')
|
||
.writeAsString(jsonEncode({'mode': _mode}));
|
||
} catch (e) {
|
||
debugPrint('[ModelManager] 识别档位持久化失败: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _loadMode() async {
|
||
if (_modeLoaded) return;
|
||
_modeLoaded = true;
|
||
try {
|
||
final root = await _rootDir();
|
||
final f = File('${root.path}/mode.json');
|
||
if (!await f.exists()) return;
|
||
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||
final m = data['mode'] as String? ?? kVariantS;
|
||
if (m == kVariantS || m == kVariantN) _mode = m;
|
||
} catch (e) {
|
||
debugPrint('[ModelManager] 识别档位读取失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 载入上次成功拉取的目录缓存(catalog.json,模型根目录下)——离线/弱网时
|
||
/// 设置弹层也能先展示模型清单。无缓存文件/损坏/空列表则保持目录为空。
|
||
Future<void> _loadCatalogCache() async {
|
||
try {
|
||
final root = await _rootDir();
|
||
final f = File('${root.path}/catalog.json');
|
||
if (!await f.exists()) return;
|
||
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||
final list = data['models'] as List? ?? const [];
|
||
if (list.isEmpty) return;
|
||
_catalog = list
|
||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||
.toList();
|
||
} catch (e) {
|
||
debugPrint('[ModelManager] 目录缓存读取失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 落盘最近一次成功拉取的 models 原始列表(含服务器可能新增的字段),
|
||
/// 供下次离线/网络慢时先展示;缓存仅作展示降级,不参与清理/自动更新决策。
|
||
Future<void> _saveCatalogCache(List<dynamic> rawModels) async {
|
||
try {
|
||
final root = await _rootDir();
|
||
await root.create(recursive: true);
|
||
await File('${root.path}/catalog.json')
|
||
.writeAsString(jsonEncode({'models': rawModels}));
|
||
} catch (e) {
|
||
debugPrint('[ModelManager] 目录缓存保存失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 读取全部激活条目(不再按目标档过滤——激活集即实际运行集,每数据集一档)
|
||
Future<List<ModelBundle>> _loadBundles(
|
||
List<ModelCatalogItem> catalog) async {
|
||
final bundles = <ModelBundle>[];
|
||
for (final item in catalog) {
|
||
final key = (datasetId: item.datasetId, variant: item.variant);
|
||
if (!_active.contains(key)) continue;
|
||
try {
|
||
final dir = await _modelDir(item.datasetId, item.variant);
|
||
final file = File('${dir.path}/model.tflite');
|
||
if (!await file.exists()) continue;
|
||
final labels = await File('${dir.path}/labels.json').exists()
|
||
? (jsonDecode(
|
||
await File('${dir.path}/labels.json').readAsString())
|
||
as List)
|
||
.map((e) => e.toString())
|
||
.toList()
|
||
: item.labels;
|
||
bundles.add(ModelBundle(
|
||
datasetId: item.datasetId,
|
||
datasetName: item.datasetName,
|
||
variant: item.variant,
|
||
version: item.version,
|
||
labels: labels,
|
||
bytes: await file.readAsBytes(),
|
||
));
|
||
} catch (e) {
|
||
debugPrint('[ModelManager] 读取 ${item.datasetName} 失败: $e');
|
||
}
|
||
}
|
||
return bundles;
|
||
}
|
||
|
||
Future<Map<String, dynamic>?> _readMeta(Directory dir) async {
|
||
final f = File('${dir.path}/meta.json');
|
||
if (!await f.exists()) return null;
|
||
try {
|
||
return jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<Directory> _rootDir() async {
|
||
if (_rootDirOverride != null) return _rootDirOverride();
|
||
final support = await getApplicationSupportDirectory();
|
||
return Directory('${support.path}/models');
|
||
}
|
||
|
||
/// 档位子路径(相对模型根目录):s 档 `models/<datasetId>/`(legacy 无子目录,
|
||
/// 目录键 = 档位标识符的 s 形态,存量设备零迁移);n 档 `models/<datasetId>/n/`。
|
||
String _subPath(int datasetId, String variant) =>
|
||
variant == kVariantS ? '$datasetId' : '$datasetId/$variant';
|
||
|
||
/// 档位目录(不存在则创建)
|
||
Future<Directory> _modelDir(int datasetId, String variant) async {
|
||
final root = await _rootDir();
|
||
final dir = Directory('${root.path}/${_subPath(datasetId, variant)}');
|
||
await dir.create(recursive: true);
|
||
return dir;
|
||
}
|
||
|
||
/// 目录条目对应的模型文件是否已存在本地(不校验版本:旧版本视为已下载,
|
||
/// 新版本由 autoUpdate 自动补齐)
|
||
Future<bool> _hasFile(ModelCatalogItem item) async {
|
||
final root = await _rootDir();
|
||
return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite')
|
||
.exists();
|
||
}
|
||
}
|