训练体系整合与标注单阶段化

- 标注:AI 预标注直写 labels_json(去候选确认两阶段);重叠去重(minIoU);全量标注按钮
- 训练:脚本迁移入 server/training/(Go 化 prepare_yolo/analyze_rfdetr,保留 train_server.py);tflite 产物自检并入训练流程(check_tflite)
- 数据目录/权重不进 git;.gitignore 迁移至仓库根
This commit is contained in:
2026-08-26 18:22:56 +08:00
parent 4f39e85882
commit a0b115d954
108 changed files with 10877 additions and 798 deletions
+243 -34
View File
@@ -1,61 +1,257 @@
import 'dart:async';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'frame_analyzer.dart';
/// camera 插件封装:后摄图像流(对应 Kotlin CameraController
class AppCameraController {
/// 相机抽象(对应 Kotlin CameraController
///
/// - Android:自写原生通道([NativeCameraController])。分析帧在 Kotlin 侧
/// 旋转成竖屏后经 EventChannel 回传,Flutter 侧恒 rotation=0——与 iOS
/// camera_avfoundation 插件,帧本来就是竖屏方向)行为一致,消除
/// "横屏传感器帧 → 90° 旋转 + FIT_COVER crop 映射"的标注偏移根因。
/// - iOS:camera 插件(原逻辑)。帧已竖屏,rotation 恒 0。
abstract class AppCameraController {
static Future<AppCameraController?> create() async {
if (defaultTargetPlatform == TargetPlatform.android) {
return NativeCameraController();
}
return PluginCameraController.create();
}
/// 分析流回调实际触发次数(诊断用,与 analyzer 帧计数区分)
int get streamCallbacks;
bool get isInitialized;
bool get isStreaming;
String? get errorDescription;
/// 检测框 overlay 应使用的旋转角。两平台帧都已竖屏 → 恒 0。
int get rotationDegrees => 0;
/// 诊断:传感器方向 / 显示旋转(仅 Android 原生通道上报)
int? get sensorOrientation => null;
int? get displayDegrees => null;
/// 预览实际应用的旋转圈数(仅 Android 原生通道上报,诊断用)
int get quarterTurns => -1;
/// 诊断:轮询原生侧帧状态(Android 返回计数;iOS 返回空)
Future<Map<dynamic, dynamic>> stats() async => const {};
Future<void> start(FrameAnalyzer analyzer);
Future<void> stop();
Future<double> getMinZoomLevel();
Future<double> getMaxZoomLevel();
Future<void> setZoomLevel(double value);
/// 预览 widgetAndroid 为原生 SurfaceViewAndroidView),iOS 为插件纹理
Widget buildPreview();
}
/// Android:自写原生相机通道(Kotlin CameraChannel)。
class NativeCameraController extends AppCameraController {
static const MethodChannel _channel = MethodChannel('observer/camera');
static const EventChannel _frames = EventChannel('observer/camera/frames');
StreamSubscription<dynamic>? _sub;
bool _streaming = false;
String? _error;
/// 原生侧注册的预览纹理(SurfaceTexture
int? _textureId;
int _textureW = 1920;
int _textureH = 1080;
int _quarterTurns = 1;
@override
int streamCallbacks = 0;
int? _sensorOrientation;
int? _displayDegrees;
@override
int? get sensorOrientation => _sensorOrientation;
@override
int? get displayDegrees => _displayDegrees;
@override
int get quarterTurns => _quarterTurns;
@override
bool get isInitialized => _streaming;
@override
bool get isStreaming => _streaming;
@override
String? get errorDescription => _error;
@override
Future<Map<dynamic, dynamic>> stats() async {
try {
final r = await _channel.invokeMethod<Map<dynamic, dynamic>>('stats');
if (r != null) {
// 旋转/显示角度随轮询实时刷新:手机旋转后预览与帧旋转都跟着变
final dd = r['displayDegrees'];
if (dd is int) _displayDegrees = dd;
final turns = r['quarterTurns'];
if (turns is int) _quarterTurns = turns;
}
return r ?? const {};
} catch (e) {
return {'pollErr': '$e'};
}
}
@override
Future<void> start(FrameAnalyzer analyzer) async {
await stop();
// 先订阅再启动:原生 start 绑定后立刻推帧,避免首帧竞态
_sub = _frames.receiveBroadcastStream().listen((event) {
// 计数放最前:任何到达的事件都先记账,后续解析失败也不丢计数
streamCallbacks++;
try {
final f = event as List;
final w = f[0] as int;
final h = f[1] as int;
final rotation = f[2] as int;
final bgra = f[3] as bool;
final bytes = f[4] as Uint8List;
analyzer.analyzeRaw(
planes: [bytes],
strides: [w * 4],
width: w,
height: h,
isBgra: true,
rgbaOrder: !bgra,
rotationDegrees: rotation,
);
} catch (e) {
_error = '帧解析: $e';
analyzer.recordStreamError('frames parse: $e');
}
}, onError: (Object e) {
_error = '$e';
analyzer.recordStreamError('frames: $e');
});
analyzer.reset();
analyzer.worker?.reset();
try {
final r = await _channel.invokeMethod<Map<dynamic, dynamic>>('start');
_textureId = r?['textureId'] as int?;
_textureW = (r?['w'] as num?)?.toInt() ?? _textureW;
_textureH = (r?['h'] as num?)?.toInt() ?? _textureH;
_quarterTurns = (r?['quarterTurns'] as num?)?.toInt() ?? 1;
_sensorOrientation = (r?['sensorOrientation'] as num?)?.toInt();
_displayDegrees = (r?['displayDegrees'] as num?)?.toInt();
} catch (e) {
_error = '$e';
analyzer.recordStreamError('camera start: $e');
rethrow;
}
_streaming = true;
}
@override
Future<void> stop() async {
_streaming = false;
await _sub?.cancel();
_sub = null;
try {
await _channel.invokeMethod('stop');
} catch (_) {}
}
@override
Future<double> getMinZoomLevel() async {
final r = await _channel.invokeMethod<List<dynamic>>('getZoomRange');
return (r != null && r.isNotEmpty ? (r[0] as num).toDouble() : 1.0);
}
@override
Future<double> getMaxZoomLevel() async {
final r = await _channel.invokeMethod<List<dynamic>>('getZoomRange');
return (r != null && r.length > 1 ? (r[1] as num).toDouble() : 1.0);
}
@override
Future<void> setZoomLevel(double value) async {
try {
await _channel.invokeMethod('setZoom', value);
} catch (_) {}
}
/// 预览纹理:Flutter 引擎渲染 Texture 时已自动应用 SurfaceTexture 变换矩阵
/// (传感器方向补偿,内容已转成自然方向的竖屏),因此:
/// 1. 区域声明旋转后的尺寸(宽高互换)——否则横屏区域会把竖屏内容横向拉伸
/// 2. RotatedBox 只按显示旋转补偿(quarterTurns = 屏转/90,竖屏 0 / 横屏 1)
/// 再 FittedBox cover= CoordinateMapper 的 FIT_COVER 数学一致)填满全屏。
/// 不用 AndroidView+SurfaceView——平台视图会盖住 Flutter UI(诊断行/设置按钮/overlay
@override
Widget buildPreview() {
final id = _textureId;
if (id == null) return const SizedBox.shrink();
return FittedBox(
fit: BoxFit.cover,
child: RotatedBox(
quarterTurns: _quarterTurns % 4,
child: SizedBox(
width: _textureH.toDouble(),
height: _textureW.toDouble(),
child: Texture(textureId: id),
),
),
);
}
}
/// iOScamera 插件封装(原实现)。
class PluginCameraController extends AppCameraController {
final List<CameraDescription> cameras;
CameraController? controller;
/// 图像流回调实际触发次数(诊断用,与 analyzer 帧计数区分)
@override
int streamCallbacks = 0;
AppCameraController._(this.cameras);
PluginCameraController._(this.cameras);
static Future<AppCameraController?> create() async {
static Future<PluginCameraController?> create() async {
final cameras = await availableCameras();
if (cameras.isEmpty) return null;
return AppCameraController._(cameras);
return PluginCameraController._(cameras);
}
@override
bool get isInitialized => controller?.value.isInitialized ?? false;
CameraController get currentController =>
controller ?? (throw StateError('camera not initialized'));
@override
bool get isStreaming => controller?.value.isStreamingImages ?? false;
/// 图像流送达时的旋转角(传感器 → 竖屏显示所需的顺时针旋转)。
/// 与 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;
}
@override
String? get errorDescription => controller?.value.errorDescription;
@override
Future<void> start(FrameAnalyzer analyzer) async {
await stop();
final desc = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first);
// iOS 用默认 bgra8888420v 在部分 iOS 版本上视频输出静默不送帧),
// Android 用 yuv420 多平面。
final fmt = defaultTargetPlatform == TargetPlatform.iOS
? ImageFormatGroup.bgra8888
: ImageFormatGroup.yuv420;
final c = CameraController(desc, ResolutionPreset.high,
enableAudio: false, imageFormatGroup: fmt);
// iOS image stream 帧已按竖屏方向输出(无需旋转),
// Android 原生通道(帧原生侧旋转成竖屏)统一 rotation=0
final c = CameraController(desc, ResolutionPreset.veryHigh,
enableAudio: false, imageFormatGroup: ImageFormatGroup.bgra8888);
controller = c;
await c.initialize();
// 相机(重新)启动后重置运动/背景参考与抽帧节流,避免旧场景残留
@@ -66,7 +262,7 @@ class AppCameraController {
await c.startImageStream((image) {
streamCallbacks++;
try {
analyzer.analyze(image, rotationDegrees);
analyzer.analyze(image, 0);
} catch (e, st) {
debugPrint('[camera] analyze error: $e\n$st');
analyzer.recordStreamError('analyze: $e');
@@ -80,6 +276,7 @@ class AppCameraController {
}
}
@override
Future<void> stop() async {
final c = controller;
if (c == null) return;
@@ -89,4 +286,16 @@ class AppCameraController {
} catch (_) {}
await c.dispose();
}
@override
Future<double> getMinZoomLevel() => controller!.getMinZoomLevel();
@override
Future<double> getMaxZoomLevel() => controller!.getMaxZoomLevel();
@override
Future<void> setZoomLevel(double value) => controller!.setZoomLevel(value);
@override
Widget buildPreview() => CameraPreview(controller!);
}
+154 -41
View File
@@ -1,12 +1,12 @@
import 'dart:ui' show PlatformDispatcher;
import 'dart:async';
import 'dart:ui' as 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 '../models/model_manager.dart';
import '../reminder/reminder.dart';
import 'app_camera_controller.dart';
import 'camera_view_model.dart';
@@ -30,17 +30,88 @@ class _CameraScreenState extends State<CameraScreen> {
String? _globalError;
String? _initError;
/// 置信度阈值(设置页滑块调整,worker 内实时生效)
double _minScore = 0.10;
/// 原生侧帧状态轮询结果(诊断用;无帧时诊断行也能实时刷新)
Map<dynamic, dynamic> _nativeStats = const {};
Timer? _statsTimer;
void _openSettings() {
final vm = _viewModel;
if (vm == null) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.black87,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheetState) => Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('识别设置',
style: TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Row(
children: [
const Text('置信度阈值',
style: TextStyle(color: Colors.white70, fontSize: 14)),
const Spacer(),
Text('${(_minScore * 100).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.greenAccent,
fontSize: 14,
fontWeight: FontWeight.bold)),
],
),
Slider(
value: _minScore,
min: 0.05,
max: 0.50,
divisions: 45,
activeColor: Colors.greenAccent,
onChanged: (v) {
setSheetState(() => _minScore = v);
_analyzer?.worker?.setMinScore(v);
},
),
const SizedBox(height: 8),
const Text(
'阈值越低识别越灵敏(低分框越多,误报也可能增加);'
'野鸡模型置信度普遍在 10%~20%,场景识别不到时可适当调低。',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
),
);
}
@override
void initState() {
super.initState();
final oldPlatform = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = (error, stack) {
setState(() => _globalError = 'Platform: $error');
final oldPlatform = ui.PlatformDispatcher.instance.onError;
ui.PlatformDispatcher.instance.onError = (error, stack) {
setState(() => _globalError =
'Platform: $error\n${stack.toString().split('\n').take(3).join('\n')}');
return oldPlatform?.call(error, stack) ?? false;
};
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
// 相机页常亮:野外观察时保持屏幕不熄(离开页面时关闭)
WakelockPlus.enable();
// 每秒轮询原生侧帧状态:无帧时诊断行也能实时刷新(camErr/计数)
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) => _pollStats());
}
Future<void> _pollStats() async {
final camera = _cameraController;
if (camera == null) return;
final s = await camera.stats();
if (!mounted) return;
setState(() => _nativeStats = s);
}
Future<void> _init() async {
@@ -49,8 +120,18 @@ class _CameraScreenState extends State<CameraScreen> {
setState(() => _permissionGranted = granted);
if (!granted) return;
// 模型热更新:优先使用已下载的数据集模型(启动时后台拉取;此处兜底等待,
// 下载慢/失败不阻塞相机启动——无下载模型时 worker 回退内置资产)
if (!ModelManager.instance.ready) {
try {
await ModelManager.instance
.refresh()
.timeout(const Duration(seconds: 15));
} catch (_) {}
}
// 模型加载/推理在后台 isolate,不阻塞 UIworker 为 null 时仅预览并提示
final worker = await DetectorWorker.create();
final worker = await DetectorWorker.create(
models: ModelManager.instance.models);
final viewModel = CameraViewModel(reminder: Reminder());
viewModel.setModelReady(worker != null);
final analyzer = FrameAnalyzer(worker: worker, viewModel: viewModel);
@@ -100,6 +181,7 @@ class _CameraScreenState extends State<CameraScreen> {
@override
void dispose() {
_statsTimer?.cancel();
WakelockPlus.disable();
_cameraController?.stop();
_analyzer?.dispose();
@@ -120,29 +202,24 @@ class _CameraScreenState extends State<CameraScreen> {
if (!_permissionGranted)
_PermissionGuide(onRequest: () => _init())
else if (vm != null && (camera?.isInitialized ?? false))
// 预览 + 检测框同几何:overlay 作为 CameraPreview 的 child
// 与纹理共享同一 Stack/尺寸,避免比例或裁剪导致的位置偏移
// 预览 + 检测框同几何:overlay 作为预览 widget 的 sibling
// 与纹理共享同一 Stack/尺寸,避免比例或裁剪导致的位置偏移
// 两平台分析帧都已竖屏(Android 原生侧旋转 / iOS 插件本来就竖屏),
// rotation 恒 0CoordinateMapper 走纯 FIT_COVER 缩放路径
ListenableBuilder(
listenable: vm,
builder: (context, _) => _ZoomablePreview(
controller: camera!.currentController,
imageWidthPx: vm.state.imageWidthPx,
imageHeightPx: vm.state.imageHeightPx,
controller: camera!,
overlay: DetectionOverlay(
results: vm.state.results,
// iOS 纹理不旋转显示(_wrapInRotatedBox 仅 Android),
// 显示方向 = buffer 原样 = 检测方向,旋转必须为 0;
// Android 纹理被 RotatedBox 旋转,需用插件报告的 rotation。
rotation: defaultTargetPlatform == TargetPlatform.iOS
? 0
: vm.state.rotation,
rotation: 0,
imageWidthPx: vm.state.imageWidthPx,
imageHeightPx: vm.state.imageHeightPx,
),
),
)
else if (camera?.isInitialized ?? false)
_ZoomablePreview(controller: camera!.currentController)
_ZoomablePreview(controller: camera!)
else
const Center(
child: Text('相机启动中…', style: TextStyle(color: Colors.white70)),
@@ -197,6 +274,7 @@ class _CameraScreenState extends State<CameraScreen> {
right: 0,
child: _CameraTopBar(
onClose: () => Navigator.of(context).pop(),
onOpenSettings: _openSettings,
),
),
],
@@ -209,6 +287,27 @@ class _CameraScreenState extends State<CameraScreen> {
return Stack(
fit: StackFit.expand,
children: [
// 模型热更新下载失败提示(已加载模型仍可用,仅提示补更新)
if (vm.state.modelReady && ModelManager.instance.error != null)
Positioned(
left: 16,
right: 16,
top: MediaQuery.of(context).padding.top + 56,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Text(
ModelManager.instance.error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
),
),
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
if (!vm.state.modelReady)
Positioned(
@@ -238,13 +337,25 @@ class _CameraScreenState extends State<CameraScreen> {
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}',
'阈值:${(_minScore * 100).toStringAsFixed(0)}% 模型:${vm.state.modelReady ? ModelManager.instance.modelsLabel : '未加载'} 帧:${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} 传感:${camera?.sensorOrientation ?? '-'} 屏转:${camera?.displayDegrees ?? '-'} 旋:${camera?.rotationDegrees ?? 0} turn:${camera?.quarterTurns ?? '-'}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
if (_nativeStats.isNotEmpty)
Text(
'原生:回调${_nativeStats['callbacks'] ?? '-'} 发出${_nativeStats['emitOk'] ?? '-'} 异常${_nativeStats['emitErr'] ?? '-'} 无订阅${_nativeStats['sinkNull'] ?? '-'} sink:${_nativeStats['sink'] ?? '-'} 配置:${_nativeStats['size'] ?? '-'} 发帧:${_nativeStats['emitSize'] ?? '-'} 错误:${_nativeStats['error'] ?? ''} 轮询:${_nativeStats['pollErr'] ?? 'ok'}${vm.state.results.isEmpty ? '' : ' 框1:(${vm.state.results.first.left.toStringAsFixed(2)},${vm.state.results.first.top.toStringAsFixed(2)},${vm.state.results.first.right.toStringAsFixed(2)},${vm.state.results.first.bottom.toStringAsFixed(2)})'}',
style: const TextStyle(
color: Colors.amberAccent, fontSize: 11),
),
if (vm.state.debugYuv.isNotEmpty)
Text(
'yuv:${vm.state.debugYuv}',
style: const TextStyle(
color: Colors.yellowAccent, fontSize: 11),
),
if (camera != null)
Text(
'streaming:${camera.currentController.value.isStreamingImages} '
'camErr:${camera.currentController.value.errorDescription ?? ''}',
'streaming:${camera.isStreaming} '
'camErr:${camera.errorDescription ?? ''}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -274,24 +385,19 @@ class _CameraScreenState extends State<CameraScreen> {
],
);
}
}
/// 双指捏合缩放预览;overlay 与纹理同几何(CameraPreview child
/// 双指捏合缩放预览;overlay 与纹理同几何(Stack 内同尺寸
class _ZoomablePreview extends StatefulWidget {
final CameraController controller;
final AppCameraController controller;
/// 检测框 overlay(随帧更新,作为 CameraPreview 的 child 与纹理同区域)
/// 检测框 overlay(随帧更新,与纹理同区域)
final Widget? overlay;
/// 当前帧图像尺寸(用于按 buffer 比例约束预览,保证无拉伸变形)
final int imageWidthPx;
final int imageHeightPx;
const _ZoomablePreview({
required this.controller,
this.overlay,
this.imageWidthPx = 0,
this.imageHeightPx = 0,
});
@override
@@ -317,7 +423,9 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
@override
Widget build(BuildContext context) {
final preview = GestureDetector(
// 预览铺满全屏(cover 裁剪由插件按视图比例完成);
// overlay 与纹理同几何:作为 Stack sibling 叠在上层,坐标与纹理区域一致
return GestureDetector(
onScaleStart: (_) => _gestureStartZoom = _currentZoom,
onScaleUpdate: (d) {
final target =
@@ -326,24 +434,24 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
_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),
child: Stack(
fit: StackFit.expand,
children: [
widget.controller.buildPreview(),
if (widget.overlay != null) widget.overlay!,
],
),
);
}
}
class _CameraTopBar extends StatelessWidget {
final VoidCallback onClose;
final VoidCallback onOpenSettings;
const _CameraTopBar({
required this.onClose,
required this.onOpenSettings,
});
@override
@@ -359,6 +467,11 @@ class _CameraTopBar extends StatelessWidget {
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: onClose,
),
IconButton(
tooltip: '识别设置',
icon: const Icon(Icons.tune, color: Colors.white70),
onPressed: onOpenSettings,
),
],
),
);
@@ -4,7 +4,6 @@ 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
@@ -20,6 +19,7 @@ class CameraUiState {
final String? debugLastError;
final int framesReceived;
final int debugLastMs;
final String debugYuv;
const CameraUiState({
this.modelReady = false,
@@ -33,20 +33,19 @@ class CameraUiState {
this.debugLastError,
this.framesReceived = 0,
this.debugLastMs = 0,
this.debugYuv = '',
});
}
/// 检测结果置信度分级与轨迹确认。
///
/// - [lowConf](模型阈值 0.10):低于此分的框在检测阶段已丢弃。
/// - [highConf]0.35):高于此分直接确认显示;真实野鸡多为 0.1~0.2,
/// 高于 0.35 视为强证据。
/// - 0.10~0.35 之间:需要多帧稳定([confirmFrames] 帧)或 活动证据
/// - 低于 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;
@@ -86,6 +85,7 @@ class CameraViewModel extends ChangeNotifier {
String? lastError,
int framesReceived = 0,
int lastProcessMs = 0,
String yuvDiag = '',
}) {
final now = DateTime.now().millisecondsSinceEpoch;
_associate(results, motionRegions, noveltyRegions, now);
@@ -132,6 +132,7 @@ class CameraViewModel extends ChangeNotifier {
debugLastError: lastError,
framesReceived: framesReceived,
debugLastMs: lastProcessMs,
debugYuv: yuvDiag.isNotEmpty ? yuvDiag : _state.debugYuv,
);
notifyListeners();
}
@@ -87,10 +87,13 @@ class _OverlayPainter extends CustomPainter {
_drawDashedRect(canvas, box, paint);
}
// 标签:框上方,含距离
// 标签:框上方,含距离;多模型时标注来源模型名(内置资产不标)
final dist = _distanceLabel(r);
final modelTag = r.modelName.isNotEmpty && r.modelName != '内置'
? '[${r.modelName}]'
: '';
final text =
'${_labels[r.label] ?? r.label} ${(r.score * 100).toInt()}%$dist';
'${_labels[r.label] ?? r.label}$modelTag ${(r.score * 100).toInt()}%$dist';
final textPainter = TextPainter(
text: TextSpan(
text: text,
+40 -6
View File
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:camera/camera.dart';
import '../detection/detection_result.dart';
@@ -44,7 +46,8 @@ class FrameAnalyzer {
int rotation,
int width,
int height,
int processMs) {
int processMs,
String yuvDiag) {
detectCalls++;
lastProcessMs = processMs;
viewModel.onFramesAnalyzed(
@@ -59,6 +62,7 @@ class FrameAnalyzer {
lastError: lastError,
framesReceived: framesReceived,
lastProcessMs: lastProcessMs,
yuvDiag: yuvDiag,
);
}
@@ -80,15 +84,45 @@ class FrameAnalyzer {
);
}
void analyze(CameraImage image, int rotationDegrees) {
/// 节流与 busy 丢帧判定(两入口共用);通过后才允许投递
bool _canSend() {
framesReceived++;
final w = worker;
if (w == null) return;
if (w == null) return false;
final now = DateTime.now().millisecondsSinceEpoch;
if (now - _lastDetectMs < intervalMs) return;
if (now - _lastDetectMs < intervalMs) return false;
_lastDetectMs = now;
if (w.busy) return; // 上一帧未返回则丢帧,避免在途积压
w.analyze(image, rotationDegrees);
if (w.busy) return false; // 上一帧未返回则丢帧,避免在途积压
return true;
}
void analyze(CameraImage image, int rotationDegrees,
{bool rgbaOrder = false}) {
if (!_canSend()) return;
worker!.analyze(image, rotationDegrees, rgbaOrder: rgbaOrder);
}
/// 原生相机通道帧(Android):字节已在 Kotlin 侧旋转成竖屏,rotation=0。
/// isBgra=true + rgbaOrder 与插件路径同语义:false=BGRA(rOff=2)/true=RGBA(rOff=0)
void analyzeRaw({
required List<Uint8List> planes,
required List<int> strides,
required int width,
required int height,
required bool isBgra,
required bool rgbaOrder,
int rotationDegrees = 0,
}) {
if (!_canSend()) return;
worker!.analyzeRaw(
planes: planes,
strides: strides,
width: width,
height: height,
isBgra: isBgra,
rgbaOrder: rgbaOrder,
rotationDegrees: rotationDegrees,
);
}
void reset() => _lastDetectMs = 0;