1
This commit is contained in:
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
/// 替换推理 worker(null = 停识别仅预览)。旧 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,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
/// 按一次快照帧:单平面 BGRA/RGBA 像素(两平台分析帧均为竖屏单平面 4 通道)。
|
||||
/// 即喂给 YOLO 推理的同一帧,尺寸与推理输入同源。
|
||||
class FrameSnapshot {
|
||||
final Uint8List plane;
|
||||
final int bytesPerRow;
|
||||
final int width;
|
||||
final int height;
|
||||
|
||||
/// true = BGRA 字节序(Android 原生通道默认 / iOS bgra8888),false = RGBA
|
||||
final bool bgra;
|
||||
|
||||
const FrameSnapshot({
|
||||
required this.plane,
|
||||
required this.bytesPerRow,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.bgra,
|
||||
});
|
||||
}
|
||||
|
||||
/// 在 isolate 执行:整帧像素行拷贝 → 解码为 Image → jpg q85 重编码。
|
||||
/// 与推理帧同源同尺寸、重编码天然剥离全部元数据(合规硬要求)
|
||||
Uint8List encodeFrameJpeg(FrameSnapshot snap) {
|
||||
final packed = Uint8List(snap.width * snap.height * 4);
|
||||
for (var y = 0; y < snap.height; y++) {
|
||||
final srcStart = y * snap.bytesPerRow;
|
||||
final dstStart = y * snap.width * 4;
|
||||
packed.setRange(dstStart, dstStart + snap.width * 4,
|
||||
snap.plane.sublist(srcStart, srcStart + snap.width * 4));
|
||||
}
|
||||
final image = img.Image.fromBytes(
|
||||
width: snap.width,
|
||||
height: snap.height,
|
||||
bytes: packed.buffer,
|
||||
numChannels: 4,
|
||||
order: snap.bgra ? img.ChannelOrder.bgra : img.ChannelOrder.rgba,
|
||||
);
|
||||
return Uint8List.fromList(img.encodeJpg(image, quality: 85));
|
||||
}
|
||||
|
||||
/// 单独同意弹窗结果存储 key(FlutterSecureStorage,与 terms_accepted 同库)
|
||||
const falseTargetConsentKey = 'false_target_consent';
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
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';
|
||||
|
||||
/// 假目标上报 API 客户端(契约见 server/README.md「假目标上报」)。
|
||||
/// 仅上传误报框裁剪图(客户端已重编码剥离元数据)+ 检测框快照字段。
|
||||
class FeedbackApi {
|
||||
final String baseUrl;
|
||||
final SessionStore sessionStore;
|
||||
final http.Client _client;
|
||||
|
||||
FeedbackApi({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();
|
||||
}
|
||||
|
||||
/// 上报假目标:[jpeg] 为上报瞬间的分析帧整图(与推理帧同源同尺寸),
|
||||
/// [detectionsJson] 为当前全部检测框快照 JSON 数组(可为空串)
|
||||
Future<int> reportFalseTarget({
|
||||
required Uint8List jpeg,
|
||||
required String detectionsJson,
|
||||
required int sourceW,
|
||||
required int sourceH,
|
||||
}) async {
|
||||
final token = await sessionStore.readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw Exception('登录已失效');
|
||||
}
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$baseUrl/api/v1/feedback/false-target'),
|
||||
)
|
||||
..headers['Authorization'] = 'Bearer $token'
|
||||
..fields['detections'] = detectionsJson
|
||||
..fields['sourceW'] = '$sourceW'
|
||||
..fields['sourceH'] = '$sourceH'
|
||||
..files.add(
|
||||
http.MultipartFile.fromBytes('file', jpeg, filename: 'frame.jpg'));
|
||||
final streamed =
|
||||
await _client.send(req).timeout(const Duration(seconds: 30));
|
||||
final res = await http.Response.fromStream(streamed);
|
||||
final Map<String, dynamic> json;
|
||||
try {
|
||||
json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('服务端响应异常 (${res.statusCode})');
|
||||
}
|
||||
if (res.statusCode != 200 || json['code'] != 0) {
|
||||
throw Exception(json['message'] as String? ?? '上报失败 (${res.statusCode})');
|
||||
}
|
||||
return ((json['data'] as Map?)?['id'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,10 @@ class _TermsScreenState extends State<TermsScreen> {
|
||||
五、识别结果说明
|
||||
本应用的动物识别结果基于人工智能模型,可能存在误差或漏检,识别结果仅供参考,不构成科学鉴定或法律依据。
|
||||
|
||||
六、其他
|
||||
六、反馈数据条款
|
||||
当您主动使用「假目标上报」功能时,本应用仅上传您确认上报瞬间的当前画面一帧(与识别使用的画面一致,上传前已在设备本地重编码、移除照片元数据等所有附加信息)及当时的识别框信息,用于改进识别模型。该功能完全自愿,您可以拒绝使用且不影响识别等其他功能;您可联系客服删除已上传的反馈图片。
|
||||
|
||||
七、其他
|
||||
本协议内容可能适时更新,更新后您继续使用即视为接受。''';
|
||||
|
||||
Future<void> _accept() async {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -367,6 +375,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image
|
||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -639,6 +655,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -32,6 +32,8 @@ dependencies:
|
||||
# 模型热更新:多模型下载(crypto 校验 sha256;path_provider 取应用私有目录持久化)
|
||||
crypto: ^3.0.0
|
||||
path_provider: ^2.1.4
|
||||
# 假目标上报:纯 Dart jpg 重编码(裁剪 + 剥离全部元数据,合规硬要求)
|
||||
image: ^4.5.4
|
||||
|
||||
# 微信/支付宝原生 SDK 配置(占位值,与 lib/config/app_config.dart 一致;接入真实支付时替换。
|
||||
# 注意:fluwx 的 universal_link 占位符会被其 pod 脚本注入 Associated Domains,
|
||||
|
||||
Reference in New Issue
Block a user