390 lines
13 KiB
Dart
390 lines
13 KiB
Dart
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('授权相机')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|