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'; import 'model_catalog_section.dart'; /// 主界面:相机预览 + 检测框 overlay + 顶栏(返回/切换摄像头) class CameraScreen extends StatefulWidget { const CameraScreen({super.key}); @override State createState() => _CameraScreenState(); } class _CameraScreenState extends State { CameraViewModel? _viewModel; FrameAnalyzer? _analyzer; AppCameraController? _cameraController; bool _initFailed = false; bool _permissionGranted = false; String? _globalError; String? _initError; /// 置信度阈值(设置页滑块调整,worker 内实时生效) double _minScore = 0.10; /// 原生侧帧状态轮询结果(诊断用;无帧时诊断行也能实时刷新) Map _nativeStats = const {}; Timer? _statsTimer; /// 当前 worker 已加载的模型 id 集合(激活集变化对比用) Set _loadedModelIds = const {}; /// 进行中的 worker 重建(并发调用共享,避免重复建/漏建) Future? _reloadInFlight; /// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker int _lastRevision = -1; void _openSettings() { final vm = _viewModel; if (vm == null) return; ModelManager.instance.refresh(); showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.black87, builder: (ctx) => StatefulBuilder( builder: (ctx, setSheetState) => SingleChildScrollView( child: 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( '阈值越低识别越灵敏(低分框越多,误报也可能增加)', style: TextStyle(color: Colors.white54, fontSize: 12), ), const SizedBox(height: 16), const Divider(color: Colors.white12), const SizedBox(height: 8), ModelCatalogSection(manager: ModelManager.instance), ], ), ), ), ), ); } @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()); // 模型清单激活集变化(下载完成自动激活/取消激活)时重建推理 worker ModelManager.instance.addListener(_onModelsChanged); } Future _pollStats() async { final camera = _cameraController; if (camera == null) return; final s = await camera.stats(); if (!mounted) return; setState(() => _nativeStats = s); } Future _init() async { final granted = await Permission.camera.request().isGranted; if (!mounted) return; setState(() => _permissionGranted = granted); if (!granted) return; // 模型热更新:启动拉取目录(只拉不下载),兜底等待;已下载/已激活模型 // 有新版本时 autoUpdate 自动重下,变化经 _onModelsChanged 重建 worker try { await ModelManager.instance .refresh() .timeout(const Duration(seconds: 15)); } catch (_) {} await _reloadWorker(); await _startCamera(); } /// 用当前激活模型重建推理 worker(激活集变化/启动时调用); /// worker 为 null(无激活模型或加载失败)时仅预览并提示。 /// 并发调用共享同一进行中的重建:refresh 通知触发的重建与 _init 的等待 /// 共用一个 Future,相机等重建完成后再启动(避免绑定旧 analyzer)。 Future _reloadWorker() { final inFlight = _reloadInFlight; if (inFlight != null) { // 重建进行中:完成后按最新状态复查,期间的变化不丢失 return inFlight.then((_) => _reloadWorker()).catchError((_) {}); } _reloadInFlight = _doReloadWorker().whenComplete(() => _reloadInFlight = null); return _reloadInFlight!; } Future _doReloadWorker() async { final mgr = ModelManager.instance; final models = mgr.models; final ids = models.map((m) => m.datasetId).toSet(); final revChanged = mgr.revision != _lastRevision; if (_viewModel != null && !revChanged && _loadedModelIds.length == ids.length && _loadedModelIds.containsAll(ids)) { return; // 激活集/模型文件未变(进度/目录刷新通知)直接跳过 } _lastRevision = mgr.revision; final worker = await DetectorWorker.create(models: models); _loadedModelIds = ids; final vm = _viewModel ?? CameraViewModel(reminder: Reminder()); vm.setModelReady(worker != null); final analyzer = FrameAnalyzer(worker: worker, viewModel: vm); final old = _analyzer; if (!mounted) { analyzer.dispose(); if (vm != _viewModel) vm.dispose(); return; } setState(() { _viewModel = vm; _analyzer = analyzer; }); old?.worker?.dispose(); // 相机已启动:重启帧流绑定新 analyzer(start 内部先 stop 再订阅) if (mounted && _cameraController != null) { await _cameraController!.start(analyzer); } } void _onModelsChanged() { final mgr = ModelManager.instance; final ids = mgr.models.map((m) => m.datasetId).toSet(); final revChanged = mgr.revision != _lastRevision; if (!revChanged && _loadedModelIds.length == ids.length && _loadedModelIds.containsAll(ids)) { return; } _reloadWorker(); } Future _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() { ModelManager.instance.removeListener(_onModelsChanged); _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 恒 0,CoordinateMapper 走纯 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( ModelManager.instance.models.isEmpty ? '未使用模型:点右上角设置,在「模型清单」中选择要使用的模型' : '识别模型加载失败:${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('授权相机')), ], ), ); } }