This commit is contained in:
2026-09-03 10:01:42 +08:00
parent 71d042906c
commit 7bfafc7be3
18 changed files with 671 additions and 228 deletions
@@ -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<MotionRegion> motionRegions,
List<MotionRegion> 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;
@@ -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<DetectionResult> 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;
+186 -45
View File
@@ -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)),
@@ -8,7 +8,7 @@ import 'motion_aggregator.dart';
/// 分块聚合为新颖区域(novelty)。
///
/// 固定机位下,常驻物体(键盘/石头/文字)永远属于背景、不产生新颖区域;
/// 走进画面的目标(环颈雉鸡移动/新出现)才会触发。比相邻帧差分更强的证据:
/// 走进画面的目标(动物移动/新出现)才会触发。比相邻帧差分更强的证据:
/// 风吹草动是持续的背景更新,不会长期标记为新颖。
class BackgroundModel {
final int maxWidth;
+14 -3
View File
@@ -61,7 +61,14 @@ class DetectorWorker {
try {
if (models == null || models.isEmpty) return null;
final payload = <List<Object?>>[
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<void> _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<String>(),
modelId: (e[2] as num).toInt(),
modelName: name,
modelName: displayName,
);
if (d == null) {
failures.add(name.isEmpty ? 'unknown' : name);
@@ -369,7 +380,7 @@ Future<void> _workerMain(SendPort mainPort) async {
results.addAll(dets);
}
results = mergeAcrossModels(results, TfliteDetector.iouThreshold);
// 低分环颈雉鸡框过视觉先验(颜色/位置),减少户外误报
// 低分目标框过视觉先验(颜色/位置),减少户外误报
results = VisualPrior.filter(
results,
planes: planes,
@@ -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;
+8 -7
View File
@@ -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 = <DetectionResult>[];
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 {
/// 读取单像素 RGB0~255)。
/// 8888 单平面按实际字节序取通道:BGRA=[b,g,r,a]iOS 插件)、
/// RGBA=[r,g,b,a]Android 自写原生通道)——字节序写死会让 Android
/// 低分框采样到 R/B 互换的颜色(橙色环颈雉鸡身被误判成"蓝色")整批误杀;
/// 低分框采样到 R/B 互换的颜色(橙色目标躯体被误判成"蓝色")整批误杀;
/// YUVy 平面 + 4:2:0 半分辨率 U/VNV12 交错或 I420 分离)。
static (double, double, double) _pixel(List<Uint8List> planes,
List<int> strides, int x, int y, int width, int height, bool isBgra,
+196 -83
View File
@@ -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<String> 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<String> 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/<datasetId>/`model.tflite + labels.json + meta.json),
/// meta 记录 {version, sha256},服务器发布新版本时按版本+摘要重下,不重复下载旧模型。
/// 双档位存储(2026-09-03):`models/<datasetId>/` 存放 s 档(legacy 布局,目录键 =
/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models/<datasetId>/n/`
/// 各目录含 model.tflite + labels.json + meta.jsonmeta 记录 {version, sha256}
/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。
/// 激活集与识别档位无关(跨档保持);推理加载「当前档位」下全部激活模型,
/// 切档即热加载新档位已激活模型([mode] 持久化本地)。
class ModelManager extends ChangeNotifier {
static final ModelManager instance = ModelManager._();
@@ -78,11 +96,13 @@ class ModelManager extends ChangeNotifier {
List<ModelBundle> _models = const [];
List<ModelCatalogItem> _catalog = const [];
final Set<int> _activeIds = {};
final Set<int> _downloadedIds = {};
final Map<int, double> _progress = {};
final Map<int, String> _errors = {};
final Set<int> _cancelRequested = {};
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 _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<ModelCatalogItem> get catalog => _catalog;
/// 激活模型 id 集合(多选叠加)
Set<int> 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<ModelBundle> 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<void> 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<void> _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 = <int>{};
final downloaded = <ModelKey>{};
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<bool> _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<bool> 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<void> _prune(List<ModelCatalogItem> 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<void> setActive(int datasetId, bool active) async {
final changed =
active ? _activeIds.add(datasetId) : _activeIds.remove(datasetId);
Future<void> 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<void> 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<void> _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<String, dynamic>;
_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<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');
}
}
Future<List<ModelBundle>> _loadBundles(
List<ModelCatalogItem> catalog) async {
final bundles = <ModelBundle>[];
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<Directory> _modelDir(int datasetId) async {
/// 档位目录:s 档存 `models/<datasetId>/`legacy 无子目录,目录键 = 档位
/// 标识符的 s 形态,存量设备零迁移);n 档存 `models/<datasetId>/n/`。
Future<Directory> _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;
}
+110 -33
View File
@@ -20,15 +20,27 @@ Stream<List<int>> _delayedChunks() async* {
yield [5, 6, 7, 8];
}
Map<String, dynamic> _item() => {
'datasetId': 7,
'datasetName': '环颈雉鸡数据集',
Map<String, dynamic> _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<String, dynamic> _catalog(List<Map<String, dynamic>> models) => {
'code': 0,
'message': 'ok',
'data': {'models': models}
};
/// 真实流式下载客户端:send 立即返回分块流(MockClient 的 Response.fromStream
@@ -38,11 +50,8 @@ class _StreamingClient extends http.BaseClient {
Future<http.StreamedResponse> 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<Map<String, dynamic>> 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<void> 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<void> 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 交错不可控,不在本层断言重启
});
}
+117 -25
View File
@@ -26,19 +26,21 @@ Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) => {
Map<String, dynamic> _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<void>.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<void>.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 档):不应删整目录');
});
}
+11 -11
View File
@@ -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);
+1 -1
View File
@@ -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,
+8 -5
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.