1
This commit is contained in:
@@ -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 端场景的能力输出 |
|
||||
| 长期 | 识别能力平台化 | 以"数据生产线 + 热更新通道"为底座,向更多垂直识别品类复制方法论 |
|
||||
|
||||
---
|
||||
|
||||
*本资料为产品与商业介绍,不构成任何投资建议;运营数据以正式披露为准。*
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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://<host>/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`:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:"审核时间"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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/<uuid>.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` 占位
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<!-- 训练数据双 tab(2026-09-07):数据集 = 物种数据集卡片;负样本 = 统一背景样本库
|
||||
(无标注,训练打包时自动混入全部数据集) -->
|
||||
<!-- 训练数据三 tab:数据集 = 物种数据集卡片;负样本 = 统一背景样本库
|
||||
(无标注,训练打包时自动混入全部数据集);假目标上报 = App 误报回流待审 -->
|
||||
<div class="page-tabs">
|
||||
<el-radio-group :model-value="pageTab" size="small" @update:model-value="switchTab">
|
||||
<el-radio-button value="datasets">数据集</el-radio-button>
|
||||
<el-radio-button value="negatives">负样本{{ negLib ? ` ${negLib.imageCount}` : '' }}</el-radio-button>
|
||||
<el-radio-button value="falseTargets">假目标上报{{ ftPendingCount ? ` ${ftPendingCount}` : '' }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
@@ -681,7 +700,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<!-- 负样本 tab:与数据集详情页同款卡片网格(点击放大/多选删除/同款分页);仅 AI 生成,无本地上传 -->
|
||||
<template v-else>
|
||||
<template v-else-if="pageTab === 'negatives'">
|
||||
<div class="toolbar">
|
||||
<div class="neg-tip">
|
||||
负样本 = 不含目标动物的背景图(内置场景池 AI 生成 + 真实随手拍)。训练打包时统一混入全部物种数据集
|
||||
@@ -738,6 +757,9 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 假目标上报 tab:审核通过迁移入负样本库当背景图;列表/审核在 FalseTargets 组件 -->
|
||||
<FalseTargets v-if="pageTab === 'falseTargets'" ref="ftRef" />
|
||||
|
||||
<!-- 负样本批量生成:内置场景池 + RF-DETR 空检自动剔除,进度在负样本 tab 内展示 -->
|
||||
<el-dialog v-model="negGenVisible" title="AI 生成负样本" width="min(480px, 94vw)">
|
||||
<el-form label-width="90px">
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '../api/request'
|
||||
|
||||
// 假目标上报审核(数据训练页第三个 tab):App 识别端误报一键回流(整帧 + 检测框快照);
|
||||
// 通过 = 整图迁移入负样本库当背景图训练;拒绝 = 删文件。
|
||||
// defineExpose(load) 供父页 tab 切换时刷新与待审数统计
|
||||
|
||||
const filter = reactive({
|
||||
phoneNum: '',
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const size = ref(20)
|
||||
|
||||
const statusMap = {
|
||||
pending: { text: '待审核', type: 'warning' },
|
||||
approved: { text: '已通过', type: 'success' },
|
||||
}
|
||||
|
||||
// <img> 无法带自定义请求头,token 以 query 参数携带(与 Datasets.vue 同约定)
|
||||
function imgUrl(row) {
|
||||
const u = `/api/v1/admin/false-targets/image?id=${row.id}`
|
||||
return `${location.origin}${u}&token=${encodeURIComponent(localStorage.getItem('adminToken') || '')}`
|
||||
}
|
||||
|
||||
function boxText(row) {
|
||||
if (!row.boxes || row.boxes.length === 0) return '-'
|
||||
return row.boxes
|
||||
.map((b) => `${b.label || (b.class === 0 ? '目标' : '疑似')} ${(b.score * 100).toFixed(0)}%`)
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
function load() {
|
||||
loading.value = true
|
||||
const params = { page: page.value, size: size.value }
|
||||
if (filter.phoneNum) params.phoneNum = filter.phoneNum
|
||||
if (filter.status) params.status = filter.status
|
||||
request
|
||||
.get('/false-targets', { params })
|
||||
.then((data) => {
|
||||
list.value = data.list || []
|
||||
total.value = data.total || 0
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filter.phoneNum = ''
|
||||
filter.status = ''
|
||||
search()
|
||||
}
|
||||
|
||||
function review(row, approve) {
|
||||
const tip = approve
|
||||
? '通过后该图将迁入负样本库参与训练,确认通过?'
|
||||
: '拒绝后该上报记录及图片将被删除,确认拒绝?'
|
||||
ElMessageBox.confirm(tip, '审核确认', { type: 'warning' })
|
||||
.then(() =>
|
||||
request.post('/false-targets/review', { ids: [row.id], approve }).then(() => {
|
||||
ElMessage.success(approve ? '已通过,图片已入负样本库' : '已拒绝')
|
||||
load()
|
||||
}),
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
defineExpose({ load })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-form :model="filter" inline @submit.prevent="search">
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="filter.phoneNum" placeholder="精确匹配" clearable style="width: 200px" @keyup.enter="search" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filter.status" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option v-for="(v, k) in statusMap" :key="k" :label="v.text" :value="k" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="上报画面" width="170">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
:src="imgUrl(row)"
|
||||
:preview-src-list="[imgUrl(row)]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
style="width: 120px; height: 68px; border-radius: 4px"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phoneNum" label="上报用户" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="species" label="来源模型" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="检测框" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ boxText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(statusMap[row.status] || {}).type || 'info'" size="small">{{ (statusMap[row.status] || {}).text || row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上报时间" width="170">
|
||||
<template #default="{ row }">{{ row.createdAt || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核时间" width="170">
|
||||
<template #default="{ row }">{{ row.reviewedAt || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<el-button type="success" size="small" @click="review(row, true)">通过</el-button>
|
||||
<el-button type="danger" size="small" @click="review(row, false)">拒绝</el-button>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pager"
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="load"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pager {
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user