This commit is contained in:
2026-09-08 22:29:00 +08:00
parent daf47ca385
commit e519e1d71d
23 changed files with 1261 additions and 6 deletions
+170
View File
@@ -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.
+133
View File
@@ -1,11 +1,18 @@
import 'dart:async';
import 'dart:convert' show jsonEncode;
import 'dart:ui' as ui show PlatformDispatcher;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../auth/session_store.dart';
import '../detection/detection_result.dart';
import '../detection/detector_worker.dart';
import '../feedback/feedback_api.dart';
import '../feedback/false_target_capture.dart';
import '../models/model_manager.dart';
import '../reminder/reminder.dart';
import 'app_camera_controller.dart';
@@ -55,6 +62,11 @@ class _CameraScreenState extends State<CameraScreen> {
/// 胶囊与顶部横幅显示「识别准备中」,不误报「模型加载失败」
bool _workerPending = false;
/// 假目标上报进行中(防重复触发)
bool _reporting = false;
static const _falseTargetConsent = FlutterSecureStorage();
void _onDevTaps() {
final now = DateTime.now();
_devTaps.add(now);
@@ -196,6 +208,99 @@ class _CameraScreenState extends State<CameraScreen> {
);
}
/// 假目标上报:一键上报当前画面——按一次快照帧(与喂给 YOLO 的帧同源同尺寸)
/// + 当前全部检测框快照,单独同意(首次)→ isolate 内整帧 jpg 重编码
/// (剥离全部元数据)→ 上传。无任何额外选择步骤,取消/失败不影响识别。
Future<void> _startFalseTargetReport() async {
final analyzer = _analyzer;
final vm = _viewModel;
if (_reporting || analyzer == null || vm == null || !vm.state.modelReady) {
return;
}
_reporting = true;
try {
final snap = await analyzer
.takeSnapshot()
.timeout(const Duration(seconds: 2), onTimeout: () => null);
if (!mounted || snap == null) {
_toast('未获取到画面,请稍后重试');
return;
}
final ok = await _ensureFalseTargetConsent();
if (!mounted || !ok) return;
_toast('正在上报…');
final boxes = List<DetectionResult>.of(vm.state.results)
.where((b) => b.width > 0 && b.height > 0)
.toList();
final detections = jsonEncode([
for (final b in boxes)
{
'label': b.label,
'class': b.classId,
'score': double.parse(b.score.toStringAsFixed(4)),
'model': b.modelName,
'cx': double.parse(b.centerX.toStringAsFixed(6)),
'cy': double.parse(b.centerY.toStringAsFixed(6)),
'w': double.parse(b.width.toStringAsFixed(6)),
'h': double.parse(b.height.toStringAsFixed(6)),
},
]);
final jpeg = await compute(encodeFrameJpeg, snap);
await FeedbackApi(sessionStore: SessionStore()).reportFalseTarget(
jpeg: jpeg,
detectionsJson: detections,
sourceW: snap.width,
sourceH: snap.height,
);
_toast('感谢反馈!我们会用它改进识别');
} catch (e) {
_toast('上报失败: $e');
} finally {
_reporting = false;
}
}
/// 单独同意(PIPL):首次上报前弹说明,可拒绝且不影响识别;同意后记录不再弹
Future<bool> _ensureFalseTargetConsent() async {
if (await _falseTargetConsent.read(key: falseTargetConsentKey) == '1') {
return true;
}
if (!mounted) return false;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('上报说明'),
content: const Text(
'将上传当前画面一帧(与识别使用的画面一致,上传前已在本地重新编码、'
'移除位置等全部照片信息)及当时的识别框,用于改进识别模型。\n\n'
'该功能完全自愿,拒绝不影响任何其他功能。已上传的图片可联系客服删除。',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('暂不上报'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('同意并继续'),
),
],
),
);
if (ok == true) {
await _falseTargetConsent.write(key: falseTargetConsentKey, value: '1');
}
return ok == true;
}
void _toast(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(msg), duration: const Duration(seconds: 2)));
}
@override
void initState() {
super.initState();
@@ -508,6 +613,34 @@ class _CameraScreenState extends State<CameraScreen> {
),
),
// 假目标上报入口:识别就绪且有框时出现(完全自愿,可忽略)
if (vm.state.modelReady && vm.state.results.isNotEmpty && !_workerPending)
Positioned(
left: 12,
bottom: MediaQuery.of(context).padding.bottom + 72,
child: Material(
color: Colors.black54,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: _startFalseTargetReport,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.flag_outlined,
color: Colors.orangeAccent, size: 16),
SizedBox(width: 5),
Text('误报上报',
style: TextStyle(color: Colors.white70, fontSize: 13)),
],
),
),
),
),
),
Positioned(
left: 8,
right: 8,
@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:camera/camera.dart';
import '../detection/detection_result.dart';
import '../detection/detector_worker.dart';
import '../feedback/false_target_capture.dart';
import 'camera_view_model.dart';
/// 抽帧节流 + 后台推理(对应 Kotlin FrameAnalyzer)。
@@ -33,10 +35,41 @@ class FrameAnalyzer {
/// 图像流回调是否到达(诊断用)
int framesReceived = 0;
/// 假目标上报快照:armed 时下一帧(节流前)拷贝单平面像素并交付。
/// Completer 泛型必须可空:运行时 future 类型决定 .timeout(onTimeout) 的
/// 回调签名,非空 Completer 会让 `() => null` 触发运行时子类型错误
Completer<FrameSnapshot?>? _snapCompleter;
FrameAnalyzer({DetectorWorker? worker, required this.viewModel}) {
attachWorker(worker);
}
/// 取下一帧快照(假目标上报用;在节流判定之前捕获,~33ms 内必有帧)
Future<FrameSnapshot?> takeSnapshot() {
final existing = _snapCompleter;
if (existing != null && !existing.isCompleted) {
return existing.future;
}
final c = Completer<FrameSnapshot?>();
_snapCompleter = c;
return c.future;
}
/// 帧到达(节流判定前):armed 则拷贝像素完成快照
void _captureIfNeeded(
Uint8List plane, int bytesPerRow, int width, int height, bool bgra) {
final c = _snapCompleter;
if (c == null || c.isCompleted) return;
_snapCompleter = null;
c.complete(FrameSnapshot(
plane: Uint8List.fromList(plane),
bytesPerRow: bytesPerRow,
width: width,
height: height,
bgra: bgra,
));
}
/// 替换推理 workernull = 停识别仅预览)。旧 worker 在此释放;若相机已
/// 启动则新 worker 立即接管后续帧——重启相机会重新 initialize
/// (iOS ~1s+)且预览闪断,原地替换即时生效(2026-09-03)。
@@ -114,6 +147,11 @@ class FrameAnalyzer {
void analyze(CameraImage image, int rotationDegrees,
{bool rgbaOrder = false}) {
final p = image.planes.isNotEmpty ? image.planes.first : null;
if (p != null) {
_captureIfNeeded(
p.bytes, p.bytesPerRow, image.width, image.height, !rgbaOrder);
}
if (!_canSend()) return;
worker!.analyze(image, rotationDegrees, rgbaOrder: rgbaOrder);
}
@@ -129,6 +167,9 @@ class FrameAnalyzer {
required bool rgbaOrder,
int rotationDegrees = 0,
}) {
if (planes.isNotEmpty) {
_captureIfNeeded(planes.first, strides.first, width, height, isBgra);
}
if (!_canSend()) return;
worker!.analyzeRaw(
planes: planes,
@@ -0,0 +1,46 @@
import 'dart:typed_data';
import 'package:image/image.dart' as img;
/// 按一次快照帧:单平面 BGRA/RGBA 像素(两平台分析帧均为竖屏单平面 4 通道)。
/// 即喂给 YOLO 推理的同一帧,尺寸与推理输入同源。
class FrameSnapshot {
final Uint8List plane;
final int bytesPerRow;
final int width;
final int height;
/// true = BGRA 字节序(Android 原生通道默认 / iOS bgra8888),false = RGBA
final bool bgra;
const FrameSnapshot({
required this.plane,
required this.bytesPerRow,
required this.width,
required this.height,
required this.bgra,
});
}
/// 在 isolate 执行:整帧像素行拷贝 → 解码为 Image → jpg q85 重编码。
/// 与推理帧同源同尺寸、重编码天然剥离全部元数据(合规硬要求)
Uint8List encodeFrameJpeg(FrameSnapshot snap) {
final packed = Uint8List(snap.width * snap.height * 4);
for (var y = 0; y < snap.height; y++) {
final srcStart = y * snap.bytesPerRow;
final dstStart = y * snap.width * 4;
packed.setRange(dstStart, dstStart + snap.width * 4,
snap.plane.sublist(srcStart, srcStart + snap.width * 4));
}
final image = img.Image.fromBytes(
width: snap.width,
height: snap.height,
bytes: packed.buffer,
numChannels: 4,
order: snap.bgra ? img.ChannelOrder.bgra : img.ChannelOrder.rgba,
);
return Uint8List.fromList(img.encodeJpg(image, quality: 85));
}
/// 单独同意弹窗结果存储 keyFlutterSecureStorage,与 terms_accepted 同库)
const falseTargetConsentKey = 'false_target_consent';
@@ -0,0 +1,66 @@
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:cupertino_http/cupertino_http.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http;
import '../auth/session_store.dart';
import '../config/app_config.dart';
/// 假目标上报 API 客户端(契约见 server/README.md「假目标上报」)。
/// 仅上传误报框裁剪图(客户端已重编码剥离元数据)+ 检测框快照字段。
class FeedbackApi {
final String baseUrl;
final SessionStore sessionStore;
final http.Client _client;
FeedbackApi({String? baseUrl, required this.sessionStore, http.Client? client})
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
_client = client ?? _defaultHttpClient();
static http.Client _defaultHttpClient() {
if (!kIsWeb && Platform.isIOS) {
return CupertinoClient.defaultSessionConfiguration();
}
return http.Client();
}
/// 上报假目标:[jpeg] 为上报瞬间的分析帧整图(与推理帧同源同尺寸),
/// [detectionsJson] 为当前全部检测框快照 JSON 数组(可为空串)
Future<int> reportFalseTarget({
required Uint8List jpeg,
required String detectionsJson,
required int sourceW,
required int sourceH,
}) async {
final token = await sessionStore.readToken();
if (token == null || token.isEmpty) {
throw Exception('登录已失效');
}
final req = http.MultipartRequest(
'POST',
Uri.parse('$baseUrl/api/v1/feedback/false-target'),
)
..headers['Authorization'] = 'Bearer $token'
..fields['detections'] = detectionsJson
..fields['sourceW'] = '$sourceW'
..fields['sourceH'] = '$sourceH'
..files.add(
http.MultipartFile.fromBytes('file', jpeg, filename: 'frame.jpg'));
final streamed =
await _client.send(req).timeout(const Duration(seconds: 30));
final res = await http.Response.fromStream(streamed);
final Map<String, dynamic> json;
try {
json = jsonDecode(res.body) as Map<String, dynamic>;
} catch (_) {
throw Exception('服务端响应异常 (${res.statusCode})');
}
if (res.statusCode != 200 || json['code'] != 0) {
throw Exception(json['message'] as String? ?? '上报失败 (${res.statusCode})');
}
return ((json['data'] as Map?)?['id'] as num?)?.toInt() ?? 0;
}
}
+4 -1
View File
@@ -46,7 +46,10 @@ class _TermsScreenState extends State<TermsScreen> {
五、识别结果说明
本应用的动物识别结果基于人工智能模型,可能存在误差或漏检,识别结果仅供参考,不构成科学鉴定或法律依据。
六、其他
六、反馈数据条款
当您主动使用「假目标上报」功能时,本应用仅上传您确认上报瞬间的当前画面一帧(与识别使用的画面一致,上传前已在设备本地重编码、移除照片元数据等所有附加信息)及当时的识别框信息,用于改进识别模型。该功能完全自愿,您可以拒绝使用且不影响识别等其他功能;您可联系客服删除已上传的反馈图片。
七、其他
本协议内容可能适时更新,更新后您继续使用即视为接受。''';
Future<void> _accept() async {
+24
View File
@@ -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:
+2
View File
@@ -32,6 +32,8 @@ dependencies:
# 模型热更新:多模型下载(crypto 校验 sha256path_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
+14
View File
@@ -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-042026-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`
+10
View File
@@ -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
+21
View File
@@ -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)
}
+18
View File
@@ -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)
}
+99
View File
@@ -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
}
+95
View File
@@ -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:"审核时间"`
}
+284
View File
@@ -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)
}
}
}
+5
View File
@@ -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
View File
@@ -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) {
+28
View File
@@ -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` JSONlabel/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` 占位
+26 -4
View File
@@ -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}]
// ---------- 假目标上报 tab2026-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">
<!-- 训练数据 tab2026-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">
+158
View File
@@ -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>