Files
observer/flutter_app/lib/camera/camera_view_model.dart
T
2026-09-09 21:05:25 +08:00

307 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DetectionResult> 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<int> _procWindow = [];
/// 推理后台 isolate 是否就绪(由相机页创建 worker 后设置)
bool modelReady = false;
final Reminder reminder;
CameraUiState _state;
CameraUiState get state => _state;
final Map<int, _Track> _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<DetectionResult> results,
int rotation,
int imageWidthPx,
int imageHeightPx,
List<MotionRegion> motionRegions,
List<MotionRegion> 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 = <DetectionResult>[];
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<DetectionResult> results,
List<MotionRegion> motionRegions,
List<MotionRegion> noveltyRegions,
int now) {
final matched = <int>{};
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<MotionRegion> motionRegions,
List<MotionRegion> 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<MotionRegion> motionRegions,
List<MotionRegion> 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<DetectionResult> dedupeVisibleOverlaps(List<DetectionResult> visible) {
if (visible.length <= 1) return visible;
final sorted = [...visible]..sort((a, b) => b.score.compareTo(a.score));
final kept = <DetectionResult>[];
for (final r in sorted) {
if (!kept.any((k) => k.isSuspect == r.isSuspect && sameTarget(k, r))) {
kept.add(r);
}
}
return kept;
}