diff --git a/flutter_app/lib/camera/camera_view_model.dart b/flutter_app/lib/camera/camera_view_model.dart index 75e0205..747b4a6 100644 --- a/flutter_app/lib/camera/camera_view_model.dart +++ b/flutter_app/lib/camera/camera_view_model.dart @@ -39,7 +39,7 @@ class CameraUiState { /// 检测结果置信度分级与轨迹确认。 /// -/// - [highConf](0.35):高于此分直接确认显示;真实环颈雉鸡多为 0.1~0.2, +/// - [highConf](0.35):高于此分直接确认显示;真实目标多为 0.1~0.2, /// 高于 0.35 视为强证据。 /// - 低于 0.35 的框:需要多帧稳定([confirmFrames] 帧)或 活动证据 /// (运动区域/背景新出现区域重叠)才确认显示。 @@ -105,7 +105,8 @@ class CameraViewModel extends ChangeNotifier { visible.add(r.copyWith(confirmed: t.confirmed)); } - // 提醒:仅新确认的目标物种轨迹(class 0,如环颈雉鸡;确认瞬间触发一次,10s 同类冷却在 Reminder 内) + // 提醒:仅新确认的目标物种轨迹(class 0 即目标,label 由训练数据决定; + // 确认瞬间触发一次,10s 同类冷却在 Reminder 内) for (final t in _tracks.values) { final isSuspect = t.result.classId > 0 || t.label == 'suspect'; if (isSuspect || !t.confirmed || t.reminded) continue; @@ -153,7 +154,7 @@ class CameraViewModel extends ChangeNotifier { for (final t in _tracks.values) { if (matched.contains(t.id)) continue; final d = _centerDist(t.result, r); - // 同标签宽松匹配;跨标签(环颈雉鸡↔疑似 抖动)收紧到 60% + // 同标签宽松匹配;跨标签(目标↔疑似 抖动)收紧到 60% final limit = t.label == r.label ? bestD : associateRadius * 0.6; if (d < limit) { bestD = d; @@ -182,7 +183,7 @@ class CameraViewModel extends ChangeNotifier { /// 显示判定(按类别策略): /// - 疑似(生境预警):设计意图是常驻静态预警,始终显示(渲染侧弱化) - /// - 环颈雉鸡:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示 + /// - 目标物种:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示 bool _shouldDisplay(_Track t, List motionRegions, List noveltyRegions) { if (t.result.classId > 0 || t.label == 'suspect') return true; @@ -197,7 +198,7 @@ class CameraViewModel extends ChangeNotifier { motionRegions.any((m) => MotionAggregator.centerInRegion(r, m)) || noveltyRegions.any((m) => MotionAggregator.centerInRegion(r, m)); - /// 物理合理性过滤:宽高比与相对尺寸(环颈雉鸡 20-100px@720 量级,参照标注脚本) + /// 物理合理性过滤:宽高比与相对尺寸(目标物种 20-100px@720 量级) bool _plausible(DetectionResult r) { final h = r.height; final w = r.width; diff --git a/flutter_app/lib/camera/detection_overlay.dart b/flutter_app/lib/camera/detection_overlay.dart index 4bfba16..30614bc 100644 --- a/flutter_app/lib/camera/detection_overlay.dart +++ b/flutter_app/lib/camera/detection_overlay.dart @@ -6,10 +6,11 @@ import '../detection/coordinate_mapper.dart'; import '../detection/detection_result.dart'; /// 检测框绘制分级: -/// - 环颈雉鸡 confirmed:红色实线 3px(强证据) -/// - 环颈雉鸡 candidate:红色虚线 2px 半透明(待确认,弱提示) +/// - 目标物种 confirmed:红色实线 3px(强证据) +/// - 目标物种 candidate:红色虚线 2px 半透明(待确认,弱提示) /// - 疑似(生境预警):黄色虚线 2px 半透明(常驻静态预警,弱化渲染) /// 标签附带距离估计(针孔模型 焦距px×参考体型/框高px)。 +/// 名称文本只取模型输出的 label(数据集训练决定,App 不内置物种名)。 class DetectionOverlay extends StatelessWidget { final List results; final int rotation; @@ -44,9 +45,11 @@ class _OverlayPainter extends CustomPainter { _OverlayPainter(this.results, this.rotation, this.imageWidthPx, this.imageHeightPx); - static const _labels = {'pheasant': '环颈雉鸡', 'suspect': '疑似'}; + // 仅保留通用「疑似」中文翻译;物种名由模型 labels 直接展示 + // (识别什么物种由训练好的模型决定,App 不内置物种名) + static const _labels = {'suspect': '疑似'}; - /// 参考体型(米):目标物种(class 0,如环颈雉鸡)身高 / suspect 植被高度 + /// 参考体型(米):目标物种(class 0)身高 / suspect 植被高度 static const double _refSizeSpeciesM = 0.45; static const double _refSizeSuspectM = 0.50; @@ -69,8 +72,8 @@ class _OverlayPainter extends CustomPainter { size.width, size.height, ); - // 颜色按类别索引而非 label 文本:模型类别名可能为中文(环颈雉)或 - // 随数据集变化,class 0 恒为目标物种(红),其余类恒为 suspect(黄) + // 颜色按类别索引而非 label 文本:模型类别名随数据集变化(物种中文名等), + // class 0 恒为目标物种(红),其余类恒为 suspect(黄) final isSuspect = r.classId > 0 || r.label == 'suspect'; final color = isSuspect ? Color(0xFFFDD835) : Color(0xFFE53935); final confirmed = r.confirmed && !isSuspect; diff --git a/flutter_app/lib/camera/model_catalog_section.dart b/flutter_app/lib/camera/model_catalog_section.dart index 0ec63f9..a1ed08c 100644 --- a/flutter_app/lib/camera/model_catalog_section.dart +++ b/flutter_app/lib/camera/model_catalog_section.dart @@ -2,9 +2,15 @@ import 'package:flutter/material.dart'; import '../models/model_manager.dart'; -/// 设置弹层「模型清单」区块:2 列封面缩略图网格。 -/// 未下载 →「使用」点击后显示下载进度,完成自动激活;已下载未激活 → 直接激活; -/// 已激活 → 再次点击取消;下载失败 → 失败提示 + 重试。 +/// 设置弹层「模型清单」区块:顶部「识别模式」分段控件(s 高识别 / n 高性能, +/// 默认 s,持久化本地;切换即热加载该档位已激活模型),下方 2 列封面缩略图网格。 +/// 双档位:每数据集至多两张卡片(s/n 各一),卡片带档位角标; +/// 卡片状态(下载进度/激活)按 (数据集, 档位) 独立记账。 +/// +/// 卡片状态机(档位 == 当前识别档位时与原单档行为一致): +/// - 未下载 →「使用/下载」:下载完成自动激活(当前档立即使用,非当前档备好待切); +/// - 已下载未激活 →「使用」直接激活;已激活且当前档 →「已使用」点击取消; +/// - 已激活但属非当前档 →「已备好」(已下载就绪,点击切换到该档立即生效)。 class ModelCatalogSection extends StatelessWidget { final ModelManager manager; @@ -15,11 +21,29 @@ class ModelCatalogSection extends StatelessWidget { return ListenableBuilder( listenable: manager, builder: (context, _) { - final items = manager.catalog; + // 目录行按 (数据集, 档位) 稳定排序:同数据集 s 前 n 后相邻展示 + final items = [...manager.catalog]..sort((a, b) { + if (a.datasetId != b.datasetId) { + return a.datasetId.compareTo(b.datasetId); + } + return a.variant.compareTo(b.variant); + }); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row( + children: [ + const Text('识别模式', + style: TextStyle(color: Colors.white70, fontSize: 14)), + const Spacer(), + _ModeToggle(manager: manager), + ], + ), + const SizedBox(height: 2), + const Text('切换即加载该档位已激活模型(激活状态跨档保留)', + style: TextStyle(color: Colors.white38, fontSize: 11)), + const SizedBox(height: 12), Row( children: [ const Text('模型清单', @@ -71,6 +95,57 @@ class ModelCatalogSection extends StatelessWidget { } } +/// 档位分段控件:s 高识别 / n 高性能 +class _ModeToggle extends StatelessWidget { + final ModelManager manager; + + const _ModeToggle({required this.manager}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.white12, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final v in const [kVariantS, kVariantN]) ...[ + if (v != kVariantS) const SizedBox(width: 2), + _seg(v), + ], + ], + ), + ); + } + + Widget _seg(String v) { + final selected = manager.mode == v; + final label = v == kVariantS ? 's 高识别' : 'n 高性能'; + return InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => manager.setMode(v), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: selected ? Colors.greenAccent : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + style: TextStyle( + color: selected ? Colors.black : Colors.white70, + fontSize: 12, + fontWeight: selected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } +} + class _ModelCard extends StatelessWidget { final ModelCatalogItem item; final ModelManager manager; @@ -79,33 +154,62 @@ class _ModelCard extends StatelessWidget { @override Widget build(BuildContext context) { - final active = manager.isActive(item.datasetId); - final downloaded = manager.isDownloaded(item.datasetId); - final progress = manager.progressOf(item.datasetId); - final error = manager.errorOf(item.datasetId); + final variant = item.variant; + final sameMode = manager.mode == variant; + final active = manager.isActive(item.datasetId, variant); + final downloaded = manager.isDownloaded(item.datasetId, variant); + final progress = manager.progressOf(item.datasetId, variant); + final error = manager.errorOf(item.datasetId, variant); + final accent = + variant == kVariantS ? Colors.greenAccent : Colors.orangeAccent; final thumb = ClipRRect( borderRadius: BorderRadius.circular(8), child: AspectRatio( aspectRatio: 4 / 3, - child: Image.network( - '${manager.baseUrl}${item.coverUrl}', - fit: BoxFit.cover, - loadingBuilder: (context, child, chunk) => chunk == null - ? child - : Container( - color: Colors.white12, - child: const Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2)))), - errorBuilder: (context, error, stack) => Container( - color: Colors.white12, - child: const Icon(Icons.image_not_supported_outlined, - color: Colors.white38), - ), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + '${manager.baseUrl}${item.coverUrl}', + fit: BoxFit.cover, + loadingBuilder: (context, child, chunk) => chunk == null + ? child + : Container( + color: Colors.white12, + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2)))), + errorBuilder: (context, error, stack) => Container( + color: Colors.white12, + child: const Icon(Icons.image_not_supported_outlined, + color: Colors.white38), + ), + ), + // 档位角标:s 高识别(绿)/ n 高性能(橙) + Positioned( + top: 4, + left: 4, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + variant == kVariantS ? 's' : 'n', + style: const TextStyle( + color: Colors.black87, + fontSize: 10, + fontWeight: FontWeight.bold), + ), + ), + ), + ], ), ), ); @@ -132,7 +236,8 @@ class _ModelCard extends StatelessWidget { ), ), TextButton( - onPressed: () => manager.cancelDownload(item.datasetId), + onPressed: () => + manager.cancelDownload(item.datasetId, variant), style: TextButton.styleFrom( foregroundColor: Colors.white54, visualDensity: VisualDensity.compact, @@ -160,22 +265,38 @@ class _ModelCard extends StatelessWidget { ), ], ); - } else if (active) { - action = SizedBox( - height: 30, - child: OutlinedButton( - onPressed: () => manager.setActive(item.datasetId, false), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.greenAccent, - side: const BorderSide(color: Colors.greenAccent)), - child: const Text('已使用', style: TextStyle(fontSize: 12)), - ), - ); + } else if (active && downloaded) { + if (sameMode) { + // 当前档已激活:点击取消 + action = SizedBox( + height: 30, + child: OutlinedButton( + onPressed: () => + manager.setActive(item.datasetId, variant, false), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.greenAccent, + side: const BorderSide(color: Colors.greenAccent)), + child: const Text('已使用', style: TextStyle(fontSize: 12)), + ), + ); + } else { + // 非当前档已备好(激活保留):点击切换到该档立即生效 + action = SizedBox( + height: 30, + child: OutlinedButton( + onPressed: () => manager.setMode(variant), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.amberAccent, + side: const BorderSide(color: Colors.amberAccent)), + child: const Text('已备好', style: TextStyle(fontSize: 12)), + ), + ); + } } else if (downloaded) { action = SizedBox( height: 30, child: FilledButton( - onPressed: () => manager.setActive(item.datasetId, true), + onPressed: () => manager.setActive(item.datasetId, variant, true), style: FilledButton.styleFrom( backgroundColor: Colors.greenAccent, foregroundColor: Colors.black, @@ -192,7 +313,7 @@ class _ModelCard extends StatelessWidget { backgroundColor: Colors.greenAccent, foregroundColor: Colors.black, visualDensity: VisualDensity.compact), - child: const Text('使用', style: TextStyle(fontSize: 12)), + child: Text(sameMode ? '使用' : '下载', style: const TextStyle(fontSize: 12)), ), ); } @@ -208,11 +329,31 @@ class _ModelCard extends StatelessWidget { children: [ Expanded(child: Center(child: thumb)), const SizedBox(height: 6), - Text(item.datasetName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)), + Row( + children: [ + Expanded( + child: Text(item.datasetName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600)), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + variant == kVariantS ? '高识别' : '高性能', + style: TextStyle(color: accent, fontSize: 9), + ), + ), + ], + ), const SizedBox(height: 2), Text('v${item.version}', style: const TextStyle(color: Colors.white38, fontSize: 10)), diff --git a/flutter_app/lib/detection/background_model.dart b/flutter_app/lib/detection/background_model.dart index d3964ae..5ef83ff 100644 --- a/flutter_app/lib/detection/background_model.dart +++ b/flutter_app/lib/detection/background_model.dart @@ -8,7 +8,7 @@ import 'motion_aggregator.dart'; /// 分块聚合为新颖区域(novelty)。 /// /// 固定机位下,常驻物体(键盘/石头/文字)永远属于背景、不产生新颖区域; -/// 走进画面的目标(环颈雉鸡移动/新出现)才会触发。比相邻帧差分更强的证据: +/// 走进画面的目标(动物移动/新出现)才会触发。比相邻帧差分更强的证据: /// 风吹草动是持续的背景更新,不会长期标记为新颖。 class BackgroundModel { final int maxWidth; diff --git a/flutter_app/lib/detection/detector_worker.dart b/flutter_app/lib/detection/detector_worker.dart index a7023dd..b6d7692 100644 --- a/flutter_app/lib/detection/detector_worker.dart +++ b/flutter_app/lib/detection/detector_worker.dart @@ -61,7 +61,14 @@ class DetectorWorker { try { if (models == null || models.isEmpty) return null; final payload = >[ - for (final m in models) [m.bytes, m.labels, m.datasetId, m.datasetName], + for (final m in models) + [ + m.bytes, + m.labels, + m.datasetId, + m.datasetName, + m.variant, + ], ]; final responses = ReceivePort(); @@ -222,11 +229,15 @@ Future _workerMain(SendPort mainPort) async { for (final entry in list[1] as List) { final e = entry as List; final name = e.length > 3 ? e[3] as String : ''; + // 模型名带档位标识(数据集名+档位,框来源可辨 s/n) + final variant = e.length > 4 ? e[4] as String : ''; + final displayName = + variant.isEmpty ? name : '$name($variant)'; final d = await TfliteDetector.fromBuffer( e[0] as Uint8List, (e[1] as List).cast(), modelId: (e[2] as num).toInt(), - modelName: name, + modelName: displayName, ); if (d == null) { failures.add(name.isEmpty ? 'unknown' : name); @@ -369,7 +380,7 @@ Future _workerMain(SendPort mainPort) async { results.addAll(dets); } results = mergeAcrossModels(results, TfliteDetector.iouThreshold); - // 低分环颈雉鸡框过视觉先验(颜色/位置),减少户外误报 + // 低分目标框过视觉先验(颜色/位置),减少户外误报 results = VisualPrior.filter( results, planes: planes, diff --git a/flutter_app/lib/detection/tflite_detector.dart b/flutter_app/lib/detection/tflite_detector.dart index d788d4f..93328a3 100644 --- a/flutter_app/lib/detection/tflite_detector.dart +++ b/flutter_app/lib/detection/tflite_detector.dart @@ -7,15 +7,16 @@ import 'package:tflite_flutter/tflite_flutter.dart'; import 'detection_result.dart'; import 'nms.dart'; -/// YOLOv8s 端侧推理实现(对应 Kotlin TFLiteDetector)。 +/// YOLO 端侧推理实现(对应 Kotlin TFLiteDetector)。 /// 模型输出布局(ultralytics litert 导出):[1, 4 + nc, anchors], /// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。 -/// 输入为 NCHW [1, 3, 1280, 1280](litert 导出保留 torch 布局)。 +/// 输入为 NCHW [1, 3, H, W](litert 导出保留 torch 布局),H/W 随模型档位: +/// s 高识别 @1280、n 高性能 @704,输入尺寸取自模型自身。 class TfliteDetector { // 输入尺寸取自模型本身(ultralytics litert 导出 NCHW [1,3,H,W],各数据集 // 训练 imgsz 可不同),默认 1280 兜底 static const int defaultInputSize = 1280; - // 环颈雉鸡数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升; + // 目标数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升; // 可运行时调整(设置页滑块),默认 0.10 double minScore = 0.10; static const double iouThreshold = 0.45; diff --git a/flutter_app/lib/detection/visual_prior.dart b/flutter_app/lib/detection/visual_prior.dart index 91c1ce1..edea92e 100644 --- a/flutter_app/lib/detection/visual_prior.dart +++ b/flutter_app/lib/detection/visual_prior.dart @@ -3,14 +3,15 @@ import 'dart:typed_data'; import 'detection_result.dart'; -/// 运行时视觉先验:对低置信度环颈雉鸡框做多线索过滤,降低户外误报。 +/// 运行时视觉先验:对低置信度目标物种框(class 0)做多线索过滤,降低户外误报。 +/// 识别哪些类为目标物种由模型训练决定,本先验不依赖任何具体物种名。 /// -/// 仅对 score < [maxScore](0.35)的 pheasant 框生效;高分框与 +/// 仅对 score < [maxScore](0.35)的目标框生效;高分框与 /// suspect(生境预警)不参与过滤,避免误杀。 /// /// 线索: /// - 颜色:绿色主导(草/叶)、蓝色主导(天空/水)、平坦低饱和(键盘/石头/文字) -/// - 位置:中心在画面上部 15%(天空区)——环颈雉鸡是地栖动物,不会出现在天空 +/// - 位置:中心在画面上部 15%(天空区)——目标物种为地面活动,不会出现在天空 /// /// 采样在原始 planes 上进行(后台 isolate 内,不依赖 UI 线程)。 class VisualPrior { @@ -39,9 +40,9 @@ class VisualPrior { if (results.isEmpty || width <= 0 || height <= 0) return results; final kept = []; for (final r in results) { - final lowConfPheasant = - (r.classId == 0 || r.label == 'pheasant') && r.score < maxScore; - if (lowConfPheasant && + // class 0 即目标物种(各数据集标签统一:0 目标 / 其余 suspect) + final lowConfTarget = r.classId == 0 && r.score < maxScore; + if (lowConfTarget && _reject(r, planes, strides, width, height, isBgra, rgbaOrder)) { continue; } @@ -86,7 +87,7 @@ class VisualPrior { /// 读取单像素 RGB(0~255)。 /// 8888 单平面按实际字节序取通道:BGRA=[b,g,r,a](iOS 插件)、 /// RGBA=[r,g,b,a](Android 自写原生通道)——字节序写死会让 Android - /// 低分框采样到 R/B 互换的颜色(橙色环颈雉鸡身被误判成"蓝色")整批误杀; + /// 低分框采样到 R/B 互换的颜色(橙色目标躯体被误判成"蓝色")整批误杀; /// YUV:y 平面 + 4:2:0 半分辨率 U/V(NV12 交错或 I420 分离)。 static (double, double, double) _pixel(List planes, List strides, int x, int y, int width, int height, bool isBgra, diff --git a/flutter_app/lib/models/model_manager.dart b/flutter_app/lib/models/model_manager.dart index 072494e..d13ea12 100644 --- a/flutter_app/lib/models/model_manager.dart +++ b/flutter_app/lib/models/model_manager.dart @@ -9,11 +9,20 @@ import 'package:path_provider/path_provider.dart'; import '../config/app_config.dart'; +/// 识别档位标识:s = 高识别(@1280 精度优先,默认),n = 高性能(@704 速度优先) +const String kVariantS = 's'; +const String kVariantN = 'n'; + +/// 模型身份键:同一数据集不同档位是两个独立条目(下载/激活/记账互不影响) +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 version; final List labels; final int sizeBytes; @@ -24,6 +33,7 @@ class ModelCatalogItem { const ModelCatalogItem({ required this.datasetId, required this.datasetName, + this.variant = kVariantS, required this.version, required this.labels, required this.sizeBytes, @@ -36,6 +46,8 @@ class ModelCatalogItem { 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, version: j['version'] as String? ?? '', labels: (j['labels'] as List? ?? const []) .map((e) => e.toString()) @@ -51,6 +63,7 @@ class ModelCatalogItem { class ModelBundle { final int datasetId; final String datasetName; + final String variant; final String version; final List labels; final Uint8List bytes; @@ -58,6 +71,7 @@ class ModelBundle { const ModelBundle({ required this.datasetId, required this.datasetName, + required this.variant, required this.version, required this.labels, required this.bytes, @@ -67,8 +81,12 @@ class ModelBundle { /// 模型热更新管理:启动时拉取模型目录(随 /app/update 公开接口下发,无需登录态), /// 按需下载/校验/持久化各数据集模型,供相机页多模型并行推理。 /// -/// 存储:应用私有目录 `models//`(model.tflite + labels.json + meta.json), -/// meta 记录 {version, sha256},服务器发布新版本时按版本+摘要重下,不重复下载旧模型。 +/// 双档位存储(2026-09-03):`models//` 存放 s 档(legacy 布局,目录键 = +/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models//n/`; +/// 各目录含 model.tflite + labels.json + meta.json,meta 记录 {version, sha256}, +/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。 +/// 激活集与识别档位无关(跨档保持);推理加载「当前档位」下全部激活模型, +/// 切档即热加载新档位已激活模型([mode] 持久化本地)。 class ModelManager extends ChangeNotifier { static final ModelManager instance = ModelManager._(); @@ -78,11 +96,13 @@ class ModelManager extends ChangeNotifier { List _models = const []; List _catalog = const []; - final Set _activeIds = {}; - final Set _downloadedIds = {}; - final Map _progress = {}; - final Map _errors = {}; - final Set _cancelRequested = {}; + final Set _active = {}; + final Set _downloaded = {}; + final Map _progress = {}; + final Map _errors = {}; + final Set _cancelRequested = {}; + String _mode = kVariantS; + bool _modeLoaded = false; bool _activeLoaded = false; bool _ready = false; bool _refreshing = false; @@ -94,27 +114,31 @@ class ModelManager extends ChangeNotifier { int _revision = 0; int get revision => _revision; - /// 服务器目录(弹层模型清单展示用) + /// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行) List get catalog => _catalog; - /// 激活模型 id 集合(多选叠加) - Set get activeDatasetIds => Set.unmodifiable(_activeIds); + /// 当前识别档位(s 高识别默认 / n 高性能),持久化,切档即热加载新档位模型 + String get mode => _mode; - bool isActive(int datasetId) => _activeIds.contains(datasetId); + bool isActive(int datasetId, String variant) => + _active.contains((datasetId: datasetId, variant: variant)); - /// 该数据集模型文件是否已下载到本地(同步判断,内存态) - bool isDownloaded(int datasetId) => _downloadedIds.contains(datasetId); + /// 该 (数据集, 档位) 模型文件是否已下载到本地(同步判断,内存态) + bool isDownloaded(int datasetId, String variant) => + _downloaded.contains((datasetId: datasetId, variant: variant)); /// 下载进度 0..1(无下载/已完成为 null) - double? progressOf(int datasetId) => _progress[datasetId]; + double? progressOf(int datasetId, String variant) => + _progress[(datasetId: datasetId, variant: variant)]; /// 下载失败原因(失败后可重试) - String? errorOf(int datasetId) => _errors[datasetId]; + String? errorOf(int datasetId, String variant) => + _errors[(datasetId: datasetId, variant: variant)]; /// 中断进行中的下载:下一个数据块到达时终止(丢弃 .part),卡片恢复「使用」。 /// 取消不记错误,可再次下载。 - void cancelDownload(int datasetId) { - _cancelRequested.add(datasetId); + void cancelDownload(int datasetId, String variant) { + _cancelRequested.add((datasetId: datasetId, variant: variant)); } ModelManager._({String? baseUrl, http.Client? client}) @@ -130,7 +154,7 @@ class ModelManager extends ChangeNotifier { _client = client ?? http.Client(), _rootDirOverride = rootDir; - /// 已激活且已下载的模型列表(空 = 未下载任何模型,相机页仅预览) + /// 已激活且已下载、且属于当前档位的模型列表(空 = 未加载任何模型,仅预览) List get models => _models; /// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空") @@ -141,10 +165,21 @@ class ModelManager extends ChangeNotifier { bool get refreshing => _refreshing; - /// 模型名摘要(诊断行展示):未下载 / 数据集名×n + /// 模型名摘要(诊断行展示):数据集名+档位 String get modelsLabel { if (_models.isEmpty) return '未下载'; - return _models.map((m) => m.datasetName).join(','); + return _models.map((m) => '${m.datasetName}(${m.variant})').join(','); + } + + /// 切换识别档位:持久化本地并热加载该档位已激活模型(激活集跨档保留) + Future setMode(String variant) async { + if (variant != kVariantS && variant != kVariantN) return; + if (_mode == variant) return; + _mode = variant; + await _saveMode(); + _models = await _loadBundles(_catalog); + _revision++; + notifyListeners(); } /// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。 @@ -162,6 +197,7 @@ class ModelManager extends ChangeNotifier { Future _doRefresh() async { try { await _loadActive(); + await _loadMode(); final res = await _client .get(Uri.parse('$baseUrl/api/v1/app/update')) .timeout(const Duration(seconds: 30)); @@ -175,19 +211,26 @@ class ModelManager extends ChangeNotifier { .toList(); // 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示 - final downloaded = {}; + final downloaded = {}; for (final item in _catalog) { - if (await _isLocal(item)) downloaded.add(item.datasetId); + if (await _isLocal(item)) { + downloaded.add((datasetId: item.datasetId, variant: item.variant)); + } } - _downloadedIds + _downloaded ..clear() ..addAll(downloaded); await _prune(_catalog); - // 服务器已下线的数据集移出激活集 - final catalogIds = _catalog.map((c) => c.datasetId).toSet(); - if (_activeIds.any((id) => !catalogIds.contains(id))) { - _activeIds.removeWhere((id) => !catalogIds.contains(id)); + // 服务器已下线的 (数据集, 档位) 移出激活集 + final catalogKeys = _catalog + .map((c) => (datasetId: c.datasetId, variant: c.variant)) + .toSet(); + final pruned = _active.where((k) => !catalogKeys.contains(k)).toList(); + if (pruned.isNotEmpty) { + for (final k in pruned) { + _active.remove(k); + } await _saveActive(); } @@ -204,7 +247,7 @@ class ModelManager extends ChangeNotifier { /// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在) Future _isLocal(ModelCatalogItem item) async { - final dir = await _modelDir(item.datasetId); + final dir = await _modelDir(item.datasetId, item.variant); try { final meta = await _readMeta(dir); final file = File('${dir.path}/model.tflite'); @@ -218,72 +261,74 @@ class ModelManager extends ChangeNotifier { } /// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta); - /// 成功自动加入激活集(下载完成即使用)。失败重试一次并记录错误。 + /// 成功自动加入激活集(下载完成即使用;非当前档位则备好待切档)。 + /// 失败重试一次并记录错误。 Future downloadModel(ModelCatalogItem item, {void Function(int received, int total)? onProgress}) async { - // 并发保护:同一数据集已有进行中的下载则直接短路(预置 0 先占位, - // 使 onProgress 首次回调前的双击/refresh 交错也被 containsKey 拦下) - if (_progress.containsKey(item.datasetId)) return false; - _cancelRequested.remove(item.datasetId); - _progress[item.datasetId] = 0; - final dir = await _modelDir(item.datasetId); + 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(item.datasetId)) break; + if (_cancelRequested.contains(key)) break; final ok = await _downloadAndVerify(item, dir, file, onProgress: (r, t) { - _progress[item.datasetId] = t == 0 ? 0 : r / t; + _progress[key] = t == 0 ? 0 : r / t; onProgress?.call(r, t); notifyListeners(); }); if (ok) { - _progress.remove(item.datasetId); - _errors.remove(item.datasetId); - _downloadedIds.add(item.datasetId); - _revision++; - // 新版本字节立即生效:自动更新时已激活模型 setActive 会因状态未变 - // 提前返回,不在此重载则重建 worker 仍读到旧模型 + _progress.remove(key); + _errors.remove(key); + _downloaded.add(key); + if (item.variant == _mode) { + // 字节替换生效(当前档位模型更新需重建 worker 读新文件) + _revision++; + } _models = await _loadBundles(_catalog); notifyListeners(); - await setActive(item.datasetId, true); + await setActive(item.datasetId, item.variant, true); return true; } - if (_cancelRequested.contains(item.datasetId)) break; + if (_cancelRequested.contains(key)) break; await file.delete().catchError((_) => file); await File('${dir.path}/model.tflite.part') .delete() .catchError((_) => file); } - if (_cancelRequested.contains(item.datasetId)) { + if (_cancelRequested.contains(key)) { // 用户取消:清理残留,不记错误 await file.delete().catchError((_) => file); await File('${dir.path}/model.tflite.part') .delete() .catchError((_) => file); - _progress.remove(item.datasetId); + _progress.remove(key); notifyListeners(); debugPrint('[ModelManager] 下载已取消: ${item.datasetName}'); return false; } - _progress.remove(item.datasetId); - _errors[item.datasetId] = '下载失败,请重试'; + _progress.remove(key); + _errors[key] = '下载失败,请重试'; notifyListeners(); debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}'); return false; } catch (e) { - if (_cancelRequested.contains(item.datasetId)) { + if (_cancelRequested.contains(key)) { await file.delete().catchError((_) => file); await File('${dir.path}/model.tflite.part') .delete() .catchError((_) => file); - _progress.remove(item.datasetId); + _progress.remove(key); notifyListeners(); debugPrint('[ModelManager] 下载已取消: ${item.datasetName}'); return false; } - _progress.remove(item.datasetId); - _errors[item.datasetId] = '下载异常:$e'; + _progress.remove(key); + _errors[key] = '下载异常:$e'; notifyListeners(); debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e'); return false; @@ -309,12 +354,16 @@ class ModelManager extends ChangeNotifier { final total = res.contentLength ?? item.sizeBytes; await for (final chunk in res.stream.timeout(const Duration(seconds: 30))) { - if (_cancelRequested.contains(item.datasetId)) break; // 用户取消 + if (_cancelRequested.contains( + (datasetId: item.datasetId, variant: item.variant))) { + break; // 用户取消 + } sink.add(chunk); received += chunk.length; onProgress?.call(received, total); } - if (_cancelRequested.contains(item.datasetId)) { + if (_cancelRequested.contains( + (datasetId: item.datasetId, variant: item.variant))) { await sink.close(); return false; } @@ -334,7 +383,7 @@ class ModelManager extends ChangeNotifier { 'version': item.version, 'sha256': item.sha256, })); - debugPrint('[ModelManager] 已下载 ${item.datasetName} ' + debugPrint('[ModelManager] 已下载 ${item.datasetName}(${item.variant}) ' '${bytes.length}B -> ${file.path}'); return true; } catch (e) { @@ -344,30 +393,47 @@ class ModelManager extends ChangeNotifier { } } - /// 清理服务器目录中已下线的数据集模型(不再发布则删本地) + /// 清理本地目录:数据集整体下线(s/n 两档都无目录条目)删整目录; + /// 数据集仍在但某档已下线时清该档子目录(s 档为同级文件,无独立目录, + /// 残留文件不再被引用,仅占用磁盘,不做细粒度清除)。 Future _prune(List catalog) async { final root = await _rootDir(); if (!await root.exists()) return; - final keep = catalog.map((c) => '${c.datasetId}').toSet(); + 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) { - // 目录 URI 末尾带 '/',pathSegments 末位为空串 → 过滤后取目录名 - final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last; - if (!keep.contains(name)) { - await e.delete(recursive: true).catchError((_) => e); + 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=取消);持久化到 `root/active.json`。 + /// 激活跨档位保留(切档后仍生效);仅影响当前档位加载时才需重建推理 worker。 /// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。 - Future setActive(int datasetId, bool active) async { - final changed = - active ? _activeIds.add(datasetId) : _activeIds.remove(datasetId); + Future setActive(int datasetId, String variant, bool active) async { + final key = (datasetId: datasetId, variant: variant); + final changed = active ? _active.add(key) : _active.remove(key); if (!changed) return; - _revision++; - _models = await _loadBundles(_catalog); + if (variant == _mode) { + _revision++; + _models = await _loadBundles(_catalog); + } await _saveActive(); notifyListeners(); } @@ -377,15 +443,20 @@ class ModelManager extends ChangeNotifier { /// 2026-09-01 用户需求:发布新模型后 App 端自动更新,无需手动触发。 Future autoUpdate() async { if (_catalog.isEmpty) return; - final tracked = {..._downloadedIds, ..._activeIds}; + final tracked = {..._downloaded, ..._active}; for (final item in _catalog) { - if (!tracked.contains(item.datasetId)) continue; - if (await _isLocal(item)) continue; - final wasActive = _activeIds.contains(item.datasetId); + final key = (datasetId: item.datasetId, variant: item.variant); + if (!tracked.contains(key)) continue; + final wasActive = _active.contains(key); try { + // 后台 fire-and-forget:本地检查/下载都可能撞上存储变动(如清理), + // 不得向外抛未处理异步异常 + if (await _isLocal(item)) continue; final ok = await downloadModel(item); // 原本未激活:下载完成自动激活后恢复原状态 - if (ok && !wasActive) await setActive(item.datasetId, false); + if (ok && !wasActive) { + await setActive(item.datasetId, item.variant, false); + } } catch (e) { debugPrint('[ModelManager] 自动更新失败: ${item.datasetName} $e'); } @@ -396,13 +467,17 @@ class ModelManager extends ChangeNotifier { try { final root = await _rootDir(); await root.create(recursive: true); - await File('${root.path}/active.json') - .writeAsString(jsonEncode({'active': _activeIds.toList()})); + await File('${root.path}/active.json').writeAsString(jsonEncode({ + 'active': [ + for (final k in _active) {'d': k.datasetId, 'v': k.variant} + ] + })); } catch (e) { debugPrint('[ModelManager] 激活集持久化失败: $e'); } } + /// 读取激活集。兼容旧版纯 int 列表(双档位前只有 s 档,int 一律归为 s)。 Future _loadActive() async { if (_activeLoaded) return; _activeLoaded = true; @@ -411,22 +486,56 @@ class ModelManager extends ChangeNotifier { final f = File('${root.path}/active.json'); if (!await f.exists()) return; final data = jsonDecode(await f.readAsString()) as Map; - _activeIds - ..clear() - ..addAll((data['active'] as List? ?? const []) - .map((e) => (e as num).toInt())); + for (final e in data['active'] as List? ?? const []) { + if (e is num) { + _active.add((datasetId: e.toInt(), variant: kVariantS)); + } else if (e is Map) { + final v = e['v'] as String? ?? kVariantS; + final d = (e['d'] as num?)?.toInt(); + if (d != null && v != kVariantS && v != kVariantN) continue; + if (d != null) _active.add((datasetId: d, variant: v)); + } + } } catch (e) { debugPrint('[ModelManager] 激活集读取失败: $e'); } } + 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 m = data['mode'] as String? ?? kVariantS; + if (m == kVariantS || m == kVariantN) _mode = m; + } catch (e) { + debugPrint('[ModelManager] 识别档位读取失败: $e'); + } + } + Future> _loadBundles( List catalog) async { final bundles = []; for (final item in catalog) { - if (!_activeIds.contains(item.datasetId)) continue; + if (item.variant != _mode) continue; + final key = (datasetId: item.datasetId, variant: item.variant); + if (!_active.contains(key)) continue; try { - final dir = await _modelDir(item.datasetId); + 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() @@ -439,6 +548,7 @@ class ModelManager extends ChangeNotifier { bundles.add(ModelBundle( datasetId: item.datasetId, datasetName: item.datasetName, + variant: item.variant, version: item.version, labels: labels, bytes: await file.readAsBytes(), @@ -466,9 +576,12 @@ class ModelManager extends ChangeNotifier { return Directory('${support.path}/models'); } - Future _modelDir(int datasetId) async { + /// 档位目录:s 档存 `models//`(legacy 无子目录,目录键 = 档位 + /// 标识符的 s 形态,存量设备零迁移);n 档存 `models//n/`。 + Future _modelDir(int datasetId, String variant) async { final root = await _rootDir(); - final dir = Directory('${root.path}/$datasetId'); + final sub = variant == kVariantS ? '' : '/$variant'; + final dir = Directory('${root.path}/$datasetId$sub'); await dir.create(recursive: true); return dir; } diff --git a/flutter_app/test/model_catalog_section_test.dart b/flutter_app/test/model_catalog_section_test.dart index 07926a9..5766837 100644 --- a/flutter_app/test/model_catalog_section_test.dart +++ b/flutter_app/test/model_catalog_section_test.dart @@ -20,15 +20,27 @@ Stream> _delayedChunks() async* { yield [5, 6, 7, 8]; } -Map _item() => { - 'datasetId': 7, - 'datasetName': '环颈雉鸡数据集', +Map _item({ + int datasetId = 7, + String variant = kVariantS, + String name = '数据集A', +}) => + { + 'datasetId': datasetId, + 'datasetName': name, + if (variant.isNotEmpty) 'variant': variant, 'version': 'v1.0.0', - 'labels': ['pheasant', 'suspect'], + 'labels': ['target', 'suspect'], 'sizeBytes': _modelBytes.length, 'sha256': _shaHex(_modelBytes), - 'downloadUrl': '/download/models/7/latest.tflite', - 'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE', + 'downloadUrl': '/download/models/$datasetId/$variant.tflite', + 'coverUrl': '/api/v1/app/cover?namePrefix=DS001', + }; + +Map _catalog(List> models) => { + 'code': 0, + 'message': 'ok', + 'data': {'models': models} }; /// 真实流式下载客户端:send 立即返回分块流(MockClient 的 Response.fromStream @@ -38,11 +50,8 @@ class _StreamingClient extends http.BaseClient { Future send(http.BaseRequest request) async { if (request.url.path == '/api/v1/app/update') { return http.StreamedResponse( - http.ByteStream.fromBytes(utf8.encode(jsonEncode({ - 'code': 0, - 'message': 'ok', - 'data': {'models': [_item()]} - }))), + http.ByteStream.fromBytes(utf8.encode( + jsonEncode(_catalog([_item()])))), 200); } return http.StreamedResponse(_delayedChunks(), 200, @@ -59,17 +68,12 @@ void main() { tearDown(() => root.delete(recursive: true)); - ModelManager manager() => ModelManager( + ModelManager manager(List> models) => ModelManager( baseUrl: 'http://test.local', client: MockClient((req) async { if (req.url.path == '/api/v1/app/update') { return http.Response.bytes( - utf8.encode(jsonEncode({ - 'code': 0, - 'message': 'ok', - 'data': {'models': [_item()]} - })), - 200); + utf8.encode(jsonEncode(_catalog(models))), 200); } if (req.url.path.startsWith('/download/models/')) { return http.Response.bytes(_modelBytes, 200); @@ -99,44 +103,55 @@ void main() { } Future pumpSection(WidgetTester tester, ModelManager m) async { + // 与设置弹层一致:区块置于可滚动容器(区块含识别模式行后超出测试视口, + // 点按前需 ensureVisible 滚动到目标) await tester.pumpWidget(MaterialApp( - home: Scaffold(body: ModelCatalogSection(manager: m)))); + home: Scaffold( + body: SingleChildScrollView( + child: ModelCatalogSection(manager: m))))); await tester.pumpAndSettle(const Duration(milliseconds: 50), EnginePhase.sendSemanticsUpdate, const Duration(seconds: 5)); } + /// 滚动到目标可见后点按(区块可超出视口) + Future tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pump(); + await tester.tap(finder); + } + testWidgets('未下载:显示使用按钮,点击后下载完成自动变为已使用', (tester) async { - final m = manager(); + final m = manager([_item()]); await tester.runAsync(() => m.refresh()); await pumpSection(tester, m); - expect(find.text('环颈雉鸡数据集'), findsOneWidget); + expect(find.text('数据集A'), findsOneWidget); expect(find.text('使用'), findsOneWidget); - await tester.tap(find.text('使用')); + await tapVisible(tester, find.text('使用')); await tester.pump(); // 下载启动,进度条出现 // 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「已使用」 await pumpUntilFound(tester, find.text('已使用')); await tester.pumpAndSettle(); - expect(m.isDownloaded(7), isTrue); - expect(m.isActive(7), isTrue); + expect(m.isDownloaded(7, kVariantS), isTrue); + expect(m.isActive(7, kVariantS), isTrue); expect(find.text('已使用'), findsOneWidget); }); testWidgets('已激活:再次点击取消使用', (tester) async { - final m = manager(); + final m = manager([_item()]); await tester.runAsync(() => m.refresh()); await tester.runAsync(() => m.downloadModel(m.catalog.first)); await pumpSection(tester, m); expect(find.text('已使用'), findsOneWidget); - await tester.tap(find.text('已使用')); + await tapVisible(tester, find.text('已使用')); // 排空 setActive 的激活集重载与落盘 IO,直至 UI 呈现「使用」 await pumpUntilFound(tester, find.text('使用')); await tester.pumpAndSettle(); - expect(m.isActive(7), isFalse); + expect(m.isActive(7, kVariantS), isFalse); expect(find.text('使用'), findsOneWidget); }); @@ -144,8 +159,7 @@ void main() { final m = ModelManager( baseUrl: 'http://test.local', client: MockClient((_) async => http.Response.bytes( - utf8.encode(jsonEncode( - {'code': 0, 'message': 'ok', 'data': {'models': []}})), + utf8.encode(jsonEncode(_catalog([]))), 200)), rootDir: () async => root, ); @@ -164,20 +178,83 @@ void main() { await tester.runAsync(() => m.refresh()); await pumpSection(tester, m); - await tester.tap(find.text('使用')); + await tapVisible(tester, find.text('使用')); // 首个分块到达,进度条与取消按钮出现(IO 链需交替驱动) await pumpUntilFound(tester, find.text('取消'), fake: const Duration(milliseconds: 400)); expect(find.text('取消'), findsOneWidget); - await tester.tap(find.text('取消')); + await tapVisible(tester, find.text('取消')); // 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「使用」 await pumpUntilFound(tester, find.text('使用'), fake: const Duration(milliseconds: 400)); await tester.pumpAndSettle(); - expect(m.isDownloaded(7), isFalse); - expect(m.isActive(7), isFalse); + expect(m.isDownloaded(7, kVariantS), isFalse); + expect(m.isActive(7, kVariantS), isFalse); expect(find.text('使用'), findsOneWidget); }); + + testWidgets('双档位:s/n 两卡独立,n 档下载备好后切档即用', (tester) async { + final m = + manager([_item(), _item(variant: kVariantN, name: '数据集B')]); + await tester.runAsync(() => m.refresh()); + await pumpSection(tester, m); + + // 分段控件默认 s;两卡并排,s 卡按钮「使用」,非当前档 n 卡按钮「下载」 + expect(find.text('s 高识别'), findsOneWidget); + expect(find.text('数据集A'), findsOneWidget); + expect(find.text('数据集B'), findsOneWidget); + expect(find.text('s'), findsOneWidget, reason: '档位角标'); + expect(find.text('n'), findsOneWidget, reason: '档位角标'); + expect(find.text('使用'), findsOneWidget); + expect(find.text('下载'), findsOneWidget, reason: '非当前档入口为「下载」'); + + // 先激活 s 档 + await tapVisible(tester, find.text('使用')); + await pumpUntilFound(tester, find.text('已使用')); + expect(m.isActive(7, kVariantS), isTrue); + + // 下载 n 档:完成后自动备好(n 非当前档),显示「已备好」而非「已使用」 + await tapVisible(tester, find.text('下载')); + await pumpUntilFound(tester, find.text('已备好')); + await tester.pumpAndSettle(); + expect(m.isActive(7, kVariantN), isTrue); + expect(m.mode, kVariantS, reason: '下载非当前档不应改变识别档位'); + expect(m.models.length, 1); + expect(m.models.first.variant, kVariantS, reason: '当前仍只加载 s 档'); + + // 点「已备好」切换档位 → n 档立即生效(已使用),s 档转为备好态 + await tapVisible(tester, find.text('已备好')); + // 「已使用」切换前后都在场(s 使用 → n 使用),须等 manager 状态真正切档 + for (var i = 0; + i < 50 && + !(m.mode == kVariantN && + m.models.isNotEmpty && + m.models.first.variant == kVariantN); + i++) { + await pumpRealIo(tester); + } + await tester.pumpAndSettle(); + expect(m.mode, kVariantN); + expect(m.models.length, 1); + expect(m.models.first.variant, kVariantN, reason: '切档后热加载 n 档模型'); + expect(find.text('已使用'), findsOneWidget, reason: 'n 档卡已使用'); + expect(find.text('已备好'), findsOneWidget, reason: 's 档转为备好态'); + }); + + testWidgets('识别模式分段控件:点击切档生效', (tester) async { + final m = manager([_item(), _item(variant: kVariantN, name: '数据集A')]); + await tester.runAsync(() => m.refresh()); + await pumpSection(tester, m); + + await tapVisible(tester, find.text('n 高性能')); + for (var i = 0; i < 50 && m.mode != kVariantN; i++) { + await pumpRealIo(tester); + } + await tester.pumpAndSettle(); + expect(m.mode, kVariantN, reason: '点击分段控件应切档'); + // 档位持久化(重启恢复)由 model_manager_test「识别档位持久化」覆盖; + // widget 测试内 tap 触发的文件写与真实 IO 交错不可控,不在本层断言重启 + }); } diff --git a/flutter_app/test/model_manager_test.dart b/flutter_app/test/model_manager_test.dart index 5a3a76c..3c42c46 100644 --- a/flutter_app/test/model_manager_test.dart +++ b/flutter_app/test/model_manager_test.dart @@ -26,19 +26,21 @@ Map _catalog(List> models) => { Map _item({ int datasetId = 7, - String name = '环颈雉鸡数据集', + String variant = kVariantS, + String name = '数据集A', String version = 'v1.0.0', String sha = '', }) => { 'datasetId': datasetId, 'datasetName': name, + if (variant.isNotEmpty) 'variant': variant, 'version': version, - 'labels': ['pheasant', 'suspect'], + 'labels': ['target', 'suspect'], 'sizeBytes': _modelBytes.length, 'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha, - 'downloadUrl': '/download/models/$datasetId/latest.tflite', - 'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE', + 'downloadUrl': '/download/models/$datasetId/$variant.tflite', + 'coverUrl': '/api/v1/app/cover?namePrefix=DS001', }; void main() { @@ -50,7 +52,20 @@ void main() { downloadHits = 0; }); - tearDown(() => root.delete(recursive: true)); + tearDown(() async { + // refresh 末尾的 autoUpdate 为不阻塞目录刷新的 fire-and-forget:其真实 + // 文件 IO 可能晚于 test body 结束,delete 撞上迟到写入会 Directory not + // empty → 等待后重试清根(迟到链结束后即可删净) + for (var i = 0; i < 40; i++) { + try { + await root.delete(recursive: true); + return; + } on FileSystemException { + await Future.delayed(const Duration(milliseconds: 10)); + } + } + await root.delete(recursive: true); + }); ModelManager manager(MockClient client) => ModelManager( baseUrl: 'http://test.local', @@ -81,7 +96,9 @@ void main() { expect(downloadHits, 0, reason: 'refresh 不应触发下载'); expect(m.models, isEmpty, reason: '未激活的模型不应出现在 models'); expect(m.catalog.length, 1); - expect(m.catalog.first.coverUrl, '/api/v1/app/cover?namePrefix=RNPHE'); + expect(m.catalog.first.coverUrl, '/api/v1/app/cover?namePrefix=DS001'); + expect(m.catalog.first.variant, kVariantS, + reason: '旧目录无 variant 字段(单档 s)应归为 s'); }); test('downloadModel:下载+校验+落盘+自动激活+进度回调', () async { @@ -95,11 +112,12 @@ void main() { expect(ok, isTrue); expect(downloadHits, 1); - expect(m.isDownloaded(7), isTrue); - expect(m.isActive(7), isTrue, reason: '下载完成应自动使用'); + expect(m.isDownloaded(7, kVariantS), isTrue); + expect(m.isActive(7, kVariantS), isTrue, reason: '下载完成应自动使用'); expect(progresses.last, 1.0); expect(m.models.length, 1); - expect(m.models.first.datasetName, '环颈雉鸡数据集'); + expect(m.models.first.datasetName, '数据集A'); + expect(m.models.first.variant, kVariantS); final dir = Directory('${root.path}/7'); expect(await File('${dir.path}/model.tflite').exists(), isTrue); @@ -111,13 +129,13 @@ void main() { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); - await m.setActive(7, false); - expect(m.isActive(7), isFalse); + await m.setActive(7, kVariantS, false); + expect(m.isActive(7, kVariantS), isFalse); expect(m.models, isEmpty); final before = downloadHits; - await m.setActive(7, true); - expect(m.isActive(7), isTrue); + await m.setActive(7, kVariantS, true); + expect(m.isActive(7, kVariantS), isTrue); expect(downloadHits, before, reason: '已下载直接使用不应重新下载'); expect(m.models.length, 1); }); @@ -149,7 +167,7 @@ void main() { } expect(downloadHits, 2, reason: '新版本应自动重下'); - expect(m2.isActive(7), isTrue, reason: '自动更新应保持激活'); + expect(m2.isActive(7, kVariantS), isTrue, reason: '自动更新应保持激活'); expect(m2.models.first.version, 'v2.0.0', reason: '自动更新后立即生效新版本字节'); expect(m2.models.first.bytes, _modelBytes); @@ -166,9 +184,9 @@ void main() { final ok = await m2.downloadModel(m2.catalog.first); expect(ok, isFalse); expect(downloadHits, 2, reason: '校验失败应重试一次'); - expect(m2.isDownloaded(7), isFalse); - expect(m2.isActive(7), isFalse); - expect(m2.errorOf(7), isNotNull); + expect(m2.isDownloaded(7, kVariantS), isFalse); + expect(m2.isActive(7, kVariantS), isFalse); + expect(m2.errorOf(7, kVariantS), isNotNull); }); test('激活集持久化:重启后恢复激活且已下载的模型', () async { @@ -179,7 +197,7 @@ void main() { // 同一 root 新建 manager 模拟重启 final m2 = manager(client([_item()])); await m2.refresh(); - expect(m2.isActive(7), isTrue, reason: '激活集应持久化'); + expect(m2.isActive(7, kVariantS), isTrue, reason: '激活集应持久化'); expect(m2.models.length, 1); expect(downloadHits, 1, reason: '重启不应触发下载'); }); @@ -193,14 +211,14 @@ void main() { final m2 = manager(client([])); await m2.refresh(); expect(m2.models, isEmpty); - expect(m2.isActive(7), isFalse, reason: '下线的模型应移出激活集'); + expect(m2.isActive(7, kVariantS), isFalse, reason: '下线的模型应移出激活集'); expect(await Directory('${root.path}/7').exists(), isFalse, reason: '下线的数据集模型目录应被清理'); }); test('多模型:只激活其一则只加载其一', () async { final m = manager( - client([_item(datasetId: 7, name: '环颈雉'), _item(datasetId: 8, name: '斑鸠')])); + client([_item(datasetId: 7, name: '数据集A'), _item(datasetId: 8, name: '数据集B')])); await m.refresh(); await m.downloadModel(m.catalog.first); // 只下载并激活 7 expect(m.models.length, 1); @@ -241,16 +259,90 @@ void main() { final fut = m.downloadModel(m.catalog.first); // 首个分块到达后取消(模拟用户在下载中点取消) await Future.delayed(const Duration(milliseconds: 20)); - m.cancelDownload(7); + m.cancelDownload(7, kVariantS); final ok = await fut; expect(ok, isFalse); expect(downloadHits, 1); - expect(m.isDownloaded(7), isFalse); - expect(m.isActive(7), isFalse); - expect(m.progressOf(7), isNull); - expect(m.errorOf(7), isNull, reason: '取消不记错误'); + expect(m.isDownloaded(7, kVariantS), isFalse); + expect(m.isActive(7, kVariantS), isFalse); + expect(m.progressOf(7, kVariantS), isNull); + expect(m.errorOf(7, kVariantS), isNull, reason: '取消不记错误'); expect(await File('${root.path}/7/model.tflite.part').exists(), isFalse, reason: '取消后 .part 残留应被清理'); }); + + test('双档位:n 档独立目录 models/7/n/,非当前档下载不加载', () async { + final m = manager(client( + [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); + await m.refresh(); + expect(m.catalog.length, 2); + + // 默认 s 档:下载 s 自动激活并加载 + await m.downloadModel(m.catalog.first); // (7, s) + expect(m.models.length, 1); + expect(m.models.first.variant, kVariantS); + + // 非当前档(n)下载:自动备好(激活保留),但 s 档加载列表不变 + final nItem = m.catalog.last; + expect(nItem.variant, kVariantN); + await m.downloadModel(nItem); + expect(m.isDownloaded(7, kVariantN), isTrue); + expect(m.isActive(7, kVariantN), isTrue, reason: '下载完成应自动备好'); + expect(m.models.length, 1, reason: '非当前档模型不应进入加载列表'); + expect(m.models.first.variant, kVariantS); + + // n 档文件在独立子目录(目录键 = 档位),s 档仍为同级文件 + expect(await File('${root.path}/7/n/model.tflite').exists(), isTrue); + expect(await File('${root.path}/7/n/meta.json').exists(), isTrue); + expect(await File('${root.path}/7/model.tflite').exists(), isTrue, + reason: 's 档 legacy 同级布局保持不变'); + + // 切档:热加载 n 档已备模型,s 档不再加载(无需重新下载) + final before = downloadHits; + await m.setMode(kVariantN); + expect(m.mode, kVariantN); + expect(downloadHits, before, reason: '切档不应触发下载'); + expect(m.models.length, 1); + expect(m.models.first.variant, kVariantN); + expect(m.models.first.datasetName, '数据集A'); + + // 切回 s 档恢复 s 模型 + await m.setMode(kVariantS); + expect(m.models.length, 1); + expect(m.models.first.variant, kVariantS); + }); + + test('识别档位持久化:重启后保持档位并加载该档已激活模型', () async { + final m = manager(client( + [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); + await m.refresh(); + await m.downloadModel(m.catalog.first); // (7, s) + await m.downloadModel(m.catalog.last); // (7, n) 备好 + await m.setMode(kVariantN); + + // 同一 root 新建 manager 模拟重启:档位与激活集都持久化 + final m2 = manager(client( + [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); + await m2.refresh(); + expect(m2.mode, kVariantN, reason: '识别档位应持久化'); + expect(m2.isActive(7, kVariantS), isTrue, reason: '激活集应持久化'); + expect(m2.models.length, 1); + expect(m2.models.first.variant, kVariantN, reason: '重启后加载当前档位模型'); + expect(downloadHits, 2, reason: '重启与切档不应触发下载'); + }); + + test('数据集仍在但 n 档下线:清理 n 档子目录,保留数据集目录', () async { + final m = manager(client([_item(variant: kVariantN)])); + await m.refresh(); + await m.downloadModel(m.catalog.first); // (7, n) + expect(await File('${root.path}/7/n/model.tflite').exists(), isTrue); + + final m2 = manager(client([_item()])); // 目录只剩 s 档 + await m2.refresh(); + expect(await File('${root.path}/7/n/model.tflite').exists(), isFalse, + reason: '数据集仍在但 n 档下线:应清理 n 档子目录'); + expect(await Directory('${root.path}/7').exists(), isTrue, + reason: '数据集仍在(s 档):不应删整目录'); + }); } diff --git a/flutter_app/test/model_merge_test.dart b/flutter_app/test/model_merge_test.dart index aba85b4..8c27299 100644 --- a/flutter_app/test/model_merge_test.dart +++ b/flutter_app/test/model_merge_test.dart @@ -17,32 +17,32 @@ DetectionResult box(String label, double score, double x, double y, void main() { test('不同模型同标签重复框:NMS 去重取高分', () { - // 环颈雉鸡模型与野兔模型都检出了同一只"环颈雉鸡"(不同模型对同一目标的重复框) + // 两个模型都对同一目标检出同标签框(重复框需去重) final all = [ - box('pheasant', 0.18, 0.3, 0.3, modelId: 1, modelName: '环颈雉鸡模型'), - box('pheasant', 0.55, 0.31, 0.3, modelId: 2, modelName: '野兔模型'), + box('target', 0.18, 0.3, 0.3, modelId: 1, modelName: '模型A'), + box('target', 0.55, 0.31, 0.3, modelId: 2, modelName: '模型B'), ]; final merged = mergeAcrossModels(all, 0.45); expect(merged.length, 1); expect(merged.first.score, 0.55); - expect(merged.first.modelName, '野兔模型'); + expect(merged.first.modelName, '模型B'); }); test('不同类别重叠:去重取高分(实测多模型对同一目标检异类别)', () { final all = [ - box('pheasant', 0.3, 0.5, 0.5, modelId: 1), - box('hare', 0.7, 0.5, 0.5, modelId: 2), // 同位置但不同类别 + box('target', 0.3, 0.5, 0.5, modelId: 1), + box('second', 0.7, 0.5, 0.5, modelId: 2), // 同位置但不同类别 ]; final merged = mergeAcrossModels(all, 0.45); expect(merged.length, 1); - expect(merged.first.label, 'hare'); + expect(merged.first.label, 'second'); expect(merged.first.score, 0.7); }); test('不同类别不重叠:都保留', () { final all = [ - box('pheasant', 0.3, 0.1, 0.1, modelId: 1), - box('hare', 0.7, 0.8, 0.8, modelId: 2), // 远处互不重叠 + box('target', 0.3, 0.1, 0.1, modelId: 1), + box('second', 0.7, 0.8, 0.8, modelId: 2), // 远处互不重叠 ]; final merged = mergeAcrossModels(all, 0.45); expect(merged.length, 2); @@ -50,8 +50,8 @@ void main() { test('同模型内部与跨模型合并一致:远处不重叠保留', () { final all = [ - box('pheasant', 0.2, 0.1, 0.1, modelId: 1, modelName: '环颈雉鸡模型'), - box('pheasant', 0.3, 0.8, 0.8, modelId: 1, modelName: '环颈雉鸡模型'), + box('target', 0.2, 0.1, 0.1, modelId: 1, modelName: '模型A'), + box('target', 0.3, 0.8, 0.8, modelId: 1, modelName: '模型A'), ]; final merged = mergeAcrossModels(all, 0.45); expect(merged.length, 2); diff --git a/flutter_app/test/motion_aggregator_test.dart b/flutter_app/test/motion_aggregator_test.dart index e2d7972..aa6167f 100644 --- a/flutter_app/test/motion_aggregator_test.dart +++ b/flutter_app/test/motion_aggregator_test.dart @@ -54,7 +54,7 @@ void main() { test('centerInRegion_matches', () { final box = DetectionResult( - label: 'hare', + label: 'target', score: 0.30, left: 0.2, top: 0.3, diff --git a/server/biz/service/training.go b/server/biz/service/training.go index 6f11ea4..17dab5b 100644 --- a/server/biz/service/training.go +++ b/server/biz/service/training.go @@ -115,11 +115,14 @@ func (s *trainingService) pollOne(ctx context.Context, runner common.TrainingRun } if !alive { // 竞态防护:脚本原子写结果文件后进程随即退出,轮询可能命中「结果未就绪 + 进程已死」窗口; - // 判死前延迟重试一次,确认文件确实缺席(结果文件迟到属正常时序,非训练失败) - time.Sleep(2 * time.Second) - if r2, e2 := runner.FetchResult(ctx, job); e2 == nil && r2 != "" { - s.handleResult(ctx, runner, job, t, r2, tail) - return + // 判死前多次延迟重试,确认文件确实缺席(实测结果文件可比进程退出迟到数秒, + // 单次 2s 重试不够稳;2026-09-03 训练 37/38 曾因文件迟到被误判失败) + for i := 0; i < 5; i++ { + time.Sleep(5 * time.Second) + if r2, e2 := runner.FetchResult(ctx, job); e2 == nil && r2 != "" { + s.handleResult(ctx, runner, job, t, r2, tail) + return + } } _ = s.finishFailed(ctx, t, "训练进程已退出(无结果文件)") return diff --git a/server/data/observer.db b/server/data/observer.db index a6e27a1..58edc84 100644 Binary files a/server/data/observer.db and b/server/data/observer.db differ diff --git a/server/workspace/trainings/RNPHE_n.tflite b/server/workspace/trainings/RNPHE_n.tflite new file mode 100644 index 0000000..848480a Binary files /dev/null and b/server/workspace/trainings/RNPHE_n.tflite differ diff --git a/server/workspace/trainings/pigeon_n.tflite b/server/workspace/trainings/pigeon_n.tflite new file mode 100644 index 0000000..1ae28ba Binary files /dev/null and b/server/workspace/trainings/pigeon_n.tflite differ diff --git a/server/workspace/trainings/quail_n.tflite b/server/workspace/trainings/quail_n.tflite new file mode 100644 index 0000000..1105a4f Binary files /dev/null and b/server/workspace/trainings/quail_n.tflite differ diff --git a/server/workspace/trainings/rabbit_n.tflite b/server/workspace/trainings/rabbit_n.tflite new file mode 100644 index 0000000..377eed8 Binary files /dev/null and b/server/workspace/trainings/rabbit_n.tflite differ