This commit is contained in:
2026-09-08 22:29:00 +08:00
parent daf47ca385
commit e519e1d71d
23 changed files with 1261 additions and 6 deletions
+133
View File
@@ -1,11 +1,18 @@
import 'dart:async';
import 'dart:convert' show jsonEncode;
import 'dart:ui' as ui show PlatformDispatcher;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../auth/session_store.dart';
import '../detection/detection_result.dart';
import '../detection/detector_worker.dart';
import '../feedback/feedback_api.dart';
import '../feedback/false_target_capture.dart';
import '../models/model_manager.dart';
import '../reminder/reminder.dart';
import 'app_camera_controller.dart';
@@ -55,6 +62,11 @@ class _CameraScreenState extends State<CameraScreen> {
/// 胶囊与顶部横幅显示「识别准备中」,不误报「模型加载失败」
bool _workerPending = false;
/// 假目标上报进行中(防重复触发)
bool _reporting = false;
static const _falseTargetConsent = FlutterSecureStorage();
void _onDevTaps() {
final now = DateTime.now();
_devTaps.add(now);
@@ -196,6 +208,99 @@ class _CameraScreenState extends State<CameraScreen> {
);
}
/// 假目标上报:一键上报当前画面——按一次快照帧(与喂给 YOLO 的帧同源同尺寸)
/// + 当前全部检测框快照,单独同意(首次)→ isolate 内整帧 jpg 重编码
/// (剥离全部元数据)→ 上传。无任何额外选择步骤,取消/失败不影响识别。
Future<void> _startFalseTargetReport() async {
final analyzer = _analyzer;
final vm = _viewModel;
if (_reporting || analyzer == null || vm == null || !vm.state.modelReady) {
return;
}
_reporting = true;
try {
final snap = await analyzer
.takeSnapshot()
.timeout(const Duration(seconds: 2), onTimeout: () => null);
if (!mounted || snap == null) {
_toast('未获取到画面,请稍后重试');
return;
}
final ok = await _ensureFalseTargetConsent();
if (!mounted || !ok) return;
_toast('正在上报…');
final boxes = List<DetectionResult>.of(vm.state.results)
.where((b) => b.width > 0 && b.height > 0)
.toList();
final detections = jsonEncode([
for (final b in boxes)
{
'label': b.label,
'class': b.classId,
'score': double.parse(b.score.toStringAsFixed(4)),
'model': b.modelName,
'cx': double.parse(b.centerX.toStringAsFixed(6)),
'cy': double.parse(b.centerY.toStringAsFixed(6)),
'w': double.parse(b.width.toStringAsFixed(6)),
'h': double.parse(b.height.toStringAsFixed(6)),
},
]);
final jpeg = await compute(encodeFrameJpeg, snap);
await FeedbackApi(sessionStore: SessionStore()).reportFalseTarget(
jpeg: jpeg,
detectionsJson: detections,
sourceW: snap.width,
sourceH: snap.height,
);
_toast('感谢反馈!我们会用它改进识别');
} catch (e) {
_toast('上报失败: $e');
} finally {
_reporting = false;
}
}
/// 单独同意(PIPL):首次上报前弹说明,可拒绝且不影响识别;同意后记录不再弹
Future<bool> _ensureFalseTargetConsent() async {
if (await _falseTargetConsent.read(key: falseTargetConsentKey) == '1') {
return true;
}
if (!mounted) return false;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('上报说明'),
content: const Text(
'将上传当前画面一帧(与识别使用的画面一致,上传前已在本地重新编码、'
'移除位置等全部照片信息)及当时的识别框,用于改进识别模型。\n\n'
'该功能完全自愿,拒绝不影响任何其他功能。已上传的图片可联系客服删除。',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('暂不上报'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('同意并继续'),
),
],
),
);
if (ok == true) {
await _falseTargetConsent.write(key: falseTargetConsentKey, value: '1');
}
return ok == true;
}
void _toast(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(msg), duration: const Duration(seconds: 2)));
}
@override
void initState() {
super.initState();
@@ -508,6 +613,34 @@ class _CameraScreenState extends State<CameraScreen> {
),
),
// 假目标上报入口:识别就绪且有框时出现(完全自愿,可忽略)
if (vm.state.modelReady && vm.state.results.isNotEmpty && !_workerPending)
Positioned(
left: 12,
bottom: MediaQuery.of(context).padding.bottom + 72,
child: Material(
color: Colors.black54,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: _startFalseTargetReport,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.flag_outlined,
color: Colors.orangeAccent, size: 16),
SizedBox(width: 5),
Text('误报上报',
style: TextStyle(color: Colors.white70, fontSize: 13)),
],
),
),
),
),
),
Positioned(
left: 8,
right: 8,
@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:camera/camera.dart';
import '../detection/detection_result.dart';
import '../detection/detector_worker.dart';
import '../feedback/false_target_capture.dart';
import 'camera_view_model.dart';
/// 抽帧节流 + 后台推理(对应 Kotlin FrameAnalyzer)。
@@ -33,10 +35,41 @@ class FrameAnalyzer {
/// 图像流回调是否到达(诊断用)
int framesReceived = 0;
/// 假目标上报快照:armed 时下一帧(节流前)拷贝单平面像素并交付。
/// Completer 泛型必须可空:运行时 future 类型决定 .timeout(onTimeout) 的
/// 回调签名,非空 Completer 会让 `() => null` 触发运行时子类型错误
Completer<FrameSnapshot?>? _snapCompleter;
FrameAnalyzer({DetectorWorker? worker, required this.viewModel}) {
attachWorker(worker);
}
/// 取下一帧快照(假目标上报用;在节流判定之前捕获,~33ms 内必有帧)
Future<FrameSnapshot?> takeSnapshot() {
final existing = _snapCompleter;
if (existing != null && !existing.isCompleted) {
return existing.future;
}
final c = Completer<FrameSnapshot?>();
_snapCompleter = c;
return c.future;
}
/// 帧到达(节流判定前):armed 则拷贝像素完成快照
void _captureIfNeeded(
Uint8List plane, int bytesPerRow, int width, int height, bool bgra) {
final c = _snapCompleter;
if (c == null || c.isCompleted) return;
_snapCompleter = null;
c.complete(FrameSnapshot(
plane: Uint8List.fromList(plane),
bytesPerRow: bytesPerRow,
width: width,
height: height,
bgra: bgra,
));
}
/// 替换推理 workernull = 停识别仅预览)。旧 worker 在此释放;若相机已
/// 启动则新 worker 立即接管后续帧——重启相机会重新 initialize
/// (iOS ~1s+)且预览闪断,原地替换即时生效(2026-09-03)。
@@ -114,6 +147,11 @@ class FrameAnalyzer {
void analyze(CameraImage image, int rotationDegrees,
{bool rgbaOrder = false}) {
final p = image.planes.isNotEmpty ? image.planes.first : null;
if (p != null) {
_captureIfNeeded(
p.bytes, p.bytesPerRow, image.width, image.height, !rgbaOrder);
}
if (!_canSend()) return;
worker!.analyze(image, rotationDegrees, rgbaOrder: rgbaOrder);
}
@@ -129,6 +167,9 @@ class FrameAnalyzer {
required bool rgbaOrder,
int rotationDegrees = 0,
}) {
if (planes.isNotEmpty) {
_captureIfNeeded(planes.first, strides.first, width, height, isBgra);
}
if (!_canSend()) return;
worker!.analyzeRaw(
planes: planes,