This commit is contained in:
2026-09-01 17:47:03 +08:00
parent ed6bfdc460
commit 046ce2f1ab
10 changed files with 185 additions and 67 deletions
+63 -46
View File
@@ -40,7 +40,12 @@ class _CameraScreenState extends State<CameraScreen> {
/// 当前 worker 已加载的模型 id 集合(激活集变化对比用)
Set<int> _loadedModelIds = const {};
bool _reloadingWorker = false;
/// 进行中的 worker 重建(并发调用共享,避免重复建/漏建)
Future<void>? _reloadInFlight;
/// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker
int _lastRevision = -1;
void _openSettings() {
final vm = _viewModel;
@@ -90,8 +95,7 @@ class _CameraScreenState extends State<CameraScreen> {
),
const SizedBox(height: 8),
const Text(
'阈值越低识别越灵敏(低分框越多,误报也可能增加)'
'不同模型的置信度分布不同,识别不到目标时可适当调低阈值。',
'阈值越低识别越灵敏(低分框越多,误报也可能增加)',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
const SizedBox(height: 16),
@@ -138,59 +142,72 @@ class _CameraScreenState extends State<CameraScreen> {
setState(() => _permissionGranted = granted);
if (!granted) return;
// 模型热更新:启动拉取目录(只拉不下载),此处兜底等待;下载模型时仅预览
if (!ModelManager.instance.ready) {
try {
await ModelManager.instance
.refresh()
.timeout(const Duration(seconds: 15));
} catch (_) {}
}
// 模型热更新:启动拉取目录(只拉不下载),兜底等待;下载/已激活模型
// 有新版本时 autoUpdate 自动重下,变化经 _onModelsChanged 重建 worker
try {
await ModelManager.instance
.refresh()
.timeout(const Duration(seconds: 15));
} catch (_) {}
await _reloadWorker();
await _startCamera();
}
/// 用当前激活模型重建推理 worker(激活集变化/启动时调用);
/// worker 为 null(无激活模型或加载失败)时仅预览并提示。
Future<void> _reloadWorker() async {
if (_reloadingWorker) return;
_reloadingWorker = true;
try {
final models = ModelManager.instance.models;
final ids = models.map((m) => m.datasetId).toSet();
if (_viewModel != null &&
_loadedModelIds.length == ids.length &&
_loadedModelIds.containsAll(ids)) {
return; // 激活集未变(进度/目录刷新通知)直接跳过
}
final worker = await DetectorWorker.create(models: models);
_loadedModelIds = ids;
final vm = _viewModel ?? CameraViewModel(reminder: Reminder());
vm.setModelReady(worker != null);
final analyzer = FrameAnalyzer(worker: worker, viewModel: vm);
final old = _analyzer;
if (!mounted) {
analyzer.dispose();
if (vm != _viewModel) vm.dispose();
return;
}
setState(() {
_viewModel = vm;
_analyzer = analyzer;
});
old?.worker?.dispose();
// 相机已启动:重启帧流绑定新 analyzer(start 内部先 stop 再订阅)
if (mounted && _cameraController != null) {
await _cameraController!.start(analyzer);
}
} finally {
_reloadingWorker = false;
/// 并发调用共享同一进行中的重建:refresh 通知触发的重建与 _init 的等待
/// 共用一个 Future,相机等重建完成后再启动(避免绑定旧 analyzer)。
Future<void> _reloadWorker() {
final inFlight = _reloadInFlight;
if (inFlight != null) {
// 重建进行中:完成后按最新状态复查,期间的变化不丢失
return inFlight.then((_) => _reloadWorker()).catchError((_) {});
}
_reloadInFlight =
_doReloadWorker().whenComplete(() => _reloadInFlight = null);
return _reloadInFlight!;
}
Future<void> _doReloadWorker() async {
final mgr = ModelManager.instance;
final models = mgr.models;
final ids = models.map((m) => m.datasetId).toSet();
final revChanged = mgr.revision != _lastRevision;
if (_viewModel != null &&
!revChanged &&
_loadedModelIds.length == ids.length &&
_loadedModelIds.containsAll(ids)) {
return; // 激活集/模型文件未变(进度/目录刷新通知)直接跳过
}
_lastRevision = mgr.revision;
final worker = await DetectorWorker.create(models: models);
_loadedModelIds = ids;
final vm = _viewModel ?? CameraViewModel(reminder: Reminder());
vm.setModelReady(worker != null);
final analyzer = FrameAnalyzer(worker: worker, viewModel: vm);
final old = _analyzer;
if (!mounted) {
analyzer.dispose();
if (vm != _viewModel) vm.dispose();
return;
}
setState(() {
_viewModel = vm;
_analyzer = analyzer;
});
old?.worker?.dispose();
// 相机已启动:重启帧流绑定新 analyzer(start 内部先 stop 再订阅)
if (mounted && _cameraController != null) {
await _cameraController!.start(analyzer);
}
}
void _onModelsChanged() {
final ids = ModelManager.instance.models.map((m) => m.datasetId).toSet();
if (_loadedModelIds.length == ids.length &&
final mgr = ModelManager.instance;
final ids = mgr.models.map((m) => m.datasetId).toSet();
final revChanged = mgr.revision != _lastRevision;
if (!revChanged &&
_loadedModelIds.length == ids.length &&
_loadedModelIds.containsAll(ids)) {
return;
}
+7 -14
View File
@@ -17,7 +17,7 @@ import 'visual_prior.dart';
/// 主 isolate 只投递帧数据、接收结果,UI 不被推理阻塞(iOS 真机卡顿根因)。
///
/// 多模型并行推理:传入 [models](各数据集下载模型)后,每帧逐模型推理,
/// 结果按类别分组跨模型 NMS 合并(同标签重复框取高分,不同标签互不压制);
/// 结果跨模型全局 NMS 合并(2026-09-01 修订:异类别重叠也去重取高分,实测多模型对同一目标检异类别);
/// 无下载模型时不启动推理(仅预览)。
class DetectorWorker {
final Isolate _isolate;
@@ -327,7 +327,7 @@ Future<void> _workerMain(SendPort mainPort) async {
}
}
// 多模型并行推理:每模型先首帧自适应判定 YUV 值域/色序,再逐模型推理;
// 汇总后按类别分组跨模型 NMS 合并(同标签重复框取高分,异标签互不压制
// 汇总后全局 NMS 合并(2026-09-01:异类别重叠也去重取高分,实测多模型对同一目标检异类别
var results = <DetectionResult>[];
for (final d in detectors) {
if (!isBgra && !d.yuvModeKnown) {
@@ -460,19 +460,12 @@ Future<void> _workerMain(SendPort mainPort) async {
}
}
/// 多模型结果合并:按类别分组,组内 NMS(不同模型检出同一目标时取高分)。
/// 各模型类别体系独立(如环颈雉鸡/疑似 vs 野兔/疑似),不同类别互不压制
/// 多模型结果合并:全局 NMS(不区分类别)。
/// 实测多个模型会对同一目标检出不同类别(误检/歧义),若异类别互不压制
/// 会出现重叠框;2026-09-01 用户实测定案:所有模型的框统一按 IoU 去重,
/// 重叠时取高分(远处真实的多目标互不重叠,正常保留)。
List<DetectionResult> mergeAcrossModels(
List<DetectionResult> all, double iouThreshold) {
if (all.length <= 1) return all;
final byLabel = <String, List<DetectionResult>>{};
for (final r in all) {
byLabel.putIfAbsent(r.label, () => []).add(r);
}
final merged = <DetectionResult>[];
for (final group in byLabel.values) {
merged.addAll(nms(group, iouThreshold));
}
merged.sort((a, b) => b.score.compareTo(a.score));
return merged;
return nms(all, iouThreshold);
}
+17 -1
View File
@@ -11,11 +11,27 @@ double iou(DetectionResult a, DetectionResult b) {
return union <= 0 ? 0 : inter / union;
}
/// 交叠/较小框面积(minIoU):同一目标的一大一小两框时比 IoU 更能命中
/// IoU = 小/大 会漏判;YOLO 同目标常输出大小两框,与 server 标注端
/// localAi.overlapThreshold 同思路,2026-09-01 用户实测修订)。
double boxOverlap(DetectionResult a, DetectionResult b) {
final x0 = a.left > b.left ? a.left : b.left;
final y0 = a.top > b.top ? a.top : b.top;
final x1 = a.right < b.right ? a.right : b.right;
final y1 = a.bottom < b.bottom ? a.bottom : b.bottom;
if (x1 <= x0 || y1 <= y0) return 0;
final inter = (x1 - x0) * (y1 - y0);
final minArea = a.width * a.height < b.width * b.height
? a.width * a.height
: b.width * b.height;
return minArea <= 0 ? 0 : inter / minArea;
}
List<DetectionResult> nms(List<DetectionResult> boxes, double iouThreshold) {
final sorted = [...boxes]..sort((a, b) => b.score.compareTo(a.score));
final kept = <DetectionResult>[];
for (final b in sorted) {
if (!kept.any((k) => iou(b, k) > iouThreshold)) kept.add(b);
if (!kept.any((k) => boxOverlap(b, k) > iouThreshold)) kept.add(b);
}
return kept;
}
+34
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -88,6 +89,11 @@ class ModelManager extends ChangeNotifier {
String? _error;
Future<void>? _inFlight;
/// 模型文件/激活集变更版本戳:下载完成或激活变化 +1,
/// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入)
int _revision = 0;
int get revision => _revision;
/// 服务器目录(弹层模型清单展示用)
List<ModelCatalogItem> get catalog => _catalog;
@@ -188,6 +194,8 @@ class ModelManager extends ChangeNotifier {
_models = await _loadBundles(_catalog);
_ready = true;
_error = null;
// 自动更新:已下载/已激活模型发现新版本后台重下(不阻塞目录刷新)
unawaited(autoUpdate());
} catch (e) {
if (!_ready) _error = '模型目录拉取失败:$e';
// 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示)
@@ -233,6 +241,11 @@ class ModelManager extends ChangeNotifier {
_progress.remove(item.datasetId);
_errors.remove(item.datasetId);
_downloadedIds.add(item.datasetId);
_revision++;
// 新版本字节立即生效:自动更新时已激活模型 setActive 会因状态未变
// 提前返回,不在此重载则重建 worker 仍读到旧模型
_models = await _loadBundles(_catalog);
notifyListeners();
await setActive(item.datasetId, true);
return true;
}
@@ -350,11 +363,32 @@ class ModelManager extends ChangeNotifier {
final changed =
active ? _activeIds.add(datasetId) : _activeIds.remove(datasetId);
if (!changed) return;
_revision++;
_models = await _loadBundles(_catalog);
await _saveActive();
notifyListeners();
}
/// 自动更新:已下载/已激活的模型,目录出现新版本时自动重下(保持原激活状态;
/// 未下载的模型不自动拉取,避免无谓流量)。下载进度经 downloadModel 通知。
/// 2026-09-01 用户需求:发布新模型后 App 端自动更新,无需手动触发。
Future<void> autoUpdate() async {
if (_catalog.isEmpty) return;
final tracked = {..._downloadedIds, ..._activeIds};
for (final item in _catalog) {
if (!tracked.contains(item.datasetId)) continue;
if (await _isLocal(item)) continue;
final wasActive = _activeIds.contains(item.datasetId);
try {
final ok = await downloadModel(item);
// 原本未激活:下载完成自动激活后恢复原状态
if (ok && !wasActive) await setActive(item.datasetId, false);
} catch (e) {
debugPrint('[ModelManager] 自动更新失败: ${item.datasetName} $e');
}
}
}
Future<void> _saveActive() async {
try {
final root = await _rootDir();
+12 -4
View File
@@ -9,9 +9,9 @@ import 'installer.dart';
/// 强制更新页:检测到新版本时的全屏阻塞页。
/// PopScope 禁返回(Android 系统返回 / iOS 边缘滑动均不可退出)。
/// 点「立即更新」在 App 内流式下载 APK(显示下载进度)→ PackageInstaller
/// 会话安装(显示安装进度),不再跳浏览器。
/// 点击更新时回调 onUpdateAccepted(调用方持久化服务器版本号,
/// 进页自动在 App 内流式下载 APK(显示下载进度)→ PackageInstaller
/// 会话安装(显示安装进度),失败可手动重试,不再跳浏览器。
/// 安装成功时回调 onUpdateAccepted(调用方持久化服务器版本号,
/// 使 APK 版本号不递增时也不反复提示)。
class UpdateScreen extends StatefulWidget {
final String version;
@@ -49,6 +49,13 @@ class _UpdateScreenState extends State<UpdateScreen> {
/// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
Timer? _installTimer;
@override
void initState() {
super.initState();
// 自动更新:进页即自动下载并安装,无需手动点击(2026-09-01 用户需求)
WidgetsBinding.instance.addPostFrameCallback((_) => _launch());
}
@override
void dispose() {
_installTimer?.cancel();
@@ -67,7 +74,6 @@ class _UpdateScreenState extends State<UpdateScreen> {
_stage = _Stage.idle;
_message = null;
});
widget.onUpdateAccepted?.call();
if (!Platform.isAndroid) {
// 更新检查本就仅 Android 触发,这里兜底非 Android 走浏览器
final uri = Uri.tryParse(widget.url);
@@ -147,6 +153,8 @@ class _UpdateScreenState extends State<UpdateScreen> {
case 'finished':
_installTimer?.cancel();
final ok = e.success == true;
// 安装成功才记录已接受版本:失败/取消时下次启动仍提示重试
if (ok) widget.onUpdateAccepted?.call();
setState(() {
_stage = ok ? _Stage.finished : _Stage.failed;
_message = ok ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试';
+20
View File
@@ -135,6 +135,26 @@ void main() {
expect(m2.models.first.version, 'v2.0.0');
});
test('autoUpdate:已激活模型出新版本,refresh 后自动重下并立即生效', () async {
final m = manager(client([_item()]));
await m.refresh();
await m.downloadModel(m.catalog.first);
expect(downloadHits, 1);
// 重启 + 服务器目录出 v2refresh 内部 autoUpdate 自动重下(无需手动)
final m2 = manager(client([_item(version: 'v2.0.0')]));
await m2.refresh();
for (var i = 0; i < 50 && downloadHits < 2; i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
expect(downloadHits, 2, reason: '新版本应自动重下');
expect(m2.isActive(7), isTrue, reason: '自动更新应保持激活');
expect(m2.models.first.version, 'v2.0.0',
reason: '自动更新后立即生效新版本字节');
expect(m2.models.first.bytes, _modelBytes);
});
test('sha256 不匹配:重试后失败、不激活、错误可见', () async {
final m = manager(client([_item()]));
await m.refresh();
+12 -1
View File
@@ -28,12 +28,23 @@ void main() {
expect(merged.first.modelName, '野兔模型');
});
test('不同类别互不压制', () {
test('不同类别重叠:去重取高分(实测多模型对同一目标检异类别)', () {
final all = [
box('pheasant', 0.3, 0.5, 0.5, modelId: 1),
box('hare', 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.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), // 远处互不重叠
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 2);
});
+19
View File
@@ -30,6 +30,25 @@ void main() {
expect(result[0].score, greaterThan(0.5));
});
test('bigBox_containsSmallBox_deduped', () {
// 同目标一大一小两框:标准 IoU = 0.04/0.13 ≈ 0.31 < 0.45 会漏,
// minIoU = inter/小框 = 1 必须去重(2026-09-01 用户实测:单模型也有重叠框)
final a = box(0.3, 0.3, 0.5, 0.5, 0.8); // 小框 0.2x0.2
final b = box(0.25, 0.25, 0.55, 0.55, 0.6); // 大框 0.3x0.3 套住小框
final result = nms([a, b], 0.45);
expect(result.length, 1);
expect(result[0].score, 0.8);
});
test('partiallyOverlapping_sameTarget_deduped', () {
// 标准 IoU = 0.075/0.195 ≈ 0.385 < 0.45 会漏;minIoU = 0.075/0.09 ≈ 0.83
final a = box(0.1, 0.1, 0.4, 0.4, 0.9); // 0.3x0.3
final b = box(0.15, 0.1, 0.55, 0.4, 0.5); // 0.4x0.3,与 a 重叠 0.25x0.3
final result = nms([a, b], 0.45);
expect(result.length, 1);
expect(result[0].score, 0.9);
});
test('iou_nonOverlapping_isZero', () {
final a = box(0.0, 0.0, 0.2, 0.2, 1);
final b = box(0.8, 0.8, 1.0, 1.0, 1);