迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)

This commit is contained in:
2026-08-24 12:35:24 +08:00
parent d056f01965
commit 961523d94c
218 changed files with 13391 additions and 3232 deletions
+389
View File
@@ -0,0 +1,389 @@
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,不阻塞 UIworker 为 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('授权相机')),
],
),
);
}
}