From 5a976556465be6f07c5b7f0b706772bd8892c552 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com>
Date: Sun, 13 Sep 2026 00:25:08 +0800
Subject: [PATCH] 1
---
flutter_app/README.md | 38 +++++++
flutter_app/ios/Runner/Info.plist | 2 +
flutter_app/lib/camera/camera_screen.dart | 6 +-
flutter_app/lib/camera/camera_view_model.dart | 16 +--
flutter_app/lib/camera/gyro_tracker.dart | 101 ++++++++++++++++++
.../lib/detection/tflite_detector.dart | 2 +-
flutter_app/pubspec.yaml | 2 +-
flutter_app/test/camera_view_model_test.dart | 22 ++++
server/biz/dao/dataset_image.go | 11 +-
server/biz/service/label_task.go | 22 ++--
server/config.yml | 3 +
server/技术设计.md | 3 +-
server_admin/src/views/DatasetDetail.vue | 6 +-
13 files changed, 200 insertions(+), 34 deletions(-)
create mode 100644 flutter_app/lib/camera/gyro_tracker.dart
diff --git a/flutter_app/README.md b/flutter_app/README.md
index 9acfd40..0817f0e 100644
--- a/flutter_app/README.md
+++ b/flutter_app/README.md
@@ -123,3 +123,41 @@ GPU delegate 默认允许 FP16 计算(YOLO 类精度损失可忽略);如
"Node number 0 (TRANSPOSE) failed to prepare"。
- **模拟器黑屏**:本机 iOS 模拟器 Impeller 渲染黑屏,验证 UI 用 VM service
(`flutter run` 输出里的 DevTools 地址),或直接真机验证。
+
+## 识别链路约束(踩过的坑)
+
+### 归一化坐标不得与像素量纲阈值直接比较(2026-09-12 漏检根因)
+
+**现象**:验证图 `RNPHE_2030`(麦田平卧雉鸡)离线推理 0.8845、与真值 IoU 0.88,
+App 端对准画面却一个框都不出;换张图/换个握持方向又偶尔能出——"有时能识别、
+有时识别不出来"。
+
+**根因**:`camera_view_model.dart` 的 `_plausible()` 拿 `w / h` 与固定窗口
+`0.3 ~ 3.0` 比较,但 `left/top/right/bottom` 是**按各自轴归一化**的(w 除以图宽、
+h 除以图高),于是:
+
+```
+归一化宽高比 = 像素宽高比 × (图高 / 图宽)
+```
+
+竖屏画幅(图高/图宽 ≈ 1.78)下 `0.3 ~ 3.0` 实际只剩像素宽高比 `0.17 ~ 1.69`;
+雉鸡是长尾鸟(本例 199×89px,像素宽高比 2.24),归一化后 3.99 > 3.0 →
+**在建轨迹之前被 `continue` 丢掉,置信度再高也不显示**。横屏时窗口是 0.53~5.33
+所以能出框——这就是"有时能、有时不能"随握持方向/目标姿态变化的来源。
+
+**修法(2026-09-12 定案)**:几何门**整体删除**(过近/过远目标同样不能丢),
+只保留"零宽/零高"的退化数据兜底;质量交给置信度(minScore)+ VisualPrior
+(仅作用于 < 0.35 的框)+ 轨迹确认(3 帧 / ≥ 0.35 / 运动证据)把关。
+
+**规矩(改识别链路时照做)**:
+
+1. 任何宽高比/尺寸判断必须先换算回**像素空间**再比阈值:
+ `aspectPx = (w * frameW) / (h * frameH)`。直接拿 `w/h` 比固定常量,在非方图上必然错。
+2. 显示链路上会**静默丢弃真框**的过滤(`continue` 不留痕)必须满足其一:
+ 只作用于低分框(同 VisualPrior 的分工)、或有可观测口径(诊断层/日志)——
+ 否则"模型检出了但没显示"无从定位。
+3. 回归用例已锁死:`test/camera_view_model_test.dart` 的「平卧长条框不被几何过滤」
+ 与「近距离大目标与远距离小目标均可显示」——两条在旧逻辑下必然失败,动这块先跑它们。
+
+**定位手法**:诊断层(状态胶囊 3 秒内连点 5 次)看「最高分」——
+**高分无框 = 卡在过滤链路;最高分也低 = 卡在采集/推理链路**。
diff --git a/flutter_app/ios/Runner/Info.plist b/flutter_app/ios/Runner/Info.plist
index 9b46e79..6fedf33 100644
--- a/flutter_app/ios/Runner/Info.plist
+++ b/flutter_app/ios/Runner/Info.plist
@@ -57,6 +57,8 @@
NSAllowsArbitraryLoads
+ NSAllowsArbitraryLoadsInWebContent
+
NSCameraUsageDescription
需要使用相机进行动物实时识别
diff --git a/flutter_app/lib/camera/camera_screen.dart b/flutter_app/lib/camera/camera_screen.dart
index 90d6f6f..c93533d 100644
--- a/flutter_app/lib/camera/camera_screen.dart
+++ b/flutter_app/lib/camera/camera_screen.dart
@@ -39,7 +39,7 @@ class _CameraScreenState extends State {
String? _initError;
/// 置信度阈值(设置页滑块调整,worker 内实时生效)
- double _minScore = 0.10;
+ double _minScore = 0.015;
/// 原生侧帧状态轮询结果(诊断用;无帧时诊断行也能实时刷新)
Map _nativeStats = const {};
@@ -181,9 +181,9 @@ class _CameraScreenState extends State {
),
Slider(
value: _minScore,
- min: 0.05,
+ min: 0.01,
max: 0.50,
- divisions: 45,
+ divisions: 49,
activeColor: Colors.greenAccent,
onChanged: (v) {
setSheetState(() => _minScore = v);
diff --git a/flutter_app/lib/camera/camera_view_model.dart b/flutter_app/lib/camera/camera_view_model.dart
index d77db82..f49b726 100644
--- a/flutter_app/lib/camera/camera_view_model.dart
+++ b/flutter_app/lib/camera/camera_view_model.dart
@@ -173,7 +173,10 @@ class CameraViewModel extends ChangeNotifier {
int now) {
final matched = {};
for (final r in results) {
- if (!_plausible(r)) continue;
+ // 仅挡退化数据(零宽/零高):尺寸与宽高比不设限——过近/过远的真实
+ // 目标同样要显示(2026-09-12 用户定案:原几何门把远处小鸟和近处
+ // 大目标一并丢弃;质量交给置信度 + 轨迹确认把关)
+ if (r.width <= 0 || r.height <= 0) continue;
_Track? best;
var bestD = associateRadius;
for (final t in _tracks.values) {
@@ -241,17 +244,6 @@ class CameraViewModel extends ChangeNotifier {
motionRegions.any((m) => MotionAggregator.centerInRegion(r, m)) ||
noveltyRegions.any((m) => MotionAggregator.centerInRegion(r, m));
- /// 物理合理性过滤:宽高比与相对尺寸(目标物种 20-100px@720 量级)
- bool _plausible(DetectionResult r) {
- final h = r.height;
- final w = r.width;
- if (w <= 0 || h <= 0) return false;
- final aspect = w / h;
- if (aspect < 0.3 || aspect > 3.0) return false;
- if (r.label == 'suspect') return h >= 0.01 && h <= 0.5;
- return h >= 0.01 && h <= 0.3;
- }
-
double _centerDist(DetectionResult a, DetectionResult b) =>
math.sqrt(math.pow(a.centerX - b.centerX, 2) +
math.pow(a.centerY - b.centerY, 2));
diff --git a/flutter_app/lib/camera/gyro_tracker.dart b/flutter_app/lib/camera/gyro_tracker.dart
new file mode 100644
index 0000000..a72a374
--- /dev/null
+++ b/flutter_app/lib/camera/gyro_tracker.dart
@@ -0,0 +1,101 @@
+import 'dart:async';
+import 'dart:math' as math;
+
+import 'package:sensors_plus/sensors_plus.dart';
+
+/// 陀螺仪全局运动追踪(主 isolate):对角速度按 dt 积分得到两次运动分析帧
+/// 之间的累计转角,折算为缩略图像素位移,供 MotionDetector 做全局运动补偿——
+/// 补偿后差分 = 目标独立运动(手持/走动的旋转分量被抵消)。
+///
+/// 符号约定(背部相机、竖屏、假设无镜像):绕设备竖轴(y)偏航 → 画面水平位移;
+/// 绕设备横轴(x)俯仰 → 画面垂直位移。kPanSign/kTiltSign 待真机校准:
+/// 若补偿后误报反而变多(差分被放大),把对应符号取反即可。
+/// 位移超限(缩略图短边 1/4)时由消费方丢弃该帧——运动过快时像素级补偿不可靠。
+class GyroTracker {
+ static const double assumedVfovDeg = 52; // 与测距口径一致
+ static const double kPanSign = 1;
+ static const double kTiltSign = 1;
+
+ StreamSubscription? _gyroSub;
+ StreamSubscription? _accSub;
+
+ double _panRad = 0; // 两次 takeShift 之间的累计偏航
+ double _tiltRad = 0; // 累计俯仰
+ DateTime? _lastGyroMs;
+
+ double _accLowZ = 9.8; // 加速度计低通(重力在设备 z 轴分量)
+ double _accLowY = 0; // 低通 y 轴分量(算俯仰用)
+ bool _hasAcc = false;
+
+ /// 当前分析窗口内的角速度峰值(|x|+|y|+|z|,rad/s):走动/车载时显著抬升。
+ /// takeShift 消费时清零(按分析窗口计量),供曝光联动等判定
+ double _recentOmega = 0;
+
+ bool _running = false;
+ bool get hasData => _hasGyro;
+ bool _hasGyro = false;
+
+ /// 角速度峰值超阈值(rad/s)→ 机位正在明显运动(手持快走/车载)
+ bool get recentlyMoving => _recentOmega > 0.5;
+
+ void start() {
+ if (_running) return;
+ _running = true;
+ _gyroSub = gyroscopeEventStream().listen((e) {
+ final now = DateTime.now();
+ final last = _lastGyroMs;
+ if (last != null) {
+ final dt =
+ (now.millisecondsSinceEpoch - last.millisecondsSinceEpoch) / 1000.0;
+ if (dt > 0 && dt < 0.5) {
+ _panRad += e.y * dt;
+ _tiltRad += e.x * dt;
+ _hasGyro = true;
+ final mag = e.x.abs() + e.y.abs() + e.z.abs();
+ if (mag > _recentOmega) _recentOmega = mag;
+ }
+ }
+ _lastGyroMs = now;
+ });
+ _accSub = accelerometerEventStream().listen((e) {
+ // 低通估重力方向 → 俯仰角(镜头朝上为正)
+ const a = 0.1;
+ _accLowZ = _accLowZ * (1 - a) + e.z * a;
+ _accLowY = _accLowY * (1 - a) + e.y * a;
+ _hasAcc = true;
+ });
+ }
+
+ void stop() {
+ _running = false;
+ _gyroSub?.cancel();
+ _accSub?.cancel();
+ _gyroSub = null;
+ _accSub = null;
+ }
+
+ /// 消费累计转角 → 缩略图像素位移 (dx, dy)。
+ /// dx>0 表示画面内容向右移动(前帧采样点左移补偿)。
+ (int, int) takeShift(double thumbW, double thumbH) {
+ final pan = _panRad;
+ final tilt = _tiltRad;
+ _panRad = 0;
+ _tiltRad = 0;
+ _recentOmega = 0;
+ if (!_hasGyro || (pan == 0 && tilt == 0)) return (0, 0);
+ final focalPx = (thumbH / 2) / math.tan(assumedVfovDeg * math.pi / 180 / 2);
+ final limit = math.max(thumbW, thumbH) / 4;
+ final dx = (kPanSign * pan * focalPx).clamp(-limit, limit).round();
+ final dy = (kTiltSign * tilt * focalPx).clamp(-limit, limit).round();
+ return (dx, dy);
+ }
+
+ /// 俯仰角(度):0=平举,正=镜头朝上。无加速度计数据返回 0。
+ double get pitchDeg {
+ if (!_hasAcc) return 0;
+ return math.atan2(_accLowY, _accLowZ) * 180 / math.pi;
+ }
+
+ /// 角速度是否持续偏大(走动/车载判定,供曝光联动等使用)
+ bool get moving => recentlyMoving;
+}
diff --git a/flutter_app/lib/detection/tflite_detector.dart b/flutter_app/lib/detection/tflite_detector.dart
index 7a8bbc0..db5c4bd 100644
--- a/flutter_app/lib/detection/tflite_detector.dart
+++ b/flutter_app/lib/detection/tflite_detector.dart
@@ -18,7 +18,7 @@ class TfliteDetector {
static const int defaultInputSize = 1280;
// 目标数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升;
// 可运行时调整(设置页滑块),默认 0.10
- double minScore = 0.10;
+ double minScore = 0.015;
static const double iouThreshold = 0.45;
static const int maxDetections = 20;
diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml
index a7c60fa..f92c518 100644
--- a/flutter_app/pubspec.yaml
+++ b/flutter_app/pubspec.yaml
@@ -2,7 +2,7 @@ name: observer
description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费"
publish_to: 'none'
-version: 1.0.40+45
+version: 1.0.42+47
environment:
sdk: ^3.12.2
diff --git a/flutter_app/test/camera_view_model_test.dart b/flutter_app/test/camera_view_model_test.dart
index 77c27e2..ce90cee 100644
--- a/flutter_app/test/camera_view_model_test.dart
+++ b/flutter_app/test/camera_view_model_test.dart
@@ -90,4 +90,26 @@ void main() {
expect(vm.state.results.length, 1, reason: '同位置只有一条轨迹可见');
expect(vm.state.results.single.label, '斑鸠', reason: '框内容跟随后到的检测');
});
+
+ test('平卧长条框不被几何过滤(RNPHE_2030 实测框:归一化宽高比 3.99)', () async {
+ final vm = CameraViewModel(reminder: _RecordingReminder());
+ // 实测模型 0.8845 检出,框 0.283×0.071(像素 199×89,长尾雉鸡),
+ // 旧逻辑 aspect > 3.0 在建轨迹前丢弃 → 高分也无框
+ final flat = box('雉鸡', 0.88, 0.405, 0.419, 0.688, 0.490);
+ feed(vm, [flat]);
+ await Future.delayed(const Duration(milliseconds: 600));
+ feed(vm, [flat]);
+ expect(vm.state.results.length, 1);
+ expect(vm.state.results.single.score, 0.88);
+ });
+
+ test('近距离大目标(占画面高 45%)与远距离小目标(高 0.8%)均可显示', () async {
+ final vm = CameraViewModel(reminder: _RecordingReminder());
+ final near = box('雉鸡', 0.9, 0.15, 0.05, 0.85, 0.50);
+ final far = box('雉鸡', 0.9, 0.45, 0.80, 0.48, 0.808);
+ feed(vm, [near, far]);
+ await Future.delayed(const Duration(milliseconds: 600));
+ feed(vm, [near, far]);
+ expect(vm.state.results.length, 2);
+ });
}
diff --git a/server/biz/dao/dataset_image.go b/server/biz/dao/dataset_image.go
index ab05755..1dff038 100644
--- a/server/biz/dao/dataset_image.go
+++ b/server/biz/dao/dataset_image.go
@@ -211,15 +211,8 @@ func (d *datasetImageDao) CountByDataset(ctx context.Context, datasetId int64) (
return int64(n), err
}
-// UpdateLabels 覆写标注 JSON(整图粒度;空串=清空标注;AI 自动标注与人工保存共用)
-func (d *datasetImageDao) UpdateLabels(ctx context.Context, id int64, labelsJson string) error {
- _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("id", id).
- Data(g.Map{"labels_json": common.NilIfEmpty(labelsJson)}).Update()
- return err
-}
-
-// UpdateLabelsAndReview 覆写标注 JSON 并同步审核状态(预标/AdminLabelSave/App 提交共用):
-// 预标检出/人工保存/App 提交 → 1;人工清空保存 → 0(回到未标注池)
+// UpdateLabelsAndReview 覆写标注 JSON 并同步审核状态(预标/VLM 补标/AdminLabelSave/App 提交共用):
+// 预标检出/VLM 追加疑似框/App 提交 → 1(含疑似待人工过目);管理端人工保存全部框 class 0→2、仍含疑似保持 1、空框→0(回到未标注池)
func (d *datasetImageDao) UpdateLabelsAndReview(ctx context.Context, id int64, labelsJson string, reviewStatus int) error {
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("id", id).
Data(g.Map{"labels_json": common.NilIfEmpty(labelsJson), "review_status": reviewStatus}).Update()
diff --git a/server/biz/service/label_task.go b/server/biz/service/label_task.go
index f8403eb..bea0591 100644
--- a/server/biz/service/label_task.go
+++ b/server/biz/service/label_task.go
@@ -239,7 +239,8 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
return
}
// 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注);
- // 有检出框 → 待审核(1),空检出 → 未标注(0)(无框可审,等人工画框)
+ // 有检出框 → 待审核(1),空检出 → 未标注(0)(无框可审,等人工画框);
+ // 含疑似框(class 1)同此口径——自动产出的疑似框必须人工过目(2026-09-12)
err := common.Serial().Submit(bgCtx, func() error {
for i := range results {
raw, jErr := json.Marshal(results[i])
@@ -478,14 +479,15 @@ func (s *labelTaskService) AdminImageVlmReview(ctx context.Context, req *dto.Adm
res.Note = "VLM 未给出有效的藏匿位坐标"
return res, nil
}
- // 追加为疑似框并落库(与 RF-DETR/人工框同层,工作台可编辑)
+ // 追加为疑似框并落库(与 RF-DETR/人工框同层,工作台可编辑);
+ // 自动链路写入疑似框(class 1)必须人工过目:整图回待审核(2026-09-12 定案)
appended := append(existing, res.Boxes...)
if err := common.Serial().Submit(ctx, func() error {
rawJson, jErr := json.Marshal(appended)
if jErr != nil {
return jErr
}
- return dao.DatasetImage.UpdateLabels(ctx, img.Id, string(rawJson))
+ return dao.DatasetImage.UpdateLabelsAndReview(ctx, img.Id, string(rawJson), consts.ReviewImagePending)
}); err != nil {
return nil, err
}
@@ -577,8 +579,9 @@ func (s *labelTaskService) AdminLabelWorkbench(ctx context.Context, req *dto.Adm
}, nil
}
-// AdminLabelSave 保存单张图标注:校验坐标 → json.Marshal 覆写 labels_json(空框=清空),
-// 并刷新数据集 labeled_count(labels_json 非空数组的图片数)。
+// AdminLabelSave 保存单张图标注:校验坐标 → json.Marshal 覆写 labels_json(空框=清空回未标注),
+// 有框全部 class 0 → 已审核(2),仍含 class 1 疑似框 → 保持待审核(1);
+// 并刷新数据集 labeled_count(review_status=2 计数)。
func (s *labelTaskService) AdminLabelSave(ctx context.Context, req *dto.AdminLabelSaveReq) (*dto.AdminLabelSaveRes, error) {
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
@@ -599,7 +602,8 @@ func (s *labelTaskService) AdminLabelSave(ctx context.Context, req *dto.AdminLab
return nil, gerror.New("标注框坐标非法(需 0~1 归一化)")
}
}
- // 空框 = 清空标注;人工保存即审核定稿(有框→2 已审核,空框→0 未标注回池)
+ // 空框 = 清空标注回未标注(0);有框全部 class 0 才算人工定稿(2),
+ // 仍含 class 1 疑似框 → 保持待审核(1)(2026-09-12 用户定案:疑似须逐框确认或删除)
raw := ""
reviewStatus := consts.ReviewImageNone
if len(req.Boxes) > 0 {
@@ -609,6 +613,12 @@ func (s *labelTaskService) AdminLabelSave(ctx context.Context, req *dto.AdminLab
}
raw = string(b)
reviewStatus = consts.ReviewImageApproved
+ for _, box := range req.Boxes {
+ if box.Class != 0 {
+ reviewStatus = consts.ReviewImagePending
+ break
+ }
+ }
}
var labeled int64
if err := common.Serial().Submit(ctx, func() error {
diff --git a/server/config.yml b/server/config.yml
index e2b4cfb..f19cc28 100644
--- a/server/config.yml
+++ b/server/config.yml
@@ -17,6 +17,9 @@ database:
user: "259278618@qq.com"
pass: "Tongli686^*^"
name: "animal_spot"
+ # SQL 控制台日志(2026-09-12):每条执行的 SQL(查询/写入/建表/迁移/回刷)自动打印控制台,
+ # 含耗时与影响行数;本地与服务器 docker logs 行为一致。见 技术设计.md「技术栈与分层」
+ debug: true
cache:
# 查询缓存 TTL(秒)
ttl: 30
diff --git a/server/技术设计.md b/server/技术设计.md
index 83ccaff..f5b2494 100644
--- a/server/技术设计.md
+++ b/server/技术设计.md
@@ -10,6 +10,7 @@ Go + GoFrame(分层规范见 `CLAUDE.md`):`controller → service → dao`
- service:`biz/service`,订单创建、回调落授权、授权查询
- dao:`biz/dao`,单表 CRUD,无业务逻辑
- 并发:回调处理为 IO 任务,可走 `common` 池;SQLite 写一律回主 goroutine 串行(无 WAL)
+- **SQL 控制台日志(2026-09-12 用户要求「执行 SQL 必须输出控制台日志」)**:`config.yml` `database.default.debug: true`——GoFrame 自动把每条执行的 SQL(查询/写入/建表/索引/迁移/回刷)打印到控制台,格式含耗时、影响行数、完整语句(参数已内联);本地与 Docker(docker logs)行为一致,新增 SQL 执行点无需手工补日志。注意日志会包含参数值(如手机号),生产环境按需知悉
## 套餐配置与数据表 DDL
@@ -408,7 +409,7 @@ training:
- `localAi` 配置节点:键与取值以 config.yml 为准,文档不复制具体数值(曾多次调整阈值致双源漂移,2026-09-02 起删除此类描述)——**部署前提:RF-DETR 服务须从 Go 服务器可达**(现跑在 Mac 上,部署时搬服务器/训练机;**未配置时图片上传/生成接口直接报错**——标注是强语义,不允许产生无标注图片;配置调不通则任务 failed,页顶错误条展示,不自动重试);**端点为配置唯一来源,无运行时覆盖**
- **预标注自动触发(2026-08-26 决策,无手动按钮)**:手动上传/AI 生成图片入库成功后,自动对**本次新增图**发起标注;**`localAi` 未配置 → 上传/生成接口直接报错**(同步检查,生成场景在第一张生成前检查避免付费资产生成后标不了);**已有 running 标注任务(忙)→ 不报错**:上传/生成照常成功,新图由「任务成功完成后自动补标未标注图」机制兜底(每轮任务成功结束时检查该数据集**从未扫描过**的图(labels 为 null/''),有则自动续一轮——失败任务不续,防配置坏时无限重试;**2026-09-02 空检出图不入自动补标池**:检出为空的图写 `labels_json='[]'` 后仍被视作未标注(管理端未标注 tab 照常展示等人工画框),但不入自动池——空检出图若留池,每轮自动续一轮只会扫到别的空图、扫完又轮回来:RNPHE_202(阈值调高后 0 检出,低阈下 12 个候选全为噪声,无一过丢弃线)单张被上一版「排除本轮已扫图」恰好挡住未暴露,2026-09-02 兔子批量 3 张空检出实测 {9}→{10,17}→{9}… 无限交替、每轮白打一次 RF-DETR 方暴露;现自动链条只扫从未扫描的图,每张图至多被自动扫一次,空检出图确需重扫由人工触发(按文件名批量/全量任务,见下));service 逐张调 local-ai 推理(common 池并行,`label_task` 记录 total/done 进度)→ 扫描结果(YOLO 归一化 xywh + 置信度 + 建议类别)**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**;**置信度分档(2026-09-02 起按两档语义调定,具体数值以 config.yml 为准不在此复制)**:conf ≥ confConfirmed 落 class 0 真目标,threshold ≤ conf < confConfirmed 落 class 1 疑似待人工复核,低于 threshold 不入库;背景(RNPHE_257 实测):COCO 通用 rfdetr 对类鸟纹理误检置信度可达 0.46~0.5 且与真目标仅差 ~0.1(真目标 0.562、两误检 0.477/0.462 曾全落 class 0),低置信段是纯噪声候选(该图 21 个原始检出中 17 个落此区间);单阈值无法分离真/假目标(类别名也无用——全部输出 "bird");误检落 class 0 会作为雉鸡正样本直接污染训练集,危害大于真目标降级疑似(后者人工一键确认即可);**重叠去重(2026-08-26 修订)**:NMS 风格、按置信度降序依次保留,与已保留框**重叠比(minIoU = 交叠面积/两框较小面积)> `localAi.overlapThreshold`**的框剔除(同目标被重复检出只留置信度最高者,跨 class 去重;人工画框不参与去重)——**用 minIoU 而非 IoU**:RF-DETR 对同一目标常输出一大一小两个框(标准 IoU 仅 0.3~0.5 会漏杀,实测红黄重叠即此形态),大框套小框时小框被覆盖比例高,minIoU 命中;相邻目标两框互有外露,minIoU 通常 < 0.3;**全图扫描**(项目既定规则:不套用生成规格的位置裁剪,候选宁多勿漏);**单图框数上限(2026-09-02,定义于 consts.go `consts.MaxPrelabelPerImage`)**:去重后仍超上限按置信度取前 N(曾按生成声明数 animal_count 裁剪致漏目标——声明 1 个不代表图内只有 1 个;手动上传图同限);不设候选确认两阶段(2026-08-26 撤销 candidates_json);`POST /admin/label-tasks` 接口保留(服务端 `filenames` 参数支持指定图批量重标,2026-09-02 前端入口收敛后仍可全量——不传 filenames 即全量扫描)
- **预标注四级漏斗(2026-09-04,树栖藏匿难例)**:树冠藏匿生成图(树栖物种目标仅占画面高 ~2%)在全图扫描下不可检——模型包固定 700x700 stretch 输入(见「预标注模型固定 700x700」,不支持动态分辨率,提高提交分辨率无增益),占图高 2% 的目标等比缩入后仅 ~15px 落在检出盲区。单张处理升级为逐级漏斗,**检测只信 RF-DETR,VLM 永不直接出框(只提议候选区)**:①**全图扫描**(现有 `Detect`,`localAi.threshold`)有候选即收(绝大多数图到此命中,不触发后级);②空检出 → **切片扫描**:`common.TileRegions` 滑窗切块(块长边/重叠比 = `localAi.tileSize`/`tileOverlap`,边缘块贴边收口保证全覆盖)逐块 `DetectRegion` 按低阈值(`localAi.tileThreshold`,小目标置信度天然偏低,量级取 RF-DETR-S 实测经验;低于全图丢弃线的候选仍入库落疑似 class 1,宁多勿漏)检测,块内坐标在 DetectRegion 内映射回原图像素,跨块重复检出由既有 minIoU 去重兜住;③仍空 → **VLM 提议候选区**:qwen3-vl-8b 按「宁可指错不可遗漏」提示词输出 ≤`consts.VlmLocateMaxRegions` 个归一化候选区(与手动藏匿位补检不同:此阶段图内无已确认框,无排除集;物种注入同 vlm-review——`gen_species` 空回退数据集名),逐区扩大 `consts.VlmRegionExpand` 倍(VLM 坐标偏粗,扩大给 RF-DETR 足够上下文,钳制图片边界)后 `DetectRegion` 精修,精修命中的框才采纳、未命中丢弃(VLM 误报被 RF-DETR 否掉);**VLM 失败/超时/输出非法一律跳过该级不阻塞任务**(三模型共存 + local-ai 服务端排队串行,无需显存互斥检查,2026-09-02 已放开);④全空 → 写 `'[]'`(语义不变:未标注 tab 展示等人工画框、不入自动补标池防乒乓)。漏斗各级候选同通道处理:conf ≥ confConfirmed 落 class 0、否则 class 1、minIoU 去重、单图上限 `consts.MaxPrelabelPerImage`,全图扫描不套生成规格的项目规则不变。单图超时 120s → `consts.LabelImageTimeoutSec`(切片 ~20 块串行 + VLM 兜底;切片在单图池任务内串行执行,不嵌套协程池)
-- **标注审核状态机(2026-09-04)**:`dataset_image.review_status`(EnsureColumn 幂等补列):`0=未标注, 1=待审核, 2=已审核`;存量迁移——labels_json 非空数组→2(既有标注视为已审定稿)、null/''/'[]'→0。流转:预标注完成→1(**空检出保持 0**——无框可审,「确认无动物」应由人工画框/清洗表达,避免空图混入已审核口径)、App 用户提交→1、管理端工作台保存(AdminLabelSave)→2、审核「通过」→2、「拒绝」→ 清 labels_json + 回 0(图重新进任务池);**疑似框语义(2026-09-07 用户定案)**:审核「通过」= 人工背书,**全部框原样保留进已标注**(含 class 1 疑似框——suspect 是合法的第二训练类别),通过不再回退未标注;存量一次性清理(已执行 2026-09-07):仅疑似框且未经人工确认的已审核图清空回未标注(兔子 781 等,混合标注图不动);**训练集打包(prepareYoloSet)只收 review_status=2**(标签质量闸门,原「labels_json 非空即收」作废);管理端图片 tab 由三变四:未标注(0)/待审核(1)/已标注(2)/已清洗,另加「标注任务」「标注记录」两个管理面板 tab(同在详情页,无独立菜单,2026-09-07 用户定案)——**任务面板=任务图集网格**(任务即勾选下发的具体图片,选几张展示几张,每张角标=任务 id+状态;头部仅任务概要条与停用;下发入口在未标注 tab 勾选图片顶栏,2026-09-07 修订),记录面板=本数据集众包记录查询/解冻(两列表接口支持 `datasetId` 过滤;`annotate_record` 领取时冗余 `dataset_id` 列,单表过滤不跨表);labeled_count 与 dataset.status='labeled' 口径同步改为 review_status=2 计数(2 必然非空框,无歧义)
+- **标注审核状态机(2026-09-04)**:`dataset_image.review_status`(EnsureColumn 幂等补列):`0=未标注, 1=待审核, 2=已审核`;存量迁移——labels_json 非空数组→2(既有标注视为已审定稿)、null/''/'[]'→0。流转:预标注完成→1(**空检出保持 0**——无框可审,「确认无动物」应由人工画框/清洗表达,避免空图混入已审核口径)、App 用户提交→1、管理端工作台保存(AdminLabelSave)→ 有框全 class 0 置 2、仍含 class 1 疑似保持 1、空框置 0、审核「通过」→2、「拒绝」→ 清 labels_json + 回 0(图重新进任务池);**疑似框语义(2026-09-07 用户定案)**:审核「通过」= 人工背书,**全部框原样保留进已标注**(含 class 1 疑似框——suspect 是合法的第二训练类别),通过不再回退未标注;存量一次性清理(已执行 2026-09-07):仅疑似框且未经人工确认的已审核图清空回未标注(兔子 781 等,混合标注图不动);**训练集打包(prepareYoloSet)只收 review_status=2**(标签质量闸门,原「labels_json 非空即收」作废);管理端图片 tab 由三变四:未标注(0)/待审核(1)/已标注(2)/已清洗,另加「标注任务」「标注记录」两个管理面板 tab(同在详情页,无独立菜单,2026-09-07 用户定案)——**任务面板=任务图集网格**(任务即勾选下发的具体图片,选几张展示几张,每张角标=任务 id+状态;头部仅任务概要条与停用;下发入口在未标注 tab 勾选图片顶栏,2026-09-07 修订),记录面板=本数据集众包记录查询/解冻(两列表接口支持 `datasetId` 过滤;`annotate_record` 领取时冗余 `dataset_id` 列,单表过滤不跨表);labeled_count 与 dataset.status='labeled' 口径同步改为 review_status=2 计数(2 必然非空框,无歧义);**疑似框必须人工过目(2026-09-12 用户定案)**:自动链路产出的标注只要含 class 1 就整图回待审核——预标注(原即有框→1)与 VLM 补标追加疑似框(改为 `UpdateLabelsAndReview` 置 1,原只改标注不改状态);人工动作仍可放行(**工作台保存定稿门槛 2026-09-12 用户定案:有框且全部 class 0 才置 2,仍含 class 1 疑似框保存后保持 1 待审核**——疑似须逐框「确认」成 class 0 或删除后才能定稿;审核「通过」维持 09-07 全框原样保留语义);存量一次性回刷(已执行 2026-09-12):`UPDATE dataset_image SET review_status = 1 WHERE review_status <> 1 AND labels_json @> '[{"class":1}]'::jsonb`(本地库 1400 行:环颈雉 141/兔子 427/斑鸠 465/鹌鹑 327/鸭子 40;**刻意不做启动自动迁移**——人工放行图仍含 class 1,启动刷会把人工背书反复冲掉);**补充回刷(2026-09-13,本次门槛定案后复查)**:再得 5 行全疑似框却已标注的遗留图(RNPHE_2059/2061、quail_1160/1162/1163,旧保存逻辑/通过遗留),同口径置 1,复查全库含 class 1 行已无 status=2;**保存后本地审核态同步(2026-09-12 用户实测定位)**:管理端工作台「确定」保存成功后前端同步该图本地 review_status(空框→0;有框全 class 0→2;仍含 class 1 疑似框→保持 1 待审核,与服务端 AdminLabelSave 一致),列表分类与 tab 计数即时更新——原先保存只更新本地框数据不改分类,保存过的图滞留旧 tab、计数虚高,直到下一次 loadAll(如随后的删除操作触发)才批量重分类,表现为「删一条却掉多条」的误判(数据库实际只删选中行,删除请求体外恒只含勾选 id)
- **自动标注退场(2026-09-04 用户定案)**:删除上传入库与生成完成(genTaskAutoLabel)两处 AutoLabel 触发及 autoSupplement 自动补标机制(补标池/空检出乒乓问题随机制一并消亡);**`localAi` 未配置不再阻断上传/生成**(标注改纯管理端手动动作,「不允许产生无标注图」强语义随自动标注退场删除);标注唯一入口 = 管理端勾选图片「预标」(AdminStartLabelTask 保留)
- **App 标注众包与时长激励(2026-09-04;图片粒度下发 2026-09-07 用户定案)**:App 用户处理标注任务换取使用时长,全链路进待审核,服务端记录每用户每张图的处理明细。**下发粒度(2026-09-07 修订)**:任务 = 管理端**勾选的具体图片集合**(不再是数据集级动态池)——`dataset_image.annotate_task_id`(EnsureColumn 幂等补列,默认 0=未下发)作占用标记:下发时 Serial 内批量写占用(校验属本数据集/未标注/未占用),**已下发图即从管理端「未标注」tab 消失**(tab 只显示 annotate_task_id=0);领取池 = 本任务占用且 review_status=0 且 clean_excluded=0 的图;停用任务时**释放未被领取过的图**(清 annotate_task_id,回到未标注 tab 可再下发),已领取未提交的可继续提交(有 annotate_record 的图不释放)。**表**:`annotate_task`(下发任务:dataset_id/name/status published|stopped/created_at);`annotate_record`(用户标注记录:phone_num/task_id/image_id/labels_json 提交快照/status pending|submitted|approved|rejected/created_at/submitted_at/reviewed_at——pending 即领取锁(partial unique index 同图仅一条 pending,超时在领取时惰性释放;**并发防重**:挑图 + 插锁全程在 Serial 临界区内串行执行,并发领取时后读方一定看到先读方刚插入的锁,同一张图不会分给多人,索引为跨实例兜底),UNIQUE(phone_num,image_id) 一人一图仅一次(拒后图仍在本任务池内可被他人再标),拒绝不删记录=惩罚统计依据);`reward_log`(发放流水:phone_num/minutes/task_id/granted_at——日上限按自然日求和);`license.annotate_frozen_until` + `license.annotate_stats_since`(EnsureColumn):冻结到期标记(>now 即冻结,时间戳对比天然自动解冻,无定时任务)与统计基线(冻结时重置,实现解冻后重新累计)。**规则**:领取 = 每次 `annotateReward.claimSize` 张(池内排除该用户已有 annotate_record 的图,冻结中/停用不可领);提交 = 框写 labels_json + review_status=1 + 记录置 submitted,每累计 `perImages`(10) 张 submitted 发 `minutes`(30) 分钟(license.expires_at = max(now,expires_at)+30min 分钟级顺延),当日已发 +30 > `dailyCapMinutes`(120) 则本次不发(App 展示今日已达上限);审核 = 管理端批量通过/拒绝,每次审核后重算该用户通过比例(reviewed_at ≥ stats_since 的 approved/(approved+rejected)),已审核样本 ≥ `minReviewed`(5) 且比例 < `freezeRatio`(0.8) → annotate_frozen_until=now+`freezeHours`(24h) 且 stats_since=now(惩罚不追溯扣已到账时长)。**接口**:admin 下发(传 `imageIds` 勾选图)/列表/停用任务、批量审核(通过/拒绝)、用户标注记录分页;App(/api/v1) 任务列表(含我的统计/冻结态)、领取、提交、我的统计——App 契约不变(仍按 taskId 领取),仅池语义变为任务占用集。**配置**:`config.yml annotateReward` 节点(数值以 config.yml 为准不在此复制),阈值类缺失回退 consts 默认
- **手动标注入口收敛(2026-09-02 决策)**:详情页删除「全量标注」按钮与图片卡片「审核」按钮,**标注入口仅两处(勾选图片驱动,顶栏)**:①「预标」= POST `/admin/label-tasks` 传选中图 `filenames`(只扫选中图,覆盖各图已有标注);②「补标」= 对选中图逐张 POST `/datasets/images/vlm-review`(VLM 藏匿位补检,追加疑似框,批量=前端循环单图接口,依赖已确认框作为排除基准)——VLM 补检按钮从标注编辑器迁出(2026-09-02);点卡片缩略图仍进人工编辑器(确认/清理/画框,编辑器内不再有 VLM 按钮),编辑器文案「图片审核」→「图片标注」;服务端全量重标能力保留但不再有 UI 入口
diff --git a/server_admin/src/views/DatasetDetail.vue b/server_admin/src/views/DatasetDetail.vue
index c68c8b6..bf5278f 100644
--- a/server_admin/src/views/DatasetDetail.vue
+++ b/server_admin/src/views/DatasetDetail.vue
@@ -1124,7 +1124,11 @@ async function saveCurrent() {
dirty.value = false
currentImage.value.boxes = confirmed.value.map((b) => ({ ...b }))
currentImage.value.labeled = confirmed.value.length > 0
- // 保存翻转 labeled 后该行会移出当前 tab(未标注→已标注等),visibleImages 收缩使
+ // 同步审核态(全部框 class 0→2;仍含疑似 class 1→保持 1 待审核;空框→0,与服务端 AdminLabelSave 一致):
+ // 不同步的话已保存图滞留旧 tab、计数虚高,直到下次 loadAll 才批量重分类,看起来像「删一条掉多条」(2026-09-12 实测定位)
+ const allConfirmed = confirmed.value.every((b) => b.class === 0)
+ currentImage.value.reviewStatus = confirmed.value.length === 0 ? 0 : allConfirmed ? 2 : 1
+ // 保存翻转分类后该行会移出当前 tab(未标注→已标注等),visibleImages 收缩使
// currentImage 静默漂移到邻图,继续编辑再保存会把框存到别的图——检测到漂移立即关弹窗
if (!currentImage.value || currentImage.value.filename !== fn) {
editorVisible.value = false