迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'auth/auth_screen.dart';
|
||||
import 'auth/session_store.dart';
|
||||
import 'camera/camera_screen.dart';
|
||||
import 'container.dart';
|
||||
import 'home/home_screen.dart';
|
||||
import 'payment/paywall_screen.dart';
|
||||
|
||||
class ObserverApp extends StatelessWidget {
|
||||
final AppContainer container;
|
||||
|
||||
const ObserverApp({super.key, required this.container});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider.value(value: container),
|
||||
Provider.value(value: container.sessionStore),
|
||||
ChangeNotifierProvider.value(value: container.authViewModel),
|
||||
ChangeNotifierProvider.value(value: container.homeViewModel),
|
||||
ChangeNotifierProvider.value(value: container.paywallViewModel),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: '视野',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
|
||||
),
|
||||
initialRoute: '/gate',
|
||||
routes: {
|
||||
'/gate': (_) => const StartupGate(),
|
||||
'/login': (_) => const AuthScreen(),
|
||||
'/home': (_) => const HomeScreen(),
|
||||
'/paywall': (_) => const PaywallScreen(),
|
||||
'/camera': (_) => const CameraScreen(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动门卫:无登录 token → 登录页;已登录 → 主界面(到期状态由主界面展示)
|
||||
class StartupGate extends StatefulWidget {
|
||||
const StartupGate({super.key});
|
||||
|
||||
@override
|
||||
State<StartupGate> createState() => _StartupGateState();
|
||||
}
|
||||
|
||||
class _StartupGateState extends State<StartupGate> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_check();
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
final session = context.read<SessionStore>();
|
||||
final token = await session.readToken();
|
||||
if (!mounted) return;
|
||||
Navigator.of(context)
|
||||
.pushReplacementNamed(token == null ? '/login' : '/home');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'auth_view_model.dart';
|
||||
|
||||
/// 登录/注册页:手机号 + 密码;注册成功后自动登录。
|
||||
class AuthScreen extends StatefulWidget {
|
||||
const AuthScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AuthScreen> createState() => _AuthScreenState();
|
||||
}
|
||||
|
||||
class _AuthScreenState extends State<AuthScreen> {
|
||||
final _phoneCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
bool _obscure = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static final _phoneRe = RegExp(r'^1[3-9]\d{9}$');
|
||||
|
||||
Future<void> _submit() async {
|
||||
final vm = context.read<AuthViewModel>();
|
||||
final phone = _phoneCtrl.text.trim();
|
||||
final password = _passwordCtrl.text;
|
||||
if (!_phoneRe.hasMatch(phone)) {
|
||||
vm.showError('请输入正确的 11 位手机号');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
vm.showError('密码至少 6 位');
|
||||
return;
|
||||
}
|
||||
final ok = await vm.submit(phone: phone, password: password);
|
||||
if (ok && mounted) {
|
||||
Navigator.of(context).pushReplacementNamed('/home');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vm = context.watch<AuthViewModel>();
|
||||
final isLogin = vm.mode == AuthMode.login;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.pets, size: 64, color: Colors.green),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'视野',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'野生动物实时识别',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _phoneCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
labelText: '手机号',
|
||||
prefixIcon: Icon(Icons.phone_android),
|
||||
border: OutlineInputBorder(),
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordCtrl,
|
||||
obscureText: _obscure,
|
||||
maxLength: 64,
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
border: const OutlineInputBorder(),
|
||||
counterText: '',
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: () => setState(() => _obscure = !_obscure),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: vm.loading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
child: vm.loading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(isLogin ? '登录' : '注册并登录'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: vm.loading ? null : vm.switchMode,
|
||||
child: Text(isLogin ? '没有账号?去注册' : '已有账号?去登录'),
|
||||
),
|
||||
if (vm.error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
vm.error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../payment/order_api.dart';
|
||||
import 'session_store.dart';
|
||||
|
||||
enum AuthMode { login, register }
|
||||
|
||||
class AuthViewModel extends ChangeNotifier {
|
||||
final OrderApi orderApi;
|
||||
final SessionStore sessionStore;
|
||||
|
||||
AuthMode _mode = AuthMode.login;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
AuthMode get mode => _mode;
|
||||
bool get loading => _loading;
|
||||
String? get error => _error;
|
||||
|
||||
AuthViewModel({required this.orderApi, required this.sessionStore});
|
||||
|
||||
void switchMode() {
|
||||
_mode = _mode == AuthMode.login ? AuthMode.register : AuthMode.login;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 本地输入校验失败提示(不进网络请求)
|
||||
void showError(String message) {
|
||||
_error = message;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 登录或注册;成功返回 true(调用方负责跳转主界面)
|
||||
Future<bool> submit({required String phone, required String password}) async {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
if (_mode == AuthMode.login) {
|
||||
final token = await orderApi.login(phone: phone, password: password);
|
||||
await sessionStore.save(phone, token);
|
||||
} else {
|
||||
await orderApi.register(phone: phone, password: password);
|
||||
final token = await orderApi.login(phone: phone, password: password);
|
||||
await sessionStore.save(phone, token);
|
||||
}
|
||||
return true;
|
||||
} on OrderApiException catch (e) {
|
||||
_error = e.message;
|
||||
return false;
|
||||
} finally {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// 登录会话持久化:token + 手机号存 secure storage。
|
||||
/// 启动时读取判断是否已登录;登出/401 时清除回登录页。
|
||||
class SessionStore {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _tokenKey = 'auth_token';
|
||||
static const _phoneKey = 'auth_phone';
|
||||
|
||||
static String? _cachedToken;
|
||||
static String? _cachedPhone;
|
||||
|
||||
Future<String?> readToken() async {
|
||||
if (_cachedToken != null) return _cachedToken;
|
||||
return _cachedToken = await _storage.read(key: _tokenKey);
|
||||
}
|
||||
|
||||
Future<String?> readPhone() async {
|
||||
if (_cachedPhone != null) return _cachedPhone;
|
||||
return _cachedPhone = await _storage.read(key: _phoneKey);
|
||||
}
|
||||
|
||||
Future<void> save(String phone, String token) async {
|
||||
_cachedPhone = phone;
|
||||
_cachedToken = token;
|
||||
await _storage.write(key: _phoneKey, value: phone);
|
||||
await _storage.write(key: _tokenKey, value: token);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_cachedPhone = null;
|
||||
_cachedToken = null;
|
||||
await _storage.delete(key: _phoneKey);
|
||||
await _storage.delete(key: _tokenKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'frame_analyzer.dart';
|
||||
|
||||
/// camera 插件封装:后摄图像流(对应 Kotlin CameraController)。
|
||||
class AppCameraController {
|
||||
final List<CameraDescription> cameras;
|
||||
CameraController? controller;
|
||||
|
||||
/// 图像流回调实际触发次数(诊断用,与 analyzer 帧计数区分)
|
||||
int streamCallbacks = 0;
|
||||
|
||||
AppCameraController._(this.cameras);
|
||||
|
||||
static Future<AppCameraController?> create() async {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return null;
|
||||
return AppCameraController._(cameras);
|
||||
}
|
||||
|
||||
bool get isInitialized => controller?.value.isInitialized ?? false;
|
||||
|
||||
CameraController get currentController =>
|
||||
controller ?? (throw StateError('camera not initialized'));
|
||||
|
||||
/// 图像流送达时的旋转角(传感器 → 竖屏显示所需的顺时针旋转)。
|
||||
/// 与 CameraX rotationDegrees 同公式;预览本身由平台旋转,检测框 overlay
|
||||
/// 用同一角度映射即可对齐。
|
||||
int get rotationDegrees {
|
||||
final c = controller;
|
||||
if (c == null) return 0;
|
||||
final deviceDegrees = switch (c.value.deviceOrientation) {
|
||||
DeviceOrientation.portraitUp => 0,
|
||||
DeviceOrientation.landscapeLeft => 90,
|
||||
DeviceOrientation.portraitDown => 180,
|
||||
DeviceOrientation.landscapeRight => 270,
|
||||
};
|
||||
final sensor = c.description.sensorOrientation;
|
||||
final isFront =
|
||||
c.description.lensDirection == CameraLensDirection.front;
|
||||
final degrees = (isFront ? sensor + deviceDegrees : sensor - deviceDegrees) % 360;
|
||||
return degrees < 0 ? degrees + 360 : degrees;
|
||||
}
|
||||
|
||||
Future<void> start(FrameAnalyzer analyzer) async {
|
||||
await stop();
|
||||
final desc = cameras.firstWhere(
|
||||
(c) => c.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => cameras.first);
|
||||
// iOS 用默认 bgra8888(420v 在部分 iOS 版本上视频输出静默不送帧),
|
||||
// Android 用 yuv420 多平面。
|
||||
final fmt = defaultTargetPlatform == TargetPlatform.iOS
|
||||
? ImageFormatGroup.bgra8888
|
||||
: ImageFormatGroup.yuv420;
|
||||
final c = CameraController(desc, ResolutionPreset.high,
|
||||
enableAudio: false, imageFormatGroup: fmt);
|
||||
controller = c;
|
||||
await c.initialize();
|
||||
// 相机(重新)启动后重置运动/背景参考与抽帧节流,避免旧场景残留
|
||||
analyzer.reset();
|
||||
analyzer.worker?.reset();
|
||||
debugPrint('[camera] initialized, starting image stream');
|
||||
try {
|
||||
await c.startImageStream((image) {
|
||||
streamCallbacks++;
|
||||
try {
|
||||
analyzer.analyze(image, rotationDegrees);
|
||||
} catch (e, st) {
|
||||
debugPrint('[camera] analyze error: $e\n$st');
|
||||
analyzer.recordStreamError('analyze: $e');
|
||||
}
|
||||
});
|
||||
debugPrint('[camera] startImageStream ok');
|
||||
} catch (e, st) {
|
||||
debugPrint('[camera] startImageStream FAILED: $e\n$st');
|
||||
analyzer.recordStreamError('startImageStream: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final c = controller;
|
||||
if (c == null) return;
|
||||
controller = null;
|
||||
try {
|
||||
await c.stopImageStream();
|
||||
} catch (_) {}
|
||||
await c.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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,不阻塞 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('授权相机')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../detection/detection_result.dart';
|
||||
import '../detection/motion_aggregator.dart';
|
||||
import '../detection/tflite_detector.dart';
|
||||
import '../reminder/reminder.dart';
|
||||
|
||||
@immutable
|
||||
class CameraUiState {
|
||||
final bool modelReady;
|
||||
final List<DetectionResult> results;
|
||||
final int rotation;
|
||||
final int imageWidthPx;
|
||||
final int imageHeightPx;
|
||||
final double debugHighestScore;
|
||||
final int debugDetectCalls;
|
||||
final int debugDetectErrors;
|
||||
final String? debugLastError;
|
||||
final int framesReceived;
|
||||
final int debugLastMs;
|
||||
|
||||
const CameraUiState({
|
||||
this.modelReady = false,
|
||||
this.results = const [],
|
||||
this.rotation = 90,
|
||||
this.imageWidthPx = 0,
|
||||
this.imageHeightPx = 0,
|
||||
this.debugHighestScore = 0,
|
||||
this.debugDetectCalls = 0,
|
||||
this.debugDetectErrors = 0,
|
||||
this.debugLastError,
|
||||
this.framesReceived = 0,
|
||||
this.debugLastMs = 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// 检测结果置信度分级与轨迹确认。
|
||||
///
|
||||
/// - [lowConf](模型阈值 0.10):低于此分的框在检测阶段已丢弃。
|
||||
/// - [highConf](0.35):高于此分直接确认显示;真实野鸡多为 0.1~0.2,
|
||||
/// 高于 0.35 视为强证据。
|
||||
/// - 0.10~0.35 之间:需要多帧稳定([confirmFrames] 帧)或 活动证据
|
||||
/// (运动区域/背景新出现区域重叠)才确认显示。
|
||||
class CameraViewModel extends ChangeNotifier {
|
||||
static const int maxTracks = 30;
|
||||
static const double motionBoost = 0.15;
|
||||
static const double lowConf = TfliteDetector.minScore;
|
||||
static const double highConf = 0.35;
|
||||
static const int confirmFrames = 3;
|
||||
static const double associateRadius = 0.12;
|
||||
static const int displayAgeMs = 500;
|
||||
static const int forgetMs = 2000;
|
||||
|
||||
/// 推理后台 isolate 是否就绪(由相机页创建 worker 后设置)
|
||||
bool modelReady = false;
|
||||
|
||||
final Reminder reminder;
|
||||
|
||||
CameraUiState _state;
|
||||
CameraUiState get state => _state;
|
||||
|
||||
final Map<int, _Track> _tracks = {};
|
||||
int _nextTrackId = 0;
|
||||
|
||||
CameraViewModel({required this.reminder}) : _state = const CameraUiState();
|
||||
|
||||
void setModelReady(bool ready) {
|
||||
if (modelReady == ready) return;
|
||||
modelReady = ready;
|
||||
_state = CameraUiState(modelReady: ready);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 帧分析回调(分析流调用)
|
||||
void onFramesAnalyzed(
|
||||
List<DetectionResult> results,
|
||||
int rotation,
|
||||
int imageWidthPx,
|
||||
int imageHeightPx,
|
||||
List<MotionRegion> motionRegions,
|
||||
List<MotionRegion> noveltyRegions, {
|
||||
int detectCalls = 0,
|
||||
int detectErrors = 0,
|
||||
String? lastError,
|
||||
int framesReceived = 0,
|
||||
int lastProcessMs = 0,
|
||||
}) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
_associate(results, motionRegions, noveltyRegions, now);
|
||||
|
||||
final visible = <DetectionResult>[];
|
||||
for (final t in _tracks.values) {
|
||||
if (now - t.firstSeenMs < displayAgeMs) continue;
|
||||
if (now - t.lastSeenMs > forgetMs) continue;
|
||||
if (!_shouldDisplay(t, motionRegions, noveltyRegions)) continue;
|
||||
var r = t.result;
|
||||
// 低分确认目标 + 活动证据 → 分数提升,便于视觉区分
|
||||
if (t.confirmed &&
|
||||
r.score < highConf &&
|
||||
_hasActivity(r, motionRegions, noveltyRegions)) {
|
||||
r = r.copyWith(score: (r.score + motionBoost).clamp(0.0, 1.0));
|
||||
}
|
||||
visible.add(r.copyWith(confirmed: t.confirmed));
|
||||
}
|
||||
|
||||
// 提醒:仅新确认的野鸡轨迹(确认瞬间触发一次,10s 同类冷却在 Reminder 内)
|
||||
for (final t in _tracks.values) {
|
||||
if (t.label != 'pheasant' || !t.confirmed || t.reminded) continue;
|
||||
final age = now - t.firstSeenMs;
|
||||
if (age >= displayAgeMs && age <= displayAgeMs + 1600 &&
|
||||
now - t.lastSeenMs <= 300) {
|
||||
t.reminded = true;
|
||||
reminder.onDetected(t.label);
|
||||
}
|
||||
}
|
||||
|
||||
var highest = 0.0;
|
||||
for (final r in results) {
|
||||
if (r.score > highest) highest = r.score;
|
||||
}
|
||||
_state = CameraUiState(
|
||||
modelReady: modelReady,
|
||||
results: visible,
|
||||
rotation: rotation,
|
||||
imageWidthPx: imageWidthPx,
|
||||
imageHeightPx: imageHeightPx,
|
||||
debugHighestScore: highest,
|
||||
debugDetectCalls: detectCalls,
|
||||
debugDetectErrors: detectErrors,
|
||||
debugLastError: lastError,
|
||||
framesReceived: framesReceived,
|
||||
debugLastMs: lastProcessMs,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 检测框 → 轨迹关联:按中心距离就近匹配(同标签优先,跨标签收紧距离),
|
||||
/// 未匹配则新建候选轨迹。
|
||||
void _associate(
|
||||
List<DetectionResult> results,
|
||||
List<MotionRegion> motionRegions,
|
||||
List<MotionRegion> noveltyRegions,
|
||||
int now) {
|
||||
final matched = <int>{};
|
||||
for (final r in results) {
|
||||
if (!_plausible(r)) continue;
|
||||
_Track? best;
|
||||
var bestD = associateRadius;
|
||||
for (final t in _tracks.values) {
|
||||
if (matched.contains(t.id)) continue;
|
||||
final d = _centerDist(t.result, r);
|
||||
// 同标签宽松匹配;跨标签(野鸡↔疑似 抖动)收紧到 60%
|
||||
final limit = t.label == r.label ? bestD : associateRadius * 0.6;
|
||||
if (d < limit) {
|
||||
bestD = d;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
if (best != null) {
|
||||
matched.add(best.id);
|
||||
best.update(r, now);
|
||||
best.seenCount++;
|
||||
if (best.seenCount >= confirmFrames || r.score >= highConf ||
|
||||
_hasActivity(r, motionRegions, noveltyRegions)) {
|
||||
best.confirmed = true;
|
||||
}
|
||||
} else {
|
||||
final t = _Track(_nextTrackId++, now, r);
|
||||
t.seenCount = 1;
|
||||
t.confirmed = r.score >= highConf ||
|
||||
_hasActivity(r, motionRegions, noveltyRegions);
|
||||
_tracks[t.id] = t;
|
||||
}
|
||||
}
|
||||
_tracks.removeWhere(
|
||||
(id, t) => !matched.contains(id) && now - t.lastSeenMs > forgetMs);
|
||||
}
|
||||
|
||||
/// 显示判定(按类别策略):
|
||||
/// - 疑似(生境预警):设计意图是常驻静态预警,始终显示(渲染侧弱化)
|
||||
/// - 野鸡:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示
|
||||
bool _shouldDisplay(_Track t, List<MotionRegion> motionRegions,
|
||||
List<MotionRegion> noveltyRegions) {
|
||||
if (t.label == 'suspect') return true;
|
||||
if (t.confirmed) return true;
|
||||
return t.result.score >= highConf ||
|
||||
_hasActivity(t.result, motionRegions, noveltyRegions);
|
||||
}
|
||||
|
||||
/// 活动证据:与运动区域或背景新出现区域重叠
|
||||
bool _hasActivity(DetectionResult r, List<MotionRegion> motionRegions,
|
||||
List<MotionRegion> noveltyRegions) =>
|
||||
motionRegions.any((m) => MotionAggregator.centerInRegion(r, m)) ||
|
||||
noveltyRegions.any((m) => MotionAggregator.centerInRegion(r, m));
|
||||
|
||||
/// 物理合理性过滤:宽高比与相对尺寸(野鸡 20-100px@720 量级,参照标注脚本)
|
||||
bool _plausible(DetectionResult r) {
|
||||
final h = r.height;
|
||||
final w = r.width;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
final aspect = w / h;
|
||||
if (aspect < 0.3 || aspect > 3.0) return false;
|
||||
if (r.label == 'suspect') return h >= 0.01 && h <= 0.5;
|
||||
return h >= 0.01 && h <= 0.3;
|
||||
}
|
||||
|
||||
double _centerDist(DetectionResult a, DetectionResult b) =>
|
||||
math.sqrt(math.pow(a.centerX - b.centerX, 2) +
|
||||
math.pow(a.centerY - b.centerY, 2));
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
reminder.release();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _Track {
|
||||
final int id;
|
||||
final String label;
|
||||
int firstSeenMs;
|
||||
int lastSeenMs;
|
||||
int seenCount = 0;
|
||||
bool confirmed = false;
|
||||
bool reminded = false;
|
||||
DetectionResult result;
|
||||
|
||||
_Track(this.id, this.firstSeenMs, this.result)
|
||||
: lastSeenMs = firstSeenMs,
|
||||
label = result.label;
|
||||
|
||||
void update(DetectionResult r, int now) {
|
||||
lastSeenMs = now;
|
||||
result = r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../detection/coordinate_mapper.dart';
|
||||
import '../detection/detection_result.dart';
|
||||
|
||||
/// 检测框绘制分级:
|
||||
/// - 野鸡 confirmed:红色实线 3px(强证据)
|
||||
/// - 野鸡 candidate:红色虚线 2px 半透明(待确认,弱提示)
|
||||
/// - 疑似(生境预警):黄色虚线 2px 半透明(常驻静态预警,弱化渲染)
|
||||
/// 标签附带距离估计(针孔模型 焦距px×参考体型/框高px)。
|
||||
class DetectionOverlay extends StatelessWidget {
|
||||
final List<DetectionResult> results;
|
||||
final int rotation;
|
||||
final int imageWidthPx;
|
||||
final int imageHeightPx;
|
||||
|
||||
const DetectionOverlay({
|
||||
super.key,
|
||||
required this.results,
|
||||
required this.rotation,
|
||||
required this.imageWidthPx,
|
||||
required this.imageHeightPx,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _OverlayPainter(results, rotation, imageWidthPx, imageHeightPx),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OverlayPainter extends CustomPainter {
|
||||
final List<DetectionResult> results;
|
||||
final int rotation;
|
||||
final int imageWidthPx;
|
||||
final int imageHeightPx;
|
||||
|
||||
_OverlayPainter(this.results, this.rotation, this.imageWidthPx,
|
||||
this.imageHeightPx);
|
||||
|
||||
static const _colors = {
|
||||
'pheasant': Color(0xFFE53935),
|
||||
'suspect': Color(0xFFFDD835),
|
||||
};
|
||||
static const _labels = {'pheasant': '野鸡', 'suspect': '疑似'};
|
||||
|
||||
/// 参考体型(米):野鸡身高 / 植被高度(参照旧 DistanceEstimator)
|
||||
static const _refSizeM = {'pheasant': 0.45, 'suspect': 0.50};
|
||||
|
||||
/// iPhone 13 主摄在 1280 高预览下的估算焦距 px(5.1mm / 5.30mm 传感器),
|
||||
/// 单目误差 ±30%,仅作参考
|
||||
static const double focalPx = 1230;
|
||||
static const double maxDistanceM = 120;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final r in results) {
|
||||
final rect = CoordinateMapper.mapToView(
|
||||
r.left,
|
||||
r.top,
|
||||
r.right,
|
||||
r.bottom,
|
||||
rotation,
|
||||
imageWidthPx,
|
||||
imageHeightPx,
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
final color = _colors[r.label] ?? Colors.white;
|
||||
final isSuspect = r.label == 'suspect';
|
||||
final confirmed = r.confirmed && !isSuspect;
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: confirmed ? 1.0 : 0.55)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = confirmed ? 3 : 2
|
||||
..isAntiAlias = true;
|
||||
final box = Rect.fromLTRB(rect.left, rect.top, rect.right, rect.bottom);
|
||||
if (confirmed) {
|
||||
canvas.drawRect(box, paint);
|
||||
} else {
|
||||
_drawDashedRect(canvas, box, paint);
|
||||
}
|
||||
|
||||
// 标签:框上方,含距离
|
||||
final dist = _distanceLabel(r);
|
||||
final text =
|
||||
'${_labels[r.label] ?? r.label} ${(r.score * 100).toInt()}%$dist';
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
color: color.withValues(alpha: confirmed ? 1.0 : 0.8),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: const [Shadow(color: Colors.black, blurRadius: 3)],
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
final top = math.max(0.0, rect.top - 22);
|
||||
final left = math.max(0.0, rect.left);
|
||||
textPainter.paint(canvas, Offset(left + 4, top));
|
||||
}
|
||||
}
|
||||
|
||||
String _distanceLabel(DetectionResult r) {
|
||||
final refH = _refSizeM[r.label];
|
||||
if (refH == null) return '';
|
||||
final hPx = r.height * imageHeightPx;
|
||||
if (hPx < 8) return '';
|
||||
final m = focalPx * refH / hPx;
|
||||
if (m > maxDistanceM) return '';
|
||||
return ' ≈${m.round()}m';
|
||||
}
|
||||
|
||||
void _drawDashedRect(Canvas canvas, Rect r, Paint paint,
|
||||
{double dash = 10, double gap = 6}) {
|
||||
void dashLine(Offset a, Offset b) {
|
||||
final total = (b - a).distance;
|
||||
if (total <= 0) return;
|
||||
final dir = (b - a) / total;
|
||||
var d = 0.0;
|
||||
while (d < total) {
|
||||
final e = math.min(d + dash, total);
|
||||
canvas.drawLine(a + dir * d, a + dir * e, paint);
|
||||
d += dash + gap;
|
||||
}
|
||||
}
|
||||
|
||||
dashLine(r.topLeft, r.topRight);
|
||||
dashLine(r.topRight, r.bottomRight);
|
||||
dashLine(r.bottomRight, r.bottomLeft);
|
||||
dashLine(r.bottomLeft, r.topLeft);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_OverlayPainter oldDelegate) =>
|
||||
oldDelegate.results != results ||
|
||||
oldDelegate.rotation != rotation ||
|
||||
oldDelegate.imageWidthPx != imageWidthPx ||
|
||||
oldDelegate.imageHeightPx != imageHeightPx;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:camera/camera.dart';
|
||||
|
||||
import '../detection/detection_result.dart';
|
||||
import '../detection/detector_worker.dart';
|
||||
import 'camera_view_model.dart';
|
||||
|
||||
/// 抽帧节流 + 后台推理(对应 Kotlin FrameAnalyzer)。
|
||||
/// 推理在后台 isolate(DetectorWorker)执行,主 isolate 只投递帧与收结果。
|
||||
class FrameAnalyzer {
|
||||
/// 连续检测:100ms 一帧
|
||||
int intervalMs = 100;
|
||||
|
||||
/// null = 模型加载失败,仅预览不分析
|
||||
final DetectorWorker? worker;
|
||||
final CameraViewModel viewModel;
|
||||
|
||||
int _lastDetectMs = 0;
|
||||
|
||||
/// 诊断计数:推理调用/异常次数
|
||||
int detectCalls = 0;
|
||||
int detectErrors = 0;
|
||||
String? lastError;
|
||||
|
||||
/// 最近一次完整处理(预处理+推理+运动检测)耗时 ms(worker 侧)
|
||||
int lastProcessMs = 0;
|
||||
|
||||
/// 图像流回调是否到达(诊断用)
|
||||
int framesReceived = 0;
|
||||
|
||||
FrameAnalyzer({required this.worker, required this.viewModel}) {
|
||||
worker?.onResult = _onResult;
|
||||
worker?.onError = _onError;
|
||||
}
|
||||
|
||||
void recordStreamError(String msg) {
|
||||
lastError = msg;
|
||||
detectErrors++;
|
||||
}
|
||||
|
||||
void _onResult(
|
||||
List<DetectionResult> results,
|
||||
List<MotionRegion> motion,
|
||||
List<MotionRegion> novelty,
|
||||
int rotation,
|
||||
int width,
|
||||
int height,
|
||||
int processMs) {
|
||||
detectCalls++;
|
||||
lastProcessMs = processMs;
|
||||
viewModel.onFramesAnalyzed(
|
||||
results,
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
motion,
|
||||
novelty,
|
||||
detectCalls: detectCalls,
|
||||
detectErrors: detectErrors,
|
||||
lastError: lastError,
|
||||
framesReceived: framesReceived,
|
||||
lastProcessMs: lastProcessMs,
|
||||
);
|
||||
}
|
||||
|
||||
void _onError(String msg) {
|
||||
detectErrors++;
|
||||
lastError = msg;
|
||||
viewModel.onFramesAnalyzed(
|
||||
const [],
|
||||
90,
|
||||
0,
|
||||
0,
|
||||
const [],
|
||||
const [],
|
||||
detectCalls: detectCalls,
|
||||
detectErrors: detectErrors,
|
||||
lastError: lastError,
|
||||
framesReceived: framesReceived,
|
||||
lastProcessMs: lastProcessMs,
|
||||
);
|
||||
}
|
||||
|
||||
void analyze(CameraImage image, int rotationDegrees) {
|
||||
framesReceived++;
|
||||
final w = worker;
|
||||
if (w == null) return;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (now - _lastDetectMs < intervalMs) return;
|
||||
_lastDetectMs = now;
|
||||
if (w.busy) return; // 上一帧未返回则丢帧,避免在途积压
|
||||
w.analyze(image, rotationDegrees);
|
||||
}
|
||||
|
||||
void reset() => _lastDetectMs = 0;
|
||||
|
||||
void dispose() => worker?.dispose();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../detection/detection_result.dart';
|
||||
import '../detection/motion_aggregator.dart';
|
||||
|
||||
/// 轻量运动检测:相邻帧 Y 通道差分 + 分块聚合。
|
||||
/// 小尺寸工作(约 128x128 内),在分析流中串行调用。
|
||||
/// 相机大幅移动时(全屏帧差)自动忽略本帧,避免误报。
|
||||
class MotionDetector {
|
||||
final int maxWidth;
|
||||
final int maxHeight;
|
||||
|
||||
List<int>? _prevGray;
|
||||
|
||||
MotionDetector({this.maxWidth = 128, this.maxHeight = 128});
|
||||
|
||||
/// 后台 isolate 用原始数据接口(不依赖 CameraImage)。
|
||||
List<MotionRegion> detectMotionRaw(
|
||||
Uint8List yPlane, int yStride, int width, int height) {
|
||||
final w = width, h = height;
|
||||
final scale = maxWidth / w < maxHeight / h ? maxWidth / w : maxHeight / h;
|
||||
final tw = (w * scale).toInt().clamp(1, maxWidth);
|
||||
final th = (h * scale).toInt().clamp(1, maxHeight);
|
||||
if (tw == 0 || th == 0) return const [];
|
||||
|
||||
// 取 Y 平面缩放灰度(最近邻下采样到 128x128 内)
|
||||
final y = yPlane;
|
||||
final gray = List<int>.filled(tw * th, 0);
|
||||
for (var oy = 0; oy < th; oy++) {
|
||||
final sy = (oy / scale).toInt().clamp(0, h - 1);
|
||||
for (var ox = 0; ox < tw; ox++) {
|
||||
final sx = (ox / scale).toInt().clamp(0, w - 1);
|
||||
gray[oy * tw + ox] = y[sy * yStride + sx];
|
||||
}
|
||||
}
|
||||
|
||||
final prev = _prevGray;
|
||||
_prevGray = List.of(gray);
|
||||
if (prev == null || prev.length != gray.length) return const [];
|
||||
|
||||
final diff = MotionAggregator.diffMask(gray, prev);
|
||||
final motionTotal = diff.fold(0, (a, b) => a + b);
|
||||
// 全屏大差异 → 相机移动/大范围变化,忽略本帧
|
||||
if (motionTotal > tw * th / 2) return const [];
|
||||
if (motionTotal < 12) return const [];
|
||||
return MotionAggregator.aggregate(diff, tw, th);
|
||||
}
|
||||
|
||||
/// 相机切换后重置参考帧,避免旧帧误差
|
||||
void reset() {
|
||||
_prevGray = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// 全局配置占位:接入真实支付前需替换以下值。
|
||||
class AppConfig {
|
||||
/// 后端服务器地址(订单创建/授权查询/套餐价格)
|
||||
/// 默认 Android 模拟器 10.0.2.2;iOS 模拟器构建时传
|
||||
/// --dart-define=API_BASE_URL=http://127.0.0.1:8080
|
||||
static const String apiBaseUrl =
|
||||
String.fromEnvironment('API_BASE_URL', defaultValue: 'http://10.0.2.2:8080');
|
||||
|
||||
/// 微信开放平台 AppID(需在微信开放平台注册包名+签名)
|
||||
static const String wechatAppId = 'wx0000000000000000';
|
||||
static const String wechatUniversalLink =
|
||||
'https://YOUR_DOMAIN.com/wechat/';
|
||||
|
||||
/// 支付宝开放平台 AppID
|
||||
static const String alipayAppId = '2020000000000000';
|
||||
static const String alipayUniversalLink =
|
||||
'https://YOUR_DOMAIN.com/alipay/';
|
||||
|
||||
/// iOS URL Scheme(微信/支付宝拉起回调,需与 Info.plist 一致)
|
||||
static const String wechatUrlScheme = 'wx0000000000000000';
|
||||
static const String alipayUrlScheme = 'alipay0000000000';
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'auth/auth_view_model.dart';
|
||||
import 'auth/session_store.dart';
|
||||
import 'home/home_view_model.dart';
|
||||
import 'payment/license_service.dart';
|
||||
import 'payment/models.dart';
|
||||
import 'payment/order_api.dart';
|
||||
import 'payment/paywall_view_model.dart';
|
||||
import 'payment/payment_service.dart';
|
||||
|
||||
/// 手动依赖注入容器(对应原 Kotlin AppContainer)
|
||||
class AppContainer {
|
||||
late final SessionStore sessionStore;
|
||||
late final OrderApi orderApi;
|
||||
late final LicenseService licenseService;
|
||||
late final AuthViewModel authViewModel;
|
||||
late final HomeViewModel homeViewModel;
|
||||
late final PaywallViewModel paywallViewModel;
|
||||
|
||||
AppContainer() {
|
||||
sessionStore = SessionStore();
|
||||
orderApi = OrderApi(sessionStore: sessionStore);
|
||||
licenseService =
|
||||
LicenseService(orderApi: orderApi, sessionStore: sessionStore);
|
||||
authViewModel = AuthViewModel(orderApi: orderApi, sessionStore: sessionStore);
|
||||
homeViewModel = HomeViewModel(licenseService: licenseService);
|
||||
paywallViewModel = PaywallViewModel(
|
||||
orderApi: orderApi,
|
||||
licenseService: licenseService,
|
||||
services: {
|
||||
PayChannel.wechat: WechatPayService(orderApi: orderApi),
|
||||
PayChannel.alipay: AlipayService(orderApi: orderApi),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'detection_result.dart';
|
||||
import 'motion_aggregator.dart';
|
||||
|
||||
/// 静态场景背景建模:运行均值 + 方差,帧差高于自适应阈值的像素记为"新出现",
|
||||
/// 分块聚合为新颖区域(novelty)。
|
||||
///
|
||||
/// 固定机位下,常驻物体(键盘/石头/文字)永远属于背景、不产生新颖区域;
|
||||
/// 走进画面的目标(野鸡移动/新出现)才会触发。比相邻帧差分更强的证据:
|
||||
/// 风吹草动是持续的背景更新,不会长期标记为新颖。
|
||||
class BackgroundModel {
|
||||
final int maxWidth;
|
||||
final int maxHeight;
|
||||
|
||||
static const double learnRate = 0.05;
|
||||
static const double kSigma = 2.5;
|
||||
static const int minDiff = 15;
|
||||
static const int minPixels = 12;
|
||||
|
||||
Float32List? _mean;
|
||||
Float32List? _var;
|
||||
int _tw = 0;
|
||||
|
||||
BackgroundModel({this.maxWidth = 128, this.maxHeight = 128});
|
||||
|
||||
/// 后台 isolate 用原始数据接口(与 MotionDetector 同源:直接取 planes[0])。
|
||||
List<MotionRegion> updateRaw(
|
||||
Uint8List yPlane, int yStride, int width, int height) {
|
||||
final scale =
|
||||
maxWidth / width < maxHeight / height ? maxWidth / width : maxHeight / height;
|
||||
final tw = (width * scale).toInt().clamp(1, maxWidth);
|
||||
final th = (height * scale).toInt().clamp(1, maxHeight);
|
||||
if (tw == 0 || th == 0) return const [];
|
||||
|
||||
final gray = Float32List(tw * th);
|
||||
for (var oy = 0; oy < th; oy++) {
|
||||
final sy = (oy / scale).toInt().clamp(0, height - 1);
|
||||
final idx = oy * tw;
|
||||
for (var ox = 0; ox < tw; ox++) {
|
||||
final sx = (ox / scale).toInt().clamp(0, width - 1);
|
||||
gray[idx + ox] = yPlane[sy * yStride + sx].toDouble();
|
||||
}
|
||||
}
|
||||
return update(gray, tw, th);
|
||||
}
|
||||
|
||||
List<MotionRegion> update(Float32List gray, int tw, int th) {
|
||||
final n = gray.length;
|
||||
final mean = _mean;
|
||||
final variance = _var;
|
||||
if (mean == null || variance == null || mean.length != n || _tw != tw) {
|
||||
_mean = Float32List.fromList(gray);
|
||||
_var = Float32List(n);
|
||||
_tw = tw;
|
||||
return const [];
|
||||
}
|
||||
|
||||
final diff = Uint8List(n);
|
||||
var fgCount = 0;
|
||||
for (var i = 0; i < n; i++) {
|
||||
final g = gray[i];
|
||||
final m = mean[i];
|
||||
final d = (g - m).abs();
|
||||
if (d > kSigma * math.sqrt(variance[i]) + minDiff) {
|
||||
diff[i] = 1;
|
||||
fgCount++;
|
||||
// 前景像素不更新背景,避免把移动目标吸收进背景
|
||||
} else {
|
||||
// 静态像素缓慢吸收进背景,适应光照漂移
|
||||
final nm = m + learnRate * (g - m);
|
||||
mean[i] = nm;
|
||||
variance[i] =
|
||||
variance[i] + learnRate * ((g - nm) * (g - nm) - variance[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 全屏大变化 → 相机移动/场景切换,重建背景
|
||||
if (fgCount > n ~/ 2) {
|
||||
_mean = null;
|
||||
_var = null;
|
||||
return const [];
|
||||
}
|
||||
if (fgCount < minPixels) return const [];
|
||||
return MotionAggregator.aggregate(diff, tw, th);
|
||||
}
|
||||
|
||||
/// 相机切换后重置,避免旧场景背景
|
||||
void reset() {
|
||||
_mean = null;
|
||||
_var = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
class ViewRect {
|
||||
final double left;
|
||||
final double top;
|
||||
final double right;
|
||||
final double bottom;
|
||||
|
||||
const ViewRect(this.left, this.top, this.right, this.bottom);
|
||||
|
||||
double get width => right - left;
|
||||
double get height => bottom - top;
|
||||
double get centerX => (left + right) / 2;
|
||||
double get centerY => (top + bottom) / 2;
|
||||
}
|
||||
|
||||
/// 模型归一化坐标 → 预览视图坐标(含传感器旋转与 FIT_CENTER 裁剪)。
|
||||
class CoordinateMapper {
|
||||
static ViewRect mapToView(
|
||||
double normLeft,
|
||||
double normTop,
|
||||
double normRight,
|
||||
double normBottom,
|
||||
int rotation,
|
||||
int imageW,
|
||||
int imageH,
|
||||
double viewW,
|
||||
double viewH,
|
||||
) {
|
||||
// 1) 旋转校正:图像方向 → 竖屏视图方向(归一化坐标)
|
||||
late final double x0, y0, x1, y1;
|
||||
switch (rotation) {
|
||||
case 90:
|
||||
x0 = 1 - normBottom;
|
||||
y0 = normLeft;
|
||||
x1 = 1 - normTop;
|
||||
y1 = normRight;
|
||||
case 180:
|
||||
x0 = 1 - normRight;
|
||||
y0 = 1 - normBottom;
|
||||
x1 = 1 - normLeft;
|
||||
y1 = 1 - normTop;
|
||||
case 270:
|
||||
x0 = normTop;
|
||||
y0 = 1 - normRight;
|
||||
x1 = normBottom;
|
||||
y1 = 1 - normLeft;
|
||||
default:
|
||||
x0 = normLeft;
|
||||
y0 = normTop;
|
||||
x1 = normRight;
|
||||
y1 = normBottom;
|
||||
}
|
||||
// 2) 旋转后图像在竖屏方向上的尺寸
|
||||
final portrait = rotation == 90 || rotation == 270;
|
||||
final portW = portrait ? imageH : imageW;
|
||||
final portH = portrait ? imageW : imageH;
|
||||
// 3) FIT_CENTER 缩放与居中偏移
|
||||
final scale = viewW / portW < viewH / portH
|
||||
? viewW / portW
|
||||
: viewH / portH;
|
||||
final offsetX = (viewW - portW * scale) / 2;
|
||||
final offsetY = (viewH - portH * scale) / 2;
|
||||
return ViewRect(
|
||||
x0 * portW * scale + offsetX,
|
||||
y0 * portH * scale + offsetY,
|
||||
x1 * portW * scale + offsetX,
|
||||
y1 * portH * scale + offsetY,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
class DetectionResult {
|
||||
final String label;
|
||||
final double score;
|
||||
final double left;
|
||||
final double top;
|
||||
final double right;
|
||||
final double bottom;
|
||||
|
||||
/// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线
|
||||
final bool confirmed;
|
||||
|
||||
const DetectionResult({
|
||||
required this.label,
|
||||
required this.score,
|
||||
required this.left,
|
||||
required this.top,
|
||||
required this.right,
|
||||
required this.bottom,
|
||||
this.confirmed = true,
|
||||
});
|
||||
|
||||
double get width => right - left;
|
||||
double get height => bottom - top;
|
||||
double get centerX => (left + right) / 2;
|
||||
double get centerY => (top + bottom) / 2;
|
||||
|
||||
DetectionResult copyWith({
|
||||
double? score,
|
||||
double? left,
|
||||
double? top,
|
||||
double? right,
|
||||
double? bottom,
|
||||
bool? confirmed,
|
||||
}) =>
|
||||
DetectionResult(
|
||||
label: label,
|
||||
score: score ?? this.score,
|
||||
left: left ?? this.left,
|
||||
top: top ?? this.top,
|
||||
right: right ?? this.right,
|
||||
bottom: bottom ?? this.bottom,
|
||||
confirmed: confirmed ?? this.confirmed,
|
||||
);
|
||||
}
|
||||
|
||||
class MotionRegion {
|
||||
final double left;
|
||||
final double top;
|
||||
final double right;
|
||||
final double bottom;
|
||||
|
||||
const MotionRegion(this.left, this.top, this.right, this.bottom);
|
||||
|
||||
double get centerX => (left + right) / 2;
|
||||
double get centerY => (top + bottom) / 2;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
import '../camera/motion_detector.dart';
|
||||
import 'background_model.dart';
|
||||
import 'detection_result.dart';
|
||||
import 'tflite_detector.dart';
|
||||
import 'visual_prior.dart';
|
||||
|
||||
/// 推理工作单元:模型加载与检测全部在后台 isolate 执行,
|
||||
/// 主 isolate 只投递帧数据、接收结果,UI 不被推理阻塞(iOS 真机卡顿根因)。
|
||||
class DetectorWorker {
|
||||
static const String modelAsset = 'assets/model.tflite';
|
||||
static const String labelsAsset = 'assets/labels.txt';
|
||||
|
||||
final Isolate _isolate;
|
||||
final ReceivePort _responses;
|
||||
|
||||
final _controlPort = Completer<SendPort>();
|
||||
final _ready = Completer<void>();
|
||||
|
||||
SendPort? _port;
|
||||
|
||||
/// 在途帧数(主 isolate 侧计数,用于丢帧)
|
||||
int _inFlight = 0;
|
||||
bool _dead = false;
|
||||
|
||||
/// 结果回调:结果 / 运动区域 / 新颖区域 / 旋转角 / 图宽 / 图高 / 处理耗时 ms
|
||||
void Function(List<DetectionResult>, List<MotionRegion>, List<MotionRegion>,
|
||||
int, int, int, int)? onResult;
|
||||
|
||||
/// 单帧处理异常回调(不影响相机流)
|
||||
void Function(String)? onError;
|
||||
|
||||
/// 最近一次创建失败的诊断原因(UI 展示用)
|
||||
static String? lastLoadError;
|
||||
|
||||
/// worker 最近上报的执行步骤(诊断用)
|
||||
static String? lastLog;
|
||||
|
||||
DetectorWorker._(this._isolate, this._responses) {
|
||||
_responses.listen(_onMessage, onDone: () {
|
||||
_dead = true;
|
||||
if (!_ready.isCompleted) {
|
||||
_ready.completeError(StateError('推理进程异常退出'));
|
||||
}
|
||||
onError?.call('推理进程异常退出');
|
||||
});
|
||||
}
|
||||
|
||||
/// 读取模型资产并启动后台推理 isolate;加载失败返回 null(App 降级为仅预览)。
|
||||
static Future<DetectorWorker?> create() async {
|
||||
try {
|
||||
final data = await rootBundle.load(modelAsset);
|
||||
final modelBytes =
|
||||
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
final labels = (await rootBundle.loadString(labelsAsset))
|
||||
.split('\n')
|
||||
.where((l) => l.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final responses = ReceivePort();
|
||||
final isolate = await Isolate.spawn(_workerMain, responses.sendPort);
|
||||
final worker = DetectorWorker._(isolate, responses);
|
||||
|
||||
final port = await worker._controlPort.future
|
||||
.timeout(const Duration(seconds: 10),
|
||||
onTimeout: () => throw TimeoutException('worker port timeout'));
|
||||
worker._port = port;
|
||||
port.send(['load', modelBytes, labels]);
|
||||
await worker._ready.future
|
||||
.timeout(const Duration(seconds: 20), onTimeout: () {
|
||||
throw TimeoutException('model load timeout');
|
||||
});
|
||||
return worker;
|
||||
} catch (e) {
|
||||
lastLoadError = e.toString();
|
||||
debugPrint('[DetectorWorker] create failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否忙(上一帧尚未返回):忙则丢帧,避免在途积压
|
||||
bool get busy => _inFlight > 0;
|
||||
|
||||
void analyze(CameraImage image, int rotationDegrees) {
|
||||
final port = _port;
|
||||
if (port == null || _dead) return;
|
||||
_inFlight++;
|
||||
port.send([
|
||||
'frame',
|
||||
[
|
||||
image.planes.map((p) => p.bytes).toList(),
|
||||
image.planes.map((p) => p.bytesPerRow).toList(),
|
||||
image.width,
|
||||
image.height,
|
||||
image.format.group == ImageFormatGroup.bgra8888,
|
||||
rotationDegrees,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
void _onMessage(dynamic msg) {
|
||||
final list = msg as List;
|
||||
switch (list[0] as String) {
|
||||
case 'port':
|
||||
_controlPort.complete(list[1] as SendPort);
|
||||
break;
|
||||
case 'ready':
|
||||
_ready.complete();
|
||||
break;
|
||||
case 'load-error':
|
||||
_ready.completeError(StateError(
|
||||
list.length > 1 ? list[1] as String : 'model load failed'));
|
||||
break;
|
||||
case 'result':
|
||||
_inFlight--;
|
||||
final dets = (list[4] as List).map((d) {
|
||||
final v = d as List;
|
||||
return DetectionResult(
|
||||
label: v[0] as String,
|
||||
score: v[1] as double,
|
||||
left: v[2] as double,
|
||||
top: v[3] as double,
|
||||
right: v[4] as double,
|
||||
bottom: v[5] as double,
|
||||
);
|
||||
}).toList();
|
||||
final motion = (list[5] as List)
|
||||
.map((m) => m as List)
|
||||
.map((v) => MotionRegion(
|
||||
v[0] as double, v[1] as double, v[2] as double, v[3] as double))
|
||||
.toList();
|
||||
final novelty = (list[6] as List)
|
||||
.map((m) => m as List)
|
||||
.map((v) => MotionRegion(
|
||||
v[0] as double, v[1] as double, v[2] as double, v[3] as double))
|
||||
.toList();
|
||||
onResult?.call(dets, motion, novelty, list[1] as int, list[2] as int,
|
||||
list[3] as int, list[7] as int);
|
||||
break;
|
||||
case 'log':
|
||||
lastLog = list[1] as String;
|
||||
debugPrint('[DetectorWorker] $lastLog');
|
||||
break;
|
||||
case 'error':
|
||||
_inFlight--;
|
||||
onError?.call(list[1] as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机切换/场景变化后重置运动与背景参考
|
||||
void reset() {
|
||||
final port = _port;
|
||||
if (port == null || _dead) return;
|
||||
port.send(['reset']);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_dead = true;
|
||||
_isolate.kill(priority: Isolate.immediate);
|
||||
_responses.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台 isolate 入口:串行处理 load / frame / reset 命令。
|
||||
/// 所有回发必须走 [mainPort](主 isolate 的端口);control 是 worker 自己的
|
||||
/// 收件箱,往 control.sendPort 发消息等于发给自己,主 isolate 永远收不到。
|
||||
Future<void> _workerMain(SendPort mainPort) async {
|
||||
final control = ReceivePort();
|
||||
mainPort.send(['port', control.sendPort]);
|
||||
mainPort.send(['log', 'worker-start']);
|
||||
|
||||
TfliteDetector? detector;
|
||||
MotionDetector? motion;
|
||||
BackgroundModel? background;
|
||||
await for (final msg in control) {
|
||||
try {
|
||||
final list = msg as List;
|
||||
switch (list[0] as String) {
|
||||
case 'load':
|
||||
mainPort.send(['log', 'load-received']);
|
||||
try {
|
||||
detector = await TfliteDetector.fromBuffer(
|
||||
list[1] as Uint8List, (list[2] as List).cast<String>());
|
||||
if (detector == null) {
|
||||
mainPort.send(['load-error', 'fromBuffer 返回 null']);
|
||||
} else {
|
||||
mainPort.send(['log', 'fromBuffer-ok']);
|
||||
motion = MotionDetector();
|
||||
background = BackgroundModel();
|
||||
mainPort.send(['ready']);
|
||||
}
|
||||
} catch (e) {
|
||||
mainPort.send(['load-error', '$e']);
|
||||
}
|
||||
break;
|
||||
case 'frame':
|
||||
final d = detector;
|
||||
final m = motion;
|
||||
final b = background;
|
||||
if (d == null || m == null || b == null) break;
|
||||
final frame = list[1] as List;
|
||||
final planes = (frame[0] as List).cast<Uint8List>();
|
||||
final strides = (frame[1] as List).cast<int>();
|
||||
final width = frame[2] as int;
|
||||
final height = frame[3] as int;
|
||||
final isBgra = frame[4] as bool;
|
||||
final rotation = frame[5] as int;
|
||||
|
||||
final sw = Stopwatch()..start();
|
||||
var results = d.detectRaw(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
isBgra: isBgra,
|
||||
);
|
||||
// 低分野鸡框过视觉先验(颜色/位置),减少户外误报
|
||||
results = VisualPrior.filter(
|
||||
results,
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
isBgra: isBgra,
|
||||
);
|
||||
final motionRegions = m.detectMotionRaw(
|
||||
planes[0], strides[0], width, height);
|
||||
final noveltyRegions =
|
||||
b.updateRaw(planes[0], strides[0], width, height);
|
||||
sw.stop();
|
||||
|
||||
mainPort.send([
|
||||
'result',
|
||||
rotation,
|
||||
width,
|
||||
height,
|
||||
results
|
||||
.map((r) =>
|
||||
[r.label, r.score, r.left, r.top, r.right, r.bottom])
|
||||
.toList(),
|
||||
motionRegions
|
||||
.map((mr) => [mr.left, mr.top, mr.right, mr.bottom])
|
||||
.toList(),
|
||||
noveltyRegions
|
||||
.map((mr) => [mr.left, mr.top, mr.right, mr.bottom])
|
||||
.toList(),
|
||||
sw.elapsedMilliseconds,
|
||||
]);
|
||||
break;
|
||||
case 'reset':
|
||||
motion?.reset();
|
||||
background?.reset();
|
||||
}
|
||||
} catch (e) {
|
||||
mainPort.send(['error', '$e']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'detection_result.dart';
|
||||
|
||||
/// 帧差运动聚合:每像素 0/1 差分掩码 → 8x8 分块统计 → 连通块聚合为运动区域。
|
||||
class MotionAggregator {
|
||||
static const int blockGrid = 8;
|
||||
static const double blockActiveRatio = 0.30;
|
||||
static const int maxRegions = 3;
|
||||
static const int diffThreshold = 25;
|
||||
|
||||
static List<MotionRegion> aggregate(List<int> diff, int width, int height) {
|
||||
final bw = width ~/ blockGrid;
|
||||
final bh = height ~/ blockGrid;
|
||||
if (bw == 0 || bh == 0) return const [];
|
||||
|
||||
final active = List<bool>.filled(blockGrid * blockGrid, false);
|
||||
for (var by = 0; by < blockGrid; by++) {
|
||||
for (var bx = 0; bx < blockGrid; bx++) {
|
||||
final blockW = bx == blockGrid - 1 ? width - bx * bw : bw;
|
||||
final blockH = by == blockGrid - 1 ? height - by * bh : bh;
|
||||
var motion = 0;
|
||||
for (var y = by * bh; y < by * bh + blockH; y++) {
|
||||
var idx = y * width + bx * bw;
|
||||
for (var x = 0; x < blockW; x++) {
|
||||
motion += diff[idx + x];
|
||||
}
|
||||
idx += width;
|
||||
}
|
||||
active[by * blockGrid + bx] =
|
||||
motion > blockW * blockH * blockActiveRatio;
|
||||
}
|
||||
}
|
||||
|
||||
final regions = <MotionRegion>[];
|
||||
final visited = List<bool>.filled(active.length, false);
|
||||
for (var i = 0; i < active.length; i++) {
|
||||
if (!active[i] || visited[i]) continue;
|
||||
var minX = blockGrid, minY = blockGrid, maxX = -1, maxY = -1;
|
||||
final stack = <int>[i];
|
||||
visited[i] = true;
|
||||
while (stack.isNotEmpty) {
|
||||
final cur = stack.removeLast();
|
||||
final bx = cur % blockGrid;
|
||||
final by = cur ~/ blockGrid;
|
||||
if (bx < minX) minX = bx;
|
||||
if (bx > maxX) maxX = bx;
|
||||
if (by < minY) minY = by;
|
||||
if (by > maxY) maxY = by;
|
||||
for (final nb in neighbors(cur)) {
|
||||
if (active[nb] && !visited[nb]) {
|
||||
visited[nb] = true;
|
||||
stack.add(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX - minX > 3 || maxY - minY > 3) continue; // 全屏噪声过滤
|
||||
regions.add(MotionRegion(
|
||||
minX * bw / width,
|
||||
minY * bh / height,
|
||||
math.min((maxX + 1) * bw, width) / width,
|
||||
math.min((maxY + 1) * bh, height) / height,
|
||||
));
|
||||
if (regions.length >= maxRegions) break;
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
static List<int> neighbors(int i) {
|
||||
final bx = i % blockGrid;
|
||||
final by = i ~/ blockGrid;
|
||||
final list = <int>[];
|
||||
if (bx > 0) list.add(i - 1);
|
||||
if (bx < blockGrid - 1) list.add(i + 1);
|
||||
if (by > 0) list.add(i - blockGrid);
|
||||
if (by < blockGrid - 1) list.add(i + blockGrid);
|
||||
return list;
|
||||
}
|
||||
|
||||
/// 检测框中心是否落在运动区域内(用于置信度提升判定)
|
||||
static bool centerInRegion(DetectionResult box, MotionRegion region) =>
|
||||
box.centerX >= region.left &&
|
||||
box.centerX <= region.right &&
|
||||
box.centerY >= region.top &&
|
||||
box.centerY <= region.bottom;
|
||||
|
||||
/// 帧差掩码:|g - prev| > threshold → 1
|
||||
static List<int> diffMask(List<int> gray, List<int> prev,
|
||||
[int threshold = diffThreshold]) {
|
||||
final diff = List<int>.filled(gray.length, 0);
|
||||
for (var i = 0; i < gray.length; i++) {
|
||||
diff[i] = (gray[i] - prev[i]).abs() > threshold ? 1 : 0;
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'detection_result.dart';
|
||||
|
||||
double iou(DetectionResult a, DetectionResult b) {
|
||||
final x0 = a.left > b.left ? a.left : b.left;
|
||||
final y0 = a.top > b.top ? a.top : b.top;
|
||||
final x1 = a.right < b.right ? a.right : b.right;
|
||||
final y1 = a.bottom < b.bottom ? a.bottom : b.bottom;
|
||||
if (x1 <= x0 || y1 <= y0) return 0;
|
||||
final inter = (x1 - x0) * (y1 - y0);
|
||||
final union = a.width * a.height + b.width * b.height - inter;
|
||||
return union <= 0 ? 0 : inter / union;
|
||||
}
|
||||
|
||||
List<DetectionResult> nms(List<DetectionResult> boxes, double iouThreshold) {
|
||||
final sorted = [...boxes]..sort((a, b) => b.score.compareTo(a.score));
|
||||
final kept = <DetectionResult>[];
|
||||
for (final b in sorted) {
|
||||
if (!kept.any((k) => iou(b, k) > iouThreshold)) kept.add(b);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:tflite_flutter/tflite_flutter.dart';
|
||||
|
||||
import 'detection_result.dart';
|
||||
import 'nms.dart';
|
||||
|
||||
/// YOLOv8n 端侧推理实现(对应 Kotlin TFLiteDetector)。
|
||||
/// 模型输出布局(ultralytics litert 导出):[1, 4 + nc, anchors],
|
||||
/// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。
|
||||
/// 输入为 NCHW [1, 3, 704, 704](litert 导出保留 torch 布局)。
|
||||
class TfliteDetector {
|
||||
static const int inputSize = 704;
|
||||
// 野鸡数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升
|
||||
static const double minScore = 0.10;
|
||||
static const double iouThreshold = 0.45;
|
||||
static const int maxDetections = 20;
|
||||
static const String modelAsset = 'assets/model.tflite';
|
||||
static const String labelsAsset = 'assets/labels.txt';
|
||||
|
||||
final Interpreter _interpreter;
|
||||
final List<String> _labels;
|
||||
final int _numClasses;
|
||||
final int _numAnchors;
|
||||
|
||||
final Float32List _input =
|
||||
Float32List(1 * inputSize * inputSize * 3);
|
||||
|
||||
/// 输出按模型形状 [1, 4+nc, anchors] 的嵌套 List 组织,
|
||||
/// run() 要求输出对象形状与模型完全一致(扁平 List 会被拒)。
|
||||
final List<List<List<double>>> _output;
|
||||
|
||||
TfliteDetector._(this._interpreter, this._labels, this._numClasses,
|
||||
this._numAnchors, this._output);
|
||||
|
||||
/// 模型缺失或加载失败返回 null(App 降级为仅预览)。
|
||||
/// 在后台 isolate 内调用(模型字节由主 isolate 读取后传入)。
|
||||
static Future<TfliteDetector?> fromBuffer(
|
||||
Uint8List bytes, List<String> labels) async {
|
||||
try {
|
||||
final interpreter = Interpreter.fromBuffer(
|
||||
bytes,
|
||||
options: InterpreterOptions()..threads = 4,
|
||||
);
|
||||
return TfliteDetector._fromModel(interpreter, labels);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出布局 [1, 4+nc, anchors] 取自模型本身,类别数不与 labels 文件长度耦合。
|
||||
factory TfliteDetector._fromModel(
|
||||
Interpreter interpreter, List<String> labels) {
|
||||
final shape = interpreter.getOutputTensor(0).shape;
|
||||
final numClasses =
|
||||
shape.length >= 3 && shape[1] > 4 ? shape[1] - 4 : labels.length;
|
||||
final numAnchors = shape.length >= 3 && shape[2] > 0 ? shape[2] : 2100;
|
||||
final output = List.generate(
|
||||
1,
|
||||
(_) => List.generate(
|
||||
numClasses + 4,
|
||||
(_) => List<double>.filled(numAnchors, 0),
|
||||
),
|
||||
);
|
||||
return TfliteDetector._(
|
||||
interpreter, labels, numClasses, numAnchors, output);
|
||||
}
|
||||
|
||||
/// 原始数据接口(后台 isolate 用,不依赖 CameraImage)。
|
||||
/// 输出坐标统一反算为原图归一化空间(与 MotionDetector 一致),
|
||||
/// 否则 CENTER_CROP 裁剪偏移会让检测框系统性偏移。
|
||||
List<DetectionResult> detectRaw({
|
||||
required List<Uint8List> planes,
|
||||
required List<int> strides,
|
||||
required int width,
|
||||
required int height,
|
||||
required bool isBgra,
|
||||
}) {
|
||||
preprocess(
|
||||
planes: planes,
|
||||
strides: strides,
|
||||
width: width,
|
||||
height: height,
|
||||
isBgra: isBgra);
|
||||
// 传原始字节视图而非 Float32List:tflite_flutter 会对非 ByteBuffer/Uint8List
|
||||
// 输入调用 resizeInputTensor(1 维 [1486848]),使 node 0 TRANSPOSE prepare 失败
|
||||
_interpreter.run(_input.buffer.asUint8List(), _output);
|
||||
final dets = postprocess();
|
||||
// 反算与 preprocess 的 scale/dx/dy 公式一致(704 输入空间 → 原图归一化)
|
||||
final scale = inputSize / width < inputSize / height
|
||||
? inputSize / width
|
||||
: inputSize / height;
|
||||
final dx = (inputSize - width * scale) / 2;
|
||||
final dy = (inputSize - height * scale) / 2;
|
||||
if (dx == 0 && dy == 0) return dets;
|
||||
return dets
|
||||
.map((r) => r.copyWith(
|
||||
left: (r.left * inputSize - dx) / (width * scale),
|
||||
right: (r.right * inputSize - dx) / (width * scale),
|
||||
top: (r.top * inputSize - dy) / (height * scale),
|
||||
bottom: (r.bottom * inputSize - dy) / (height * scale),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 按像素格式分派:iOS bgra8888 单平面 / Android yuv420 多平面。
|
||||
void preprocess({
|
||||
required List<Uint8List> planes,
|
||||
required List<int> strides,
|
||||
required int width,
|
||||
required int height,
|
||||
required bool isBgra,
|
||||
}) {
|
||||
if (isBgra) {
|
||||
_preprocessBgra(planes[0], strides[0], width, height);
|
||||
} else {
|
||||
_preprocessYuv(planes, strides, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/// BGRA8888 单平面(iOS):每像素 4 字节 [b,g,r,a],双线性采样,
|
||||
/// letterbox(等比缩到长边 704,短边黑边补 0,与 YOLO 训练一致)。
|
||||
void _preprocessBgra(Uint8List src, int stride, int srcW, int srcH) {
|
||||
final plane = inputSize * inputSize;
|
||||
final scale = inputSize / srcW < inputSize / srcH
|
||||
? inputSize / srcW
|
||||
: inputSize / srcH;
|
||||
final dx = (inputSize - srcW * scale) / 2;
|
||||
final dy = (inputSize - srcH * scale) / 2;
|
||||
|
||||
for (var oy = 0; oy < inputSize; oy++) {
|
||||
final syf = (oy - dy) / scale;
|
||||
if (syf < 0 || syf >= srcH) {
|
||||
for (var ox = 0; ox < inputSize; ox++) {
|
||||
final p = oy * inputSize + ox;
|
||||
_input[p] = 0;
|
||||
_input[p + plane] = 0;
|
||||
_input[p + 2 * plane] = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (var ox = 0; ox < inputSize; ox++) {
|
||||
final p = oy * inputSize + ox;
|
||||
final sxf = (ox - dx) / scale;
|
||||
if (sxf < 0 || sxf >= srcW) {
|
||||
_input[p] = 0;
|
||||
_input[p + plane] = 0;
|
||||
_input[p + 2 * plane] = 0;
|
||||
continue;
|
||||
}
|
||||
final x0 = sxf.floor(), y0 = syf.floor();
|
||||
final x1 = x0 < srcW - 1 ? x0 + 1 : x0;
|
||||
final y1 = y0 < srcH - 1 ? y0 + 1 : y0;
|
||||
final fx = sxf - x0, fy = syf - y0;
|
||||
|
||||
// BGRA 字节序:+0 B、+1 G、+2 R、+3 A
|
||||
final i00 = y0 * stride + x0 * 4;
|
||||
final i10 = y0 * stride + x1 * 4;
|
||||
final i01 = y1 * stride + x0 * 4;
|
||||
final i11 = y1 * stride + x1 * 4;
|
||||
final r00 = src[i00 + 2].toDouble();
|
||||
final g00 = src[i00 + 1].toDouble();
|
||||
final b00 = src[i00].toDouble();
|
||||
final r10 = src[i10 + 2].toDouble();
|
||||
final g10 = src[i10 + 1].toDouble();
|
||||
final b10 = src[i10].toDouble();
|
||||
final r01 = src[i01 + 2].toDouble();
|
||||
final g01 = src[i01 + 1].toDouble();
|
||||
final b01 = src[i01].toDouble();
|
||||
final r11 = src[i11 + 2].toDouble();
|
||||
final g11 = src[i11 + 1].toDouble();
|
||||
final b11 = src[i11].toDouble();
|
||||
|
||||
_input[p] = _bl(r00, r10, r01, r11, fx, fy) / 255.0;
|
||||
_input[p + plane] = _bl(g00, g10, g01, g11, fx, fy) / 255.0;
|
||||
_input[p + 2 * plane] = _bl(b00, b10, b01, b11, fx, fy) / 255.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// letterbox 缩放 + YUV → RGB 归一化 0~1(NCHW),双线性采样。
|
||||
/// 兼容 NV12(iOS 双平面,UV 交错)与 I420(Android 三平面)。
|
||||
void _preprocessYuv(
|
||||
List<Uint8List> planes, List<int> strides, int srcW, int srcH) {
|
||||
final plane = inputSize * inputSize;
|
||||
final y = planes[0];
|
||||
final nv12 = planes.length == 2;
|
||||
final uv = nv12 ? planes[1] : null;
|
||||
final u = nv12 ? null : planes[1];
|
||||
final v = nv12 ? null : planes[2];
|
||||
final yStride = strides[0];
|
||||
final uvStride = strides[1];
|
||||
|
||||
// U/V 平面采样(nv12:偶位 U 奇位 V;i420:三平面分离)
|
||||
double uAt(int x, int y) => nv12
|
||||
? uv![y * uvStride + x * 2] - 128.0
|
||||
: u![y * uvStride + x] - 128.0;
|
||||
double vAt(int x, int y) => nv12
|
||||
? uv![y * uvStride + x * 2 + 1] - 128.0
|
||||
: v![y * uvStride + x] - 128.0;
|
||||
|
||||
final scale = inputSize / srcW < inputSize / srcH
|
||||
? inputSize / srcW
|
||||
: inputSize / srcH;
|
||||
final dx = (inputSize - srcW * scale) / 2;
|
||||
final dy = (inputSize - srcH * scale) / 2;
|
||||
|
||||
for (var oy = 0; oy < inputSize; oy++) {
|
||||
final syf = (oy - dy) / scale;
|
||||
if (syf < 0 || syf >= srcH) {
|
||||
for (var ox = 0; ox < inputSize; ox++) {
|
||||
final p = oy * inputSize + ox;
|
||||
_input[p] = 0;
|
||||
_input[p + plane] = 0;
|
||||
_input[p + 2 * plane] = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (var ox = 0; ox < inputSize; ox++) {
|
||||
final p = oy * inputSize + ox;
|
||||
final sxf = (ox - dx) / scale;
|
||||
if (sxf < 0 || sxf >= srcW) {
|
||||
_input[p] = 0;
|
||||
_input[p + plane] = 0;
|
||||
_input[p + 2 * plane] = 0;
|
||||
continue;
|
||||
}
|
||||
final x0 = sxf.floor(), y0 = syf.floor();
|
||||
final x1 = x0 < srcW - 1 ? x0 + 1 : x0;
|
||||
final y1 = y0 < srcH - 1 ? y0 + 1 : y0;
|
||||
final fx = sxf - x0, fy = syf - y0;
|
||||
|
||||
// Y 双线性
|
||||
final y00 = y[y0 * yStride + x0].toDouble();
|
||||
final y10 = y[y0 * yStride + x1].toDouble();
|
||||
final y01 = y[y1 * yStride + x0].toDouble();
|
||||
final y11 = y[y1 * yStride + x1].toDouble();
|
||||
final yy = _bl(y00, y10, y01, y11, fx, fy);
|
||||
|
||||
// U/V 双线性(4:2:0 半分辨率,按像素坐标定位后除 2)
|
||||
final maxUx = srcW ~/ 2 - 1;
|
||||
final maxUy = srcH ~/ 2 - 1;
|
||||
final ux0 = (x0 ~/ 2).clamp(0, maxUx).toInt();
|
||||
final uy0 = (y0 ~/ 2).clamp(0, maxUy).toInt();
|
||||
final ux1 = (x1 ~/ 2).clamp(0, maxUx).toInt();
|
||||
final uy1 = (y1 ~/ 2).clamp(0, maxUy).toInt();
|
||||
final u00 = uAt(ux0, uy0);
|
||||
final u10 = uAt(ux1, uy0);
|
||||
final u01 = uAt(ux0, uy1);
|
||||
final u11 = uAt(ux1, uy1);
|
||||
final uu = _bl(u00, u10, u01, u11, fx, fy);
|
||||
|
||||
final v00 = vAt(ux0, uy0);
|
||||
final v10 = vAt(ux1, uy0);
|
||||
final v01 = vAt(ux0, uy1);
|
||||
final v11 = vAt(ux1, uy1);
|
||||
final vv = _bl(v00, v10, v01, v11, fx, fy);
|
||||
|
||||
// 有限范围展开(VideoRange Y 16~235,Cb/Cr 16~240)
|
||||
final yr = (yy - 16.0) * (255.0 / 219.0);
|
||||
final un = uu * (255.0 / 224.0);
|
||||
final vn = vv * (255.0 / 224.0);
|
||||
|
||||
// NCHW:r/g/b 分平面存储
|
||||
_input[p] = (yr + 1.402 * vn) / 255.0;
|
||||
_input[p + plane] = (yr - 0.344136 * un - 0.714136 * vn) / 255.0;
|
||||
_input[p + 2 * plane] = (yr + 1.772 * un) / 255.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static double _bl(double a, double b, double c, double d, double fx,
|
||||
double fy) =>
|
||||
(1 - fx) * (1 - fy) * a + fx * (1 - fy) * b +
|
||||
(1 - fx) * fy * c + fx * fy * d;
|
||||
|
||||
List<DetectionResult> postprocess() {
|
||||
final out = _output[0];
|
||||
final boxes = <DetectionResult>[];
|
||||
for (var a = 0; a < _numAnchors; a++) {
|
||||
final cx = out[0][a];
|
||||
final cy = out[1][a];
|
||||
final w = out[2][a];
|
||||
final h = out[3][a];
|
||||
var bestCls = 0;
|
||||
var bestScore = 0.0;
|
||||
for (var c = 0; c < _numClasses; c++) {
|
||||
final s = out[4 + c][a];
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestCls = c;
|
||||
}
|
||||
}
|
||||
final label =
|
||||
bestCls < _labels.length ? _labels[bestCls] : 'unknown';
|
||||
// 低分池保留,供运动检测提升显示
|
||||
if (bestScore < minScore) continue;
|
||||
boxes.add(DetectionResult(
|
||||
label: label,
|
||||
score: bestScore,
|
||||
left: (cx - w / 2).clamp(0.0, 1.0),
|
||||
top: (cy - h / 2).clamp(0.0, 1.0),
|
||||
right: (cx + w / 2).clamp(0.0, 1.0),
|
||||
bottom: (cy + h / 2).clamp(0.0, 1.0),
|
||||
));
|
||||
}
|
||||
final kept = nms(boxes, iouThreshold);
|
||||
return kept.take(maxDetections).toList();
|
||||
}
|
||||
|
||||
void dispose() => _interpreter.close();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'detection_result.dart';
|
||||
|
||||
/// 运行时视觉先验:对低置信度野鸡框做多线索过滤,降低户外误报。
|
||||
///
|
||||
/// 仅对 score < [maxScore](0.35)的 pheasant 框生效;高分框与
|
||||
/// suspect(生境预警)不参与过滤,避免误杀。
|
||||
///
|
||||
/// 线索:
|
||||
/// - 颜色:绿色主导(草/叶)、蓝色主导(天空/水)、平坦低饱和(键盘/石头/文字)
|
||||
/// - 位置:中心在画面上部 15%(天空区)——野鸡是地栖动物,不会出现在天空
|
||||
///
|
||||
/// 采样在原始 planes 上进行(后台 isolate 内,不依赖 UI 线程)。
|
||||
class VisualPrior {
|
||||
static const double maxScore = 0.35;
|
||||
static const double skyTopRatio = 0.15;
|
||||
|
||||
// 颜色判定阈值(与 tflite_detector 的 YUV 有限范围展开一致)
|
||||
static const double greenDiff = 20;
|
||||
static const double blueDiff = 10;
|
||||
static const double flatRange = 10;
|
||||
|
||||
// 采样点中满足条件的比例超过即拒绝
|
||||
static const double greenRatio = 0.5;
|
||||
static const double blueRatio = 0.4;
|
||||
static const double flatRatio = 0.6;
|
||||
|
||||
static List<DetectionResult> filter(
|
||||
List<DetectionResult> results, {
|
||||
required List<Uint8List> planes,
|
||||
required List<int> strides,
|
||||
required int width,
|
||||
required int height,
|
||||
required bool isBgra,
|
||||
}) {
|
||||
if (results.isEmpty || width <= 0 || height <= 0) return results;
|
||||
final kept = <DetectionResult>[];
|
||||
for (final r in results) {
|
||||
final lowConfPheasant = r.label == 'pheasant' && r.score < maxScore;
|
||||
if (lowConfPheasant && _reject(r, planes, strides, width, height, isBgra)) {
|
||||
continue;
|
||||
}
|
||||
kept.add(r);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
static bool _reject(DetectionResult r, List<Uint8List> planes,
|
||||
List<int> strides, int width, int height, bool isBgra) {
|
||||
// 位置线索:detectRaw 输出为图像坐标系,centerY 直接可判天空区
|
||||
if (r.centerY < skyTopRatio) return true;
|
||||
|
||||
// 颜色线索:框中心 ±20% 区域 5×5 采样(小框采样点重合也没关系)
|
||||
final cx = (r.centerX * width).round().clamp(0, width - 1).toInt();
|
||||
final cy = (r.centerY * height).round().clamp(0, height - 1).toInt();
|
||||
final halfW = math.max(1.0, r.width * width * 0.2);
|
||||
final halfH = math.max(1.0, r.height * height * 0.2);
|
||||
|
||||
var green = 0, blue = 0, flat = 0, total = 0;
|
||||
for (var gy = -2; gy <= 2; gy++) {
|
||||
for (var gx = -2; gx <= 2; gx++) {
|
||||
final px = (cx + gx * halfW / 2).round().clamp(0, width - 1).toInt();
|
||||
final py = (cy + gy * halfH / 2).round().clamp(0, height - 1).toInt();
|
||||
final (r_, g_, b_) = _pixel(planes, strides, px, py, width, height, isBgra);
|
||||
total++;
|
||||
final mn = math.min(r_, math.min(g_, b_));
|
||||
final mx = math.max(r_, math.max(g_, b_));
|
||||
if (g_ - r_ > greenDiff && g_ - b_ > greenDiff) green++;
|
||||
if (b_ > r_ + blueDiff) blue++;
|
||||
if (mx - mn < flatRange) flat++;
|
||||
}
|
||||
}
|
||||
if (total == 0) return false;
|
||||
if (green / total > greenRatio) return true;
|
||||
if (blue / total > blueRatio) return true;
|
||||
if (flat / total > flatRatio) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 读取单像素 RGB(0~255)。
|
||||
/// BGRA 单平面:每像素 4 字节 [b,g,r,a];
|
||||
/// YUV:y 平面 + 4:2:0 半分辨率 U/V(NV12 交错或 I420 分离)。
|
||||
static (double, double, double) _pixel(List<Uint8List> planes,
|
||||
List<int> strides, int x, int y, int width, int height, bool isBgra) {
|
||||
if (isBgra) {
|
||||
final src = planes[0];
|
||||
final i = y * strides[0] + x * 4;
|
||||
return (src[i + 2].toDouble(), src[i + 1].toDouble(), src[i].toDouble());
|
||||
}
|
||||
final yy =
|
||||
(planes[0][y * strides[0] + x] - 16.0) * (255.0 / 219.0);
|
||||
final nv12 = planes.length == 2;
|
||||
final ux = (x ~/ 2).clamp(0, width ~/ 2 - 1).toInt();
|
||||
final uy = (y ~/ 2).clamp(0, height ~/ 2 - 1).toInt();
|
||||
final uvStride = strides[1];
|
||||
final un = ((nv12
|
||||
? planes[1][uy * uvStride + ux * 2].toDouble()
|
||||
: planes[1][uy * uvStride + ux].toDouble()) -
|
||||
128.0) *
|
||||
(255.0 / 224.0);
|
||||
final vn = ((nv12
|
||||
? planes[1][uy * uvStride + ux * 2 + 1].toDouble()
|
||||
: planes[2][uy * uvStride + ux].toDouble()) -
|
||||
128.0) *
|
||||
(255.0 / 224.0);
|
||||
final r = yy + 1.402 * vn;
|
||||
final g = yy - 0.344136 * un - 0.714136 * vn;
|
||||
final b = yy + 1.772 * un;
|
||||
return (r, g, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../auth/session_store.dart';
|
||||
import 'home_view_model.dart';
|
||||
|
||||
/// 主界面:当前账号到期时间 + 搜索按钮(强制服务端校验后进相机)+ 充值入口
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<HomeViewModel>().refresh();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onSearch() async {
|
||||
final vm = context.read<HomeViewModel>();
|
||||
final allowed = await vm.verifyForCamera();
|
||||
if (!mounted) return;
|
||||
if (vm.sessionExpired) {
|
||||
_toLogin();
|
||||
return;
|
||||
}
|
||||
if (!allowed) {
|
||||
if (vm.error != null) {
|
||||
// 网络/服务端异常:无法确认授权状态,提示错误
|
||||
_showBlocked(vm.error);
|
||||
return;
|
||||
}
|
||||
// 服务端确认无有效授权:直接进充值页
|
||||
await _onRecharge();
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).pushNamed('/camera');
|
||||
if (mounted) vm.refresh();
|
||||
}
|
||||
|
||||
Future<void> _onRecharge() async {
|
||||
await Navigator.of(context).pushNamed('/paywall');
|
||||
if (mounted) context.read<HomeViewModel>().refresh();
|
||||
}
|
||||
|
||||
Future<void> _toLogin() async {
|
||||
final container = context.read<SessionStore>();
|
||||
await container.clear();
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
}
|
||||
|
||||
void _showBlocked(String? error) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('授权不可用'),
|
||||
content: Text(error ?? '授权已过期,请充值后续费'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
_onRecharge();
|
||||
},
|
||||
child: const Text('去充值'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vm = context.watch<HomeViewModel>();
|
||||
final license = vm.license;
|
||||
final active = license?.isActive ?? false;
|
||||
final statusText = vm.loading
|
||||
? '加载中…'
|
||||
: (license == null || license.expiresAt == null
|
||||
? '未充值'
|
||||
: (active
|
||||
? '有效至 ${_fmt(license.expiresAt!)}'
|
||||
: '已过期(${_fmt(license.expiresAt!)})'));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('视野'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '退出登录',
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: _toLogin,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
_StatusCard(
|
||||
licenseText: statusText,
|
||||
active: active,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
SizedBox(
|
||||
height: 88,
|
||||
child: FilledButton.icon(
|
||||
onPressed: vm.verifying ? null : _onSearch,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(44),
|
||||
),
|
||||
),
|
||||
icon: vm.verifying
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.visibility, size: 32),
|
||||
label: Text(
|
||||
vm.verifying ? '正在校验授权…' : '打开视野',
|
||||
style: const TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _onRecharge,
|
||||
icon: const Icon(Icons.payment),
|
||||
label: const Text('充值', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
if (vm.error != null && vm.license != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
vm.error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _fmt(DateTime t) {
|
||||
String p(int n) => n.toString().padLeft(2, '0');
|
||||
return '${t.year}-${p(t.month)}-${p(t.day)} ${p(t.hour)}:${p(t.minute)}';
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusCard extends StatelessWidget {
|
||||
final String licenseText;
|
||||
final bool active;
|
||||
|
||||
const _StatusCard({
|
||||
required this.licenseText,
|
||||
required this.active,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
active ? Icons.verified_user : Icons.error_outline,
|
||||
color: active ? Colors.green : Colors.orange,
|
||||
size: 36,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('当前账户',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
licenseText,
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../payment/license_service.dart';
|
||||
import '../payment/models.dart';
|
||||
import '../payment/order_api.dart';
|
||||
|
||||
/// 主界面状态:到期时间展示 + 搜索入口强制校验 + 退出登录
|
||||
class HomeViewModel extends ChangeNotifier {
|
||||
final LicenseService licenseService;
|
||||
|
||||
LicenseStatus? _license;
|
||||
bool _loading = true;
|
||||
bool _verifying = false;
|
||||
String? _error;
|
||||
bool _sessionExpired = false;
|
||||
|
||||
LicenseStatus? get license => _license;
|
||||
bool get loading => _loading;
|
||||
bool get verifying => _verifying;
|
||||
String? get error => _error;
|
||||
bool get sessionExpired => _sessionExpired;
|
||||
|
||||
HomeViewModel({required this.licenseService});
|
||||
|
||||
/// 进入主界面/支付返回后刷新(本地缓存优先,服务端为准)
|
||||
Future<void> refresh() async {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
_license = await licenseService.check();
|
||||
} catch (_) {
|
||||
// check 内部已兜底,这里仅防御
|
||||
} finally {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索按钮:强制服务端校验授权,active 才允许进入相机。
|
||||
/// 网络失败/会话失效一律不放行;返回值表示是否可进入。
|
||||
Future<bool> verifyForCamera() async {
|
||||
_verifying = true;
|
||||
_sessionExpired = false;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final license = await licenseService.verifyServer();
|
||||
_license = license;
|
||||
return license.isActive;
|
||||
} on SessionExpiredException {
|
||||
_sessionExpired = true;
|
||||
return false;
|
||||
} on OrderApiException catch (e) {
|
||||
_error = e.message;
|
||||
return false;
|
||||
} finally {
|
||||
_verifying = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearSessionExpired() {
|
||||
_sessionExpired = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:fluwx/fluwx.dart' as fluwx;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tobias/tobias.dart' as tobias;
|
||||
|
||||
import 'app.dart';
|
||||
import 'config/app_config.dart';
|
||||
import 'container.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 注册微信/支付宝 SDK(AppID 占位,接入真实商户后替换 app_config.dart)
|
||||
try {
|
||||
await fluwx.Fluwx().registerApi(
|
||||
appId: AppConfig.wechatAppId,
|
||||
universalLink: AppConfig.wechatUniversalLink,
|
||||
);
|
||||
} catch (_) {}
|
||||
try {
|
||||
await tobias.Tobias().registerApp(
|
||||
AppConfig.alipayAppId,
|
||||
universalLink: AppConfig.alipayUniversalLink,
|
||||
);
|
||||
} catch (_) {}
|
||||
|
||||
runApp(ObserverApp(container: AppContainer()));
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../auth/session_store.dart';
|
||||
import 'models.dart';
|
||||
import 'order_api.dart';
|
||||
|
||||
/// 授权管理:本地缓存用于主界面展示到期时间;识别入口强制服务端校验。
|
||||
/// 缓存按手机号隔离(键含账号),切换账号不会读到上一账号的到期时间。
|
||||
class LicenseService {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
final OrderApi orderApi;
|
||||
final SessionStore sessionStore;
|
||||
|
||||
LicenseService({required this.orderApi, required this.sessionStore});
|
||||
|
||||
/// 主界面展示/启动加载:先读本账号本地缓存,有效则直接用;
|
||||
/// 无效或缺失时询问服务端(网络失败按缓存兜底,未登录按过期处理)。
|
||||
Future<LicenseStatus> check() async {
|
||||
final phone = await sessionStore.readPhone();
|
||||
if (phone == null) return const LicenseStatus(active: false);
|
||||
|
||||
final cached = await _readCache(phone);
|
||||
final now = DateTime.now();
|
||||
if (cached != null && cached.expiresAt!.isAfter(now)) return cached;
|
||||
|
||||
if (await sessionStore.readToken() == null) {
|
||||
return const LicenseStatus(active: false);
|
||||
}
|
||||
try {
|
||||
final remote = await orderApi.fetchLicense();
|
||||
try {
|
||||
if (remote.active && remote.expiresAt != null) {
|
||||
await _writeCache(phone, remote.expiresAt!);
|
||||
} else if (!remote.active) {
|
||||
await _clearCache(phone);
|
||||
}
|
||||
} catch (_) {
|
||||
// 本地缓存不可用不影响授权状态展示
|
||||
}
|
||||
return remote;
|
||||
} on OrderApiException {
|
||||
return cached ?? const LicenseStatus(active: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 识别入口强制校验:必须走服务端且 active 才放行,失败即抛错(不进相机)
|
||||
Future<LicenseStatus> verifyServer() async {
|
||||
if (await sessionStore.readToken() == null) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
return orderApi.fetchLicense();
|
||||
}
|
||||
|
||||
/// 支付成功后立即刷新(清缓存强制走服务端,避免旧授权干扰)
|
||||
Future<LicenseStatus> refresh() async {
|
||||
final phone = await sessionStore.readPhone();
|
||||
if (phone != null) await _clearCache(phone);
|
||||
return orderApi.fetchLicense();
|
||||
}
|
||||
|
||||
Future<LicenseStatus?> _readCache(String phone) async {
|
||||
try {
|
||||
final raw = await _storage.read(key: _cacheKey(phone));
|
||||
final t = raw == null ? null : DateTime.tryParse(raw);
|
||||
if (t == null || !t.isAfter(DateTime.now())) return null;
|
||||
return LicenseStatus(active: true, expiresAt: t);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeCache(String phone, DateTime expiresAt) =>
|
||||
_storage.write(key: _cacheKey(phone), value: expiresAt.toIso8601String());
|
||||
|
||||
Future<void> _clearCache(String phone) => _storage.delete(key: _cacheKey(phone));
|
||||
|
||||
static String _cacheKey(String phone) => 'license_expires_at:$phone';
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/// 充值套餐(来自后端 GET /api/v1/plans,价格以服务端 config.yml 为准)
|
||||
class Plan {
|
||||
final String id;
|
||||
final int days;
|
||||
final int priceYuan;
|
||||
|
||||
const Plan({
|
||||
required this.id,
|
||||
required this.days,
|
||||
required this.priceYuan,
|
||||
});
|
||||
|
||||
/// 展示名由天数派生(接口无 label 字段)
|
||||
String get label => '$days天';
|
||||
|
||||
factory Plan.fromJson(Map<String, dynamic> json) => Plan(
|
||||
id: json['planId'] as String,
|
||||
days: json['days'] as int,
|
||||
priceYuan: (json['priceCents'] as num) ~/ 100,
|
||||
);
|
||||
}
|
||||
|
||||
/// 支付渠道
|
||||
enum PayChannel { wechat, alipay }
|
||||
|
||||
/// 订单(由后端创建)
|
||||
class Order {
|
||||
final String orderId;
|
||||
|
||||
/// 微信支付参数(prepay_id / partner_id / nonce_str / time_stamp / sign 等)
|
||||
final Map<String, dynamic> wechatParams;
|
||||
|
||||
/// 支付宝订单串(orderStr)
|
||||
final String? alipayOrderStr;
|
||||
|
||||
const Order({
|
||||
required this.orderId,
|
||||
this.wechatParams = const {},
|
||||
this.alipayOrderStr,
|
||||
});
|
||||
}
|
||||
|
||||
/// 授权状态(服务端为准)
|
||||
class LicenseStatus {
|
||||
final bool active;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
const LicenseStatus({required this.active, this.expiresAt});
|
||||
|
||||
bool get isActive => active && (expiresAt?.isAfter(DateTime.now()) ?? false);
|
||||
|
||||
factory LicenseStatus.fromJson(Map<String, dynamic> json) => LicenseStatus(
|
||||
active: json['active'] == true,
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.tryParse(json['expiresAt'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 支付结果
|
||||
class PayResult {
|
||||
final bool success;
|
||||
final String? message;
|
||||
final String? orderId;
|
||||
|
||||
const PayResult({required this.success, this.message, this.orderId});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:cupertino_http/cupertino_http.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../auth/session_store.dart';
|
||||
import '../config/app_config.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// 后端账号/支付/授权 API 客户端(契约见 docs/PaymentApi.md)。
|
||||
/// 后端未部署或请求失败时抛出 [OrderApiException];登录失效抛出 [SessionExpiredException],
|
||||
/// 由 UI 层回登录页。
|
||||
class OrderApiException implements Exception {
|
||||
final String message;
|
||||
const OrderApiException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// 登录已失效(后端返回 code 61):token 过期/被清,UI 应清除会话回登录页
|
||||
class SessionExpiredException extends OrderApiException {
|
||||
const SessionExpiredException() : super('登录已失效,请重新登录');
|
||||
}
|
||||
|
||||
class OrderApi {
|
||||
final String baseUrl;
|
||||
final SessionStore sessionStore;
|
||||
final http.Client _client;
|
||||
|
||||
// iOS 26 对 dart:io 原生 socket 访问本地网络存在拦截 bug(权限已允许仍
|
||||
// 拒绝连接),改用 NSURLSession 网络栈(CupertinoClient)绕过;非 Apple
|
||||
// 平台回退 IOClient。
|
||||
OrderApi({String? baseUrl, required this.sessionStore, http.Client? client})
|
||||
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
|
||||
_client = client ?? _defaultHttpClient();
|
||||
|
||||
static http.Client _defaultHttpClient() {
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
return CupertinoClient.defaultSessionConfiguration();
|
||||
}
|
||||
return http.Client();
|
||||
}
|
||||
|
||||
/// 注册账号;重复注册等业务错误由 [_decode] 抛出
|
||||
Future<void> register({
|
||||
required String phone,
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await _post('/api/v1/auth/register',
|
||||
jsonEncode({'phone': phone, 'password': password}),
|
||||
auth: false);
|
||||
_decode(res);
|
||||
}
|
||||
|
||||
/// 登录,成功返回 token(由调用方存入 SessionStore)
|
||||
Future<String> login({
|
||||
required String phone,
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await _post('/api/v1/auth/login',
|
||||
jsonEncode({'phone': phone, 'password': password}),
|
||||
auth: false);
|
||||
final json = _decode(res);
|
||||
return json['token'] as String;
|
||||
}
|
||||
|
||||
/// 创建订单,返回支付参数(微信 prepay 参数或支付宝 orderStr)
|
||||
Future<Order> createOrder({
|
||||
required String planId,
|
||||
required PayChannel channel,
|
||||
}) async {
|
||||
final body = jsonEncode({'planId': planId, 'channel': channel.name});
|
||||
final res = await _post('/api/v1/orders', body);
|
||||
final json = _decode(res);
|
||||
final params = (json['payParams'] as Map?)?.cast<String, dynamic>() ?? {};
|
||||
return Order(
|
||||
orderId: json['orderId'] as String,
|
||||
wechatParams: params,
|
||||
alipayOrderStr: params['orderStr'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// 客户端支付完成后通知服务端(幂等),服务端据异步回调落授权
|
||||
Future<void> confirmOrder(String orderId) async {
|
||||
final res =
|
||||
await _post('/api/v1/orders/$orderId/confirm', jsonEncode({}));
|
||||
_decode(res);
|
||||
}
|
||||
|
||||
/// 拉取套餐价格方案(config.yml 静态定价,客户端不硬编码)
|
||||
Future<List<Plan>> fetchPlans() async {
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _client.get(
|
||||
Uri.parse('$baseUrl/api/v1/plans'),
|
||||
headers: {'Accept': 'application/json', ...await _authHeaders()},
|
||||
).timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
if (e is OrderApiException) rethrow;
|
||||
throw OrderApiException('网络请求失败: $e');
|
||||
}
|
||||
final json = _decode(res);
|
||||
return (json['list'] as List)
|
||||
.map((e) => Plan.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 查询授权状态(服务端为准)
|
||||
Future<LicenseStatus> fetchLicense() async {
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _client.get(
|
||||
Uri.parse('$baseUrl/api/v1/license'),
|
||||
headers: {'Accept': 'application/json', ...await _authHeaders()},
|
||||
).timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
if (e is OrderApiException) rethrow;
|
||||
throw OrderApiException('网络请求失败: $e');
|
||||
}
|
||||
final json = _decode(res);
|
||||
return LicenseStatus.fromJson(json);
|
||||
}
|
||||
|
||||
Future<http.Response> _post(String path, String body,
|
||||
{bool auth = true}) async {
|
||||
try {
|
||||
return await _client
|
||||
.post(
|
||||
Uri.parse('$baseUrl$path'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...auth ? await _authHeaders() : const <String, String>{},
|
||||
},
|
||||
body: body,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
if (e is OrderApiException) rethrow;
|
||||
throw OrderApiException('网络请求失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _authHeaders() async {
|
||||
final token = await sessionStore.readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
return {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decode(http.Response res) {
|
||||
final Map<String, dynamic> json;
|
||||
try {
|
||||
json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw OrderApiException('服务端响应异常 (${res.statusCode})');
|
||||
}
|
||||
if (res.statusCode != 200 || json['code'] != 0) {
|
||||
final message = json['message'] as String? ?? '服务端错误 (${res.statusCode})';
|
||||
if (json['code'] == 61) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
throw OrderApiException(message);
|
||||
}
|
||||
return json['data'] as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fluwx/fluwx.dart' as fluwx;
|
||||
import 'package:tobias/tobias.dart' as tobias;
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import 'models.dart';
|
||||
import 'order_api.dart';
|
||||
|
||||
/// 支付服务抽象:创建订单 + 拉起渠道支付 + 返回支付结果
|
||||
abstract class PaymentService {
|
||||
/// 支付完成后会通知服务端落授权;成功与否以 [PayResult.success] 为准
|
||||
Future<PayResult> pay(Plan plan, PayChannel channel);
|
||||
}
|
||||
|
||||
/// 微信支付(fluwx 实现)
|
||||
class WechatPayService implements PaymentService {
|
||||
WechatPayService({required this.orderApi});
|
||||
|
||||
final OrderApi orderApi;
|
||||
|
||||
@override
|
||||
Future<PayResult> pay(Plan plan, PayChannel channel) async {
|
||||
assert(channel == PayChannel.wechat);
|
||||
final order =
|
||||
await orderApi.createOrder(planId: plan.id, channel: channel);
|
||||
final p = order.wechatParams;
|
||||
|
||||
final launched = await fluwx.Fluwx().pay(
|
||||
which: fluwx.Payment(
|
||||
appId: AppConfig.wechatAppId,
|
||||
partnerId: p['partnerId'] as String? ?? '',
|
||||
prepayId: p['prepayId'] as String? ?? '',
|
||||
packageValue: p['packageValue'] as String? ?? 'Sign=WXPay',
|
||||
nonceStr: p['nonceStr'] as String? ?? '',
|
||||
timestamp: int.tryParse('${p['timeStamp'] ?? p['timestamp']}') ?? 0,
|
||||
sign: p['sign'] as String? ?? '',
|
||||
),
|
||||
);
|
||||
if (!launched) {
|
||||
return const PayResult(
|
||||
success: false, message: '未安装微信或拉起支付失败,请稍后再试');
|
||||
}
|
||||
|
||||
// 等待微信回调(errCode == 0 为成功),10s 超时
|
||||
final completer = Completer<PayResult>();
|
||||
final cancel = fluwx.Fluwx().addSubscriber((response) {
|
||||
if (response is! fluwx.WeChatPaymentResponse) return;
|
||||
completer.complete(PayResult(
|
||||
success: response.isSuccessful,
|
||||
message: response.isSuccessful ? null : (response.errStr ?? '微信支付未完成'),
|
||||
orderId: order.orderId,
|
||||
));
|
||||
});
|
||||
try {
|
||||
final result =
|
||||
await completer.future.timeout(const Duration(seconds: 10));
|
||||
if (result.success) await orderApi.confirmOrder(order.orderId);
|
||||
return result;
|
||||
} on TimeoutException {
|
||||
return PayResult(
|
||||
success: false,
|
||||
message: '等待微信支付结果超时,请确认支付状态',
|
||||
orderId: order.orderId);
|
||||
} finally {
|
||||
cancel.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 支付宝支付(tobias 实现,resultStatus 9000 为成功)
|
||||
class AlipayService implements PaymentService {
|
||||
AlipayService({required this.orderApi});
|
||||
|
||||
final OrderApi orderApi;
|
||||
|
||||
@override
|
||||
Future<PayResult> pay(Plan plan, PayChannel channel) async {
|
||||
assert(channel == PayChannel.alipay);
|
||||
final order =
|
||||
await orderApi.createOrder(planId: plan.id, channel: channel);
|
||||
final orderStr = order.alipayOrderStr;
|
||||
if (orderStr == null) {
|
||||
return const PayResult(success: false, message: '订单缺少支付参数');
|
||||
}
|
||||
|
||||
final map = await tobias.Tobias().pay(
|
||||
orderStr,
|
||||
universalLink: AppConfig.alipayUniversalLink,
|
||||
);
|
||||
final status = map['resultStatus']?.toString() ?? '';
|
||||
final ok = status == '9000';
|
||||
if (ok) await orderApi.confirmOrder(order.orderId);
|
||||
return PayResult(
|
||||
success: ok,
|
||||
message: ok ? null : _alipayMessage(status),
|
||||
orderId: order.orderId,
|
||||
);
|
||||
}
|
||||
|
||||
static String _alipayMessage(String status) => switch (status) {
|
||||
'8000' => '支付结果确认中,请稍后查看',
|
||||
'6001' => '用户取消支付',
|
||||
'6002' => '网络异常,支付未完成',
|
||||
'6004' => '支付结果未知,请查询订单状态',
|
||||
_ => '支付宝支付未完成',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'models.dart';
|
||||
import 'paywall_view_model.dart';
|
||||
|
||||
/// 付费墙:拉取套餐价格 → 选套餐 → 微信/支付宝支付 → 解锁进入相机
|
||||
class PaywallScreen extends StatefulWidget {
|
||||
const PaywallScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PaywallScreen> createState() => _PaywallScreenState();
|
||||
}
|
||||
|
||||
class _PaywallScreenState extends State<PaywallScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<PaywallViewModel>().loadPlans();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vm = context.watch<PaywallViewModel>();
|
||||
final loading = vm.state.state == PayState.loading;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('视野 · 会员'), centerTitle: true),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 16, 24, 16),
|
||||
child: Text(
|
||||
'开通会员解锁完整功能,按自然日计费,到期自动失效',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
children: [
|
||||
_PlansSection(vm: vm, loading: loading),
|
||||
const SizedBox(height: 24),
|
||||
_PayButton(
|
||||
label: '微信支付',
|
||||
icon: Icons.wechat,
|
||||
color: const Color(0xFF07C160),
|
||||
enabled: !loading && vm.state.selectedPlan != null,
|
||||
onTap: loading
|
||||
? null
|
||||
: () async {
|
||||
final license = await vm.pay(PayChannel.wechat);
|
||||
if (license != null && context.mounted) {
|
||||
_onPaid(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PayButton(
|
||||
label: '支付宝支付',
|
||||
icon: Icons.account_balance_wallet,
|
||||
color: const Color(0xFF1677FF),
|
||||
enabled: !loading && vm.state.selectedPlan != null,
|
||||
onTap: loading
|
||||
? null
|
||||
: () async {
|
||||
final license = await vm.pay(PayChannel.alipay);
|
||||
if (license != null && context.mounted) {
|
||||
_onPaid(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (vm.state.state == PayState.failed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Text(
|
||||
vm.state.message ?? '支付失败',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 支付成功:返回主界面(主界面刷新展示新到期时间)
|
||||
void _onPaid(BuildContext context) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 套餐区三态:加载中 / 失败可重试 / 套餐卡片列表
|
||||
class _PlansSection extends StatelessWidget {
|
||||
final PaywallViewModel vm;
|
||||
final bool loading;
|
||||
|
||||
const _PlansSection({required this.vm, required this.loading});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (vm.plansLoading && vm.plans.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 48),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (vm.plansError != null && vm.plans.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
vm.plansError!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.read<PaywallViewModel>().loadPlans(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
...vm.plans.map((p) => _PlanCard(
|
||||
plan: p,
|
||||
selected: vm.state.selectedPlan?.id == p.id,
|
||||
enabled: !loading,
|
||||
onTap: () => vm.selectPlan(p),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanCard extends StatelessWidget {
|
||||
final Plan plan;
|
||||
final bool selected;
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PlanCard({
|
||||
required this.plan,
|
||||
required this.selected,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: selected ? 3 : 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: selected ? Colors.orange : Colors.grey.shade300,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: enabled ? onTap : null,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
title: Text(
|
||||
plan.label,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 17),
|
||||
),
|
||||
subtitle: Text('${plan.days} 天 · 自然日'),
|
||||
trailing: Text(
|
||||
'¥${plan.priceYuan}',
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PayButton extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _PayButton({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
disabledBackgroundColor: Colors.grey.shade300,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
onPressed: enabled ? onTap : null,
|
||||
icon: Icon(icon, size: 20),
|
||||
label: Text(label, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'license_service.dart';
|
||||
import 'models.dart';
|
||||
import 'order_api.dart';
|
||||
import 'payment_service.dart';
|
||||
|
||||
enum PayState { idle, loading, success, failed }
|
||||
|
||||
@immutable
|
||||
class PaywallUiState {
|
||||
final PayState state;
|
||||
final String? message;
|
||||
final Plan? selectedPlan;
|
||||
|
||||
const PaywallUiState({
|
||||
this.state = PayState.idle,
|
||||
this.message,
|
||||
this.selectedPlan,
|
||||
});
|
||||
}
|
||||
|
||||
class PaywallViewModel extends ChangeNotifier {
|
||||
final OrderApi orderApi;
|
||||
final LicenseService licenseService;
|
||||
final Map<PayChannel, PaymentService> services;
|
||||
|
||||
PaywallUiState _state = const PaywallUiState();
|
||||
PaywallUiState get state => _state;
|
||||
|
||||
List<Plan> _plans = const [];
|
||||
bool _plansLoading = false;
|
||||
String? _plansError;
|
||||
|
||||
/// 套餐价格方案(来自后端,客户端不硬编码)
|
||||
List<Plan> get plans => _plans;
|
||||
bool get plansLoading => _plansLoading;
|
||||
String? get plansError => _plansError;
|
||||
|
||||
PaywallViewModel({
|
||||
required this.orderApi,
|
||||
required this.licenseService,
|
||||
required this.services,
|
||||
});
|
||||
|
||||
/// 进充值页时拉取套餐;已有数据或加载中跳过,失败可重试
|
||||
Future<void> loadPlans() async {
|
||||
if (_plans.isNotEmpty || _plansLoading) return;
|
||||
_plansLoading = true;
|
||||
_plansError = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
_plans = await orderApi.fetchPlans();
|
||||
} on SessionExpiredException {
|
||||
_plansError = '登录已失效,请重新登录后再充值';
|
||||
} on OrderApiException catch (e) {
|
||||
_plansError = e.message;
|
||||
} finally {
|
||||
_plansLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void selectPlan(Plan? plan) {
|
||||
_state = PaywallUiState(selectedPlan: plan);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 发起支付;成功后刷新授权并通知页面进入相机
|
||||
Future<LicenseStatus?> pay(PayChannel channel) async {
|
||||
final plan = _state.selectedPlan;
|
||||
if (plan == null) return null;
|
||||
final service = services[channel];
|
||||
if (service == null) return null;
|
||||
|
||||
_state = PaywallUiState(state: PayState.loading, selectedPlan: plan);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await service.pay(plan, channel);
|
||||
if (!result.success) {
|
||||
_state = PaywallUiState(
|
||||
state: PayState.failed,
|
||||
message: result.message ?? '支付失败,请重试',
|
||||
selectedPlan: plan,
|
||||
);
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
final license = await licenseService.refresh();
|
||||
_state = PaywallUiState(
|
||||
state: PayState.success,
|
||||
selectedPlan: plan,
|
||||
);
|
||||
notifyListeners();
|
||||
return license;
|
||||
} on SessionExpiredException {
|
||||
_state = PaywallUiState(
|
||||
state: PayState.failed,
|
||||
message: '登录已失效,请重新登录后再充值',
|
||||
selectedPlan: plan,
|
||||
);
|
||||
notifyListeners();
|
||||
return null;
|
||||
} on OrderApiException catch (e) {
|
||||
_state = PaywallUiState(
|
||||
state: PayState.failed,
|
||||
message: '支付不可用: ${e.message}',
|
||||
selectedPlan: plan,
|
||||
);
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_state = const PaywallUiState();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:vibration/vibration.dart';
|
||||
|
||||
/// 提醒:同类目标 10s 内只提醒一次。
|
||||
/// 震动/提示音默认开启,不提供关闭入口。
|
||||
class Reminder {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
String? _lastAlertLabel;
|
||||
int _lastAlertAt = 0;
|
||||
|
||||
/// 同类目标 10s 内只提醒一次
|
||||
void onDetected(String label) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (label == _lastAlertLabel && now - _lastAlertAt < 10000) return;
|
||||
_lastAlertAt = now;
|
||||
_lastAlertLabel = label;
|
||||
|
||||
_vibrate();
|
||||
_playTone();
|
||||
}
|
||||
|
||||
void _vibrate() => Vibration.vibrate(duration: 200);
|
||||
|
||||
Future<void> _playTone() async {
|
||||
await _player.stop();
|
||||
await _player.play(AssetSource('beep.wav'));
|
||||
}
|
||||
|
||||
void release() {
|
||||
_player.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user