Files
observer/flutter_app/lib/camera/camera_screen.dart
T
2026-09-01 17:47:03 +08:00

572 lines
20 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';
import 'model_catalog_section.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;
/// 当前 worker 已加载的模型 id 集合(激活集变化对比用)
Set<int> _loadedModelIds = const {};
/// 进行中的 worker 重建(并发调用共享,避免重复建/漏建)
Future<void>? _reloadInFlight;
/// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker
int _lastRevision = -1;
void _openSettings() {
final vm = _viewModel;
if (vm == null) return;
ModelManager.instance.refresh();
showModalBottomSheet<void>(
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<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;
// 模型热更新:启动拉取目录(只拉不下载),兜底等待;已下载/已激活模型
// 有新版本时 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<void> _reloadWorker() {
final inFlight = _reloadInFlight;
if (inFlight != null) {
// 重建进行中:完成后按最新状态复查,期间的变化不丢失
return inFlight.then((_) => _reloadWorker()).catchError((_) {});
}
_reloadInFlight =
_doReloadWorker().whenComplete(() => _reloadInFlight = null);
return _reloadInFlight!;
}
Future<void> _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<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() {
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 恒 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(
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('授权相机')),
],
),
);
}
}