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