迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'frame_analyzer.dart';
|
||||
|
||||
/// camera 插件封装:后摄图像流(对应 Kotlin CameraController)。
|
||||
class AppCameraController {
|
||||
final List<CameraDescription> cameras;
|
||||
CameraController? controller;
|
||||
|
||||
/// 图像流回调实际触发次数(诊断用,与 analyzer 帧计数区分)
|
||||
int streamCallbacks = 0;
|
||||
|
||||
AppCameraController._(this.cameras);
|
||||
|
||||
static Future<AppCameraController?> create() async {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return null;
|
||||
return AppCameraController._(cameras);
|
||||
}
|
||||
|
||||
bool get isInitialized => controller?.value.isInitialized ?? false;
|
||||
|
||||
CameraController get currentController =>
|
||||
controller ?? (throw StateError('camera not initialized'));
|
||||
|
||||
/// 图像流送达时的旋转角(传感器 → 竖屏显示所需的顺时针旋转)。
|
||||
/// 与 CameraX rotationDegrees 同公式;预览本身由平台旋转,检测框 overlay
|
||||
/// 用同一角度映射即可对齐。
|
||||
int get rotationDegrees {
|
||||
final c = controller;
|
||||
if (c == null) return 0;
|
||||
final deviceDegrees = switch (c.value.deviceOrientation) {
|
||||
DeviceOrientation.portraitUp => 0,
|
||||
DeviceOrientation.landscapeLeft => 90,
|
||||
DeviceOrientation.portraitDown => 180,
|
||||
DeviceOrientation.landscapeRight => 270,
|
||||
};
|
||||
final sensor = c.description.sensorOrientation;
|
||||
final isFront =
|
||||
c.description.lensDirection == CameraLensDirection.front;
|
||||
final degrees = (isFront ? sensor + deviceDegrees : sensor - deviceDegrees) % 360;
|
||||
return degrees < 0 ? degrees + 360 : degrees;
|
||||
}
|
||||
|
||||
Future<void> start(FrameAnalyzer analyzer) async {
|
||||
await stop();
|
||||
final desc = cameras.firstWhere(
|
||||
(c) => c.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => cameras.first);
|
||||
// iOS 用默认 bgra8888(420v 在部分 iOS 版本上视频输出静默不送帧),
|
||||
// Android 用 yuv420 多平面。
|
||||
final fmt = defaultTargetPlatform == TargetPlatform.iOS
|
||||
? ImageFormatGroup.bgra8888
|
||||
: ImageFormatGroup.yuv420;
|
||||
final c = CameraController(desc, ResolutionPreset.high,
|
||||
enableAudio: false, imageFormatGroup: fmt);
|
||||
controller = c;
|
||||
await c.initialize();
|
||||
// 相机(重新)启动后重置运动/背景参考与抽帧节流,避免旧场景残留
|
||||
analyzer.reset();
|
||||
analyzer.worker?.reset();
|
||||
debugPrint('[camera] initialized, starting image stream');
|
||||
try {
|
||||
await c.startImageStream((image) {
|
||||
streamCallbacks++;
|
||||
try {
|
||||
analyzer.analyze(image, rotationDegrees);
|
||||
} catch (e, st) {
|
||||
debugPrint('[camera] analyze error: $e\n$st');
|
||||
analyzer.recordStreamError('analyze: $e');
|
||||
}
|
||||
});
|
||||
debugPrint('[camera] startImageStream ok');
|
||||
} catch (e, st) {
|
||||
debugPrint('[camera] startImageStream FAILED: $e\n$st');
|
||||
analyzer.recordStreamError('startImageStream: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final c = controller;
|
||||
if (c == null) return;
|
||||
controller = null;
|
||||
try {
|
||||
await c.stopImageStream();
|
||||
} catch (_) {}
|
||||
await c.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import 'dart:ui' show PlatformDispatcher;
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../detection/detector_worker.dart';
|
||||
import '../reminder/reminder.dart';
|
||||
import 'app_camera_controller.dart';
|
||||
import 'camera_view_model.dart';
|
||||
import 'detection_overlay.dart';
|
||||
import 'frame_analyzer.dart';
|
||||
|
||||
/// 主界面:相机预览 + 检测框 overlay + 顶栏(返回/切换摄像头)
|
||||
class CameraScreen extends StatefulWidget {
|
||||
const CameraScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CameraScreen> createState() => _CameraScreenState();
|
||||
}
|
||||
|
||||
class _CameraScreenState extends State<CameraScreen> {
|
||||
CameraViewModel? _viewModel;
|
||||
FrameAnalyzer? _analyzer;
|
||||
AppCameraController? _cameraController;
|
||||
bool _initFailed = false;
|
||||
bool _permissionGranted = false;
|
||||
String? _globalError;
|
||||
String? _initError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final oldPlatform = PlatformDispatcher.instance.onError;
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
setState(() => _globalError = 'Platform: $error');
|
||||
return oldPlatform?.call(error, stack) ?? false;
|
||||
};
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
|
||||
// 相机页常亮:野外观察时保持屏幕不熄(离开页面时关闭)
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final granted = await Permission.camera.request().isGranted;
|
||||
if (!mounted) return;
|
||||
setState(() => _permissionGranted = granted);
|
||||
if (!granted) return;
|
||||
|
||||
// 模型加载/推理在后台 isolate,不阻塞 UI;worker 为 null 时仅预览并提示
|
||||
final worker = await DetectorWorker.create();
|
||||
final viewModel = CameraViewModel(reminder: Reminder());
|
||||
viewModel.setModelReady(worker != null);
|
||||
final analyzer = FrameAnalyzer(worker: worker, viewModel: viewModel);
|
||||
if (!mounted) {
|
||||
analyzer.dispose();
|
||||
viewModel.dispose();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_viewModel = viewModel;
|
||||
_analyzer = analyzer;
|
||||
});
|
||||
|
||||
await _startCamera();
|
||||
}
|
||||
|
||||
Future<void> _startCamera() async {
|
||||
final analyzer = _analyzer;
|
||||
if (analyzer == null) return;
|
||||
try {
|
||||
final controller = await AppCameraController.create();
|
||||
if (controller == null) {
|
||||
setState(() {
|
||||
_initFailed = true;
|
||||
_initError = '未找到可用摄像头';
|
||||
});
|
||||
return;
|
||||
}
|
||||
await controller.start(analyzer);
|
||||
if (!mounted) {
|
||||
controller.stop();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_cameraController = controller;
|
||||
_initFailed = false;
|
||||
_initError = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_initFailed = true;
|
||||
_initError = '$e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WakelockPlus.disable();
|
||||
_cameraController?.stop();
|
||||
_analyzer?.dispose();
|
||||
_viewModel?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vm = _viewModel;
|
||||
final camera = _cameraController;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (!_permissionGranted)
|
||||
_PermissionGuide(onRequest: () => _init())
|
||||
else if (vm != null && (camera?.isInitialized ?? false))
|
||||
// 预览 + 检测框同几何:overlay 作为 CameraPreview 的 child,
|
||||
// 与纹理共享同一 Stack/尺寸,避免比例或裁剪导致的位置偏移
|
||||
ListenableBuilder(
|
||||
listenable: vm,
|
||||
builder: (context, _) => _ZoomablePreview(
|
||||
controller: camera!.currentController,
|
||||
imageWidthPx: vm.state.imageWidthPx,
|
||||
imageHeightPx: vm.state.imageHeightPx,
|
||||
overlay: DetectionOverlay(
|
||||
results: vm.state.results,
|
||||
// iOS 纹理不旋转显示(_wrapInRotatedBox 仅 Android),
|
||||
// 显示方向 = buffer 原样 = 检测方向,旋转必须为 0;
|
||||
// Android 纹理被 RotatedBox 旋转,需用插件报告的 rotation。
|
||||
rotation: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? 0
|
||||
: vm.state.rotation,
|
||||
imageWidthPx: vm.state.imageWidthPx,
|
||||
imageHeightPx: vm.state.imageHeightPx,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (camera?.isInitialized ?? false)
|
||||
_ZoomablePreview(controller: camera!.currentController)
|
||||
else
|
||||
const Center(
|
||||
child: Text('相机启动中…', style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
|
||||
// 帧级动态层(横幅/诊断行)单独订阅 viewModel,避免整屏重建
|
||||
if (vm != null)
|
||||
ListenableBuilder(
|
||||
listenable: vm,
|
||||
builder: (context, _) => _buildDiagnosticsLayer(camera),
|
||||
),
|
||||
|
||||
if (_initFailed)
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('相机初始化失败', style: TextStyle(color: Colors.white)),
|
||||
if (_initError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
_initError!,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent, fontSize: 11),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _initFailed = false);
|
||||
_startCamera();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _CameraTopBar(
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiagnosticsLayer(AppCameraController? camera) {
|
||||
final vm = _viewModel!;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
|
||||
if (!vm.state.modelReady)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: MediaQuery.of(context).padding.top + 56,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'识别模型加载失败:${DetectorWorker.lastLoadError ?? '未知原因'}\n最后步骤:${DetectorWorker.lastLog ?? '-'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'模型:${vm.state.modelReady ? '已加载' : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 旋:${vm.state.rotation}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
if (camera != null)
|
||||
Text(
|
||||
'streaming:${camera.currentController.value.isStreamingImages} '
|
||||
'camErr:${camera.currentController.value.errorDescription ?? '无'}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.cyanAccent, fontSize: 11),
|
||||
),
|
||||
if (vm.state.debugLastError != null)
|
||||
Text(
|
||||
vm.state.debugLastError!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent, fontSize: 11),
|
||||
),
|
||||
if (_globalError != null)
|
||||
Text(
|
||||
_globalError!,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 双指捏合缩放预览;overlay 与纹理同几何(CameraPreview child)
|
||||
class _ZoomablePreview extends StatefulWidget {
|
||||
final CameraController controller;
|
||||
|
||||
/// 检测框 overlay(随帧更新,作为 CameraPreview 的 child 与纹理同区域)
|
||||
final Widget? overlay;
|
||||
|
||||
/// 当前帧图像尺寸(用于按 buffer 比例约束预览,保证无拉伸变形)
|
||||
final int imageWidthPx;
|
||||
final int imageHeightPx;
|
||||
|
||||
const _ZoomablePreview({
|
||||
required this.controller,
|
||||
this.overlay,
|
||||
this.imageWidthPx = 0,
|
||||
this.imageHeightPx = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
|
||||
}
|
||||
|
||||
class _ZoomablePreviewState extends State<_ZoomablePreview> {
|
||||
double _minZoom = 1.0;
|
||||
double _maxZoom = 1.0;
|
||||
double _currentZoom = 1.0;
|
||||
double _gestureStartZoom = 1.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.getMinZoomLevel().then((v) {
|
||||
if (mounted) setState(() => _minZoom = v);
|
||||
});
|
||||
widget.controller.getMaxZoomLevel().then((v) {
|
||||
if (mounted) setState(() => _maxZoom = v);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final preview = GestureDetector(
|
||||
onScaleStart: (_) => _gestureStartZoom = _currentZoom,
|
||||
onScaleUpdate: (d) {
|
||||
final target =
|
||||
(_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom);
|
||||
if ((target - _currentZoom).abs() < 0.01) return;
|
||||
_currentZoom = target;
|
||||
widget.controller.setZoomLevel(target);
|
||||
},
|
||||
child: CameraPreview(widget.controller, child: widget.overlay),
|
||||
);
|
||||
|
||||
final w = widget.imageWidthPx.toDouble();
|
||||
final h = widget.imageHeightPx.toDouble();
|
||||
if (w <= 0 || h <= 0) return preview;
|
||||
// 按 buffer 比例约束显示区域:纹理与 overlay 同区域等比显示(无变形)
|
||||
return Center(
|
||||
child: AspectRatio(aspectRatio: w / h, child: preview),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CameraTopBar extends StatelessWidget {
|
||||
final VoidCallback onClose;
|
||||
|
||||
const _CameraTopBar({
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black54,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: '返回',
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PermissionGuide extends StatelessWidget {
|
||||
final VoidCallback onRequest;
|
||||
|
||||
const _PermissionGuide({required this.onRequest});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'需要相机权限才能进行实时识别',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: onRequest, child: const Text('授权相机')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../detection/coordinate_mapper.dart';
|
||||
import '../detection/detection_result.dart';
|
||||
|
||||
/// 检测框绘制分级:
|
||||
/// - 野鸡 confirmed:红色实线 3px(强证据)
|
||||
/// - 野鸡 candidate:红色虚线 2px 半透明(待确认,弱提示)
|
||||
/// - 疑似(生境预警):黄色虚线 2px 半透明(常驻静态预警,弱化渲染)
|
||||
/// 标签附带距离估计(针孔模型 焦距px×参考体型/框高px)。
|
||||
class DetectionOverlay extends StatelessWidget {
|
||||
final List<DetectionResult> results;
|
||||
final int rotation;
|
||||
final int imageWidthPx;
|
||||
final int imageHeightPx;
|
||||
|
||||
const DetectionOverlay({
|
||||
super.key,
|
||||
required this.results,
|
||||
required this.rotation,
|
||||
required this.imageWidthPx,
|
||||
required this.imageHeightPx,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _OverlayPainter(results, rotation, imageWidthPx, imageHeightPx),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OverlayPainter extends CustomPainter {
|
||||
final List<DetectionResult> results;
|
||||
final int rotation;
|
||||
final int imageWidthPx;
|
||||
final int imageHeightPx;
|
||||
|
||||
_OverlayPainter(this.results, this.rotation, this.imageWidthPx,
|
||||
this.imageHeightPx);
|
||||
|
||||
static const _colors = {
|
||||
'pheasant': Color(0xFFE53935),
|
||||
'suspect': Color(0xFFFDD835),
|
||||
};
|
||||
static const _labels = {'pheasant': '野鸡', 'suspect': '疑似'};
|
||||
|
||||
/// 参考体型(米):野鸡身高 / 植被高度(参照旧 DistanceEstimator)
|
||||
static const _refSizeM = {'pheasant': 0.45, 'suspect': 0.50};
|
||||
|
||||
/// iPhone 13 主摄在 1280 高预览下的估算焦距 px(5.1mm / 5.30mm 传感器),
|
||||
/// 单目误差 ±30%,仅作参考
|
||||
static const double focalPx = 1230;
|
||||
static const double maxDistanceM = 120;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final r in results) {
|
||||
final rect = CoordinateMapper.mapToView(
|
||||
r.left,
|
||||
r.top,
|
||||
r.right,
|
||||
r.bottom,
|
||||
rotation,
|
||||
imageWidthPx,
|
||||
imageHeightPx,
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
final color = _colors[r.label] ?? Colors.white;
|
||||
final isSuspect = r.label == 'suspect';
|
||||
final confirmed = r.confirmed && !isSuspect;
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: confirmed ? 1.0 : 0.55)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = confirmed ? 3 : 2
|
||||
..isAntiAlias = true;
|
||||
final box = Rect.fromLTRB(rect.left, rect.top, rect.right, rect.bottom);
|
||||
if (confirmed) {
|
||||
canvas.drawRect(box, paint);
|
||||
} else {
|
||||
_drawDashedRect(canvas, box, paint);
|
||||
}
|
||||
|
||||
// 标签:框上方,含距离
|
||||
final dist = _distanceLabel(r);
|
||||
final text =
|
||||
'${_labels[r.label] ?? r.label} ${(r.score * 100).toInt()}%$dist';
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
color: color.withValues(alpha: confirmed ? 1.0 : 0.8),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: const [Shadow(color: Colors.black, blurRadius: 3)],
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
final top = math.max(0.0, rect.top - 22);
|
||||
final left = math.max(0.0, rect.left);
|
||||
textPainter.paint(canvas, Offset(left + 4, top));
|
||||
}
|
||||
}
|
||||
|
||||
String _distanceLabel(DetectionResult r) {
|
||||
final refH = _refSizeM[r.label];
|
||||
if (refH == null) return '';
|
||||
final hPx = r.height * imageHeightPx;
|
||||
if (hPx < 8) return '';
|
||||
final m = focalPx * refH / hPx;
|
||||
if (m > maxDistanceM) return '';
|
||||
return ' ≈${m.round()}m';
|
||||
}
|
||||
|
||||
void _drawDashedRect(Canvas canvas, Rect r, Paint paint,
|
||||
{double dash = 10, double gap = 6}) {
|
||||
void dashLine(Offset a, Offset b) {
|
||||
final total = (b - a).distance;
|
||||
if (total <= 0) return;
|
||||
final dir = (b - a) / total;
|
||||
var d = 0.0;
|
||||
while (d < total) {
|
||||
final e = math.min(d + dash, total);
|
||||
canvas.drawLine(a + dir * d, a + dir * e, paint);
|
||||
d += dash + gap;
|
||||
}
|
||||
}
|
||||
|
||||
dashLine(r.topLeft, r.topRight);
|
||||
dashLine(r.topRight, r.bottomRight);
|
||||
dashLine(r.bottomRight, r.bottomLeft);
|
||||
dashLine(r.bottomLeft, r.topLeft);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_OverlayPainter oldDelegate) =>
|
||||
oldDelegate.results != results ||
|
||||
oldDelegate.rotation != rotation ||
|
||||
oldDelegate.imageWidthPx != imageWidthPx ||
|
||||
oldDelegate.imageHeightPx != imageHeightPx;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:camera/camera.dart';
|
||||
|
||||
import '../detection/detection_result.dart';
|
||||
import '../detection/detector_worker.dart';
|
||||
import 'camera_view_model.dart';
|
||||
|
||||
/// 抽帧节流 + 后台推理(对应 Kotlin FrameAnalyzer)。
|
||||
/// 推理在后台 isolate(DetectorWorker)执行,主 isolate 只投递帧与收结果。
|
||||
class FrameAnalyzer {
|
||||
/// 连续检测:100ms 一帧
|
||||
int intervalMs = 100;
|
||||
|
||||
/// null = 模型加载失败,仅预览不分析
|
||||
final DetectorWorker? worker;
|
||||
final CameraViewModel viewModel;
|
||||
|
||||
int _lastDetectMs = 0;
|
||||
|
||||
/// 诊断计数:推理调用/异常次数
|
||||
int detectCalls = 0;
|
||||
int detectErrors = 0;
|
||||
String? lastError;
|
||||
|
||||
/// 最近一次完整处理(预处理+推理+运动检测)耗时 ms(worker 侧)
|
||||
int lastProcessMs = 0;
|
||||
|
||||
/// 图像流回调是否到达(诊断用)
|
||||
int framesReceived = 0;
|
||||
|
||||
FrameAnalyzer({required this.worker, required this.viewModel}) {
|
||||
worker?.onResult = _onResult;
|
||||
worker?.onError = _onError;
|
||||
}
|
||||
|
||||
void recordStreamError(String msg) {
|
||||
lastError = msg;
|
||||
detectErrors++;
|
||||
}
|
||||
|
||||
void _onResult(
|
||||
List<DetectionResult> results,
|
||||
List<MotionRegion> motion,
|
||||
List<MotionRegion> novelty,
|
||||
int rotation,
|
||||
int width,
|
||||
int height,
|
||||
int processMs) {
|
||||
detectCalls++;
|
||||
lastProcessMs = processMs;
|
||||
viewModel.onFramesAnalyzed(
|
||||
results,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
motion,
|
||||
novelty,
|
||||
detectCalls: detectCalls,
|
||||
detectErrors: detectErrors,
|
||||
lastError: lastError,
|
||||
framesReceived: framesReceived,
|
||||
lastProcessMs: lastProcessMs,
|
||||
);
|
||||
}
|
||||
|
||||
void _onError(String msg) {
|
||||
detectErrors++;
|
||||
lastError = msg;
|
||||
viewModel.onFramesAnalyzed(
|
||||
const [],
|
||||
90,
|
||||
0,
|
||||
0,
|
||||
const [],
|
||||
const [],
|
||||
detectCalls: detectCalls,
|
||||
detectErrors: detectErrors,
|
||||
lastError: lastError,
|
||||
framesReceived: framesReceived,
|
||||
lastProcessMs: lastProcessMs,
|
||||
);
|
||||
}
|
||||
|
||||
void analyze(CameraImage image, int rotationDegrees) {
|
||||
framesReceived++;
|
||||
final w = worker;
|
||||
if (w == null) return;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (now - _lastDetectMs < intervalMs) return;
|
||||
_lastDetectMs = now;
|
||||
if (w.busy) return; // 上一帧未返回则丢帧,避免在途积压
|
||||
w.analyze(image, rotationDegrees);
|
||||
}
|
||||
|
||||
void reset() => _lastDetectMs = 0;
|
||||
|
||||
void dispose() => worker?.dispose();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../detection/detection_result.dart';
|
||||
import '../detection/motion_aggregator.dart';
|
||||
|
||||
/// 轻量运动检测:相邻帧 Y 通道差分 + 分块聚合。
|
||||
/// 小尺寸工作(约 128x128 内),在分析流中串行调用。
|
||||
/// 相机大幅移动时(全屏帧差)自动忽略本帧,避免误报。
|
||||
class MotionDetector {
|
||||
final int maxWidth;
|
||||
final int maxHeight;
|
||||
|
||||
List<int>? _prevGray;
|
||||
|
||||
MotionDetector({this.maxWidth = 128, this.maxHeight = 128});
|
||||
|
||||
/// 后台 isolate 用原始数据接口(不依赖 CameraImage)。
|
||||
List<MotionRegion> detectMotionRaw(
|
||||
Uint8List yPlane, int yStride, int width, int height) {
|
||||
final w = width, h = height;
|
||||
final scale = maxWidth / w < maxHeight / h ? maxWidth / w : maxHeight / h;
|
||||
final tw = (w * scale).toInt().clamp(1, maxWidth);
|
||||
final th = (h * scale).toInt().clamp(1, maxHeight);
|
||||
if (tw == 0 || th == 0) return const [];
|
||||
|
||||
// 取 Y 平面缩放灰度(最近邻下采样到 128x128 内)
|
||||
final y = yPlane;
|
||||
final gray = List<int>.filled(tw * th, 0);
|
||||
for (var oy = 0; oy < th; oy++) {
|
||||
final sy = (oy / scale).toInt().clamp(0, h - 1);
|
||||
for (var ox = 0; ox < tw; ox++) {
|
||||
final sx = (ox / scale).toInt().clamp(0, w - 1);
|
||||
gray[oy * tw + ox] = y[sy * yStride + sx];
|
||||
}
|
||||
}
|
||||
|
||||
final prev = _prevGray;
|
||||
_prevGray = List.of(gray);
|
||||
if (prev == null || prev.length != gray.length) return const [];
|
||||
|
||||
final diff = MotionAggregator.diffMask(gray, prev);
|
||||
final motionTotal = diff.fold(0, (a, b) => a + b);
|
||||
// 全屏大差异 → 相机移动/大范围变化,忽略本帧
|
||||
if (motionTotal > tw * th / 2) return const [];
|
||||
if (motionTotal < 12) return const [];
|
||||
return MotionAggregator.aggregate(diff, tw, th);
|
||||
}
|
||||
|
||||
/// 相机切换后重置参考帧,避免旧帧误差
|
||||
void reset() {
|
||||
_prevGray = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user