diff --git a/flutter_app/lib/camera/camera_screen.dart b/flutter_app/lib/camera/camera_screen.dart index e95480b..0a9e383 100644 --- a/flutter_app/lib/camera/camera_screen.dart +++ b/flutter_app/lib/camera/camera_screen.dart @@ -40,7 +40,12 @@ class _CameraScreenState extends State { /// 当前 worker 已加载的模型 id 集合(激活集变化对比用) Set _loadedModelIds = const {}; - bool _reloadingWorker = false; + + /// 进行中的 worker 重建(并发调用共享,避免重复建/漏建) + Future? _reloadInFlight; + + /// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker + int _lastRevision = -1; void _openSettings() { final vm = _viewModel; @@ -90,8 +95,7 @@ class _CameraScreenState extends State { ), 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 { 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 _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 _reloadWorker() { + final inFlight = _reloadInFlight; + if (inFlight != null) { + // 重建进行中:完成后按最新状态复查,期间的变化不丢失 + return inFlight.then((_) => _reloadWorker()).catchError((_) {}); + } + _reloadInFlight = + _doReloadWorker().whenComplete(() => _reloadInFlight = null); + return _reloadInFlight!; + } + + Future _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; } diff --git a/flutter_app/lib/detection/detector_worker.dart b/flutter_app/lib/detection/detector_worker.dart index b41b932..a7023dd 100644 --- a/flutter_app/lib/detection/detector_worker.dart +++ b/flutter_app/lib/detection/detector_worker.dart @@ -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 _workerMain(SendPort mainPort) async { } } // 多模型并行推理:每模型先首帧自适应判定 YUV 值域/色序,再逐模型推理; - // 汇总后按类别分组跨模型 NMS 合并(同标签重复框取高分,异标签互不压制) + // 汇总后全局 NMS 合并(2026-09-01:异类别重叠也去重取高分,实测多模型对同一目标检异类别) var results = []; for (final d in detectors) { if (!isBgra && !d.yuvModeKnown) { @@ -460,19 +460,12 @@ Future _workerMain(SendPort mainPort) async { } } -/// 多模型结果合并:按类别分组,组内 NMS(不同模型检出同一目标时取高分)。 -/// 各模型类别体系独立(如环颈雉鸡/疑似 vs 野兔/疑似),不同类别互不压制。 +/// 多模型结果合并:全局 NMS(不区分类别)。 +/// 实测多个模型会对同一目标检出不同类别(误检/歧义),若异类别互不压制 +/// 会出现重叠框;2026-09-01 用户实测定案:所有模型的框统一按 IoU 去重, +/// 重叠时取高分(远处真实的多目标互不重叠,正常保留)。 List mergeAcrossModels( List all, double iouThreshold) { if (all.length <= 1) return all; - final byLabel = >{}; - for (final r in all) { - byLabel.putIfAbsent(r.label, () => []).add(r); - } - final merged = []; - 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); } diff --git a/flutter_app/lib/detection/nms.dart b/flutter_app/lib/detection/nms.dart index d5e33dc..bda0e06 100644 --- a/flutter_app/lib/detection/nms.dart +++ b/flutter_app/lib/detection/nms.dart @@ -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 nms(List boxes, double iouThreshold) { final sorted = [...boxes]..sort((a, b) => b.score.compareTo(a.score)); final kept = []; 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; } diff --git a/flutter_app/lib/models/model_manager.dart b/flutter_app/lib/models/model_manager.dart index b35cf2c..314ab1d 100644 --- a/flutter_app/lib/models/model_manager.dart +++ b/flutter_app/lib/models/model_manager.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -88,6 +89,11 @@ class ModelManager extends ChangeNotifier { String? _error; Future? _inFlight; + /// 模型文件/激活集变更版本戳:下载完成或激活变化 +1, + /// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入) + int _revision = 0; + int get revision => _revision; + /// 服务器目录(弹层模型清单展示用) List 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 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 _saveActive() async { try { final root = await _rootDir(); diff --git a/flutter_app/lib/update/update_screen.dart b/flutter_app/lib/update/update_screen.dart index 658a7c4..282a502 100644 --- a/flutter_app/lib/update/update_screen.dart +++ b/flutter_app/lib/update/update_screen.dart @@ -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 { /// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」 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 { _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 { case 'finished': _installTimer?.cancel(); final ok = e.success == true; + // 安装成功才记录已接受版本:失败/取消时下次启动仍提示重试 + if (ok) widget.onUpdateAccepted?.call(); setState(() { _stage = ok ? _Stage.finished : _Stage.failed; _message = ok ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试'; diff --git a/flutter_app/test/model_manager_test.dart b/flutter_app/test/model_manager_test.dart index 1b0ab23..5a3a76c 100644 --- a/flutter_app/test/model_manager_test.dart +++ b/flutter_app/test/model_manager_test.dart @@ -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); + + // 重启 + 服务器目录出 v2:refresh 内部 autoUpdate 自动重下(无需手动) + final m2 = manager(client([_item(version: 'v2.0.0')])); + await m2.refresh(); + for (var i = 0; i < 50 && downloadHits < 2; i++) { + await Future.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(); diff --git a/flutter_app/test/model_merge_test.dart b/flutter_app/test/model_merge_test.dart index 43d9111..aba85b4 100644 --- a/flutter_app/test/model_merge_test.dart +++ b/flutter_app/test/model_merge_test.dart @@ -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); }); diff --git a/flutter_app/test/nms_test.dart b/flutter_app/test/nms_test.dart index 1eb508b..95f3e5a 100644 --- a/flutter_app/test/nms_test.dart +++ b/flutter_app/test/nms_test.dart @@ -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); diff --git a/server/data/observer.db b/server/data/observer.db index e586e51..82054ce 100644 Binary files a/server/data/observer.db and b/server/data/observer.db differ diff --git a/server/技术设计.md b/server/技术设计.md index 6fbcbd2..4c34a6f 100644 --- a/server/技术设计.md +++ b/server/技术设计.md @@ -383,7 +383,7 @@ training: ### 模型版本(每数据集一个模型,多模型体系) -**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注(类别名不同则自然互补,同类名跨模型 NMS 去重)。 +**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注。**合并去重(2026-09-01 用户实测修订)**:单模型 NMS 与跨模型合并统一按 **minIoU**(交叠/较小框面积,阈值 0.45)**全局去重**(不分标签)——实测多模型会对同一目标检出不同类别、单模型会输出一大一小两框(标准 IoU=小/大 会漏判),重叠一律取高分框;与 server 标注端 `localAi.overlapThreshold`(0.3,minIoU 风格)同思路。远处真实多目标互不重叠,正常保留。 - 版本号规则:`m..`,**同一数据集内**每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);`UNIQUE(dataset_id, version)` 防重复 - 文件布局(2026-08-27 重构;命名 2026-08-28 改):`workspace/trainings/<文件名前缀>.tflite` 即当前生效模型唯一位(前缀空回退数据集名——存量数据集无前缀;改名/改前缀时模型文件随命名迁移)——训练成功时从训练机直写(原子覆盖),客户端固定下载该文件;**无 `.tflite` 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚)**;每数据集一个文件互不影响