Files
observer/flutter_app/lib/models/model_manager.dart
T
admin 87ab06221c 综合训练(多物种合并模型)全链路:合并打包/独立版本序列/App 覆盖互斥 + 管理端发布入口
- 后端:/admin/trainings/combined 发起(≥2 数据集、类别重映射、防重名、负样本单份);
  model_training/model_version 加 kind+dataset_ids(迁移 v14),综合任务 dataset_id=0、
  文件基名 combined(_n)、版本序列独立;训练列表补 published 标记
- 管理端:数据训练页工具栏发起综合训练;横幅常驻进行中任务 + 每档最近一条已结束任务,
  成功未发布给「发布模型」入口(可关闭收起)
- App:目录解析 kind/datasetIds、激活覆盖互斥、自动更新退场改目标档待办横幅手动一键下载
2026-09-09 15:45:51 +08:00

699 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 字段 → 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())
.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.jsonmeta 记录 {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 无 charsethttp 包默认按 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();
}
}