训练体系整合与标注单阶段化
- 标注:AI 预标注直写 labels_json(去候选确认两阶段);重叠去重(minIoU);全量标注按钮 - 训练:脚本迁移入 server/training/(Go 化 prepare_yolo/analyze_rfdetr,保留 train_server.py);tflite 产物自检并入训练流程(check_tflite) - 数据目录/权重不进 git;.gitignore 迁移至仓库根
This commit is contained in:
@@ -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);
|
||||
|
||||
/// 预览 widget:Android 为原生 SurfaceView(AndroidView),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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS:camera 插件封装(原实现)。
|
||||
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 用默认 bgra8888(420v 在部分 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!);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user