Files
observer/flutter_app/lib/camera/app_camera_controller.dart
T
2026-08-25 16:01:07 +08:00

302 lines
9.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/// 相机抽象(对应 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;
@override
int streamCallbacks = 0;
PluginCameraController._(this.cameras);
static Future<PluginCameraController?> create() async {
final cameras = await availableCameras();
if (cameras.isEmpty) return null;
return PluginCameraController._(cameras);
}
@override
bool get isInitialized => controller?.value.isInitialized ?? false;
@override
bool get isStreaming => controller?.value.isStreamingImages ?? false;
@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 image stream 帧已按竖屏方向输出(无需旋转),
// 与 Android 原生通道(帧原生侧旋转成竖屏)统一 rotation=0
final c = CameraController(desc, ResolutionPreset.veryHigh,
enableAudio: false, imageFormatGroup: ImageFormatGroup.bgra8888);
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, 0);
} 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;
}
}
@override
Future<void> stop() async {
final c = controller;
if (c == null) return;
controller = null;
try {
await c.stopImageStream();
} 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!);
}