diff --git a/docs/视野-投资人产品资料.md b/docs/视野-投资人产品资料.md new file mode 100644 index 0000000..6d3cade --- /dev/null +++ b/docs/视野-投资人产品资料.md @@ -0,0 +1,170 @@ +# 视野 · 野生动物实时识别 App + +**产品资料(投资人口径)** + +--- + +## 一、项目概览 + +**「视野」是一款跑在手机上、打开摄像头即可实时识别野生动物的付费工具 App,以及支撑它的全套 AI 数据与模型生产线。** + +- **端侧实时识别**:手机取景框内实时框选目标、标注物种,无需网络,深山野外可用 +- **订阅 + 广告双变现**:按天/周/月购买使用时长,免费用户看广告也能赚时长;App 端已预留微信/支付宝支付入口,支付链路开发推进中,种子用户经运营发放授权持续使用中 +- **自建 AI 数据生产线**:AI 文生图造数据 + AI 预标注 + 人工审核,把野外采集与人工标注的成本降低一个数量级 +- **模型热更新**:模型迭代不需要重新发版,App 自动下载最新模型,识别能力持续进化 +- **数据飞轮**:用户通过众包标注换取使用时长,越用越准、越准越多人用 + +产品已于 2026 年 8 月上线,完成从数据生产、模型训练、支付变现到版本运营的**全链路闭环**,目前处于种子用户运营阶段。 + +--- + +## 二、市场与痛点 + +### 目标场景 + +野生动物实时识别是一项横跨多个场景的刚性需求: + +- **户外观察与自然教育**:观鸟、徒步、自然研学人群对"看到即认识"的强需求 +- **合法狩猎与护农防控**:持证狩猎与农区野生动物防控场景,要求快速、准确地分辨目标 +- **野生动物监测与科研辅助**:红外相机影像筛查、野外调查记录等重复性识别工作 + +### 行业痛点 + +| 痛点 | 现状 | +|---|---| +| 识别靠经验 | 野外物种辨识门槛高,新手辨不清、认不准,学习成本大 | +| 现有工具不"实时" | 拍照-上传-识别的工具链延迟高,野外弱网/无网环境基本不可用 | +| 小目标识别是硬骨头 | 远距离、草丛遮挡、林缘一瞥的目标,通用识别模型漏检率高 | +| 长尾场景数据稀缺 | 野外真实采集数据成本极高,稀有场景(雪地、夜行、远距、遮挡)样本难凑齐 | +| 专业设备贵 | 传统野外监测设备与定制识别方案价格高,难以覆盖个人用户 | + +### 我们的判断 + +野生动物识别的本质难点不在模型结构,而在**数据**:目标小、遮挡多、场景长尾。谁能用低成本持续生产高质量训练数据,谁就拥有这个品类的定价权。 + +--- + +## 三、产品方案 + +### 3.1 App:打开相机即识别 + +- **实时取景识别**:摄像头画面逐帧推理,目标实时框选 + 物种名标注,识别到目标震动与提示音提醒,解放双眼 +- **离线可用**:模型全部跑在手机端(TFLite 量化模型,数 MB 级),无信号山区照常用 +- **双档位模型**:「高识别档」追求难目标不漏检,「高性能档」照顾低配手机流畅度,用户一键切换 +- **多模型并行**:支持同时挂载多个物种模型并行推理、结果合并,一个 App 覆盖多物种识别 +- **模型管理页**:用户自由下载、删除、启用模型,内置兜底模型,新物种即插即用 + +### 3.2 变现:时长 = 统一价值单位,订阅与广告双引擎 + +- 三档套餐:**日卡 ¥10 / 周卡 ¥56 / 月卡 ¥180**,微信、支付宝支付入口已在 App 端预留,支付链路开发推进中 +- **看广告赚时长(开发中)**:免费用户观看激励视频广告即可获取使用时长——用户体验零门槛,平台获得广告分成收入 +- 授权以服务端为准,识别入口强制校验,到期出付费墙,充值或看广告即恢复 +- 授权绑定手机号账号,多设备共享,时长自动顺延叠加 + +### 3.3 增长机制:标注众包赚时长 + +**用户给 AI 打工,时长当工资**——这是产品的原创设计: + +- 管理端下发标注任务,用户在手机上画框提交,每有效提交 10 张**即时到账 30 分钟**使用时长,每日上限 2 小时 +- 质量约束内置:审核通过率低于 80% 自动冻结资格 24 小时,人工审核兜底 +- 对用户:免费用时长;对平台:**标注成本转化为用户留存**,提交记录、审核状态全程可追溯 +- **假目标一键上报(规划中)**:识别误报时用户点一下「假目标」,该目标即作为难负样本回流服务器——真实场景的误报正是模型最稀缺的训练素材,与 AI 生成数据管线互补,形成"越用越准"的部署反馈闭环 + +--- + +## 四、核心技术壁垒 + +### 4.1 自建 AI 数据生产线(核心护城河) + +从"创建数据集"到"可训练数据打包",全程管理端点按钮完成: + +``` +创建数据集 → VLM 自动生成提示词参数池 → AI 文生图批量造图 +→ RF-DETR 四级漏斗预标注 → 人工审核闸门 → 智能数据清洗 → 打包训练 +``` + +- **AI 文生图造数据**:按物种自动生成场景、动作、遮挡、距离、性别配比的训练图,单张约 40 秒,可批量上千张;长尾场景(雪地、密林、远距小目标)不再依赖野外蹲守拍摄 +- **预标注四级漏斗**:全图扫描 → 空检自动升级切片扫描 → VLM 推理藏匿位提议候选 → 精修复核,专治"小目标+遮挡"漏检;AI 预标 + 人工审核,标注效率数量级提升 +- **数据清洗**:按目标尺寸分档统计配额,超配桶内整图感知哈希去重、优先保留稀缺小目标样本,自动生成清洗候选清单 +- **负样本库**:自动生成无目标场景图并以 AI 检测质检(有误检即剔除),压低野外误报率 + +### 4.2 一键训练流水线 + +- 管理端一键发起,双档位并行训练,GPU 独占排队,训练进度、日志、指标逐轮可视 +- 训练产物自动自检(模型输入输出校验),失败即报、不带病发布 +- 训练完成一键发布,版本、校验和、指标自动登记 + +### 4.3 模型热更新:识别能力绕过应用商店进化 + +- App 启动自动检查新模型,下载、校验、原子替换,失败自动回退旧模型 +- **模型迭代周期以"天"计**,无需重新打包 APK、无需应用商店审核 +- 新物种模型上线 = 后台上传发布,全量用户次日即用 + +### 4.4 端侧工程能力 + +- 自研原生相机通道,竖屏帧处理,中低端安卓机流畅实时推理 +- 多模型并行推理 + 跨模型结果合并,准确率与流畅度可按机型自适配 + +--- + +## 五、商业模式与数据飞轮 + +### 收入模型:双引擎,免费用户也产生收入 + +**引擎一:订阅现金流** + +时间授权订阅,现金流直接、无账期: + +| 套餐 | 价格 | 定位 | +|---|---|---| +| 日卡 | ¥10 | 尝鲜/单次出行 | +| 周卡 | ¥56 | 短期高频使用 | +| 月卡 | ¥180 | 核心用户订阅 | + +**引擎二:广告分成收入** + +- 免费用户观看激励视频广告换取使用时长,平台按展示获得广告分成 +- 广告发放的"时长"是边际成本近乎为零的虚拟权益,却带来真实现金流——**免费用户规模越大,广告收入越高** +- 广告时长与付费订阅共享同一套授权体系,形成"免费体验 → 广告换时长 → 付费订阅"的自然升级漏斗 + +### 成本结构优势 + +- **数据成本**:AI 生成替代野外采集,AI 预标注替代人工逐张标注 → 边际数据成本趋近电费 +- **标注成本**:众包机制把剩余标注工作转化为用户换取时长的行为 → 现金成本近乎为零 +- **发版成本**:模型热更新绕过应用商店 → 迭代零摩擦 + +### 数据飞轮 + +``` +更多用户 → 更多众包标注 + 更多真实场景反馈 → 模型更准 + ↑ ↓ + └────── 识别能力强,口碑拉新 ←────────────┘ +``` + +每一个付费用户同时是收入来源和数据生产者。物种覆盖越广、模型越准,护城河越深。 + +--- + +## 六、产品进展 + +- **产品上线**:Android App 已完成开发与线上分发,含应用内更新通道 +- **种子运营**:**种子用户经运营发放授权持续使用中**,处于种子运营阶段;微信/支付宝支付入口已预留、支付链路开发推进中;激励视频广告变现机制已设计完成,接入推进中 +- **闭环跑通**:数据生产线、训练流水线、模型发布与热更新全链路已进入日常运转,已交付多个物种识别模型并持续迭代版本 +- **管理后台完备**:数据集、标注、训练、发布、订单、账号、版本管理一站式后台,单人即可运营 + +--- + +## 七、发展路线图 + +| 阶段 | 方向 | 说明 | +|---|---|---| +| 短期 | 变现通道开通 | 微信/支付宝支付链路开发上线(App 端入口已预留);激励视频广告 SDK 接入上线,"看广告赚时长"面向全量用户 | +| 短期 | 物种矩阵扩张 | 架构按"一个物种 = 一条数据集 + 一组模型"设计,新物种边际成本极低,逐个上线扩充可识别物种库 | +| 短期 | 识别率持续提升 | 数据飞轮驱动,长尾场景(远距/遮挡/恶劣天气)样本持续补充 | +| 短期 | iOS 上架 | 客户端为 Flutter 双端工程,Android 已上线,iOS 随 App Store 审核流程同步发布 | +| 中期 | 场景延伸 | 野外监测、科研辅助、护农防控等 B 端场景的能力输出 | +| 长期 | 识别能力平台化 | 以"数据生产线 + 热更新通道"为底座,向更多垂直识别品类复制方法论 | + +--- + +*本资料为产品与商业介绍,不构成任何投资建议;运营数据以正式披露为准。* diff --git a/docs/视野-投资人产品资料.pdf b/docs/视野-投资人产品资料.pdf new file mode 100644 index 0000000..1ee133d Binary files /dev/null and b/docs/视野-投资人产品资料.pdf differ diff --git a/flutter_app/lib/camera/camera_screen.dart b/flutter_app/lib/camera/camera_screen.dart index 390086a..90d6f6f 100644 --- a/flutter_app/lib/camera/camera_screen.dart +++ b/flutter_app/lib/camera/camera_screen.dart @@ -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 { /// 胶囊与顶部横幅显示「识别准备中」,不误报「模型加载失败」 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 { ); } + /// 假目标上报:一键上报当前画面——按一次快照帧(与喂给 YOLO 的帧同源同尺寸) + /// + 当前全部检测框快照,单独同意(首次)→ isolate 内整帧 jpg 重编码 + /// (剥离全部元数据)→ 上传。无任何额外选择步骤,取消/失败不影响识别。 + Future _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.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 _ensureFalseTargetConsent() async { + if (await _falseTargetConsent.read(key: falseTargetConsentKey) == '1') { + return true; + } + if (!mounted) return false; + final ok = await showDialog( + 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 { ), ), + // 假目标上报入口:识别就绪且有框时出现(完全自愿,可忽略) + 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, diff --git a/flutter_app/lib/camera/frame_analyzer.dart b/flutter_app/lib/camera/frame_analyzer.dart index 82a84e6..ddab33b 100644 --- a/flutter_app/lib/camera/frame_analyzer.dart +++ b/flutter_app/lib/camera/frame_analyzer.dart @@ -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? _snapCompleter; + FrameAnalyzer({DetectorWorker? worker, required this.viewModel}) { attachWorker(worker); } + /// 取下一帧快照(假目标上报用;在节流判定之前捕获,~33ms 内必有帧) + Future takeSnapshot() { + final existing = _snapCompleter; + if (existing != null && !existing.isCompleted) { + return existing.future; + } + final c = Completer(); + _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, diff --git a/flutter_app/lib/feedback/false_target_capture.dart b/flutter_app/lib/feedback/false_target_capture.dart new file mode 100644 index 0000000..601fad0 --- /dev/null +++ b/flutter_app/lib/feedback/false_target_capture.dart @@ -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'; diff --git a/flutter_app/lib/feedback/feedback_api.dart b/flutter_app/lib/feedback/feedback_api.dart new file mode 100644 index 0000000..a05cb4d --- /dev/null +++ b/flutter_app/lib/feedback/feedback_api.dart @@ -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 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 json; + try { + json = jsonDecode(res.body) as Map; + } 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; + } +} diff --git a/flutter_app/lib/legal/terms_screen.dart b/flutter_app/lib/legal/terms_screen.dart index b738f77..57612e8 100644 --- a/flutter_app/lib/legal/terms_screen.dart +++ b/flutter_app/lib/legal/terms_screen.dart @@ -46,7 +46,10 @@ class _TermsScreenState extends State { 五、识别结果说明 本应用的动物识别结果基于人工智能模型,可能存在误差或漏检,识别结果仅供参考,不构成科学鉴定或法律依据。 -六、其他 +六、反馈数据条款 +当您主动使用「假目标上报」功能时,本应用仅上传您确认上报瞬间的当前画面一帧(与识别使用的画面一致,上传前已在设备本地重编码、移除照片元数据等所有附加信息)及当时的识别框信息,用于改进识别模型。该功能完全自愿,您可以拒绝使用且不影响识别等其他功能;您可联系客服删除已上传的反馈图片。 + +七、其他 本协议内容可能适时更新,更新后您继续使用即视为接受。'''; Future _accept() async { diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock index e1cb2d7..91a32e7 100644 --- a/flutter_app/pubspec.lock +++ b/flutter_app/pubspec.lock @@ -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: diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 9e35712..2eb8034 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -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, diff --git a/server/README.md b/server/README.md index 384ccb6..54d3a1e 100644 --- a/server/README.md +++ b/server/README.md @@ -12,6 +12,7 @@ | 订单确认 | 客户端支付成功后 `POST /api/v1/orders/{orderId}/confirm` 幂等通知,加速授权刷新 | | 授权查询 | `GET /api/v1/license`(Bearer token)返回授权状态,App 识别入口强制服务端校验 | | 标注众包赚时长 | App 用户处理管理端下发的标注任务获取使用时长:任务 = 管理端**勾选的具体图片集合**(图片粒度下发,2026-09-07;已下发图即从「未标注」tab 消失,停用任务释放未领取图回池),每次领取 10 张(锁定向、超时自动释放、已处理过的图不再分配),手机上画框提交 → 图片进「待审核」;每有效提交 10 张**即时到账 30 分钟**(license 到期时间分钟级顺延),**每日上限 2 小时**;质量惩罚:审核通过比例 <80%(已审核样本 ≥5)自动冻结领取资格 24 小时(时间戳对比天然自动解冻、统计重新累计),管理端可手动解冻;服务端 `annotate_record` 记录每用户每张图的处理明细(领取/提交快照/审核结果) | +| 假目标上报(负样本回流,2026-09-08) | App 识别页一键「误报上报」:上报瞬间按一次快照帧(**与喂给 YOLO 的帧同源同尺寸**,整图,jpg q85 重编码剥离全部元数据)+ 当前全部检测框快照(归一化坐标/类别/置信度/来源模型,`detections` JSON),无任何额外选择步骤;服务端落 `false_target_report` 待审(每用户**每日上限 50 条**防灌水);管理端「数据训练 → 假目标上报」tab 审核(radio 带待审数)——**通过 = 整图迁移入负样本库**(`__negative__` 数据集,训练打包自动混入当背景学习,压制同类误报),拒绝 = 删文件;**合规**:首次使用单独同意弹窗(可拒绝且不影响识别),用户协议含反馈图训练使用授权条款 | | 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**(1000 / 5600 / 18000) | | 后台管理端 | `server_admin/`(Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token`) | | 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**(iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 | @@ -58,6 +59,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发 | `annotate_task` | 标注众包任务(2026-09-04;2026-09-07 图片粒度下发) | `id`(PK)、`dataset_id`、`name`、`status`(published/stopped)、`created_at`(任务图集 = `dataset_image.annotate_task_id` 占用本任务 id 的图,不落任务表) | | `annotate_record` | 用户标注记录(2026-09-04) | `id`(PK)、`phone_num`、`task_id`、`dataset_id`(领取时冗余,详情页过滤用)、`image_id`、`labels_json`(提交快照)、`status`(pending 领取锁/submitted/approved/rejected)、`created_at`、`submitted_at`、`reviewed_at`;UNIQUE(phone_num,image_id),partial unique(image_id) WHERE status='pending' | | `reward_log` | 标注时长发放流水(2026-09-04) | `id`(PK)、`phone_num`、`minutes`、`task_id`、`granted_at`(日上限 = 自然日求和) | +| `false_target_report` | 假目标上报(2026-09-08) | `id`(PK)、`phone_num`、`file`(uuid.jpg 分析帧整图)、`labels_json`(检测框快照:label/class/score/model/归一化 xywh)、`species`(来源模型名去重拼接)、`status`(pending/approved;**拒绝即删记录与文件,无 rejected 存量**)、`created_at`、`reviewed_at`(文件落 `datasetDir/false_targets/`,审核通过迁移入负样本库图片目录) | 建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。 @@ -232,6 +234,18 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow 管理页面由 `server_admin/` 构建产物提供,访问 `http:///admin/`。金额均为整数分,前端展示 ÷100 转元。 +### POST /api/v1/feedback/false-target + +假目标上报(需 Bearer token,`multipart/form-data`):App 识别页一键上报当前画面。字段:`file`(上报瞬间的分析帧整图 jpg,与推理帧同源同尺寸,客户端重编码已剥离元数据)+ `detections`(当前全部检测框快照 JSON 数组 `[{label,class,score,model,cx,cy,w,h}]`,归一化坐标,可空)+ `sourceW`/`sourceH`(分析帧宽高)。每用户每日上限 50 条,超限报错。响应 `data: {id}`。 + +### 管理端假目标上报(`/api/v1/admin`,X-Admin-Token 鉴权;页面入口 = 数据训练页第三个 tab) + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/admin/false-targets` | 上报分页列表,筛选 `phoneNum/status`(pending/approved),条目含画面预览地址/检测框快照/来源模型名 | +| GET | `/admin/false-targets/image` | 上报画面预览(静态字节流,`id` 定位) | +| POST | `/admin/false-targets/review` | 审核 `{"ids":[1],"approve":true}`:通过 = 画面迁移入负样本库(`__negative__` 数据集当背景图训练)+ 置 approved;**拒绝 = 删除记录与图片文件**;仅 pending 可审 | + ### GET /api/v1/models 模型目录(需 Bearer token,客户端模型管理页拉取):返回**全部数据集当前生效模型**。响应 `data`: diff --git a/server/biz/consts/consts.go b/server/biz/consts/consts.go index d200e27..3dbe4b4 100644 --- a/server/biz/consts/consts.go +++ b/server/biz/consts/consts.go @@ -17,6 +17,16 @@ const ( TableAnnotateRecord = "annotate_record" TableRewardLog = "reward_log" + TableFalseTargetReport = "false_target_report" + + // 假目标上报状态(技术设计.md「假目标上报」):App 上报 → 管理端审核 → + // 通过 = 迁移入负样本库当背景图;拒绝 = 记录与文件同删(无 rejected 存量状态) + FalseTargetPending = "pending" + FalseTargetApproved = "approved" + + // 假目标上报每用户每日上限(防灌水/滥用) + FalseTargetDailyLimit = 50 + // dataset_image.review_status 审核三态(技术设计.md「标注审核状态机」): // 预标/App 提交 → 待审核;人工保存/审核通过 → 已审核;拒绝清标注 → 未标注(回任务池)。 // 不变式:review_status=0 ⟹ labels_json 无框;训练集只收 ReviewApproved diff --git a/server/biz/controller/admin.go b/server/biz/controller/admin.go index e5e404f..fbd9c71 100644 --- a/server/biz/controller/admin.go +++ b/server/biz/controller/admin.go @@ -261,3 +261,24 @@ func (c *cAdmin) ListAnnotateRecords(ctx context.Context, req *dto.AdminAnnotate func (c *cAdmin) UnfreezeAnnotate(ctx context.Context, req *dto.AdminAnnotateUnfreezeReq) (*dto.AdminAnnotateUnfreezeRes, error) { return service.Annotate.AdminUnfreeze(ctx, req) } + +// ListFalseTargets 假目标上报列表 +func (c *cAdmin) ListFalseTargets(ctx context.Context, req *dto.AdminFalseTargetListReq) (*dto.AdminFalseTargetListRes, error) { + return service.FalseTarget.AdminList(ctx, req) +} + +// FalseTargetImage 假目标裁剪图预览(直写响应体) +func (c *cAdmin) FalseTargetImage(ctx context.Context, req *dto.AdminFalseTargetImageReq) (*dto.AdminFalseTargetImageRes, error) { + path, err := service.FalseTarget.ImageFile(ctx, req.Id) + if err != nil { + return nil, err + } + r := ghttp.RequestFromCtx(ctx) + r.Response.ServeFile(path) + return &dto.AdminFalseTargetImageRes{}, nil +} + +// ReviewFalseTargets 审核假目标上报(通过=入负样本库;拒绝=删文件) +func (c *cAdmin) ReviewFalseTargets(ctx context.Context, req *dto.AdminFalseTargetReviewReq) (*dto.AdminFalseTargetReviewRes, error) { + return service.FalseTarget.AdminReview(ctx, req) +} diff --git a/server/biz/controller/false_target.go b/server/biz/controller/false_target.go new file mode 100644 index 0000000..356615a --- /dev/null +++ b/server/biz/controller/false_target.go @@ -0,0 +1,18 @@ +package controller + +import ( + "context" + + "observer-server/biz/model/dto" + "observer-server/biz/service" +) + +// cFalseTarget 假目标上报接口(登录态,手机号取自 token) +type cFalseTarget struct{} + +var FalseTarget = &cFalseTarget{} + +// Report 上报假目标(multipart:误报框裁剪图 + 检测框快照) +func (c *cFalseTarget) Report(ctx context.Context, req *dto.FalseTargetReportReq) (*dto.FalseTargetReportRes, error) { + return service.FalseTarget.AppReport(ctx, req) +} diff --git a/server/biz/dao/false_target_report.go b/server/biz/dao/false_target_report.go new file mode 100644 index 0000000..9f07ddc --- /dev/null +++ b/server/biz/dao/false_target_report.go @@ -0,0 +1,99 @@ +package dao + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" + "observer-server/biz/model/entity" +) + +// FalseTargetReport 假目标上报表 DAO(技术设计.md「假目标上报」)。 +type falseTargetReportDao struct{} + +var FalseTargetReport = &falseTargetReportDao{} + +func init() { + ctx := context.Background() + _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS false_target_report ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone_num TEXT NOT NULL, + file TEXT NOT NULL, + labels_json TEXT NOT NULL DEFAULT '', + species TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', -- pending/approved(拒绝即删记录,无 rejected 存量) + created_at TEXT NOT NULL, + reviewed_at TEXT +)`) + if err != nil { + panic(err) + } +} + +// Insert 插入上报记录并返回自增 id +func (d *falseTargetReportDao) Insert(ctx context.Context, m *entity.FalseTargetReport) (int64, error) { + res, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Data(g.Map{ + "phone_num": m.PhoneNum, + "file": m.File, + "labels_json": m.LabelsJson, + "species": m.Species, + "status": m.Status, + "created_at": m.CreatedAt, + }).Insert() + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// CountTodayByPhone 某用户当日(自然日)上报条数(日限额判定) +func (d *falseTargetReportDao) CountTodayByPhone(ctx context.Context, phone string, dayStart *gtime.Time) (int, error) { + return g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx). + Where("phone_num", phone). + WhereGTE("created_at", dayStart). + Count() +} + +// PageByFilter 管理端分页(phoneNum 精确过滤、status 过滤,按上报时间倒序) +func (d *falseTargetReportDao) PageByFilter(ctx context.Context, phone, status string, page, size int) ([]*entity.FalseTargetReport, int64, error) { + base := func() *gdb.Model { + m := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx) + if phone != "" { + m = m.Where("phone_num", phone) + } + if status != "" { + m = m.Where("status", status) + } + return m + } + total, err := base().Count() + if err != nil { + return nil, 0, err + } + var list []*entity.FalseTargetReport + err = base().OrderDesc("id").Limit((page-1)*size, size).Scan(&list) + return list, int64(total), err +} + +// GetByIds 按 id 批量取(审核前校验) +func (d *falseTargetReportDao) GetByIds(ctx context.Context, ids []int64) ([]*entity.FalseTargetReport, error) { + var list []*entity.FalseTargetReport + err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).WhereIn("id", ids).Scan(&list) + return list, err +} + +// SetStatus 审核通过落状态(仅 pending 可迁转,重复审核由调用方先查后写保证幂等) +func (d *falseTargetReportDao) SetStatus(ctx context.Context, id int64, status string, reviewedAt *gtime.Time) error { + _, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Where("id", id). + Data(g.Map{"status": status, "reviewed_at": reviewedAt}).Update() + return err +} + +// DeleteByIds 删除记录(拒绝即删:记录与文件同删,不留 rejected 存量) +func (d *falseTargetReportDao) DeleteByIds(ctx context.Context, ids []int64) error { + _, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).WhereIn("id", ids).Delete() + return err +} diff --git a/server/biz/model/dto/false_target.go b/server/biz/model/dto/false_target.go new file mode 100644 index 0000000..ccc3175 --- /dev/null +++ b/server/biz/model/dto/false_target.go @@ -0,0 +1,95 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gtime" +) + +// 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流负样本库。 +// 合规:客户端 jpg 重编码剥离全部元数据,首用单独同意由 App 承担。 + +// ---------- App ---------- + +// FalseTargetReportReq 上报假目标(multipart):file = 上报瞬间的分析帧整图 +// (与喂给 YOLO 的帧同源同尺寸,jpg q85 重编码),detections = 当前全部检测框 +// 快照 JSON([{label,class,score,model,cx,cy,w,h}],归一化坐标,供审核参考)。 +type FalseTargetReportReq struct { + g.Meta `path:"/feedback/false-target" method:"post" summary:"上报假目标" tags:"客户端" mime:"multipart/form-data"` + File *ghttp.UploadFile `json:"file" v:"required" dc:"分析帧整图(jpg,与推理帧同尺寸)"` + Detections string `json:"detections" dc:"检测框快照JSON数组,空=上报时无框"` + SourceW int `json:"sourceW" v:"min:0" dc:"分析帧宽"` + SourceH int `json:"sourceH" v:"min:0" dc:"分析帧高"` +} + +type FalseTargetReportRes struct { + Id int64 `json:"id"` +} + +// ---------- 管理端 ---------- + +// AdminFalseTargetListReq 上报分页列表 +type AdminFalseTargetListReq struct { + g.Meta `path:"/false-targets" method:"get" summary:"假目标上报列表" tags:"管理端"` + PhoneNum string `json:"phoneNum" v:"length:0,20" dc:"手机号精确过滤"` + Status string `json:"status" v:"in:,pending,approved" dc:"状态过滤,空=全部"` + Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"` + Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"` +} + +// FalseTargetBox 检测框快照(归一化坐标相对原分析帧) +type FalseTargetBox struct { + Label string `json:"label"` + Class int `json:"class"` + Score float64 `json:"score"` + Model string `json:"model"` + Cx float64 `json:"cx"` + Cy float64 `json:"cy"` + W float64 `json:"w"` + H float64 `json:"h"` +} + +// FalseTargetSnapshot labels_json 序列化结构:框快照 + 原分析帧尺寸 +type FalseTargetSnapshot struct { + SourceW int `json:"sourceW"` + SourceH int `json:"sourceH"` + Boxes []*FalseTargetBox `json:"boxes"` +} + +// AdminFalseTargetItem 上报条目(附裁剪图预览地址与检测框快照) +type AdminFalseTargetItem struct { + Id int64 `json:"id"` + PhoneNum string `json:"phoneNum"` + Species string `json:"species"` + Status string `json:"status"` + ImageUrl string `json:"imageUrl"` + Boxes []*FalseTargetBox `json:"boxes"` + SourceW int `json:"sourceW"` + SourceH int `json:"sourceH"` + CreatedAt *gtime.Time `json:"createdAt"` + ReviewedAt *gtime.Time `json:"reviewedAt"` +} + +type AdminFalseTargetListRes struct { + Total int64 `json:"total"` + List []*AdminFalseTargetItem `json:"list"` +} + +// AdminFalseTargetImageReq 裁剪图预览(静态字节流) +type AdminFalseTargetImageReq struct { + g.Meta `path:"/false-targets/image" method:"get" summary:"假目标裁剪图" tags:"管理端"` + Id int64 `json:"id" v:"required|min:1" dc:"上报 id"` +} + +type AdminFalseTargetImageRes struct{} + +// AdminFalseTargetReviewReq 审核:通过 = 迁移入负样本库当背景图;拒绝 = 删文件 +type AdminFalseTargetReviewReq struct { + g.Meta `path:"/false-targets/review" method:"post" summary:"审核假目标上报" tags:"管理端"` + Ids []int64 `json:"ids" v:"required|min-length:1" dc:"上报 id 列表"` + Approve bool `json:"approve" dc:"true=通过 false=拒绝"` +} + +type AdminFalseTargetReviewRes struct { + Affected int64 `json:"affected"` // 实际审核条数(过滤掉不存在/非 pending) +} diff --git a/server/biz/model/entity/false_target_report.go b/server/biz/model/entity/false_target_report.go new file mode 100644 index 0000000..a6a1c41 --- /dev/null +++ b/server/biz/model/entity/false_target_report.go @@ -0,0 +1,16 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// FalseTargetReport 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流, +// 客户端仅上传误报框裁剪图(剥离元数据);管理端审核通过后迁移入负样本库当背景图训练。 +type FalseTargetReport struct { + Id int64 `json:"id" orm:"id" description:"自增主键"` + PhoneNum string `json:"phoneNum" orm:"phone_num" description:"上报用户手机号"` + File string `json:"file" orm:"file" description:"裁剪图文件名(uuid.jpg)"` + LabelsJson string `json:"labelsJson" orm:"labels_json" description:"检测框快照(label/class/score/归一化xywh)"` + Species string `json:"species" orm:"species" description:"上报时来源模型名(=数据集名)"` + Status string `json:"status" orm:"status" description:"pending|approved"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"上报时间"` + ReviewedAt *gtime.Time `json:"reviewedAt" orm:"reviewed_at" description:"审核时间"` +} diff --git a/server/biz/service/false_target.go b/server/biz/service/false_target.go new file mode 100644 index 0000000..15928d9 --- /dev/null +++ b/server/biz/service/false_target.go @@ -0,0 +1,284 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "image" + _ "image/jpeg" + "os" + "path/filepath" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" + "observer-server/biz/dao" + "observer-server/biz/model/dto" + "observer-server/biz/model/entity" + "observer-server/common" +) + +// falseTargetService 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流, +// 客户端仅上传误报框裁剪图(jpg 重编码已剥离全部元数据,最小必要);管理端审核通过 +// 迁移入负样本库当背景图训练,拒绝删文件。不变式:pending/approved 记录 ⟺ 文件存在。 +type falseTargetService struct{} + +var FalseTarget = &falseTargetService{} + +// falseTargetMaxBytes 裁剪图上限(客户端按框 ±15% 裁剪、jpg q85,远小于该值) +const falseTargetMaxBytes = 8 << 20 + +// ---------- App ---------- + +// AppReport 上报假目标:校验图片 → UUID 命名落盘待审目录 → Serial 内查日限额 + 插记录 +// (入库失败删已落盘文件,保证记录存在 ⟺ 文件存在) +func (s *falseTargetService) AppReport(ctx context.Context, req *dto.FalseTargetReportReq) (*dto.FalseTargetReportRes, error) { + phone := common.PhoneFromCtx(ctx) + if req.File == nil { + return nil, gerror.New("缺少图片文件") + } + ext := strings.ToLower(filepath.Ext(req.File.Filename)) + if ext != ".jpg" && ext != ".jpeg" { + return nil, gerror.New("仅支持 jpg 图片") + } + if req.File.Size > falseTargetMaxBytes { + return nil, gerror.New("图片过大") + } + f, err := req.File.Open() + if err != nil { + return nil, gerror.Wrap(err, "读取上传文件失败") + } + _, _, err = image.DecodeConfig(f) + _ = f.Close() + if err != nil { + return nil, gerror.New("图片内容无效") + } + // 检测框快照:仅作审核参考的元数据,解析失败/坐标非法的框直接丢弃, + // 不阻断图片上报(主体是画面本身) + var allBoxes []*dto.FalseTargetBox + if strings.TrimSpace(req.Detections) != "" { + _ = json.Unmarshal([]byte(req.Detections), &allBoxes) + } + boxes := make([]*dto.FalseTargetBox, 0, len(allBoxes)) + for _, b := range allBoxes { + if b == nil || b.Cx < 0 || b.Cy < 0 || b.W <= 0 || b.H <= 0 || b.Cx > 1 || b.Cy > 1 { + continue + } + boxes = append(boxes, b) + } + + if err := os.MkdirAll(common.FalseTargetsDir(ctx), 0o755); err != nil { + return nil, gerror.Wrap(err, "创建上报目录失败") + } + name := common.UuidV4() + ".jpg" + if err := saveUploadAs(req.File, common.FalseTargetsDir(ctx), name); err != nil { + return nil, gerror.Wrap(err, "图片保存失败") + } + snapshot, err := json.Marshal(dto.FalseTargetSnapshot{ + SourceW: req.SourceW, + SourceH: req.SourceH, + Boxes: boxes, + }) + if err != nil { + _ = os.Remove(filepath.Join(common.FalseTargetsDir(ctx), name)) + return nil, gerror.Wrap(err, "快照序列化失败") + } + // 来源模型(=数据集名):快照内去重后逗号拼接(多模型并行推理时可能多个) + modelSet := map[string]bool{} + models := make([]string, 0, len(boxes)) + for _, b := range boxes { + if b.Model != "" && !modelSet[b.Model] { + modelSet[b.Model] = true + models = append(models, b.Model) + } + } + now := gtime.Now() + dayStart := gtime.New(now.Format("Y-m-d") + " 00:00:00") + var id int64 + err = common.Serial().Submit(ctx, func() error { + cnt, err := dao.FalseTargetReport.CountTodayByPhone(ctx, phone, dayStart) + if err != nil { + return err + } + if cnt >= consts.FalseTargetDailyLimit { + return gerror.Newf("今日上报已达上限(%d 条/天)", consts.FalseTargetDailyLimit) + } + id, err = dao.FalseTargetReport.Insert(ctx, &entity.FalseTargetReport{ + PhoneNum: phone, + File: name, + LabelsJson: string(snapshot), + Species: strings.Join(models, ","), + Status: consts.FalseTargetPending, + CreatedAt: now, + }) + return err + }) + if err != nil { + _ = os.Remove(filepath.Join(common.FalseTargetsDir(ctx), name)) + return nil, err + } + return &dto.FalseTargetReportRes{Id: id}, nil +} + +// saveUploadAs 上传文件落盘为指定文件名(f.Save 随原文件名,先暂存再改名) +func saveUploadAs(f *ghttp.UploadFile, dir, name string) error { + tmp, err := f.Save(dir) + if err != nil { + return err + } + if tmp == name { + return nil + } + if err := os.Rename(filepath.Join(dir, tmp), filepath.Join(dir, name)); err != nil { + _ = os.Remove(filepath.Join(dir, tmp)) + return err + } + return nil +} + +// ---------- 管理端 ---------- + +// AdminList 上报分页(附裁剪图预览地址 + 检测框快照解码) +func (s *falseTargetService) AdminList(ctx context.Context, req *dto.AdminFalseTargetListReq) (*dto.AdminFalseTargetListRes, error) { + page, size := common.NormalizePage(req.Page, req.Size) + list, total, err := dao.FalseTargetReport.PageByFilter(ctx, req.PhoneNum, req.Status, page, size) + if err != nil { + return nil, err + } + res := &dto.AdminFalseTargetListRes{Total: total, List: make([]*dto.AdminFalseTargetItem, 0, len(list))} + for _, r := range list { + item := &dto.AdminFalseTargetItem{ + Id: r.Id, + PhoneNum: r.PhoneNum, + Species: r.Species, + Status: r.Status, + ImageUrl: fmt.Sprintf("/api/v1/admin/false-targets/image?id=%d", r.Id), + CreatedAt: r.CreatedAt, + ReviewedAt: r.ReviewedAt, + } + if r.LabelsJson != "" { + var snap dto.FalseTargetSnapshot + if json.Unmarshal([]byte(r.LabelsJson), &snap) == nil { + item.Boxes = snap.Boxes + item.SourceW = snap.SourceW + item.SourceH = snap.SourceH + } + } + res.List = append(res.List, item) + } + return res, nil +} + +// ImageFile 上报画面路径(controller 直写响应体;pending/approved 记录文件必然存在) +func (s *falseTargetService) ImageFile(ctx context.Context, id int64) (string, error) { + reports, err := dao.FalseTargetReport.GetByIds(ctx, []int64{id}) + if err != nil { + return "", err + } + if len(reports) == 0 { + return "", gerror.New("上报记录不存在") + } + path := filepath.Join(common.FalseTargetsDir(ctx), reports[0].File) + if _, err := os.Stat(path); err != nil { + return "", gerror.New("图片文件不存在") + } + return path, nil +} + +// AdminReview 审核:通过 = 裁剪图迁移入负样本库(__negative__,背景图训练); +// 拒绝 = 删文件。仅 pending 可审(重复审核跳过)。Serial + 逐条迁移, +// 入库失败把已迁移文件迁回待审目录(回滚),保持记录存在 ⟺ 文件存在不变式。 +func (s *falseTargetService) AdminReview(ctx context.Context, req *dto.AdminFalseTargetReviewReq) (*dto.AdminFalseTargetReviewRes, error) { + res := &dto.AdminFalseTargetReviewRes{} + reports, err := dao.FalseTargetReport.GetByIds(ctx, req.Ids) + if err != nil { + return nil, err + } + pending := make([]*entity.FalseTargetReport, 0, len(reports)) + for _, r := range reports { + if r.Status == consts.FalseTargetPending { + pending = append(pending, r) + } + } + if len(pending) == 0 { + return res, nil + } + now := gtime.Now() + // 负样本库懒创建内部走 Serial,须在本 Serial 块外先行调用(防串行队列嵌套等待死锁) + var lib *entity.Dataset + if req.Approve { + lib, err = Dataset.ensureNegativeLibrary(ctx) + if err != nil { + return nil, err + } + } + err = common.Serial().Submit(ctx, func() error { + fromDir := common.FalseTargetsDir(ctx) + moved := make([]string, 0, len(pending)) // 已迁出文件名(失败回迁) + toDir := "" + if req.Approve { + toDir = common.DatasetImagesDir(ctx, consts.NegativeDatasetName) + if err := os.MkdirAll(toDir, 0o755); err != nil { + return gerror.Wrap(err, "创建负样本库目录失败") + } + } + for _, r := range pending { + if req.Approve { + name := r.File + if _, err := os.Stat(filepath.Join(toDir, name)); err == nil { + // 极端重名兜底:换新 uuid + name = common.UuidV4() + ".jpg" + } + if err := os.Rename(filepath.Join(fromDir, r.File), filepath.Join(toDir, name)); err != nil { + s.rollbackMoves(ctx, fromDir, toDir, moved) + return gerror.Wrap(err, "迁移图片失败") + } + moved = append(moved, r.File) + if _, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{ + DatasetId: lib.Id, + Filename: name, + Source: "manual", + CreatedAt: now, + }); err != nil { + s.rollbackMoves(ctx, fromDir, toDir, moved) + return err + } + } + if req.Approve { + if err := dao.FalseTargetReport.SetStatus(ctx, r.Id, consts.FalseTargetApproved, now); err != nil { + s.rollbackMoves(ctx, fromDir, toDir, moved) + return err + } + } else { + // 拒绝 = 记录与文件同删(无 rejected 存量状态) + if err := dao.FalseTargetReport.DeleteByIds(ctx, []int64{r.Id}); err != nil { + s.rollbackMoves(ctx, fromDir, toDir, moved) + return err + } + _ = os.Remove(filepath.Join(fromDir, r.File)) + } + res.Affected++ + } + if req.Approve && res.Affected > 0 { + return dao.Dataset.UpdateCounters(ctx, lib.Id, res.Affected, -1, "") + } + return nil + }) + if err != nil { + return nil, err + } + return res, nil +} + +// rollbackMoves 审核中途失败:已迁移入负样本库的文件迁回待审目录 +func (s *falseTargetService) rollbackMoves(ctx context.Context, fromDir, toDir string, moved []string) { + for _, name := range moved { + if err := os.Rename(filepath.Join(toDir, name), filepath.Join(fromDir, name)); err != nil { + g.Log().Errorf(ctx, "假目标审核回滚迁回失败: %s: %+v", name, err) + } + } +} diff --git a/server/common/workspace.go b/server/common/workspace.go index 6d6152c..3513796 100644 --- a/server/common/workspace.go +++ b/server/common/workspace.go @@ -27,6 +27,11 @@ func DatasetImagesDir(ctx context.Context, datasetName string) string { return filepath.Join(DatasetDir(ctx), "datasets", datasetName) } +// FalseTargetsDir 假目标上报待审裁剪图目录(审核通过迁移入负样本库图片目录) +func FalseTargetsDir(ctx context.Context) string { + return filepath.Join(DatasetDir(ctx), "false_targets") +} + // TrainingModelPath 当前生效模型文件路径(modelName 为模型文件基名 = 数据集文件名前缀, // 空则回退数据集名——见 service.modelFileName;训练成功即从训练机直写,客户端固定下载) func TrainingModelPath(ctx context.Context, modelName string) string { diff --git a/server/data/observer.db b/server/data/observer.db index 35c7705..603b20e 100644 Binary files a/server/data/observer.db and b/server/data/observer.db differ diff --git a/server/main.go b/server/main.go index 6d3b004..75f4ea5 100644 --- a/server/main.go +++ b/server/main.go @@ -41,7 +41,7 @@ func main() { }) s.Group("/api/v1", func(group *ghttp.RouterGroup) { group.Middleware(common.UnifiedResponse, common.AuthRequired) - common.BindController(group, controller.Order, controller.License, controller.ModelCatalog, controller.Annotate) + common.BindController(group, controller.Order, controller.License, controller.ModelCatalog, controller.Annotate, controller.FalseTarget) }) // 支付回调组:不做统一包装,按渠道应答格式直接返回(微信 SUCCESS/FAIL JSON、支付宝 success/failure 文本) s.Group("/api/v1/payment", func(group *ghttp.RouterGroup) { diff --git a/server/技术设计.md b/server/技术设计.md index 393966a..68c0085 100644 --- a/server/技术设计.md +++ b/server/技术设计.md @@ -445,6 +445,34 @@ training: - 性能:N 模型并行推理墙钟 ≈ 单模型 1.2~1.5 倍(多核并行);用户按需下载控制 N;帧率不足时可降采样 - 版本比较规则同 APK(数字段比较,m 前缀剥离后按 x.y.z) +## 假目标上报(部署反馈负样本回流,2026-09-08) + +**定位**:识别端误报 = 模型真实混淆边界,是负样本中价值最高的「难负样本」;一键上报把真实拍摄分布的误报回流进负样本库,与 AI 生成数据管线互补(误报为稀疏信号,做补充不做替代)。 + +**DDL**: + +```sql +CREATE TABLE IF NOT EXISTS false_target_report ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone_num TEXT NOT NULL, + file TEXT NOT NULL, -- uuid.jpg(分析帧整图,与推理帧同源同尺寸) + labels_json TEXT NOT NULL DEFAULT '', -- 快照 {sourceW,sourceH,boxes:[{label,class,score,model,cx,cy,w,h}]} + species TEXT NOT NULL DEFAULT '', -- 来源模型名去重逗号拼接(= 数据集名,可多个) + status TEXT NOT NULL DEFAULT 'pending', -- pending/approved(拒绝即删记录与文件,无 rejected 存量) + created_at TEXT NOT NULL, + reviewed_at TEXT +); +``` + +**数据流**:App 一键上报,无选择步骤——按一次快照帧(**与喂给 YOLO 的帧同源同尺寸**,保证所见即所传)+ 当前全部检测框快照(`detections` JSON:label/class/score/model/归一化 xywh,非法框服务端直接丢弃不拦上报)→ 单独同意(首次)→ isolate 内**整帧 jpg q85 重编码**(天然剥离 EXIF/GPS 等全部元数据)→ multipart 上传 → 服务端落 `datasetDir/false_targets/.jpg` + 记录 pending(Serial 内查日限额,每用户每日 50 条)→ 管理端「数据训练 → 假目标上报」tab 审核:**通过 = 文件迁移入 `datasets/__negative__/`(os.Rename 同盘直迁)+ dataset_image 入库(source=manual,负样本库无标注/审核流程)+ image_count++**;**拒绝 = 记录与文件同删(无 rejected 状态)**。保证「记录存在 ⟺ 文件存在」不变式(入库失败回滚删文件)。整帧入负样本库的误判风险(画面中真目标未检出却被当背景)由人工审核借助检测框快照把关。 + +**合规三约束**(PIPL + 单独同意): +1. **客户端重编码 jpg** 剥离全部元数据(GPS/设备信息),服务端不接收原始帧 +2. App 首次使用**单独同意弹窗**(写明收集内容/用途/可拒绝且拒绝不影响识别/删除渠道),本地记录同意标记;用户协议含「反馈图授权用于模型训练」条款 +3. 功能完全可选,不挂任何奖励(避免为利益灌水),服务端另有每用户每日 50 条限额兜底 + +**训练用法**:审核通过图入负样本库(`__negative__`,固定保留名),`prepare_yolo` 打包时统一混入全部物种数据集当背景图(无标注),压制同类误报。误报裁剪块含「像目标但不是」的纹理,正是背景图最需要的难例。 + ## 待办/风险 - 微信支付需商户号(APP 支付权限)、APIv3 密钥与平台证书;支付宝需商户应用与密钥 —— 当前均未配置,接口按真实 SDK 契约实现,配置走 `config.yml` 占位 diff --git a/server_admin/src/views/Datasets.vue b/server_admin/src/views/Datasets.vue index c0c5238..e0d3b2d 100644 --- a/server_admin/src/views/Datasets.vue +++ b/server_admin/src/views/Datasets.vue @@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { EditPen, Loading, Picture, Plus, VideoPlay } from '@element-plus/icons-vue' import { useRouter } from 'vue-router' import request from '../api/request' +import FalseTargets from './FalseTargets.vue' const router = useRouter() const loading = ref(false) @@ -45,9 +46,22 @@ const trainingIds = reactive(new Set()) // 训练发起中的数据集 id(请 // ---------- 负样本库(技术设计.md「负样本库」):source=negative 特殊数据集, // 统一背景样本训练打包时混入全部物种数据集;图片无标注/审核流程,仅上传/删除 ---------- -const pageTab = ref('datasets') // datasets 数据集卡片 | negatives 负样本库 +const pageTab = ref('datasets') // datasets 数据集卡片 | negatives 负样本库 | falseTargets 假目标上报 const negLib = ref(null) // 负样本库数据集记录 {id, name, imageCount} const negList = ref([]) // 图片列表 [{id, filename, url}] + +// ---------- 假目标上报 tab(2026-09-08):审核列表内嵌 FalseTargets 组件,radio 带待审数 ---------- +const ftRef = ref(null) +const ftPendingCount = ref(0) + +function loadFtPendingCount() { + request + .get('/false-targets', { params: { status: 'pending', page: 1, size: 1 } }) + .then((data) => { + ftPendingCount.value = data.total || 0 + }) + .catch(() => {}) +} const negLoading = ref(false) const negSelected = ref(new Set()) // 勾选的图片 id // 分页与详情页同款(客户端分页;每页条数 localStorage 持久化) @@ -114,6 +128,9 @@ function switchTab(tab) { pageTab.value = tab if (tab === 'negatives') { loadNegatives().then(loadNegGenTask) + } else if (tab === 'falseTargets') { + loadFtPendingCount() + ftRef.value?.load() } } @@ -559,6 +576,7 @@ function cancelTrain(row, t) { let trainPoll = null onMounted(() => { load() + loadFtPendingCount() trainPoll = setInterval(() => { if (list.value.some((r) => trList(r).some((t) => t.status === 'running'))) load() }, 10000) @@ -572,12 +590,13 @@ onBeforeUnmount(() => { -