迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)

This commit is contained in:
2026-08-24 12:35:24 +08:00
parent d056f01965
commit 961523d94c
218 changed files with 13391 additions and 3232 deletions
@@ -0,0 +1,238 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../detection/detection_result.dart';
import '../detection/motion_aggregator.dart';
import '../detection/tflite_detector.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;
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,
});
}
/// 检测结果置信度分级与轨迹确认。
///
/// - [lowConf](模型阈值 0.10):低于此分的框在检测阶段已丢弃。
/// - [highConf]0.35):高于此分直接确认显示;真实野鸡多为 0.1~0.2,
/// 高于 0.35 视为强证据。
/// - 0.10~0.35 之间:需要多帧稳定([confirmFrames] 帧)或 活动证据
/// (运动区域/背景新出现区域重叠)才确认显示。
class CameraViewModel extends ChangeNotifier {
static const int maxTracks = 30;
static const double motionBoost = 0.15;
static const double lowConf = TfliteDetector.minScore;
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,
}) {
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,
);
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;
}
}