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'; /// 识别档位偏好取值 s/n 同 [kVariantS]/[kVariantN](2026-09-11 多物种综合识别 /// 在 App 端下线:目录中 combined 条目直接忽略,历史 multi_* / 两维识别方式 /// 配置自动归并为对应档位,不再有 multi_* 取值) /// 模型身份键:同一数据集不同档位是两个独立条目(下载/激活/记账互不影响) 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 datasetIds; // combined:覆盖的数据集 id 列表 final String version; final List 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 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 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//` 存放 s 档(legacy 布局,目录键 = /// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models//n/`; /// 各目录含 model.tflite + labels.json + meta.json,meta 记录 {version, sha256}, /// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。 /// 识别档位 [mode] 只是用户偏好(持久化:s 高精度默认 / n 高性能,切换不直接 /// 热换运行中模型):作为各卡默认目标档与「下载完成自动启用」的判定依据 /// (2026-09-11 多物种综合识别 App 端下线——不再展示综合卡,目录中 combined /// 条目直接忽略,历史 multi_* / 两维识别方式配置自动归并到对应档位)。 /// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用 /// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03)。 /// 激活集是**会话态**(2026-09-03 修订):每次进入视野页 [resetForSession] 清空、 /// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档 /// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览 /// 界面并自行调整。 /// 目录**缓存优先**(2026-09-03):最近一次成功拉取的 models 目录落盘 /// catalog.json,[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络 /// 成功后再以权威目录覆盖并落盘;清理/激活同步只在网络成功(fetched)后执行 /// ——缓存降级时不清文件不下载,离线首启不误删已下载模型。 /// 模型更新**手动制**(2026-09-09):无自动更新/待办横幅,新版本由用户在 /// 卡片上重新下载(使用中重下会原地生效)。 class ModelManager extends ChangeNotifier { static final ModelManager instance = ModelManager._(); final String baseUrl; final http.Client _client; final Future Function()? _rootDirOverride; List _models = const []; List _catalog = const []; final Set _active = {}; final Set _downloaded = {}; final Map _progress = {}; final Map _errors = {}; final Set _cancelRequested = {}; String _mode = kVariantS; bool _modeLoaded = false; bool _ready = false; bool _refreshing = false; String? _error; Future? _inFlight; /// 模型文件/激活集变更版本戳:下载完成或激活变化 +1, /// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入) int _revision = 0; int get revision => _revision; /// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行) List get catalog => _catalog; /// 识别档位偏好(s 高精度 / n 高性能):作为各卡默认目标档 /// (不直接切换运行——运行由激活集驱动) String get mode => _mode; 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 Function()? rootDir, }) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl, _client = client ?? http.Client(), _rootDirOverride = rootDir; /// 已激活且已下载的模型列表(每个数据集至多一个档位;空 = 未加载任何模型,仅预览) List 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(','); } /// 切换识别档位偏好(s 高精度 / n 高性能):只改默认目标档并持久化。 Future setMode(String mode) async { if (mode != kVariantS && mode != kVariantN) return; if (_mode == mode) return; _mode = mode; await _saveMode(); notifyListeners(); } /// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。 Future 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 _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; final data = body['data'] as Map? ?? const {}; final list = data['models'] as List? ?? const []; _catalog = list .map((e) => ModelCatalogItem.fromJson(e as Map)) .toList(); await _saveCatalogCache(list); fetched = true; } catch (e) { // 拉取失败:保留缓存/旧目录继续展示;本实例从未拉取成功过才记错误 // (有缓存兜底时同样提示,说明当前展示的目录未经最新网络确认) if (!_ready) _error = '模型目录拉取失败:$e'; } // 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新 if (_catalog.isEmpty && !fetched) return; // 只拉目录不下载;扫描本地已有模型文件供清单展示(版本是否落后由 // 卡片对照目录版本提示「更新」)。缓存目录同样扫描:离线重开也能正确 // 标出已下载档位 final downloaded = {}; 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; } catch (e) { if (!_ready) _error = '模型目录拉取失败:$e'; // 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示) } } /// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta)。 /// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且 /// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别); /// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。 Future 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)) { // 用户显式下载且该数据集尚无档位在使用:自动激活识别档位默认档 // 条目;目录没有默认档(存量单档物种)时激活本条,保证下载即有 // 识别。走 setActive 统一做同档覆盖互斥(同数据集至多一档运行) final hasTarget = _catalog.any((c) => c.datasetId == item.datasetId && c.variant == mode); if (item.variant == mode || !hasTarget) { await setActive(item.datasetId, item.variant, true); } } 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 _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 _prune(List 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 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 = { ..._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 _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 _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; final r = data['recognition'] as String?; if (r != null) { // 四选一时期(2026-09-11 定稿后、多物种下线前)的配置:后缀即档位 if (r.endsWith(kVariantN)) { _mode = kVariantN; } else if (r.endsWith(kVariantS)) { _mode = kVariantS; } return; } // 更早 {mode 档位, source 单/多源} 两维结构:只看档位(multi 已下线) if (data['mode'] == kVariantN) _mode = kVariantN; } catch (e) { debugPrint('[ModelManager] 识别偏好读取失败: $e'); } } /// 载入上次成功拉取的目录缓存(catalog.json,模型根目录下)——离线/弱网时 /// 设置弹层也能先展示模型清单。无缓存文件/损坏/空列表则保持目录为空。 Future _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; final list = data['models'] as List? ?? const []; if (list.isEmpty) return; _catalog = list .map((e) => ModelCatalogItem.fromJson(e as Map)) .toList(); } catch (e) { debugPrint('[ModelManager] 目录缓存读取失败: $e'); } } /// 落盘最近一次成功拉取的 models 原始列表(含服务器可能新增的字段), /// 供下次离线/网络慢时先展示;缓存仅作展示降级,不参与清理/自动更新决策。 Future _saveCatalogCache(List 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> _loadBundles( List catalog) async { final bundles = []; 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 _rootDir() async { if (_rootDirOverride != null) return _rootDirOverride(); final support = await getApplicationSupportDirectory(); return Directory('${support.path}/models'); } /// 档位子路径(相对模型根目录):s 档 `models//`(legacy 无子目录, /// 目录键 = 档位标识符的 s 形态,存量设备零迁移);n 档 `models//n/`。 String _subPath(int datasetId, String variant) => variant == kVariantS ? '$datasetId' : '$datasetId/$variant'; /// 档位目录(不存在则创建) Future _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; } /// 目录条目对应的模型文件是否已存在本地(不校验版本:文件已存在即标已下载, /// 是否落后由卡片端对照目录版本提示「更新」) Future _hasFile(ModelCatalogItem item) async { final root = await _rootDir(); return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite') .exists(); } }