1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试';
|
||||
|
||||
@@ -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<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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user