import 'dart:math' as math; import 'package:flutter/foundation.dart'; import '../detection/detection_result.dart'; import '../detection/motion_aggregator.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; 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 = '', }); } /// 检测结果置信度分级与轨迹确认。 /// /// - [highConf](0.35):高于此分直接确认显示;真实环颈雉鸡多为 0.1~0.2, /// 高于 0.35 视为强证据。 /// - 低于 0.35 的框:需要多帧稳定([confirmFrames] 帧)或 活动证据 /// (运动区域/背景新出现区域重叠)才确认显示。 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; /// 推理后台 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; _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)); } // 提醒:仅新确认的目标物种轨迹(class 0,如环颈雉鸡;确认瞬间触发一次,10s 同类冷却在 Reminder 内) for (final t in _tracks.values) { final isSuspect = t.result.classId > 0 || t.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; } _state = CameraUiState( modelReady: modelReady, results: 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, ); 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; } } 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.result.classId > 0 || 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.classId > 0 || 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; void update(DetectionResult r, int now) { lastSeenMs = now; result = r; } }