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
@@ -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));
}
/// 单独同意弹窗结果存储 keyFlutterSecureStorage,与 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;
}
}