Files
observer/flutter_app/lib/camera/camera_screen.dart
T
2026-08-28 17:33:35 +08:00

503 lines
17 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 'dart:ui' as ui show PlatformDispatcher;
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';
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;
/// 置信度阈值(设置页滑块调整,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 = 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 {
final granted = await Permission.camera.request().isGranted;
if (!mounted) return;
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(
models: ModelManager.instance.models);
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() {
_statsTimer?.cancel();
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 作为预览 widget 的 sibling
// 与纹理共享同一 Stack/尺寸,避免比例或裁剪导致的位置偏移。
// 两平台分析帧都已竖屏(Android 原生侧旋转 / iOS 插件本来就竖屏),
// rotation 恒 0CoordinateMapper 走纯 FIT_COVER 缩放路径
ListenableBuilder(
listenable: vm,
builder: (context, _) => _ZoomablePreview(
controller: camera!,
overlay: DetectionOverlay(
results: vm.state.results,
rotation: 0,
imageWidthPx: vm.state.imageWidthPx,
imageHeightPx: vm.state.imageHeightPx,
),
),
)
else if (camera?.isInitialized ?? false)
_ZoomablePreview(controller: camera!)
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(),
onOpenSettings: _openSettings,
),
),
],
),
);
}
Widget _buildDiagnosticsLayer(AppCameraController? camera) {
final vm = _viewModel!;
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(
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(
'阈值:${(_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.isStreaming} '
'camErr:${camera.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 与纹理同几何(Stack 内同尺寸)
class _ZoomablePreview extends StatefulWidget {
final AppCameraController controller;
/// 检测框 overlay(随帧更新,与纹理同区域)
final Widget? overlay;
const _ZoomablePreview({
required this.controller,
this.overlay,
});
@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) {
// 预览铺满全屏(cover 裁剪由插件按视图比例完成);
// overlay 与纹理同几何:作为 Stack sibling 叠在上层,坐标与纹理区域一致
return 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: 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
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,
),
IconButton(
tooltip: '识别设置',
icon: const Icon(Icons.tune, color: Colors.white70),
onPressed: onOpenSettings,
),
],
),
);
}
}
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('授权相机')),
],
),
);
}
}