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;
}