- 标注:AI 预标注直写 labels_json(去候选确认两阶段);重叠去重(minIoU);全量标注按钮 - 训练:脚本迁移入 server/training/(Go 化 prepare_yolo/analyze_rfdetr,保留 train_server.py);tflite 产物自检并入训练流程(check_tflite) - 数据目录/权重不进 git;.gitignore 迁移至仓库根
240 lines
7.5 KiB
Dart
240 lines
7.5 KiB
Dart
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<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;
|
||
|
||
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<int, _Track> _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<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));
|
||
}
|
||
|
||
// 提醒:仅新确认的野鸡轨迹(确认瞬间触发一次,10s 同类冷却在 Reminder 内)
|
||
for (final t in _tracks.values) {
|
||
if (t.label != 'pheasant' || !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<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;
|
||
}
|
||
}
|
||
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;
|
||
|
||
void update(DetectionResult r, int now) {
|
||
lastSeenMs = now;
|
||
result = r;
|
||
}
|
||
}
|