import 'dart:math' as math; import 'package:flutter/foundation.dart'; import '../detection/detection_result.dart'; import '../detection/motion_aggregator.dart'; import '../detection/nms.dart'; import '../reminder/reminder.dart'; @immutable class CameraUiState { final bool modelReady; final List results; final int rotation; final int imageWidthPx; final int imageHeightPx; final double debugHighestScore; final int debugDetectCalls; final int debugDetectErrors; final String? debugLastError; final int framesReceived; final int debugLastMs; final String debugYuv; /// 近 [CameraViewModel.latencyWindow] 帧推理耗时滚动平均(毫秒;无样本为 null)。 /// 给 C 端用户看的设备识别延迟。 final double? latencyAvgMs; const CameraUiState({ this.modelReady = false, this.results = const [], this.rotation = 90, this.imageWidthPx = 0, this.imageHeightPx = 0, this.debugHighestScore = 0, this.debugDetectCalls = 0, this.debugDetectErrors = 0, this.debugLastError, this.framesReceived = 0, this.debugLastMs = 0, this.debugYuv = '', this.latencyAvgMs, }); } /// 检测结果置信度分级与轨迹确认。 /// /// - [highConf](0.35):高于此分直接确认显示;真实目标多为 0.1~0.2, /// 高于 0.35 视为强证据。 /// - 低于 0.35 的框:需要多帧稳定([confirmFrames] 帧)或 活动证据 /// (运动区域/背景新出现区域重叠)才确认显示。 /// - 同位置去重(2026-09-03):多模型对同一目标交替检出时各轨迹都会在 /// 忘记窗内持续显示 → 轨迹层跨标签同目标补挂 + 显示层 /// [dedupeVisibleOverlaps] 同类别强重叠只保留高置信度框。 class CameraViewModel extends ChangeNotifier { static const int maxTracks = 30; static const double motionBoost = 0.15; static const double highConf = 0.35; static const int confirmFrames = 3; static const double associateRadius = 0.12; static const int displayAgeMs = 500; static const int forgetMs = 2000; /// 延迟滚动平均窗口(帧数):单帧抖动大,取近期均值给用户展示 static const int latencyWindow = 30; final List _procWindow = []; /// 推理后台 isolate 是否就绪(由相机页创建 worker 后设置) bool modelReady = false; final Reminder reminder; CameraUiState _state; CameraUiState get state => _state; final Map _tracks = {}; int _nextTrackId = 0; CameraViewModel({required this.reminder}) : _state = const CameraUiState(); void setModelReady(bool ready) { if (modelReady == ready) return; modelReady = ready; // 换 worker / 停识别:旧窗口样本作废,从零起算 _procWindow.clear(); _state = CameraUiState(modelReady: ready); notifyListeners(); } /// 帧分析回调(分析流调用) void onFramesAnalyzed( List results, int rotation, int imageWidthPx, int imageHeightPx, List motionRegions, List noveltyRegions, { int detectCalls = 0, int detectErrors = 0, String? lastError, int framesReceived = 0, int lastProcessMs = 0, String yuvDiag = '', }) { final now = DateTime.now().millisecondsSinceEpoch; _associate(results, motionRegions, noveltyRegions, now); final visible = []; for (final t in _tracks.values) { if (now - t.firstSeenMs < displayAgeMs) continue; if (now - t.lastSeenMs > forgetMs) continue; if (!_shouldDisplay(t, motionRegions, noveltyRegions)) continue; var r = t.result; // 低分确认目标 + 活动证据 → 分数提升,便于视觉区分 if (t.confirmed && r.score < highConf && _hasActivity(r, motionRegions, noveltyRegions)) { r = r.copyWith(score: (r.score + motionBoost).clamp(0.0, 1.0)); } visible.add(r.copyWith(confirmed: t.confirmed)); } // 提醒:仅新确认的目标物种轨迹(label 非 suspect 即目标——单物种模型 // class 0、综合模型各物种索引 0..N-1;确认瞬间触发一次,10s 冷却在 Reminder 内) for (final t in _tracks.values) { final isSuspect = t.result.label == 'suspect'; if (isSuspect || !t.confirmed || t.reminded) continue; final age = now - t.firstSeenMs; if (age >= displayAgeMs && age <= displayAgeMs + 1600 && now - t.lastSeenMs <= 300) { t.reminded = true; reminder.onDetected(t.label); } } var highest = 0.0; for (final r in results) { if (r.score > highest) highest = r.score; } if (lastProcessMs > 0) { _procWindow.add(lastProcessMs); if (_procWindow.length > latencyWindow) { _procWindow.removeAt(0); } } final latencyAvg = _procWindow.isEmpty ? null : _procWindow.reduce((a, b) => a + b) / _procWindow.length; _state = CameraUiState( modelReady: modelReady, results: dedupeVisibleOverlaps(visible), rotation: rotation, imageWidthPx: imageWidthPx, imageHeightPx: imageHeightPx, debugHighestScore: highest, debugDetectCalls: detectCalls, debugDetectErrors: detectErrors, debugLastError: lastError, framesReceived: framesReceived, debugLastMs: lastProcessMs, debugYuv: yuvDiag.isNotEmpty ? yuvDiag : _state.debugYuv, latencyAvgMs: latencyAvg, ); notifyListeners(); } /// 检测框 → 轨迹关联:按中心距离就近匹配(同标签优先,跨标签收紧距离), /// 未匹配则新建候选轨迹。 void _associate( List results, List motionRegions, List noveltyRegions, int now) { final matched = {}; for (final r in results) { if (!_plausible(r)) continue; _Track? best; var bestD = associateRadius; for (final t in _tracks.values) { if (matched.contains(t.id)) continue; final d = _centerDist(t.result, r); // 同标签宽松匹配;跨标签(目标↔疑似 抖动)收紧到 60% final limit = t.label == r.label ? bestD : associateRadius * 0.6; if (d < limit) { bestD = d; best = t; } } // 跨标签同目标补挂(2026-09-03 用户实测修订:重叠位置只保留高置信度框): // 中心距超过跨标签收紧半径、但几何强重叠指向同一位置(不同模型对同一 // 目标的框偏移/紧致度不同)的检测并入既有轨迹,防止同目标两条轨迹并存 // → 同位置双名常驻/重复提醒。疑似↔目标(不同类别预警)不并入。 if (best == null) { _Track? adopt; var adoptD = double.infinity; for (final t in _tracks.values) { if (matched.contains(t.id)) continue; if (t.isSuspect != r.isSuspect || !sameTarget(t.result, r)) continue; final d = _centerDist(t.result, r); if (d < adoptD) { adoptD = d; adopt = t; } } best = adopt; } if (best != null) { matched.add(best.id); best.update(r, now); best.seenCount++; if (best.seenCount >= confirmFrames || r.score >= highConf || _hasActivity(r, motionRegions, noveltyRegions)) { best.confirmed = true; } } else { final t = _Track(_nextTrackId++, now, r); t.seenCount = 1; t.confirmed = r.score >= highConf || _hasActivity(r, motionRegions, noveltyRegions); _tracks[t.id] = t; } } _tracks.removeWhere( (id, t) => !matched.contains(id) && now - t.lastSeenMs > forgetMs); } /// 显示判定(按类别策略): /// - 疑似(生境预警):设计意图是常驻静态预警,始终显示(渲染侧弱化) /// - 目标物种:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示 bool _shouldDisplay(_Track t, List motionRegions, List noveltyRegions) { if (t.label == 'suspect') return true; if (t.confirmed) return true; return t.result.score >= highConf || _hasActivity(t.result, motionRegions, noveltyRegions); } /// 活动证据:与运动区域或背景新出现区域重叠 bool _hasActivity(DetectionResult r, List motionRegions, List noveltyRegions) => motionRegions.any((m) => MotionAggregator.centerInRegion(r, m)) || noveltyRegions.any((m) => MotionAggregator.centerInRegion(r, m)); /// 物理合理性过滤:宽高比与相对尺寸(目标物种 20-100px@720 量级) bool _plausible(DetectionResult r) { final h = r.height; final w = r.width; if (w <= 0 || h <= 0) return false; final aspect = w / h; if (aspect < 0.3 || aspect > 3.0) return false; if (r.label == 'suspect') return h >= 0.01 && h <= 0.5; return h >= 0.01 && h <= 0.3; } double _centerDist(DetectionResult a, DetectionResult b) => math.sqrt(math.pow(a.centerX - b.centerX, 2) + math.pow(a.centerY - b.centerY, 2)); @override void dispose() { reminder.release(); super.dispose(); } } class _Track { final int id; final String label; int firstSeenMs; int lastSeenMs; int seenCount = 0; bool confirmed = false; bool reminded = false; DetectionResult result; _Track(this.id, this.firstSeenMs, this.result) : lastSeenMs = firstSeenMs, label = result.label; /// 当前框是否疑似类别(随关联的最新检测更新——轨迹框在跨标签补挂后 /// 会换成其他标签的框;轨迹 label 仅创建时记账) bool get isSuspect => result.isSuspect; void update(DetectionResult r, int now) { lastSeenMs = now; result = r; } } /// 同屏抑制(2026-09-03 用户实测修订:同一位置/重叠位置出现同/异模型检出的 /// 物种只保留高置信度者)。根因:多个模型对同一目标**交替**检出时,每帧合并 /// 层只压掉当帧低分框,但各自轨迹都落在 2s 忘记窗内持续显示 → 同位置双名 /// 常驻。显示层兜底:可见框内同类别(都目标/都疑似)、且 [sameTarget] 强 /// 重叠指向同一位置的框每帧只保留最高分者。异类别(目标×疑似生境预警)是 /// 两种语义不同的框,同位置也各自保留。 List dedupeVisibleOverlaps(List visible) { if (visible.length <= 1) return visible; final sorted = [...visible]..sort((a, b) => b.score.compareTo(a.score)); final kept = []; for (final r in sorted) { if (!kept.any((k) => k.isSuspect == r.isSuspect && sameTarget(k, r))) { kept.add(r); } } return kept; }