1
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
/// 读取单像素 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<Uint8List> planes,
|
||||
List<int> strides, int x, int y, int width, int height, bool isBgra,
|
||||
|
||||
@@ -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.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<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;
|
||||
}
|
||||
|
||||
@@ -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 交错不可控,不在本层断言重启
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 档):不应删整目录');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user