This commit is contained in:
2026-09-07 10:11:36 +08:00
parent 47a06af0d4
commit b8dd969cb1
33 changed files with 3247 additions and 173 deletions
+27 -7
View File
@@ -11,14 +11,15 @@
| 支付回调 | 微信支付 / 支付宝异步回调验签落授权(`/api/v1/payment` 分组,按渠道应答格式直返,不做统一包装) |
| 订单确认 | 客户端支付成功后 `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` 记录每用户每张图的处理明细(领取/提交快照/审核结果) |
| 套餐 | `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 文件,删历史版本仅删记录 |
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 四级漏斗标注(全图扫描→空检切片扫描→VLM 提议候选区精修,见技术设计.md「预标注四级漏斗」)**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗为**审核视图(不做手动画框)**:点击框选中,列表可确认疑似框/删除误检框/清空并保存——AI 自动标注结果直接作为标注,人工仅审核确认;封面(上传/生成统一 1248x704 转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 |
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程**双 tab2026-09-07**:「数据集」tab = 数据集卡片列表(封面图/描述/图片数/已标注数/**训练状态徽标**),「负样本」tab = 负样本库图片网格(上传/删除,见技术设计.md「负样本库」——训练打包时统一混入全部物种数据集);**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片不自动标注(2026-09-04 自动标注退场)**:标注唯一入口 = 勾选图片顶栏「预标」(RF-DETR 四级漏斗检测,见技术设计.md「预标注四级漏斗」),进度条展示在页顶;**预标完成进「待审核」,人工审核通过才「已标注」**(`dataset_image.review_status` 0 未标注/1 待审核/2 已审核 三态,训练集只收已审核图);点击原图/标注图弹窗放大进入标注编辑器(画框/确认/清理,保存即视为已审核);封面(上传/生成统一 1248x704 转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 |
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置);**双档位(2026-09-03)**:一次发起按档位各建一条任务——高识别档 s(基座 `training.model`、imgsz `training.imgsz`=1280/ 高性能档 n(基座 `training.modelN`=yolov8n.pt、imgsz `training.imgszN`=704),请求传 `variants:["s","n"]` 限定(省略=双档;n 档配置缺失时请求报错),epochs/batch/device 双档共用,任务带 `variant` 快照;**GPU 独占排队(2026-09-03**:并发度 1 不变——已有 running 时新任务落 `queued` 排队(不再拒绝),10s 轮询在 running 结束后自动按创建顺序晋级启动、一次一个(训练机单 GPU 串行跑多档/多数据集),取消 running=杀进程、queued=直接置失败;进度/日志/指标监控(每 epoch 粒度);训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化(task.json 传 model/imgsz),产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
| 模型版本与热更新 | **每数据集每档位一个模型**2026-09-03 双档位):训练成功后一键「发布」(训练任务操作列)——tflite 已由训练成功直写最终位置:s 档 `workspace/trainings/<文件名前缀>.tflite`、n 档 `<文件名前缀>_n.tflite`(前缀空回退数据集名),发布仅落 `model_version` 记录(sha256/大小/指标/类别名,带 `variant` 档位列);版本序列每数据集全局共用 m1.0.0 递增(s/n 交替发布走同一序列,无档位独立序列),`is_latest` 按 (数据集, 档位) 各记一条——发布只清同档位旧记录,s/n 两档互不影响,目录可分别发布、分别下发。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/档位 `variant` s|n/版本/类别/大小/sha256/下载地址;**每数据集最多 2 条 = s/n 两档各自的 is_latest**),下载 URL s 档 `/download/trainings/<文件名前缀>.tflite`、n 档 `/download/trainings/<文件名前缀>_n.tflite`(前缀空回退数据集名);**App 模型管理页**用户自由下载/删除/启用模型,识别时**按当前识别档位(s 高识别 / n 高性能,全局切换)加载该档位已启用模型**并行推理 + 跨模型 NMS 合并(按类别名),内置 assets 模型兜底 |
| 标注 | **图片入库自动触发**手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 检测(**四级漏斗2026-09-04**:全图扫描→空检自动升级切片扫描→仍空 VLM 提议候选区+RF-DETR 精修→全空写空标注等人工,切片块长边/重叠/切片阈值`localAi.tileSize`/`tileOverlap`/`tileThreshold`,见技术设计.md「预标注四级漏斗」;`label_task` 记录进度,页顶进度条展示;**localAi 未配置 → 上传/生成接口直接报错;已有标注任务在跑(忙)→ 不报错**,当前任务成功完成后自动补标未标注图)→ 扫描结果(**重叠去重**:NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除——重叠比 = 交叠面积/两框较小面积,RF-DETR 同目标常输出一大一小两框,此判据能命中,同目标只留置信度最高者)**直接写 `dataset_image.labels_json`**(覆盖该图已有标注,即重标语义);点击弹窗放大进入**审核视图(不做手动画框)**:点击框选中,列表确认疑似框/删除误检框/清空 → 保存即整体覆写 `dataset_image.labels_json`YOLO 归一化 JSON 数组,AI 标注直写、人工仅审核确认);`POST /admin/label-tasks` 详情页「全量标注」按钮入口(另有自动触发),可发起全量/指定图重标;自动/手动/混合并存,训练前自动整理prepare_yolo 逻辑在服务端 |
| 标注 | **无自动标注(2026-09-04 退场,用户定案)**:上传/生成入库不触发任何检测,`localAi` 未配置不再阻断入库;标注唯一入口 = 管理端勾选图片顶栏「预标」→ `POST /admin/label-tasks`RF-DETR **四级漏斗**:全图扫描→空检自动升级切片扫描→仍空 VLM 提议候选区+RF-DETR 精修;切片参数`localAi.tileSize`/`tileOverlap`/`tileThreshold`,见技术设计.md「预标注四级漏斗」;扫描结果 minIoU 重叠去重后直写 `dataset_image.labels_json`,空检出写 `[]` 且 review_status 保持未标注);**预标完成 →「待审核」(review_status=1),人工审核通过才「已标注」(=2)**,训练集打包只收已审核图prepareYoloSet 质量闸门);工作台弹窗人工画框/确认后保存即视为已审核;管理端对待审核图批量「通过/拒绝」(拒绝 = 清标注回未标注池,并计入对应 App 用户的低质统计,见「标注众包赚时长」 |
## 架构与数据流
@@ -46,14 +47,17 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| 表 | 说明 | 关键字段 |
|---|---|---|
| `payment_order` | 支付订单 | `order_id`(PK)、`phone_num``plan_id``channel`(wechat/alipay)、`amount_cents``status`(created/paid/closed)、`wx_trade_no`(UNIQUE)、`alipay_trade_no`(UNIQUE)、`created_at``paid_at` |
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`remark`(管理端备注)、`created_at``updated_at` |
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL;标注奖励分钟级顺延)、`annotate_frozen_until`(标注低质冻结到期标记,NULL/过期=正常)、`annotate_stats_since`(通过比例统计基线,冻结时重置实现解冻后重新累计)、`remark`(管理端备注)、`created_at``updated_at` |
| `app_version` | App 版本管理 | `id`(PK)、`version`(x.y.z, UNIQUE)、`notes`(更新说明)、`created_at``updated_at`(下载地址不落表:APK 固定文件 `app.apkDir`/`observer-latest.apk`,默认 `./workspace/` |
| `dataset` | 训练数据集 | `id`(PK)、`name`(UNIQUE)、`source`(manual/ai)、`image_count``labeled_count``status`(building/synced/labeled)、`cover`(封面文件名,UUID 命名 jpg,如 `9f2a...-xx.jpg`)、`description``created_at``updated_at`(图片文件在 `app.datasetDir`/`datasets/<name>/`,DB 只存元数据;AI 标注/训练机 SSH 配置走 `config.yml``localAi` / `training.ssh` 节点);**生成参数池(创建时 VLM 自动生成,界面不维护,可 `POST /datasets/gen-pools` 重新生成)**`gen_species`(单值=数据集物种)、`gen_tone`(单值 轮廓色词 深色/浅色)、`gen_heights`(数值 站高cm,距离感公式用)、`gen_scenes`/`gen_actions`/`gen_occlusions`(JSON 数组 各≥3条)、`gen_classes`(单值 第二标注类别名="suspect",第一类别=gen_species,训练 data.yaml names);**单物种规则:每数据集只对应一个物种(生成图片固定按数据集名),不同物种拆到不同数据集** |
| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id``filename``source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 自动标注与人工标注同存、人工可修改/清理,null/''/'[]'=未标注)、`clean_excluded`(0/12026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`created_at` |
| `dataset` | 训练数据集 | `id`(PK)、`name`(UNIQUE)、`source`(manual/ai/**negative**=负样本库,2026-09-07:固定保留名 `__negative__`,训练打包时混入全部物种数据集当背景学习)、`image_count``labeled_count``status`(building/synced/labeled)、`cover`(封面文件名,UUID 命名 jpg,如 `9f2a...-xx.jpg`)、`description``created_at``updated_at`(图片文件在 `app.datasetDir`/`datasets/<name>/`,DB 只存元数据;AI 标注/训练机 SSH 配置走 `config.yml``localAi` / `training.ssh` 节点);**生成参数池(创建时 VLM 自动生成,界面不维护,可 `POST /datasets/gen-pools` 重新生成)**`gen_species`(单值=数据集物种)、`gen_tone`(单值 轮廓色词 深色/浅色)、`gen_heights`(数值 站高cm,距离感公式用)、`gen_scenes`/`gen_actions`/`gen_occlusions`(JSON 数组 各≥3条)、`gen_classes`(单值 第二标注类别名="suspect",第一类别=gen_species,训练 data.yaml names);**单物种规则:每数据集只对应一个物种(生成图片固定按数据集名),不同物种拆到不同数据集** |
| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id``filename``source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 预标与人工标注同存、人工可修改/清理,null/''/'[]'=无框)、`review_status`(2026-09-04 审核三态:0 未标注/1 待审核/2 已审核;预标与 App 提交→1,人工保存与审核通过→2,拒绝清标注→0;训练集只收 2)、`clean_excluded`(0/12026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`annotate_task_id`(2026-09-07 众包下发的任务占用标记,0=未下发;下发即从「未标注」tab 消失,停用任务释放未领取图回 0)、`created_at` |
| `model_training` | 训练任务 | `id`(PK)、`name``status`(queued/running/success/failedqueued=GPU 忙排队中,2026-09-03)、`dataset_id``variant`(s/n 档位,default s2026-09-03)、`imgsz`/`epochs`/`batch`/`device`(参数快照)、`current_epoch`/`total_epochs``metrics`(JSON)、`log_tail``pid``error``started_at`/`finished_at``created_at` |
| `model_version` | 模型版本(每数据集全局共用序列) | `id`(PK)、`dataset_id``variant`(s/ndefault s;存量行迁移为 s)、`version`(m1.0.0 递增, 同数据集 UNIQUE,s/n 交替发布共用序列)、`training_id``metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256``size_bytes``is_latest`(按 (数据集,档位) 各记一条)、`notes``created_at`(模型文件不落表:发布即写 `trainings/<文件名前缀>.tflite`s/`<文件名前缀>_n.tflite`(n,前缀空回退数据集名),客户端固定下载,无存档回退) |
| `label_task` | 标注任务 | `id`(PK)、`dataset_id``filenames`(JSON 选中图片列表,NULL=全量)、`status`(running/done)、`total`/`done``created_at``finished_at` |
| `gen_task` | AI 生成任务(异步批量) | `id`(PK)、`dataset_id``status`(running/done/failed)、`total`/`done``error``created_at``finished_at` |
| `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`(日上限 = 自然日求和) |
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
@@ -180,7 +184,8 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
| POST | `/admin/app-versions` | 下发新版本(multipart/form-data):`notes` + `file`APK 文件,仅接受 `.apk`);**版本号从文件名识别**,文件须命名为 `observer-x.y.z.apk`(如 `observer-1.0.1.apk`),格式不符拒绝;版本号不可重复,APK 上传覆盖 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);检测到新版本即强制更新 |
| POST | `/admin/app-versions/delete` | 删除版本记录 `{"id":1}`:删**最新版本**时联动删除 APK 文件(客户端不再提示更新、下载 404);删历史版本只删记录不动文件 |
| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","namePrefix":"pheasant","source":"manual"\|"ai","cover":"<文件名>"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名;物种=数据集名(单物种规则),创建时同步调 VLM(qwen3.6-35b-a3b) 自动生成物种/场景/动作/遮挡/站高/类别名等生成参数池,响应含 `poolsGenerated`/`poolError`——VLM 失败不阻断创建,参数可事后用 gen-pools 补生成;封面优先用 cover 参数(新建对话框预生成封面回传,跳过自动生成),否则参数池成功后自动生成 16:9(1248x704)封面(1 雄 1 雌并排,响应含 `coverGenerated`/`coverError`,失败可在编辑模式重新生成);模型生成图统一转 jpg 落盘) |
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs |
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs**排除负样本库**source=negative,仅「负样本」tab 展示) |
| POST | `/admin/datasets/negative` | 获取负样本库(2026-09-07):不存在则自动创建(source=negative 固定名 `__negative__`),返回数据集记录;其 id 供既有 `/admin/datasets/upload``/admin/datasets/images``/admin/datasets/images/delete``/admin/datasets/image` 复用(负样本无标注/审核/清洗流程) |
| POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"name":"新名","namePrefix":"pheasant","description":"...","cover":"a.jpg"}`:名称(改名)/文件名前缀/描述/封面,空值字段不覆盖原值;gen_* 生成参数池不在此维护(仅 VLM 生成,见 gen-pools);改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 |
| POST | `/admin/datasets/gen-pools` | 重新生成数据集生成参数池 `{"datasetId":1}`:按数据集名(物种)调 VLM(qwen3.6-35b-a3b) 生成轮廓色/站高/场景/动作/遮挡并写表(覆盖旧值);失败报错保留旧值 |
| POST | `/admin/datasets/images/vlm-review` | VLM 藏匿位补检 `{"datasetId":1,"imageId":5}`(两阶段标注第二阶段,**须与图像生成显存互斥**):qwen3.6-35b-a3b(+mmproj) 排除已确认框,按环境/季节/时间/天气/光线/地形/习性综合判读画面推理藏身位,追加 ≤3 个疑似框(class 1)进同一 labels_json |
@@ -198,7 +203,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
| POST | `/admin/datasets/sync` | 同步数据集到训练机 `{"id":1}`:按 `training` 通道推送图片到训练机 `datasetDir/<name>/`subprocess 同机 cp、ssh 异机 scp/rsync);训练启动前自动执行 |
| POST | `/admin/datasets/clean/preview` | 数据清洗预览 `{"datasetId":1,"quotas":{...}}`(quotas 各档配额缺省用代码默认):按标注框高占比细档(<2/2-3/3-4/4-6/6-8/8-12/12-20/>20%)统计分布与超配量,超配桶按图内最小确认目标尺寸升序优先保留(小目标样本稀缺先保)、再整图 dHash 去近重复,其余进 `candidates`(含主/最小目标占比 + 目标数 + 实拍生成标识);返回各档分布 + 候选清单 + 已排除列表;<2% 极远档固定豁免,仅 class1 疑似图/空检 `[]` 不入清单 |
| POST | `/admin/datasets/clean/apply` | 清洗执行 `{"imageIds":[1,2],"exclude":true}`:批量置/清 `clean_excluded`(排除=打标记,prepare_yolo 打包跳过;false=恢复),不删文件不删标注 |
| POST | `/admin/label-tasks` | 发起预标注 `{"datasetId":1,"filenames":["a.jpg","b.jpg"]}`(详情页「全量标注」按钮入口,图片入库亦自动触发):**filenames 缺省=全量**,指定图则只扫描选中图(全量重标/单张修补);调 `config.yml` `localAi` 配置的 AI 端点 RF-DETR 逐张推理(common 池并行,未配置报错),扫描结果做**重叠去重**(NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除,同目标只留置信度最高者)后**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**`label_task` 记录进度 |
| POST | `/admin/label-tasks` | 发起预标注 `{"datasetId":1,"filenames":["a.jpg","b.jpg"]}`(详情页勾选图片「预标」按钮入口,**2026-09-04 起为标注唯一触发方式,入库不再自动标注**):**filenames 缺省=全量**,指定图则只扫描选中图(全量重标/单张修补);调 `config.yml` `localAi` 配置的 AI 端点 RF-DETR 逐张推理(common 池并行,未配置报错),扫描结果做**重叠去重**(NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除,同目标只留置信度最高者)后**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**并置 review_status=1 待审核(空检出 `[]` 保持未标注)`label_task` 记录进度 |
| GET | `/admin/label-tasks` | 标注任务列表:`page/size` 分页(含 status/total/done |
| GET | `/admin/label-tasks/detail` | 标注任务详情:返回数据集全部图片 + 每张标注框(`boxes`YOLO 归一化 xywh + 置信度 + 类别) |
| POST | `/admin/label-tasks/save` | 保存单张标注 `{"datasetId":1,"filename":"a.jpg","boxes":[{"class":0,"cx":0.5,"cy":0.4,"w":0.1,"h":0.2}]}`:整体覆写该图 `labels_json`(空 boxes=清空标注),返回该数据集当前 `labeledCount` |
@@ -208,6 +213,21 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
| POST | `/admin/trainings/cancel` | 取消训练 `{"id":1}`running=杀训练进程置 failedqueued=无进程直接置 failed |
| POST | `/admin/trainings/publish` | 发布为最新模型 `{"id":1,"notes":"..."}`(仅 success):读训练成功已直写的 tflite(s 档 `<文件名前缀>.tflite` / n 档 `<文件名前缀>_n.tflite`)算 sha256/大小 → 插入 `model_version`(版本号数据集内递增 m1.0.0 → m1.0.1+ 同档位旧版 `is_latest=0`s/n 互不影响) |
| GET | `/admin/label-workbench` | 标注工作台数据 `{"datasetId":1}`:数据集全部图片 + 每张标注框(`boxes`labels_json 全量)——无历史标注任务时详情页工作台的数据源 |
| POST | `/admin/annotate-tasks` | 下发标注任务 `{"datasetId":1,"name":"...","imageIds":[...]}`2026-09-07 图片粒度下发):勾选的未标注图批量占用(`annotate_task_id` 写任务 id;校验属本数据集/未标注/未占用,任一非法整单拒绝),**已下发图即从「未标注」tab 消失**;同数据集已有 published 任务时拒绝 |
| GET | `/admin/annotate-tasks` | 标注任务列表:`datasetId` 可选过滤(**无独立菜单——任务面板内嵌数据训练详情页「标注任务」tab**,同 tab 还有「下发标注任务」按钮),含数据集名/池余量/各状态记录数/状态 |
| POST | `/admin/annotate-tasks/stop` | 停用任务 `{"id":1}`:整体不可再领取(已领取未提交的可继续提交);**释放未被领取过的图**(清 `annotate_task_id`,回到「未标注」tab 可再次下发) |
| POST | `/admin/annotate-review` | 批量审核 `{"imageIds":[...],"approve":true}`(详情页「待审核」tab):通过 → review_status=2;拒绝 → 清 labels_json 回未标注池 + 对应 submitted 记录置 rejected(计入该用户低质统计:通过比例 <80% 且已审核样本 ≥5 → 冻结 24h,自动解冻) |
| GET | `/admin/annotate-records` | 用户标注记录分页:`datasetId/phone/taskId/status` 过滤,每用户每张图的领取/提交快照/审核结果(**「标注记录」tab 同样内嵌详情页**) |
| POST | `/admin/annotate-unfreeze` | 手动解冻 `{"phone":"..."}`:清冻结标记与统计基线 |
### 标注众包(AppBearer token
| 接口 | 说明 |
|---|---|
| GET | `/api/v1/annotate/tasks` 可领任务列表(published + 池余量),附我的统计:今日已得时长/上限、进度(已提交 x/10)、通过比例、冻结到期时间(冻结中不可领取) |
| POST | `/api/v1/annotate/claim` 领取 `{"taskId":1}`:分配 ≤10 张未处理过的**任务下发图**(池 = 任务占用的未标注图,2026-09-07 起任务为勾选图片集合;pending 锁定,超时惰性释放;返回图片 URL + 数据集物种名) |
| POST | `/api/v1/annotate/submit` 提交 `{"imageId":1,"boxes":[...]}`:写 labels_json + review_status=1 待审核 + 记录置 submitted;每累计 10 张即时发 30 分钟(到期时间顺延),当日累计超 2 小时不再发 |
| GET | `/api/v1/annotate/me` 我的标注统计:累计提交/通过/拒绝、通过比例、累计获得时长、今日已得、冻结状态 |
管理页面由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
+38
View File
@@ -13,6 +13,17 @@ const (
TableLabelTask = "label_task"
TableGenTask = "gen_task"
TableAnnotateTask = "annotate_task"
TableAnnotateRecord = "annotate_record"
TableRewardLog = "reward_log"
// dataset_image.review_status 审核三态(技术设计.md「标注审核状态机」):
// 预标/App 提交 → 待审核;人工保存/审核通过 → 已审核;拒绝清标注 → 未标注(回任务池)。
// 不变式:review_status=0 ⟹ labels_json 无框;训练集只收 ReviewApproved
ReviewImageNone = 0
ReviewImagePending = 1
ReviewImageApproved = 2
// 订单状态机 created → paid(closed 仅超时/失败关闭)
OrderStatusCreated = "created"
OrderStatusPaid = "paid"
@@ -47,6 +58,12 @@ const (
DatasetStatusLabeled = "labeled"
DatasetStatusSynced = "synced"
// 负样本库(技术设计.md「负样本库」):source=negative 的特殊数据集,统一背景样本
// 训练打包时混入全部物种数据集(空标签 = 背景);固定保留名同是目录名,
// AdminCreateDataset 拒绝用户使用该名,AdminDeleteDataset 拒绝删除整库
DatasetSourceNegative = "negative"
NegativeDatasetName = "__negative__"
// 标注任务状态
LabelTaskRunning = "running"
LabelTaskDone = "done"
@@ -75,6 +92,27 @@ const (
// L3 候选区扩大倍数(VLM 坐标偏粗,扩大裁剪给 RF-DETR 足够上下文,钳制图片边界)
VlmRegionExpand = 2.0
// 标注众包与时长激励(技术设计.md「App 标注众包与时长激励」):
// 以下为 config.yml annotateReward 缺失/非法时的回退默认值,可配项含义见 config.yml 注释
AnnotateClaimSize = 10 // 每次领取图片数
AnnotateClaimTimeoutHour = 2 // 领取锁超时(pending 超时视为放弃,领取时惰性释放)
AnnotateRewardPerImages = 10 // 每累计提交 N 张发一次时长
AnnotateRewardMinutes = 30 // 每次发放分钟数(expires_at 分钟级顺延)
AnnotateDailyCapMinutes = 120 // 每日发放上限(自然日)
AnnotateFreezeRatio = 0.8 // 通过比例低于该值触发冻结
AnnotateFreezeMinReviewed = 5 // 触发冻结判定的最小已审核样本数(防小样本误冻)
AnnotateFreezeHours = 24 // 冻结时长(annotate_frozen_until 时间戳对比天然自动解冻)
// 众包任务状态
AnnotateTaskPublished = "published"
AnnotateTaskStopped = "stopped"
// 用户标注记录状态机:领取 pending → 提交 submitted → 审核 approved/rejected
AnnotateRecordPending = "pending"
AnnotateRecordSubmitted = "submitted"
AnnotateRecordApproved = "approved"
AnnotateRecordRejected = "rejected"
// 模型版本号前缀(m1.0.0),同数据集内递增
ModelVersionPrefix = "m"
+37
View File
@@ -82,6 +82,11 @@ func (c *cAdmin) DeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteR
return service.Dataset.AdminDeleteDataset(ctx, req)
}
// NegativeLibrary 获取负样本库(无则创建)
func (c *cAdmin) NegativeLibrary(ctx context.Context, req *dto.AdminDatasetNegativeReq) (*dto.AdminDatasetNegativeRes, error) {
return service.Dataset.AdminNegativeLibrary(ctx, req)
}
// UploadImages 上传图片
func (c *cAdmin) UploadImages(ctx context.Context, req *dto.AdminDatasetUploadReq) (*dto.AdminDatasetUploadRes, error) {
return service.Dataset.AdminUploadImages(ctx, req)
@@ -219,3 +224,35 @@ func (c *cAdmin) SaveLabel(ctx context.Context, req *dto.AdminLabelSaveReq) (*dt
func (c *cAdmin) VlmReview(ctx context.Context, req *dto.AdminImageVlmReviewReq) (*dto.AdminImageVlmReviewRes, error) {
return service.LabelTask.AdminImageVlmReview(ctx, req)
}
// ---------- 标注众包(2026-09-04 ----------
// CreateAnnotateTask 下发标注任务
func (c *cAdmin) CreateAnnotateTask(ctx context.Context, req *dto.AdminAnnotateTaskCreateReq) (*dto.AdminAnnotateTaskCreateRes, error) {
return service.Annotate.AdminCreateTask(ctx, req)
}
// ListAnnotateTasks 标注任务列表
func (c *cAdmin) ListAnnotateTasks(ctx context.Context, req *dto.AdminAnnotateTaskListReq) (*dto.AdminAnnotateTaskListRes, error) {
return service.Annotate.AdminListTasks(ctx, req)
}
// StopAnnotateTask 停用标注任务
func (c *cAdmin) StopAnnotateTask(ctx context.Context, req *dto.AdminAnnotateTaskStopReq) (*dto.AdminAnnotateTaskStopRes, error) {
return service.Annotate.AdminStopTask(ctx, req)
}
// AnnotateReview 批量审核标注(通过/拒绝)
func (c *cAdmin) AnnotateReview(ctx context.Context, req *dto.AdminAnnotateReviewReq) (*dto.AdminAnnotateReviewRes, error) {
return service.Annotate.AdminReview(ctx, req)
}
// ListAnnotateRecords 用户标注记录列表
func (c *cAdmin) ListAnnotateRecords(ctx context.Context, req *dto.AdminAnnotateRecordListReq) (*dto.AdminAnnotateRecordListRes, error) {
return service.Annotate.AdminListRecords(ctx, req)
}
// UnfreezeAnnotate 手动解冻标注资格
func (c *cAdmin) UnfreezeAnnotate(ctx context.Context, req *dto.AdminAnnotateUnfreezeReq) (*dto.AdminAnnotateUnfreezeRes, error) {
return service.Annotate.AdminUnfreeze(ctx, req)
}
+47
View File
@@ -0,0 +1,47 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/net/ghttp"
"observer-server/biz/model/dto"
"observer-server/biz/service"
)
// cAnnotate 标注众包接口(登录态,手机号取自 token)
type cAnnotate struct{}
var Annotate = &cAnnotate{}
// Tasks 可领任务列表 + 我的统计
func (c *cAnnotate) Tasks(ctx context.Context, req *dto.AnnotateTaskListReq) (*dto.AnnotateTaskListRes, error) {
return service.Annotate.AppTaskList(ctx, req)
}
// Claim 领取标注图片
func (c *cAnnotate) Claim(ctx context.Context, req *dto.AnnotateClaimReq) (*dto.AnnotateClaimRes, error) {
return service.Annotate.AppClaim(ctx, req)
}
// Submit 提交标注
func (c *cAnnotate) Submit(ctx context.Context, req *dto.AnnotateSubmitReq) (*dto.AnnotateSubmitRes, error) {
return service.Annotate.AppSubmit(ctx, req)
}
// Me 我的标注统计
func (c *cAnnotate) Me(ctx context.Context, req *dto.AnnotateMeReq) (*dto.AnnotateMeRes, error) {
return service.Annotate.AppMe(ctx, req)
}
// Image 标注图片访问(直写响应体:service 校验归属返回路径,controller 输出二进制;
// 登录态鉴权,与管理端 image 端点隔离)
func (c *cAnnotate) Image(ctx context.Context, req *dto.AnnotateImageReq) (*dto.AnnotateImageRes, error) {
path, err := service.Dataset.ImageFile(ctx, req.DatasetId, req.Filename)
if err != nil {
return nil, err
}
r := ghttp.RequestFromCtx(ctx)
r.Response.ServeFile(path)
return &dto.AnnotateImageRes{}, nil
}
+264
View File
@@ -0,0 +1,264 @@
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"
"observer-server/common"
)
// AnnotateRecord 用户标注记录表 DAOpending 即领取锁(partial unique index 同图仅一条),
// UNIQUE(phone_num, image_id) 一人一图仅一次;拒绝不删记录(低质统计与审计依据)。
type annotateRecordDao struct{}
var AnnotateRecord = &annotateRecordDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS annotate_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_num TEXT NOT NULL,
task_id INTEGER NOT NULL,
dataset_id INTEGER NOT NULL DEFAULT 0,
image_id INTEGER NOT NULL,
labels_json TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
submitted_at TEXT,
reviewed_at TEXT,
UNIQUE (phone_num, image_id)
)`)
if err != nil {
panic(err)
}
// 存量表补列(领取时冗余数据集 id,管理端详情页按数据集过滤记录)
common.EnsureColumn(ctx, consts.TableAnnotateRecord, "dataset_id", "dataset_id INTEGER NOT NULL DEFAULT 0")
// 领取锁:同一张图同时只允许一条 pendingpartial unique indexSQLite 原生支持)
if _, err := g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_annotate_record_image_pending
ON annotate_record (image_id) WHERE status = 'pending'`); err != nil {
panic(err)
}
}
// InsertInTx 事务内插入领取记录(锁竞争由 partial unique index 兜底,冲突即失败回滚)
func (d *annotateRecordDao) InsertInTx(ctx context.Context, tx gdb.TX, m *entity.AnnotateRecord) error {
_, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).TX(tx).Data(g.Map{
"phone_num": m.PhoneNum,
"task_id": m.TaskId,
"dataset_id": m.DatasetId,
"image_id": m.ImageId,
"labels_json": m.LabelsJson,
"status": m.Status,
"created_at": m.CreatedAt,
}).Insert()
return err
}
// DeleteExpiredPending 惰性释放过期领取锁(领取时调用;pending 未产生任何标注,直接删除)
func (d *annotateRecordDao) DeleteExpiredPending(ctx context.Context, before *gtime.Time) error {
_, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("status", consts.AnnotateRecordPending).
WhereLT("created_at", before).
Delete()
return err
}
// ListImageIdsByPhone 某用户全部记录的图片 id(领取时排除自己处理过的图,含全部状态)
func (d *annotateRecordDao) ListImageIdsByPhone(ctx context.Context, phone string) ([]int64, error) {
out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("phone_num", phone).Fields("image_id").Array()
if err != nil {
return nil, err
}
return gconvInt64Slice(out), nil
}
// ListPendingImageIds 当前全部领取锁的图片 id(领取时排除他人锁定的图)
func (d *annotateRecordDao) ListPendingImageIds(ctx context.Context) ([]int64, error) {
out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("status", consts.AnnotateRecordPending).Fields("image_id").Array()
if err != nil {
return nil, err
}
return gconvInt64Slice(out), nil
}
// ListPendingImageIdsByTask 某任务当前领取锁的图片 id(停用任务释放时保留这些图——
// 已领取未提交的可继续提交;过期锁由调用方先经 DeleteExpiredPending 惰性清理)
func (d *annotateRecordDao) ListPendingImageIdsByTask(ctx context.Context, taskId int64) ([]int64, error) {
out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("task_id", taskId).
Where("status", consts.AnnotateRecordPending).Fields("image_id").Array()
if err != nil {
return nil, err
}
return gconvInt64Slice(out), nil
}
// GetMyPending 某用户在某图上的领取记录(提交校验:必须存在且为 pending)
func (d *annotateRecordDao) GetMyPending(ctx context.Context, phone string, imageId int64) (*entity.AnnotateRecord, error) {
var one *entity.AnnotateRecord
err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("phone_num", phone).Where("image_id", imageId).
Where("status", consts.AnnotateRecordPending).Scan(&one)
if err != nil {
return nil, err
}
return one, nil
}
// MarkSubmitted 提交:写快照 + 置 submitted
func (d *annotateRecordDao) MarkSubmitted(ctx context.Context, id int64, labelsJson string, submittedAt *gtime.Time) error {
_, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).Where("id", id).
Data(g.Map{"labels_json": labelsJson, "status": consts.AnnotateRecordSubmitted, "submitted_at": submittedAt}).
Update()
return err
}
// MarkReviewedByImages 按图片批量审核当前提交中的记录(通过→approved / 拒绝→rejected):
// 只命中 status=submitted 的记录(历史已审核记录不动)
func (d *annotateRecordDao) MarkReviewedByImages(ctx context.Context, imageIds []int64, status string, reviewedAt *gtime.Time) error {
if len(imageIds) == 0 {
return nil
}
_, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
WhereIn("image_id", imageIds).
Where("status", consts.AnnotateRecordSubmitted).
Data(g.Map{"status": status, "reviewed_at": reviewedAt}).
Update()
return err
}
// ListSubmittedPhonesByImages 被审核图片对应的提交用户(审核后重算通过比例用,去重)
func (d *annotateRecordDao) ListSubmittedPhonesByImages(ctx context.Context, imageIds []int64) ([]string, error) {
if len(imageIds) == 0 {
return []string{}, nil
}
out, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
WhereIn("image_id", imageIds).
Where("status", consts.AnnotateRecordSubmitted).
Fields("DISTINCT phone_num").Array()
if err != nil {
return nil, err
}
phones := make([]string, 0, len(out))
for _, v := range out {
if s := v.String(); s != "" {
phones = append(phones, s)
}
}
return phones, nil
}
// StatusCount 按 task_id × status 聚合的任务记录数(任务列表进度展示)
type StatusCount struct {
TaskId int64 `orm:"task_id"`
Status string `orm:"status"`
Cnt int64 `orm:"cnt"`
}
// CountByTaskIds 多任务各状态记录数(一次 GROUP BY;IN 按 ≤100 分批由调用方保证)
func (d *annotateRecordDao) CountByTaskIds(ctx context.Context, taskIds []int64) (map[int64]map[string]int64, error) {
out := make(map[int64]map[string]int64)
if len(taskIds) == 0 {
return out, nil
}
var rows []StatusCount
err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
WhereIn("task_id", taskIds).
Fields("task_id, status, COUNT(*) AS cnt").
Group("task_id, status").Scan(&rows)
if err != nil {
return nil, err
}
for _, r := range rows {
if out[r.TaskId] == nil {
out[r.TaskId] = make(map[string]int64)
}
out[r.TaskId][r.Status] = r.Cnt
}
return out, nil
}
// UserStats 用户统计:累计提交数 + 审核通过/拒绝数(reviewedSince 非空时只统计其后的审核——
// 冻结重置基线后「重新累计」语义)
type UserStats struct {
SubmittedTotal int64
Approved int64
Rejected int64
}
// CountUserStats 用户统计查询
func (d *annotateRecordDao) CountUserStats(ctx context.Context, phone string, reviewedSince *gtime.Time) (*UserStats, error) {
stats := &UserStats{}
// 累计提交 = 已产生标注的全部记录(submitted/approved/rejectedpending 是未处理的锁不算)
n, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("phone_num", phone).
WhereIn("status", []string{consts.AnnotateRecordSubmitted, consts.AnnotateRecordApproved, consts.AnnotateRecordRejected}).
Count()
if err != nil {
return nil, err
}
stats.SubmittedTotal = int64(n)
base := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).Where("phone_num", phone)
if reviewedSince != nil {
base = base.WhereGTE("reviewed_at", reviewedSince)
}
approved, err := base.Clone().Where("status", consts.AnnotateRecordApproved).Count()
if err != nil {
return nil, err
}
rejected, err := base.Where("status", consts.AnnotateRecordRejected).Count()
if err != nil {
return nil, err
}
stats.Approved = int64(approved)
stats.Rejected = int64(rejected)
return stats, nil
}
// PageByFilter 管理端记录分页(datasetId>0/phone 精确/taskId/status 过滤,id 倒序)
func (d *annotateRecordDao) PageByFilter(ctx context.Context, phone string, taskId, datasetId int64, status string, page, size int) ([]*entity.AnnotateRecord, int64, error) {
base := func() *gdb.Model {
m := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx)
if phone != "" {
m = m.Where("phone_num", phone)
}
if taskId > 0 {
m = m.Where("task_id", taskId)
}
if datasetId > 0 {
m = m.Where("dataset_id", datasetId)
}
if status != "" {
m = m.Where("status", status)
}
return m
}
total, err := base().Count()
if err != nil {
return nil, 0, err
}
var list []*entity.AnnotateRecord
err = base().OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
if err != nil {
if common.IsNoRows(err) {
return []*entity.AnnotateRecord{}, int64(total), nil
}
return nil, 0, err
}
return list, int64(total), nil
}
func gconvInt64Slice(vals gdb.Array) []int64 {
out := make([]int64, 0, len(vals))
for _, v := range vals {
out = append(out, v.Int64())
}
return out
}
+114
View File
@@ -0,0 +1,114 @@
package dao
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"observer-server/biz/consts"
"observer-server/biz/model/entity"
"observer-server/common"
)
// AnnotateTask 标注众包任务表 DAO:管理端下发/停用,App 端只读 published。
type annotateTaskDao struct{}
var AnnotateTask = &annotateTaskDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS annotate_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
created_at TEXT NOT NULL
)`)
if err != nil {
panic(err)
}
}
// InsertInTx 事务内下发任务(与批量占用同事务,service 编排多表一致性)
func (d *annotateTaskDao) InsertInTx(ctx context.Context, tx gdb.TX, m *entity.AnnotateTask) (int64, error) {
res, err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx).TX(tx).Data(g.Map{
"dataset_id": m.DatasetId,
"name": m.Name,
"status": m.Status,
"created_at": m.CreatedAt,
}).Insert()
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// GetById 按主键取
func (d *annotateTaskDao) GetById(ctx context.Context, id int64) (*entity.AnnotateTask, error) {
var one *entity.AnnotateTask
err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx).Where("id", id).Scan(&one)
if err != nil {
return nil, err
}
return one, nil
}
// GetPublishedByDataset 某数据集当前 published 任务(下发查重:同数据集同时只允许一个开放任务)
func (d *annotateTaskDao) GetPublishedByDataset(ctx context.Context, datasetId int64) (*entity.AnnotateTask, error) {
var one *entity.AnnotateTask
err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx).
Where("dataset_id", datasetId).
Where("status", consts.AnnotateTaskPublished).
OrderDesc("id").Scan(&one)
if err != nil {
return nil, err
}
return one, nil
}
// ListPublished 全部开放任务(App 任务列表)
func (d *annotateTaskDao) ListPublished(ctx context.Context) ([]*entity.AnnotateTask, error) {
var list []*entity.AnnotateTask
err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx).
Where("status", consts.AnnotateTaskPublished).
OrderDesc("id").Scan(&list)
if err != nil {
if common.IsNoRows(err) {
return []*entity.AnnotateTask{}, nil
}
return nil, err
}
return list, nil
}
// Page 管理端任务分页(datasetId>0 按数据集过滤——详情页 tab 内嵌展示;id 倒序)
func (d *annotateTaskDao) Page(ctx context.Context, datasetId int64, page, size int) ([]*entity.AnnotateTask, int64, error) {
base := func() *gdb.Model {
m := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx)
if datasetId > 0 {
m = m.Where("dataset_id", datasetId)
}
return m
}
total, err := base().Count()
if err != nil {
return nil, 0, err
}
var list []*entity.AnnotateTask
err = base().OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
if err != nil {
if common.IsNoRows(err) {
return []*entity.AnnotateTask{}, int64(total), nil
}
return nil, 0, err
}
return list, int64(total), nil
}
// Stop 停用任务(整体不可再领取;已领取未提交的可继续提交)
func (d *annotateTaskDao) Stop(ctx context.Context, id int64) error {
_, err := g.DB().Model(consts.TableAnnotateTask).Ctx(ctx).Where("id", id).
Data(g.Map{"status": consts.AnnotateTaskStopped}).Update()
return err
}
+22 -4
View File
@@ -95,6 +95,22 @@ func (d *datasetDao) GetById(ctx context.Context, id int64) (*entity.Dataset, er
return &e, nil
}
// GetByIds 按主键批量查询(众包任务/记录组装用)
func (d *datasetDao) GetByIds(ctx context.Context, ids []int64) ([]*entity.Dataset, error) {
if len(ids) == 0 {
return []*entity.Dataset{}, nil
}
var list []*entity.Dataset
err := g.DB().Model(consts.TableDataset).Ctx(ctx).WhereIn("id", ids).Scan(&list)
if err != nil {
if common.IsNoRows(err) {
return []*entity.Dataset{}, nil
}
return nil, err
}
return list, nil
}
// GetByName 按名称查询(目录名即数据集名),不存在返回 nil
func (d *datasetDao) GetByName(ctx context.Context, name string) (*entity.Dataset, error) {
var e entity.Dataset
@@ -217,9 +233,10 @@ func (d *datasetDao) DeleteById(ctx context.Context, id int64) error {
}
// PageByKeyword 数据集分页:名称模糊匹配(Like 命中目录名,数据库内仅元数据)
// 排序同 Page:序号升序,同号按创建时间倒序(id 降序)
// 排序同 Page:序号升序,同号按创建时间倒序(id 降序);负样本库不进列表(独立 tab 展示)
func (d *datasetDao) PageByKeyword(ctx context.Context, keyword string, page, size int) ([]*entity.Dataset, int64, error) {
base := g.DB().Model(consts.TableDataset).Ctx(ctx).WhereLike("name", "%"+keyword+"%")
base := g.DB().Model(consts.TableDataset).Ctx(ctx).WhereLike("name", "%"+keyword+"%").
Where("source !=", consts.DatasetSourceNegative)
total, err := base.Count()
if err != nil {
return nil, 0, err
@@ -248,9 +265,10 @@ func (d *datasetDao) ListAll(ctx context.Context) ([]*entity.Dataset, error) {
return list, nil
}
// Page 数据集分页:序号升序,同号按创建时间倒序(id 降序)
// Page 数据集分页:序号升序,同号按创建时间倒序(id 降序);负样本库不进列表(独立 tab 展示)
func (d *datasetDao) Page(ctx context.Context, page, size int) ([]*entity.Dataset, int64, error) {
base := g.DB().Model(consts.TableDataset).Ctx(ctx)
base := g.DB().Model(consts.TableDataset).Ctx(ctx).
Where("source !=", consts.DatasetSourceNegative)
total, err := base.Count()
if err != nil {
return nil, 0, err
+99 -11
View File
@@ -36,6 +36,16 @@ func init() {
common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json TEXT")
common.EnsureColumn(ctx, consts.TableDatasetImage, "animal_count", "animal_count INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableDatasetImage, "clean_excluded", "clean_excluded INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableDatasetImage, "review_status", "review_status INTEGER NOT NULL DEFAULT 0")
// 存量库迁移:众包任务图片粒度下发占用标记(2026-09-07,0=未下发)
common.EnsureColumn(ctx, consts.TableDatasetImage, "annotate_task_id", "annotate_task_id INTEGER NOT NULL DEFAULT 0")
// 存量迁移(幂等):加列后既有标注统一视为已审定稿(非空框→2);上线后不变式
// review_status=0 ⟹ labels_json 无框 恒成立(拒绝清框、提交/预标置 1、审核置 2),
// 故本 UPDATE 只会命中存量行,不会误改新数据
if _, err := g.DB().Exec(ctx, `UPDATE dataset_image SET review_status = 2
WHERE review_status = 0 AND labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]'`); err != nil {
panic(err)
}
// 存量库迁移:删除 prompt 列(生成提示词不再存储,2026-09-02 决策)
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(dataset_image)")
if err != nil {
@@ -94,16 +104,15 @@ func (d *datasetImageDao) ListByDataset(ctx context.Context, datasetId int64) ([
return list, nil
}
// ListUnlabeledByDataset 从未扫描过的图片(labels_json 为 null 或空串;自动补标轮次用)。
// '[]'(已自动扫描但检出为空)不在此列:空检出图重扫结果不变,若仍入池,自动补标的
// 「排除本轮已扫图」只换子集不灭池,会乒乓死循环(2026-09-02 兔子 3 张空检出实测:
// {9}→{10,17}→{9}… 无限交替续跑,每轮白打一次 RF-DETR);空检出图仍在管理端未标注
// tab 等人工画框,重扫走手动触发(AdminStartLabelTask 按文件名批量或全量)。
func (d *datasetImageDao) ListUnlabeledByDataset(ctx context.Context, datasetId int64) ([]*entity.DatasetImage, error) {
// ListPoolByTask 众包任务池(技术设计.md「App 标注众包与时长激励」;2026-09-07 图片粒度下发):
// 本任务占用(annotate_task_id=taskId)且未审核未清洗排除的图;待审核(1)/已审核(2)不进池——
// 分别等管理端审核与已定稿
func (d *datasetImageDao) ListPoolByTask(ctx context.Context, taskId int64) ([]*entity.DatasetImage, error) {
var list []*entity.DatasetImage
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
Where("dataset_id", datasetId).
Where("labels_json IS NULL OR labels_json = ''").
Where("annotate_task_id", taskId).
Where("review_status", consts.ReviewImageNone).
Where("clean_excluded", 0).
OrderAsc("id").Scan(&list)
if err != nil {
if common.IsNoRows(err) {
@@ -114,6 +123,66 @@ func (d *datasetImageDao) ListUnlabeledByDataset(ctx context.Context, datasetId
return list, nil
}
// CountPoolByTaskIds 多任务池余量(一次 GROUP BY;众包任务列表展示用)
func (d *datasetImageDao) CountPoolByTaskIds(ctx context.Context, taskIds []int64) (map[int64]int64, error) {
out := make(map[int64]int64)
if len(taskIds) == 0 {
return out, nil
}
var rows []struct {
AnnotateTaskId int64 `orm:"annotate_task_id"`
Cnt int64 `orm:"cnt"`
}
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
WhereIn("annotate_task_id", taskIds).
Where("review_status", consts.ReviewImageNone).
Where("clean_excluded", 0).
Fields("annotate_task_id, COUNT(*) AS cnt").
Group("annotate_task_id").
Scan(&rows)
if err != nil {
return nil, err
}
for _, r := range rows {
out[r.AnnotateTaskId] = r.Cnt
}
return out, nil
}
// OccupyByTask 下发占用:批量写 annotate_task_idSerial 内调用,防与并发下发竞态;IN ≤100 分批)
func (d *datasetImageDao) OccupyByTask(ctx context.Context, ids []int64, taskId int64) error {
for start := 0; start < len(ids); start += 100 {
end := start + 100
if end > len(ids) {
end = len(ids)
}
if _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
WhereIn("id", ids[start:end]).
Where("annotate_task_id", 0).
Data(g.Map{"annotate_task_id": taskId}).Update(); err != nil {
return err
}
}
return nil
}
// ReleaseByTask 停用释放:清指定图的任务占用标记回未标注(仅当仍占用本任务,防误清新任务标记;IN ≤100 分批)
func (d *datasetImageDao) ReleaseByTask(ctx context.Context, ids []int64, taskId int64) error {
for start := 0; start < len(ids); start += 100 {
end := start + 100
if end > len(ids) {
end = len(ids)
}
if _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
WhereIn("id", ids[start:end]).
Where("annotate_task_id", taskId).
Data(g.Map{"annotate_task_id": 0}).Update(); err != nil {
return err
}
}
return nil
}
// GetByIds 按 id 批量取(删除图片定位文件用)
func (d *datasetImageDao) GetByIds(ctx context.Context, ids []int64) ([]*entity.DatasetImage, error) {
var list []*entity.DatasetImage
@@ -154,11 +223,30 @@ func (d *datasetImageDao) UpdateLabels(ctx context.Context, id int64, labelsJson
return err
}
// CountLabeledByDataset 某数据集已标注图片数(labels_json 非空数组的图片行数)
// UpdateLabelsAndReview 覆写标注 JSON 并同步审核状态(预标/AdminLabelSave/App 提交共用):
// 预标检出/人工保存/App 提交 → 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": labelsJson, "review_status": reviewStatus}).Update()
return err
}
// SetReviewStatus 只改审核状态(审核通过=2 / 拒绝先清标注再置 0,两步由 service 编排)
func (d *datasetImageDao) SetReviewStatus(ctx context.Context, ids []int64, reviewStatus int) error {
if len(ids) == 0 {
return nil
}
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).WhereIn("id", ids).
Data(g.Map{"review_status": reviewStatus}).Update()
return err
}
// CountLabeledByDataset 某数据集已审核图片数(review_status=22026-09-04 起已标注口径
// = 人工审核通过,labels_json 非空只是必要条件)
func (d *datasetImageDao) CountLabeledByDataset(ctx context.Context, datasetId int64) (int64, error) {
n, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
Where("dataset_id", datasetId).
Where("labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]'").
Where("review_status", consts.ReviewImageApproved).
Count()
return int64(n), err
}
@@ -184,7 +272,7 @@ func (d *datasetImageDao) CountStatByDatasets(ctx context.Context, datasetIds []
}
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
WhereIn("dataset_id", datasetIds[start:end]).
Fields("dataset_id, COUNT(*) AS cnt, SUM(CASE WHEN labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]' THEN 1 ELSE 0 END) AS labeled_cnt").
Fields("dataset_id, COUNT(*) AS cnt, SUM(CASE WHEN review_status = 2 THEN 1 ELSE 0 END) AS labeled_cnt").
Group("dataset_id").
Scan(&rows)
if err != nil {
+25
View File
@@ -32,6 +32,9 @@ func init() {
panic(err)
}
common.EnsureColumn(ctx, consts.TableLicense, "remark", "remark TEXT")
// 标注众包(2026-09-04):冻结到期标记 + 通过比例统计基线(技术设计.md「App 标注众包与时长激励」)
common.EnsureColumn(ctx, consts.TableLicense, "annotate_frozen_until", "annotate_frozen_until TEXT")
common.EnsureColumn(ctx, consts.TableLicense, "annotate_stats_since", "annotate_stats_since TEXT")
}
// GetByPhone 按手机号查询(走缓存,键含手机号)
@@ -110,6 +113,28 @@ func (d *licenseDao) ClearAuthInTx(ctx context.Context, tx gdb.TX, phone string)
return err
}
// ExtendExpiresInTx 事务内写到期时间(标注奖励分钟级顺延;新值由 service 按 max(now, expires_at) 计算)
func (d *licenseDao) ExtendExpiresInTx(ctx context.Context, tx gdb.TX, phone string, expiresAt any) error {
_, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx).
Where("phone_num", phone).
Data(gdb.Map{"expires_at": expiresAt, "updated_at": gtime.Now()}).
Update()
return err
}
// UpdateAnnotateState 写标注冻结标记与统计基线(传 nil 即清除;写后清缓存)
func (d *licenseDao) UpdateAnnotateState(ctx context.Context, phone string, frozenUntil, statsSince any) error {
_, err := g.DB().Model(consts.TableLicense).Ctx(ctx).
Where("phone_num", phone).
Data(gdb.Map{"annotate_frozen_until": frozenUntil, "annotate_stats_since": statsSince, "updated_at": gtime.Now()}).
Update()
if err != nil {
return err
}
common.ClearCache(ctx, "license:"+phone)
return nil
}
// SetRemarkInTx 事务内写备注(管理端 remark,空串即清空):不覆盖密码/授权字段
func (d *licenseDao) SetRemarkInTx(ctx context.Context, tx gdb.TX, phone, remark string) error {
_, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx).
+64
View File
@@ -0,0 +1,64 @@
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"
)
// RewardLog 标注时长发放流水表 DAO:只增不改(审计);日上限按自然日求和。
type rewardLogDao struct{}
var RewardLog = &rewardLogDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS reward_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_num TEXT NOT NULL,
minutes INTEGER NOT NULL,
task_id INTEGER NOT NULL DEFAULT 0,
granted_at TEXT NOT NULL
)`)
if err != nil {
panic(err)
}
}
// InsertInTx 事务内落一条发放流水(与 expires_at 顺延同事务保证原子)
func (d *rewardLogDao) InsertInTx(ctx context.Context, tx gdb.TX, phone string, minutes int, taskId int64, grantedAt *gtime.Time) error {
_, err := g.DB().Model(consts.TableRewardLog).Ctx(ctx).TX(tx).Data(g.Map{
"phone_num": phone,
"minutes": minutes,
"task_id": taskId,
"granted_at": grantedAt,
}).Insert()
return err
}
// SumMinutesByPhoneSince 某时间点(含)之后累计发放分钟数(日上限 = 传当日 0 点)
func (d *rewardLogDao) SumMinutesByPhoneSince(ctx context.Context, phone string, since *gtime.Time) (int, error) {
v, err := g.DB().Model(consts.TableRewardLog).Ctx(ctx).
Where("phone_num", phone).
WhereGTE("granted_at", since).
Fields("COALESCE(SUM(minutes), 0) AS total").Value()
if err != nil {
return 0, err
}
return v.Int(), nil
}
// SumMinutesByPhone 累计发放分钟数(我的统计)
func (d *rewardLogDao) SumMinutesByPhone(ctx context.Context, phone string) (int, error) {
v, err := g.DB().Model(consts.TableRewardLog).Ctx(ctx).
Where("phone_num", phone).
Fields("COALESCE(SUM(minutes), 0) AS total").Value()
if err != nil {
return 0, err
}
return v.Int(), nil
}
+204
View File
@@ -0,0 +1,204 @@
package dto
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// 标注众包与时长激励(技术设计.md「App 标注众包与时长激励」):
// 管理端 = 下发/停用任务、批量审核、用户标注记录、手动解冻;App = 任务列表/领取/提交/我的统计。
// ---------- 管理端 ----------
// AdminAnnotateTaskCreateReq 下发标注任务(2026-09-07 图片粒度:勾选的具体未标注图集合即任务图集,
// 下发即占用(annotate_task_id 写任务 id)从未标注 tab 消失,停用任务释放未领取图)
type AdminAnnotateTaskCreateReq struct {
g.Meta `path:"/annotate-tasks" method:"post" summary:"下发标注任务" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集"`
Name string `json:"name" v:"required|length:1,50" dc:"任务名"`
ImageIds []int64 `json:"imageIds" v:"required|min-length:1" dc:"勾选的未标注图片 id 列表"`
}
type AdminAnnotateTaskCreateRes struct {
Id int64 `json:"id"`
}
// AdminAnnotateTaskListReq 标注任务列表(datasetId 缺省=全部;详情页 tab 内嵌时按数据集过滤)
type AdminAnnotateTaskListReq struct {
g.Meta `path:"/annotate-tasks" method:"get" summary:"标注任务列表" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"integer|min:0" dc:"数据集过滤,0=全部"`
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
// AdminAnnotateTaskItem 任务条目:池余量 + 各状态记录数
type AdminAnnotateTaskItem struct {
Id int64 `json:"id"`
DatasetId int64 `json:"datasetId"`
DatasetName string `json:"datasetName"`
Name string `json:"name"`
Status string `json:"status"`
PoolRemain int64 `json:"poolRemain"`
Pending int64 `json:"pending"`
Submitted int64 `json:"submitted"`
Approved int64 `json:"approved"`
Rejected int64 `json:"rejected"`
CreatedAt *gtime.Time `json:"createdAt"`
}
type AdminAnnotateTaskListRes struct {
Total int64 `json:"total"`
List []*AdminAnnotateTaskItem `json:"list"`
}
// AdminAnnotateTaskStopReq 停用任务
type AdminAnnotateTaskStopReq struct {
g.Meta `path:"/annotate-tasks/stop" method:"post" summary:"停用标注任务" tags:"管理端"`
Id int64 `json:"id" v:"required|min:1" dc:"任务 id"`
}
type AdminAnnotateTaskStopRes struct{}
// AdminAnnotateReviewReq 批量审核:通过 → review_status=2;拒绝 → 清标注回未标注池 +
// 对应 submitted 记录置 rejected(触发低质统计/冻结判定)
type AdminAnnotateReviewReq struct {
g.Meta `path:"/annotate-review" method:"post" summary:"批量审核标注" tags:"管理端"`
ImageIds []int64 `json:"imageIds" v:"required" dc:"图片 id 列表"`
Approve bool `json:"approve" dc:"true=通过 false=拒绝"`
}
type AdminAnnotateReviewRes struct {
Affected int64 `json:"affected"` // 实际变更的图片数(过滤掉不存在/非待审核)
FrozenPhones []string `json:"frozenPhones"` // 本次触发冻结的用户(空=无)
}
// AdminAnnotateRecordListReq 用户标注记录分页(每用户每张图的处理明细;datasetId 缺省=全部)
type AdminAnnotateRecordListReq struct {
g.Meta `path:"/annotate-records" method:"get" summary:"用户标注记录列表" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"integer|min:0" dc:"数据集过滤,0=全部"`
Phone string `json:"phone" v:"length:0,20" dc:"手机号精确过滤"`
TaskId int64 `json:"taskId" v:"integer|min:0" dc:"任务过滤,0=全部"`
Status string `json:"status" v:"in:,pending,submitted,approved,rejected" dc:"状态过滤,空=全部"`
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
// AdminAnnotateRecordItem 记录条目(附图片文件名与数据集名,内存组装)
type AdminAnnotateRecordItem struct {
Id int64 `json:"id"`
PhoneNum string `json:"phoneNum"`
TaskId int64 `json:"taskId"`
DatasetName string `json:"datasetName"`
ImageId int64 `json:"imageId"`
Filename string `json:"filename"`
ImageUrl string `json:"imageUrl"`
Status string `json:"status"`
Boxes []*AdminLabelBox `json:"boxes"`
CreatedAt *gtime.Time `json:"createdAt"`
SubmittedAt *gtime.Time `json:"submittedAt"`
ReviewedAt *gtime.Time `json:"reviewedAt"`
}
type AdminAnnotateRecordListRes struct {
Total int64 `json:"total"`
List []*AdminAnnotateRecordItem `json:"list"`
}
// AdminAnnotateUnfreezeReq 手动解冻
type AdminAnnotateUnfreezeReq struct {
g.Meta `path:"/annotate-unfreeze" method:"post" summary:"手动解冻标注资格" tags:"管理端"`
Phone string `json:"phone" v:"required|length:6,20" dc:"手机号"`
}
type AdminAnnotateUnfreezeRes struct{}
// ---------- AppBearer token,手机号取登录态) ----------
// AnnotateStats 我的标注统计(任务列表/我的页共用)
type AnnotateStats struct {
SubmittedTotal int64 `json:"submittedTotal"` // 累计有效提交
Approved int64 `json:"approved"` // 审核通过
Rejected int64 `json:"rejected"` // 审核拒绝
ApproveRatio float64 `json:"approveRatio"` // 当前统计基线内通过比例(0~1,无样本=1)
ProgressDone int64 `json:"progressDone"` // 距下次奖励进度(累计提交 mod perImages)
RewardPerImages int `json:"rewardPerImages"` // 每次奖励所需提交数
RewardMinutes int `json:"rewardMinutes"` // 每次奖励分钟数
TotalEarnedMinutes int `json:"totalEarnedMinutes"` // 累计获得分钟数
TodayEarnedMinutes int `json:"todayEarnedMinutes"` // 今日已得(自然日)
DailyCapMinutes int `json:"dailyCapMinutes"` // 每日上限
FrozenUntil *gtime.Time `json:"frozenUntil"` // 冻结到期(nil=未冻结)
ExpiresAt *gtime.Time `json:"expiresAt"` // 当前授权到期
}
// AnnotateTaskListReq App 可领任务列表
type AnnotateTaskListReq struct {
g.Meta `path:"/annotate/tasks" method:"get" summary:"可领标注任务列表" tags:"标注众包"`
}
// AnnotateTaskItem 任务条目(含池余量;冻结中仍展示但不可领取)
type AnnotateTaskItem struct {
Id int64 `json:"id"`
Name string `json:"name"`
DatasetId int64 `json:"datasetId"`
DatasetName string `json:"datasetName"`
Species string `json:"species"`
PoolRemain int64 `json:"poolRemain"`
}
type AnnotateTaskListRes struct {
List []*AnnotateTaskItem `json:"list"`
Stats *AnnotateStats `json:"stats"`
}
// AnnotateClaimReq 领取:分配 ≤ClaimSize 张未处理过的池内图(pending 锁定)
type AnnotateClaimReq struct {
g.Meta `path:"/annotate/claim" method:"post" summary:"领取标注图片" tags:"标注众包"`
TaskId int64 `json:"taskId" v:"required|min:1" dc:"任务 id"`
}
// AnnotateClaimImage 分配的单张图(宽高供 App 端 canvas 画框)
type AnnotateClaimImage struct {
ImageId int64 `json:"imageId"`
Url string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
type AnnotateClaimRes struct {
Images []*AnnotateClaimImage `json:"images"`
Species string `json:"species"`
}
// AnnotateSubmitReq 提交标注:写 labels_json + review_status=1 待审核,每累计 RewardPerImages
// 张发 RewardMinutes 分钟(当日超 DailyCapMinutes 不再发);boxes 可为空数组(画面无目标主张)
type AnnotateSubmitReq struct {
g.Meta `path:"/annotate/submit" method:"post" summary:"提交标注" tags:"标注众包"`
ImageId int64 `json:"imageId" v:"required|min:1" dc:"图片 id"`
Boxes []*AdminLabelBox `json:"boxes" dc:"标注框(空数组=画面无目标)"`
}
type AnnotateSubmitRes struct {
Granted bool `json:"granted"` // 本次是否触发发放
Minutes int `json:"minutes"` // 本次发放分钟数(granted 时有效)
TodayEarnedMinutes int `json:"todayEarnedMinutes"` // 今日已得
ExpiresAt *gtime.Time `json:"expiresAt"` // 发放后的授权到期
ProgressDone int64 `json:"progressDone"` // 距下次奖励进度
}
// AnnotateMeReq 我的标注统计
type AnnotateMeReq struct {
g.Meta `path:"/annotate/me" method:"get" summary:"我的标注统计" tags:"标注众包"`
}
type AnnotateMeRes struct {
Stats *AnnotateStats `json:"stats"`
}
// AnnotateImageReq 标注图片访问(登录态;controller 直写响应体,服务端校验图片归属)
type AnnotateImageReq struct {
g.Meta `path:"/annotate/image" method:"get" summary:"标注图片访问" tags:"标注众包"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集"`
Filename string `json:"filename" v:"required" dc:"文件名"`
}
type AnnotateImageRes struct{}
+30 -15
View File
@@ -125,6 +125,18 @@ type AdminDatasetDeleteReq struct {
type AdminDatasetDeleteRes struct{}
// AdminDatasetNegativeReq 获取负样本库(技术设计.md「负样本库」):固定保留名特殊数据集,
// 不存在则自动创建;管理端「负样本」tab 进入时调用,返回 id 供既有上传/图片列表/删除接口复用
type AdminDatasetNegativeReq struct {
g.Meta `path:"/datasets/negative" method:"post" summary:"获取负样本库(无则创建)" tags:"管理端"`
}
type AdminDatasetNegativeRes struct {
Id int64 `json:"id"`
Name string `json:"name"`
ImageCount int64 `json:"imageCount"`
}
// AdminDatasetUploadReq 上传图片(multipart 多文件;重名跳过并计数)
type AdminDatasetUploadReq struct {
g.Meta `path:"/datasets/upload" method:"post" summary:"上传图片" tags:"管理端" mime:"multipart/form-data"`
@@ -182,14 +194,16 @@ type AdminDatasetImagesReq struct {
}
// AdminImageItem 图片条目(url 为管理端图片访问地址,前端拼 apiBaseUrl 使用;
// cleanExcluded=1 的图归「已清洗」tab——数据清洗排除,训练集打包跳过,可恢复
// cleanExcluded=1 的图归「已清洗」tab——数据清洗排除,训练集打包跳过,可恢复
// annotateTaskId>0 的图已下发给众包任务,不进「未标注」tab)
type AdminImageItem struct {
Id int64 `json:"id"`
Filename string `json:"filename"`
Source string `json:"source"` // manual | ai
Url string `json:"url"`
CleanExcluded int `json:"cleanExcluded"` // 1=已清洗排除
CreatedAt *gtime.Time `json:"createdAt"`
Id int64 `json:"id"`
Filename string `json:"filename"`
Source string `json:"source"` // manual | ai
Url string `json:"url"`
CleanExcluded int `json:"cleanExcluded"` // 1=已清洗排除
AnnotateTaskId int64 `json:"annotateTaskId"` // 众包任务占用标记(0=未下发)
CreatedAt *gtime.Time `json:"createdAt"`
}
type AdminDatasetImagesRes struct {
@@ -367,7 +381,7 @@ type AdminLabelTaskListRes struct {
type AdminLabelTaskStartReq struct {
g.Meta `path:"/label-tasks" method:"post" summary:"发起预标注" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
Filenames []string `json:"filenames" v:"length:0,500" dc:"选中图片列表(多选批量);缺省=全量扫描"`
Filenames []string `json:"filenames" dc:"选中图片列表(多选批量);缺省=全量扫描"`
}
type AdminLabelTaskStartRes struct {
@@ -407,14 +421,15 @@ type AdminLabelBox struct {
Class int `json:"class"` // 0 确认 | 1 疑似
}
// AdminLabelImageItem 工作台单张图:全部标注(labels_jsonAI 自动标注与人工框同层,人工可修改/清理)
// AdminLabelImageItem 工作台单张图:全部标注(labels_jsonAI 预标与人工框同层,人工可修改/清理)
type AdminLabelImageItem struct {
Filename string `json:"filename"`
Url string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
Boxes []*AdminLabelBox `json:"boxes"`
Labeled bool `json:"labeled"` // 有标注(AI 自动标注或人工保存)
Filename string `json:"filename"`
Url string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
Boxes []*AdminLabelBox `json:"boxes"`
Labeled bool `json:"labeled"` // 有标注AI 预标或人工保存)
ReviewStatus int `json:"reviewStatus"` // 审核三态 0=未标注,1=待审核,2=已审核
}
type AdminLabelTaskDetailRes struct {
@@ -0,0 +1,20 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// AnnotateRecord 用户标注记录(服务端记录每用户每张图的处理明细,技术设计.md「App 标注众包与时长激励」):
// pending 即领取锁(partial unique index 同图仅一条 pending,超时惰性释放);
// UNIQUE(phone_num, image_id) 一人一图仅一次(拒后图回池可被他人再标);
// 拒绝不删记录——低质统计(通过比例)与审计依据。
type AnnotateRecord struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"用户手机号"`
TaskId int64 `json:"taskId" orm:"task_id" description:"所属任务"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"数据集(领取时冗余,详情页按数据集过滤记录用)"`
ImageId int64 `json:"imageId" orm:"image_id" description:"图片"`
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"提交快照(YOLO 归一化 JSON 数组)"`
Status string `json:"status" orm:"status" description:"pending|submitted|approved|rejected"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"领取时间"`
SubmittedAt *gtime.Time `json:"submittedAt" orm:"submitted_at" description:"提交时间"`
ReviewedAt *gtime.Time `json:"reviewedAt" orm:"reviewed_at" description:"审核时间"`
}
+13
View File
@@ -0,0 +1,13 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// AnnotateTask 标注众包任务(管理端下发):池子动态 = 数据集内 review_status=0
// 且 clean_excluded=0 的图(不落表,实时查询),新增图自动入池,停用即整体不可领。
type AnnotateTask struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"数据集(任务池来源)"`
Name string `json:"name" orm:"name" description:"任务名"`
Status string `json:"status" orm:"status" description:"published|stopped"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"下发时间"`
}
+10 -8
View File
@@ -5,12 +5,14 @@ import "github.com/gogf/gf/v2/os/gtime"
// DatasetImage 数据集图片:文件在 datasets/<数据集名>/<filename>
// 标注为 JSON 数组存本表(元素同 dto.AdminLabelBoxclass/cx/cy/w/h/confidenceYOLO 归一化)。
type DatasetImage struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
Filename string `json:"filename" orm:"filename" description:"唯一文件名(防重名加时间戳后缀)"`
Source string `json:"source" orm:"source" description:"manual|ai"`
AnimalCount int `json:"animalCount" orm:"animal_count" description:"AI 生成提示词中的声明目标数量(0=手动上传,仅信息性,自动标注上限固定 3 与声明数无关)"`
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 自动标注与人工标注同存,null/''/'[]'=未标注)"`
CleanExcluded int `json:"cleanExcluded" orm:"clean_excluded" description:"数据清洗排除标记(0=正常,1=已从训练集排除;只影响训练集打包,图片/标注不动,可恢复)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
Filename string `json:"filename" orm:"filename" description:"唯一文件名(防重名加时间戳后缀)"`
Source string `json:"source" orm:"source" description:"manual|ai"`
AnimalCount int `json:"animalCount" orm:"animal_count" description:"AI 生成提示词中的声明目标数量(0=手动上传,仅信息性,自动标注上限固定 3 与声明数无关)"`
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 预标与人工标注同存,null/''/'[]'=无框)"`
ReviewStatus int `json:"reviewStatus" orm:"review_status" description:"审核三态(0=未标注,1=待审核,2=已审核;训练集只收2)"`
CleanExcluded int `json:"cleanExcluded" orm:"clean_excluded" description:"数据清洗排除标记(0=正常,1=已从训练集排除;只影响训练集打包,图片/标注不动,可恢复)"`
AnnotateTaskId int64 `json:"annotateTaskId" orm:"annotate_task_id" description:"众包任务占用标记(0=未下发;>0=已下发给该任务,从未标注tab隐藏,停用任务释放未领取图回0)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
}
+8 -6
View File
@@ -5,10 +5,12 @@ import "github.com/gogf/gf/v2/os/gtime"
// License 手机号账号与授权:一账号一记录;password 不对外(json 排除);
// expires_at 未充值(含被撤销)时为 NULL;购买的套餐记录在 payment_order,本表不存。
type License struct {
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"`
Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"`
ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL"`
Remark string `json:"remark" orm:"remark" description:"管理端备注(客户端不可见)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"`
Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"`
ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL;标注奖励分钟级顺延"`
AnnotateFrozenUntil *gtime.Time `json:"-" orm:"annotate_frozen_until" description:"标注低质冻结到期标记(NULL/过期=正常,时间戳对比天然自动解冻)"`
AnnotateStatsSince *gtime.Time `json:"-" orm:"annotate_stats_since" description:"通过比例统计基线(冻结时重置,解冻后重新累计)"`
Remark string `json:"remark" orm:"remark" description:"管理端备注(客户端不可见)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
+12
View File
@@ -0,0 +1,12 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// RewardLog 标注时长发放流水:日上限 = 自然日求和;只增不改(审计)。
type RewardLog struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"用户手机号"`
Minutes int `json:"minutes" orm:"minutes" description:"本次发放分钟数"`
TaskId int64 `json:"taskId" orm:"task_id" description:"触发发放的任务(0=未知)"`
GrantedAt *gtime.Time `json:"grantedAt" orm:"granted_at" description:"发放时间"`
}
+724
View File
@@ -0,0 +1,724 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"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"
)
// annotateService 标注众包与时长激励(技术设计.md「App 标注众包与时长激励」):
// 管理端下发数据集级动态池任务 → App 用户领取(pending 锁)/提交(进待审核)→
// 每累计 N 张即时发时长(日上限封顶)→ 管理端审核,通过比例过低自动冻结领取资格。
type annotateService struct{}
var Annotate = &annotateService{}
// annotateRewardCfg annotateReward 配置(缺失/非法回退 consts 默认)
type annotateRewardCfg struct {
ClaimSize int
ClaimTimeoutHour int
RewardPerImages int
RewardMinutes int
DailyCapMinutes int
FreezeRatio float64
FreezeMinReviewed int
FreezeHours int
}
func annotateRewardConfig(ctx context.Context) annotateRewardCfg {
get := func(key string, def int) int {
v := g.Cfg().MustGet(ctx, "annotateReward."+key, def).Int()
if v <= 0 {
return def
}
return v
}
ratio := g.Cfg().MustGet(ctx, "annotateReward.freezeRatio", consts.AnnotateFreezeRatio).Float64()
if ratio <= 0 || ratio > 1 {
ratio = consts.AnnotateFreezeRatio
}
return annotateRewardCfg{
ClaimSize: get("claimSize", consts.AnnotateClaimSize),
ClaimTimeoutHour: get("claimTimeoutHour", consts.AnnotateClaimTimeoutHour),
RewardPerImages: get("rewardPerImages", consts.AnnotateRewardPerImages),
RewardMinutes: get("rewardMinutes", consts.AnnotateRewardMinutes),
DailyCapMinutes: get("dailyCapMinutes", consts.AnnotateDailyCapMinutes),
FreezeRatio: ratio,
FreezeMinReviewed: get("freezeMinReviewed", consts.AnnotateFreezeMinReviewed),
FreezeHours: get("freezeHours", consts.AnnotateFreezeHours),
}
}
// speciesOf 任务物种展示名:gen_species 空回退数据集名(单物种规则)
func annotateSpecies(d *entity.Dataset) string {
if d.GenSpecies != "" {
return d.GenSpecies
}
return d.Name
}
// annotateImageUrl App 端图片访问地址(登录态 Bearer 鉴权,controller 直写响应体,
// 与管理端 /admin/datasets/image 的 X-Admin-Token 鉴权隔离——App 用户无管理 token)
func annotateImageUrl(ctx context.Context, datasetId int64, filename string) string {
return fmt.Sprintf("/api/v1/annotate/image?%s",
url.Values{"datasetId": {fmt.Sprintf("%d", datasetId)}, "filename": {filename}}.Encode())
}
func annotateFrozen(lic *entity.License) *gtime.Time {
if lic == nil || lic.AnnotateFrozenUntil == nil {
return nil
}
if lic.AnnotateFrozenUntil.Timestamp() <= gtime.Now().Timestamp() {
return nil // 已过期 = 自动解冻
}
return lic.AnnotateFrozenUntil
}
// ---------- 管理端 ----------
// AdminCreateTask 下发任务(2026-09-07 图片粒度):勾选的具体图片集合即任务图集——
// dataset_image.annotate_task_id 写任务 id 作占用标记(已下发图即从管理端「未标注」tab 消失),
// 停用任务时释放未领取图回未标注。同数据集同时只允许一个 published 任务;
// Serial 内查重 + 逐图校验(属本数据集/未标注/未占用,任一非法整单拒绝)+ 事务内插任务与批量占用。
func (s *annotateService) AdminCreateTask(ctx context.Context, req *dto.AdminAnnotateTaskCreateReq) (*dto.AdminAnnotateTaskCreateRes, error) {
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
if dataset.Source == consts.DatasetSourceNegative {
return nil, gerror.New("负样本库不参与标注众包")
}
images, err := dao.DatasetImage.GetByIds(ctx, req.ImageIds)
if err != nil {
return nil, err
}
if len(images) != len(req.ImageIds) {
return nil, gerror.New("部分图片不存在,请刷新后重试")
}
for _, img := range images {
if img.DatasetId != dataset.Id {
return nil, gerror.Newf("图片 %d 不属于该数据集", img.Id)
}
if img.ReviewStatus != consts.ReviewImageNone {
return nil, gerror.Newf("图片 %d 已标注,只能下发未标注图", img.Id)
}
if img.AnnotateTaskId != 0 {
return nil, gerror.Newf("图片 %d 已下发给其他任务,请刷新后重试", img.Id)
}
}
var taskId int64
err = common.Serial().Submit(ctx, func() error {
running, err := dao.AnnotateTask.GetPublishedByDataset(ctx, dataset.Id)
if err != nil {
return err
}
if running != nil {
return gerror.Newf("该数据集已有进行中的标注任务(id=%d),请先停用", running.Id)
}
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
taskId, err = dao.AnnotateTask.InsertInTx(ctx, tx, &entity.AnnotateTask{
DatasetId: dataset.Id,
Name: req.Name,
Status: consts.AnnotateTaskPublished,
CreatedAt: gtime.Now(),
})
if err != nil {
return err
}
return dao.DatasetImage.OccupyByTask(ctx, req.ImageIds, taskId)
})
})
if err != nil {
return nil, err
}
return &dto.AdminAnnotateTaskCreateRes{Id: taskId}, nil
}
// AdminListTasks 任务分页:组装数据集名/池余量/各状态记录数
func (s *annotateService) AdminListTasks(ctx context.Context, req *dto.AdminAnnotateTaskListReq) (*dto.AdminAnnotateTaskListRes, error) {
page, size := common.NormalizePage(req.Page, req.Size)
list, total, err := dao.AnnotateTask.Page(ctx, req.DatasetId, page, size)
if err != nil {
return nil, err
}
res := &dto.AdminAnnotateTaskListRes{Total: total, List: make([]*dto.AdminAnnotateTaskItem, 0, len(list))}
if len(list) == 0 {
return res, nil
}
taskIds := make([]int64, 0, len(list))
for _, t := range list {
taskIds = append(taskIds, t.Id)
}
names := Training.datasetNameMap(ctx)
poolCnt, err := dao.DatasetImage.CountPoolByTaskIds(ctx, taskIds)
if err != nil {
return nil, err
}
recCnt, err := dao.AnnotateRecord.CountByTaskIds(ctx, taskIds)
if err != nil {
return nil, err
}
for _, t := range list {
item := &dto.AdminAnnotateTaskItem{
Id: t.Id,
DatasetId: t.DatasetId,
DatasetName: names[t.DatasetId],
Name: t.Name,
Status: t.Status,
PoolRemain: poolCnt[t.Id],
CreatedAt: t.CreatedAt,
}
if m := recCnt[t.Id]; m != nil {
item.Pending = m[consts.AnnotateRecordPending]
item.Submitted = m[consts.AnnotateRecordSubmitted]
item.Approved = m[consts.AnnotateRecordApproved]
item.Rejected = m[consts.AnnotateRecordRejected]
}
res.List = append(res.List, item)
}
return res, nil
}
// AdminStopTask 停用任务(整体不可再领取;已领取未提交的可继续提交)。
// Serial 内停用 + 释放:未被领取过的图清占用标记回未标注(重新出现在未标注 tab 可再次下发);
// 有 pending 锁的图保留占用(用户可继续提交)。过期锁先按领取同语义惰性清理,防已放弃的锁
// 永久占图(停用任务不再有领取触发清理)。
func (s *annotateService) AdminStopTask(ctx context.Context, req *dto.AdminAnnotateTaskStopReq) (*dto.AdminAnnotateTaskStopRes, error) {
t, err := dao.AnnotateTask.GetById(ctx, req.Id)
if err != nil {
return nil, err
}
if t == nil {
return nil, gerror.New("标注任务不存在")
}
cfg := annotateRewardConfig(ctx)
err = common.Serial().Submit(ctx, func() error {
if err := dao.AnnotateTask.Stop(ctx, req.Id); err != nil {
return err
}
if err := dao.AnnotateRecord.DeleteExpiredPending(ctx, gtime.Now().Add(-time.Duration(cfg.ClaimTimeoutHour)*time.Hour)); err != nil {
return err
}
pool, err := dao.DatasetImage.ListPoolByTask(ctx, req.Id)
if err != nil {
return err
}
lockedIds, err := dao.AnnotateRecord.ListPendingImageIdsByTask(ctx, req.Id)
if err != nil {
return err
}
locked := make(map[int64]bool, len(lockedIds))
for _, id := range lockedIds {
locked[id] = true
}
release := make([]int64, 0, len(pool))
for _, img := range pool {
if !locked[img.Id] {
release = append(release, img.Id)
}
}
return dao.DatasetImage.ReleaseByTask(ctx, release, req.Id)
})
if err != nil {
return nil, err
}
return &dto.AdminAnnotateTaskStopRes{}, nil
}
// AdminReview 批量审核:通过 → review_status=2;拒绝 → 清标注回未标注池。
// 多表变更(dataset_image + annotate_record + license 冻结)走 Serial + 事务;
// 审核后对涉及用户重算通过比例,低于阈值自动冻结(解冻后按 stats_since 重新累计)。
func (s *annotateService) AdminReview(ctx context.Context, req *dto.AdminAnnotateReviewReq) (*dto.AdminAnnotateReviewRes, error) {
res := &dto.AdminAnnotateReviewRes{FrozenPhones: []string{}}
// 只审待审核图(重复审核/未标注图直接忽略)
images, err := dao.DatasetImage.GetByIds(ctx, req.ImageIds)
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(images))
for _, img := range images {
if img.ReviewStatus == consts.ReviewImagePending {
ids = append(ids, img.Id)
}
}
if len(ids) == 0 {
return res, nil
}
now := gtime.Now()
recordStatus := consts.AnnotateRecordRejected
if req.Approve {
recordStatus = consts.AnnotateRecordApproved
}
var phones []string
err = common.Serial().Submit(ctx, func() error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if req.Approve {
if err := dao.DatasetImage.SetReviewStatus(ctx, ids, consts.ReviewImageApproved); err != nil {
return err
}
} else {
// 拒绝:清标注(不变式 0 ⟹ 无框)回未标注池,可被再次领取
for _, id := range ids {
if err := dao.DatasetImage.UpdateLabelsAndReview(ctx, id, "", consts.ReviewImageNone); err != nil {
return err
}
}
}
if err := dao.AnnotateRecord.MarkReviewedByImages(ctx, ids, recordStatus, now); err != nil {
return err
}
phones, err = dao.AnnotateRecord.ListSubmittedPhonesByImages(ctx, ids)
return err
})
})
if err != nil {
return nil, err
}
res.Affected = int64(len(ids))
// 审核后逐用户重算通过比例(IO 次数 = 涉及用户数,审核批量 ≤ 每页图片数,可控)
for _, phone := range phones {
frozen, err := s.maybeFreeze(ctx, phone)
if err != nil {
g.Log().Errorf(ctx, "标注低质冻结判定失败(%s): %+v", phone, err)
continue
}
if frozen {
res.FrozenPhones = append(res.FrozenPhones, phone)
}
}
return res, nil
}
// maybeFreeze 通过比例过低自动冻结:已审核样本(stats_since 基线后)≥ FreezeMinReviewed 且
// approved/(approved+rejected) < FreezeRatio → frozen_until = now + FreezeHours、基线重置(解冻后重新累计)。
// 已到账时长不追溯扣回。返回是否触发冻结。
func (s *annotateService) maybeFreeze(ctx context.Context, phone string) (bool, error) {
lic, err := dao.License.GetByPhone(ctx, phone)
if err != nil {
return false, err
}
if lic == nil {
return false, nil
}
stats, err := dao.AnnotateRecord.CountUserStats(ctx, phone, lic.AnnotateStatsSince)
if err != nil {
return false, err
}
reviewed := stats.Approved + stats.Rejected
if reviewed < int64(annotateRewardConfig(ctx).FreezeMinReviewed) {
return false, nil // 样本不足不判(防小样本误冻)
}
cfg := annotateRewardConfig(ctx)
if float64(stats.Approved)/float64(reviewed) >= cfg.FreezeRatio {
return false, nil
}
frozenUntil := gtime.Now().Add(time.Duration(cfg.FreezeHours) * time.Hour)
if err := dao.License.UpdateAnnotateState(ctx, phone, frozenUntil, gtime.Now()); err != nil {
return false, err
}
g.Log().Infof(ctx, "标注低质冻结: %s 通过 %d/%d,冻结至 %s", phone, stats.Approved, reviewed, frozenUntil)
return true, nil
}
// AdminListRecords 用户标注记录分页(附图片文件/URL 与数据集名,内存组装)
func (s *annotateService) AdminListRecords(ctx context.Context, req *dto.AdminAnnotateRecordListReq) (*dto.AdminAnnotateRecordListRes, error) {
page, size := common.NormalizePage(req.Page, req.Size)
list, total, err := dao.AnnotateRecord.PageByFilter(ctx, req.Phone, req.TaskId, req.DatasetId, req.Status, page, size)
if err != nil {
return nil, err
}
res := &dto.AdminAnnotateRecordListRes{Total: total, List: make([]*dto.AdminAnnotateRecordItem, 0, len(list))}
if len(list) == 0 {
return res, nil
}
imageIds := make([]int64, 0, len(list))
for _, r := range list {
imageIds = append(imageIds, r.ImageId)
}
images, err := dao.DatasetImage.GetByIds(ctx, imageIds)
if err != nil {
return nil, err
}
imageMap := make(map[int64]*entity.DatasetImage, len(images))
datasetIds := make([]int64, 0)
for _, img := range images {
imageMap[img.Id] = img
datasetIds = append(datasetIds, img.DatasetId)
}
names := Training.datasetNameMap(ctx)
for _, r := range list {
item := &dto.AdminAnnotateRecordItem{
Id: r.Id,
PhoneNum: r.PhoneNum,
TaskId: r.TaskId,
ImageId: r.ImageId,
Status: r.Status,
CreatedAt: r.CreatedAt,
SubmittedAt: r.SubmittedAt,
ReviewedAt: r.ReviewedAt,
}
if img := imageMap[r.ImageId]; img != nil {
item.Filename = img.Filename
item.DatasetName = names[img.DatasetId]
item.ImageUrl = datasetImageUrl(ctx, img.DatasetId, img.Filename) // 管理端页面展示:admin token 鉴权
}
if r.LabelsJson != "" {
var boxes []*dto.AdminLabelBox
if json.Unmarshal([]byte(r.LabelsJson), &boxes) == nil {
item.Boxes = boxes
}
}
res.List = append(res.List, item)
}
return res, nil
}
// AdminUnfreeze 手动解冻:清冻结标记与统计基线(历史重新累计)
func (s *annotateService) AdminUnfreeze(ctx context.Context, req *dto.AdminAnnotateUnfreezeReq) (*dto.AdminAnnotateUnfreezeRes, error) {
lic, err := dao.License.GetByPhone(ctx, req.Phone)
if err != nil {
return nil, err
}
if lic == nil {
return nil, gerror.New("账号不存在")
}
if err := dao.License.UpdateAnnotateState(ctx, req.Phone, nil, nil); err != nil {
return nil, err
}
return &dto.AdminAnnotateUnfreezeRes{}, nil
}
// ---------- App ----------
// AppTaskList 可领任务列表 + 我的统计(冻结中仍展示,领取时拒绝)
func (s *annotateService) AppTaskList(ctx context.Context, req *dto.AnnotateTaskListReq) (*dto.AnnotateTaskListRes, error) {
phone := common.PhoneFromCtx(ctx)
tasks, err := dao.AnnotateTask.ListPublished(ctx)
if err != nil {
return nil, err
}
res := &dto.AnnotateTaskListRes{List: make([]*dto.AnnotateTaskItem, 0, len(tasks))}
stats, err := s.buildStats(ctx, phone)
if err != nil {
return nil, err
}
res.Stats = stats
if len(tasks) == 0 {
return res, nil
}
datasetIds := make([]int64, 0, len(tasks))
taskIds := make([]int64, 0, len(tasks))
for _, t := range tasks {
datasetIds = append(datasetIds, t.DatasetId)
taskIds = append(taskIds, t.Id)
}
datasets, err := dao.Dataset.GetByIds(ctx, datasetIds)
if err != nil {
return nil, err
}
datasetMap := make(map[int64]*entity.Dataset, len(datasets))
for _, d := range datasets {
datasetMap[d.Id] = d
}
names := Training.datasetNameMap(ctx)
poolCnt, err := dao.DatasetImage.CountPoolByTaskIds(ctx, taskIds)
if err != nil {
return nil, err
}
for _, t := range tasks {
item := &dto.AnnotateTaskItem{
Id: t.Id,
Name: t.Name,
DatasetId: t.DatasetId,
DatasetName: names[t.DatasetId],
PoolRemain: poolCnt[t.Id],
}
if d := datasetMap[t.DatasetId]; d != nil {
item.Species = annotateSpecies(d)
}
res.List = append(res.List, item)
}
return res, nil
}
// AppClaim 领取:惰性释放过期锁 → 任务占用池内挑未处理过且未被锁定的图 → Serial+事务插入 pending 记录
func (s *annotateService) AppClaim(ctx context.Context, req *dto.AnnotateClaimReq) (*dto.AnnotateClaimRes, error) {
phone := common.PhoneFromCtx(ctx)
cfg := annotateRewardConfig(ctx)
task, err := dao.AnnotateTask.GetById(ctx, req.TaskId)
if err != nil {
return nil, err
}
if task == nil {
return nil, gerror.New("标注任务不存在")
}
if task.Status != consts.AnnotateTaskPublished {
return nil, gerror.New("任务已停用")
}
lic, err := dao.License.GetByPhone(ctx, phone)
if err != nil {
return nil, err
}
if lic == nil {
return nil, gerror.New("账号不存在")
}
if annotateFrozen(lic) != nil {
return nil, gerror.Newf("标注资格已冻结(%s 解冻),暂不可领取任务", lic.AnnotateFrozenUntil)
}
dataset, err := dao.Dataset.GetById(ctx, task.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
res := &dto.AnnotateClaimRes{Images: []*dto.AnnotateClaimImage{}, Species: annotateSpecies(dataset)}
// 惰性释放:超时未提交的领取锁直接删除(未产生任何标注,非处理记录)
if err := dao.AnnotateRecord.DeleteExpiredPending(ctx, gtime.Now().Add(-time.Duration(cfg.ClaimTimeoutHour)*time.Hour)); err != nil {
return nil, err
}
// 候选挑选放在 Serial 临界区内(挑图 + 插锁全程串行):两个用户并发领取时,
// 后者读到的 pending 锁一定包含前者刚插入的,同一张图不会分给多人——
// partial unique index(image_id WHERE pending) 是跨实例兜底的最后一道防线
now := gtime.Now()
var picked []*entity.DatasetImage
err = common.Serial().Submit(ctx, func() error {
pool, err := dao.DatasetImage.ListPoolByTask(ctx, task.Id)
if err != nil {
return err
}
mine, err := dao.AnnotateRecord.ListImageIdsByPhone(ctx, phone)
if err != nil {
return err
}
locked, err := dao.AnnotateRecord.ListPendingImageIds(ctx)
if err != nil {
return err
}
exclude := make(map[int64]bool, len(mine)+len(locked))
for _, id := range mine {
exclude[id] = true
}
for _, id := range locked {
exclude[id] = true
}
picked = make([]*entity.DatasetImage, 0, cfg.ClaimSize)
for _, img := range pool {
if len(picked) >= cfg.ClaimSize {
break
}
if exclude[img.Id] {
continue
}
picked = append(picked, img)
}
if len(picked) == 0 {
return nil
}
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, img := range picked {
if err := dao.AnnotateRecord.InsertInTx(ctx, tx, &entity.AnnotateRecord{
PhoneNum: phone,
TaskId: task.Id,
DatasetId: task.DatasetId,
ImageId: img.Id,
Status: consts.AnnotateRecordPending,
CreatedAt: now,
}); err != nil {
return err
}
}
return nil
})
})
if err != nil {
return nil, err
}
if len(picked) == 0 {
return res, nil
}
dir := common.DatasetImagesDir(ctx, dataset.Name)
for _, img := range picked {
item := &dto.AnnotateClaimImage{
ImageId: img.Id,
Url: annotateImageUrl(ctx, dataset.Id, img.Filename),
}
if data, rErr := os.ReadFile(filepath.Join(dir, img.Filename)); rErr == nil {
item.Width, item.Height = imageSize(data)
}
res.Images = append(res.Images, item)
}
return res, nil
}
// AppSubmit 提交标注:写 labels_json + 待审核;每累计 RewardPerImages 张有效提交即时发时长
// expires_at = max(now, expires_at) + RewardMinutes 分钟级顺延;当日累计超上限不再发)
func (s *annotateService) AppSubmit(ctx context.Context, req *dto.AnnotateSubmitReq) (*dto.AnnotateSubmitRes, error) {
phone := common.PhoneFromCtx(ctx)
cfg := annotateRewardConfig(ctx)
for _, box := range req.Boxes {
if box.Cx < 0 || box.Cy < 0 || box.W <= 0 || box.H <= 0 || box.Cx > 1 || box.Cy > 1 {
return nil, gerror.New("标注框坐标非法(需 0~1 归一化)")
}
}
raw, err := json.Marshal(req.Boxes)
if err != nil {
return nil, gerror.Wrap(err, "标注序列化失败")
}
record, err := dao.AnnotateRecord.GetMyPending(ctx, phone, req.ImageId)
if err != nil {
return nil, err
}
if record == nil {
return nil, gerror.New("未领取该图片或已提交")
}
img, err := dao.DatasetImage.GetById(ctx, req.ImageId)
if err != nil {
return nil, err
}
if img == nil {
return nil, gerror.NewCode(common.CodeImageNotFound)
}
now := gtime.Now()
// Serial 内先提交(提交计数与发放判定串行化,防并发双发)
err = common.Serial().Submit(ctx, func() error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if err := dao.AnnotateRecord.MarkSubmitted(ctx, record.Id, string(raw), now); err != nil {
return err
}
// App 提交一律进待审核(空框=「画面无目标」的主张,同样待审核确认)
if err := dao.DatasetImage.UpdateLabelsAndReview(ctx, img.Id, string(raw), consts.ReviewImagePending); err != nil {
return err
}
// 奖励判定:累计提交数整跨 RewardPerImages → 尝试发放
stats, err := dao.AnnotateRecord.CountUserStats(ctx, phone, nil)
if err != nil {
return err
}
if stats.SubmittedTotal%int64(cfg.RewardPerImages) != 0 {
return nil
}
dayStart := gtime.New(now.Format("Y-m-d") + " 00:00:00")
todaySum, err := dao.RewardLog.SumMinutesByPhoneSince(ctx, phone, dayStart)
if err != nil {
return err
}
if todaySum+cfg.RewardMinutes > cfg.DailyCapMinutes {
g.Log().Infof(ctx, "标注奖励触达日上限不发: %s 今日已得 %d 分钟", phone, todaySum)
return nil
}
lic, err := dao.License.GetByPhoneInTx(ctx, tx, phone)
if err != nil {
return err
}
if lic == nil {
return gerror.New("账号不存在")
}
base := now
if lic.ExpiresAt != nil && lic.ExpiresAt.Timestamp() > now.Timestamp() {
base = lic.ExpiresAt
}
newExpires := base.Add(time.Duration(cfg.RewardMinutes) * time.Minute)
if err := dao.License.ExtendExpiresInTx(ctx, tx, phone, newExpires); err != nil {
return err
}
if err := dao.RewardLog.InsertInTx(ctx, tx, phone, cfg.RewardMinutes, record.TaskId, now); err != nil {
return err
}
common.ClearCache(ctx, "license:"+phone)
g.Log().Infof(ctx, "标注奖励发放: %s +%d 分钟,到期 %s", phone, cfg.RewardMinutes, newExpires)
return nil
})
})
if err != nil {
return nil, err
}
// 回程统计(展示口径,允许轻微滞后)
dayStart := gtime.New(now.Format("Y-m-d") + " 00:00:00")
todaySum, _ := dao.RewardLog.SumMinutesByPhoneSince(ctx, phone, dayStart)
lic, _ := dao.License.GetByPhone(ctx, phone)
stats, _ := dao.AnnotateRecord.CountUserStats(ctx, phone, nil)
var expiresAt *gtime.Time
if lic != nil {
expiresAt = lic.ExpiresAt
}
return &dto.AnnotateSubmitRes{
Granted: stats.SubmittedTotal > 0 && stats.SubmittedTotal%int64(cfg.RewardPerImages) == 0,
Minutes: cfg.RewardMinutes,
TodayEarnedMinutes: todaySum,
ExpiresAt: expiresAt,
ProgressDone: stats.SubmittedTotal % int64(cfg.RewardPerImages),
}, nil
}
// AppMe 我的标注统计
func (s *annotateService) AppMe(ctx context.Context, req *dto.AnnotateMeReq) (*dto.AnnotateMeRes, error) {
stats, err := s.buildStats(ctx, common.PhoneFromCtx(ctx))
if err != nil {
return nil, err
}
return &dto.AnnotateMeRes{Stats: stats}, nil
}
// buildStats 我的统计组装(任务列表/我的页/提交回程共用)
func (s *annotateService) buildStats(ctx context.Context, phone string) (*dto.AnnotateStats, error) {
cfg := annotateRewardConfig(ctx)
stats := &dto.AnnotateStats{
RewardPerImages: cfg.RewardPerImages,
RewardMinutes: cfg.RewardMinutes,
DailyCapMinutes: cfg.DailyCapMinutes,
ApproveRatio: 1, // 无样本不惩罚
ProgressDone: 0,
}
lic, err := dao.License.GetByPhone(ctx, phone)
if err != nil {
return nil, err
}
var since *gtime.Time
if lic != nil {
since = lic.AnnotateStatsSince
stats.FrozenUntil = annotateFrozen(lic)
stats.ExpiresAt = lic.ExpiresAt
}
rec, err := dao.AnnotateRecord.CountUserStats(ctx, phone, since)
if err != nil {
return nil, err
}
stats.SubmittedTotal = rec.SubmittedTotal
stats.Approved = rec.Approved
stats.Rejected = rec.Rejected
if reviewed := rec.Approved + rec.Rejected; reviewed > 0 {
stats.ApproveRatio = float64(rec.Approved) / float64(reviewed)
}
stats.ProgressDone = rec.SubmittedTotal % int64(cfg.RewardPerImages)
total, err := dao.RewardLog.SumMinutesByPhone(ctx, phone)
if err != nil {
return nil, err
}
stats.TotalEarnedMinutes = total
dayStart := gtime.New(gtime.Now().Format("Y-m-d") + " 00:00:00")
today, err := dao.RewardLog.SumMinutesByPhoneSince(ctx, phone, dayStart)
if err != nil {
return nil, err
}
stats.TodayEarnedMinutes = today
return stats, nil
}
+60 -59
View File
@@ -499,6 +499,9 @@ func (s *datasetService) CompressExistingCovers(ctx context.Context) error {
// AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录 +
// VLM 同步生成生成参数池(失败不阻断创建,poolsGenerated/poolError 反馈)。
func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminDatasetCreateReq) (*dto.AdminDatasetCreateRes, error) {
if req.Name == consts.NegativeDatasetName {
return nil, gerror.New("该名称为负样本库保留名,请更换数据集名称")
}
now := gtime.Now()
var id int64
err := common.Serial().Submit(ctx, func() error {
@@ -571,6 +574,47 @@ func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminD
return res, nil
}
// AdminNegativeLibrary 获取负样本库(技术设计.md「负样本库」):source=negative 的固定保留名
// 特殊数据集,不存在则 Serial 内懒创建(防并发重建)+ 建目录;返回 id 与图片数,
// 管理端「负样本」tab 复用既有上传/图片列表/删除接口(负样本无标注/审核/清洗/生成流程)
func (s *datasetService) AdminNegativeLibrary(ctx context.Context, req *dto.AdminDatasetNegativeReq) (*dto.AdminDatasetNegativeRes, error) {
now := gtime.Now()
var lib *entity.Dataset
err := common.Serial().Submit(ctx, func() error {
existing, err := dao.Dataset.GetByName(ctx, consts.NegativeDatasetName)
if err != nil {
return err
}
if existing != nil {
lib = existing
return nil
}
id, err := dao.Dataset.Insert(ctx, &entity.Dataset{
Name: consts.NegativeDatasetName,
Source: consts.DatasetSourceNegative,
Status: consts.DatasetStatusBuilding,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return err
}
lib, err = dao.Dataset.GetById(ctx, id)
return err
})
if err != nil {
return nil, err
}
if err := os.MkdirAll(common.DatasetImagesDir(ctx, lib.Name), 0o755); err != nil {
return nil, gerror.Wrap(err, "创建负样本库目录失败")
}
cnt, err := dao.DatasetImage.CountByDataset(ctx, lib.Id)
if err != nil {
return nil, err
}
return &dto.AdminDatasetNegativeRes{Id: lib.Id, Name: lib.Name, ImageCount: cnt}, nil
}
// ensureJpeg 模型生成的图片统一转 jpg:解码校验 + 非 jpegpng/webp 等)转 jpeg(Quality 92)。
// 生成服务返回格式不可控,扩展名统一 .jpg,内容须与扩展名一致(RF-DETR 按扩展名推断 mime 提交)。
func ensureJpeg(data []byte) ([]byte, error) {
@@ -819,6 +863,9 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD
if d == nil {
return gerror.NewCode(common.CodeDatasetNotFound)
}
if d.Source == consts.DatasetSourceNegative {
return gerror.New("负样本库不可删除整库(如需清理请逐张删除图片)")
}
name = d.Name
modelName = modelFileName(d.Name, d.NamePrefix)
// 预标注任务进行中(RF-DETR 正在扫该数据集图片)
@@ -904,13 +951,7 @@ func (s *datasetService) AdminUploadImages(ctx context.Context, req *dto.AdminDa
if len(saved) == 0 {
return res, nil
}
// 标注强语义:未配置标注服务时不允许产生无标注图(文件已落盘,失败则清掉)
if common.LocalAiClient(ctx) == nil {
for _, name := range saved {
_ = os.Remove(filepath.Join(dir, name))
}
return nil, gerror.NewCode(common.CodeLocalAiNotConfigured)
}
// 标注不再强制(2026-09-04 自动标注退场):未配置 localAi 也可上传,图片以未标注态入池
addedIds := make([]int64, 0, len(saved))
err = common.Serial().Submit(ctx, func() error {
for _, name := range saved {
@@ -943,30 +984,7 @@ func (s *datasetService) AdminUploadImages(ctx context.Context, req *dto.AdminDa
}
return nil, err
}
// 自动触发标注:忙(已有 running 任务)不报错,由任务完成后的自动补标轮兜底;其他失败回滚本次入库
newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds)
if err != nil {
return nil, err
}
if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil {
// 触发失败回滚本次入库(manual 上传文件非付费资产,可删)
rollbackIds := make([]int64, 0, len(newImages))
for _, img := range newImages {
rollbackIds = append(rollbackIds, img.Id)
if rErr := os.Remove(filepath.Join(dir, img.Filename)); rErr != nil {
g.Log().Warningf(ctx, "回滚删除图片文件失败: %s: %v", img.Filename, rErr)
}
}
if rErr := common.Serial().Submit(ctx, func() error {
if dErr := dao.DatasetImage.DeleteByIds(ctx, rollbackIds); dErr != nil {
return dErr
}
return dao.Dataset.UpdateCounters(ctx, dataset.Id, -int64(len(rollbackIds)), 0, "")
}); rErr != nil {
g.Log().Errorf(ctx, "自动标注触发失败后的入库回滚失败: %v", rErr)
}
return nil, err
}
// 上传不触发标注2026-09-04 自动标注退场):图片以未标注态入池(管理端预标/众包任务池)
return res, nil
}
@@ -1244,10 +1262,10 @@ func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.Admin
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
// 标注强语义:未配置标注服务时不允许开始生成(避免付费资产生成后无法标注)
if common.LocalAiClient(ctx) == nil {
return nil, gerror.NewCode(common.CodeLocalAiNotConfigured)
if dataset.Source == consts.DatasetSourceNegative {
return nil, gerror.New("负样本库不支持 AI 生成图片")
}
// 标注不再强制(2026-09-04 自动标注退场):未配置 localAi 也可生成,图以未标注态入池
dir := common.DatasetImagesDir(ctx, dataset.Name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, gerror.Wrap(err, "创建图片目录失败")
@@ -1386,7 +1404,6 @@ func (s *datasetService) runGenTask(taskId int64, dataset *entity.Dataset, req *
})
}
if failed != "" {
s.genTaskAutoLabel(bgCtx, dataset, addedIds)
// 部分失败:已入库图按付费资产保留,计数须计入(曾漏更新致 image_count 漂移为负)
if err := common.Serial().Submit(bgCtx, func() error {
return dao.Dataset.UpdateCounters(bgCtx, dataset.Id, int64(generated), -1, "")
@@ -1396,33 +1413,16 @@ func (s *datasetService) runGenTask(taskId int64, dataset *entity.Dataset, req *
_ = dao.GenTask.Finish(bgCtx, taskId, failed)
return
}
// 全部成功:更新数据集计数 → 自动标注 → 置 done
// 全部成功:更新数据集计数 → 置 done(图以未标注态入池,2026-09-04 自动标注退场)
if err := common.Serial().Submit(bgCtx, func() error {
return dao.Dataset.UpdateCounters(bgCtx, dataset.Id, int64(generated), -1, "")
}); err != nil {
g.Log().Errorf(bgCtx, "生成任务 %d 更新数据集计数失败: %+v", taskId, err)
}
s.genTaskAutoLabel(bgCtx, dataset, addedIds)
_ = dao.GenTask.Finish(bgCtx, taskId, "")
}()
}
// genTaskAutoLabel 生成任务完成后对本次新增图触发自动标注:忙(已有 running 任务)不报错,
// 由标注任务完成后的自动补标轮兜底;其他失败只记日志(付费资产,不可删)。
func (s *datasetService) genTaskAutoLabel(ctx context.Context, dataset *entity.Dataset, addedIds []int64) {
if len(addedIds) == 0 {
return
}
newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds)
if err != nil {
g.Log().Errorf(ctx, "生成任务后读取新增图失败: %+v", err)
return
}
if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil {
g.Log().Errorf(ctx, "生成任务后自动标注触发失败: %+v", err)
}
}
// AdminGenTaskQuery 生成任务进度(最近一次;无任务返回 nil)
func (s *datasetService) AdminGenTaskQuery(ctx context.Context, req *dto.AdminGenTaskQueryReq) (*dto.AdminGenTaskQueryRes, error) {
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
@@ -1467,12 +1467,13 @@ func (s *datasetService) AdminListImages(ctx context.Context, req *dto.AdminData
items := make([]*dto.AdminImageItem, 0, len(list))
for _, v := range list {
items = append(items, &dto.AdminImageItem{
Id: v.Id,
Filename: v.Filename,
Source: v.Source,
Url: datasetImageUrl(ctx, dataset.Id, v.Filename),
CleanExcluded: v.CleanExcluded,
CreatedAt: v.CreatedAt,
Id: v.Id,
Filename: v.Filename,
Source: v.Source,
Url: datasetImageUrl(ctx, dataset.Id, v.Filename),
CleanExcluded: v.CleanExcluded,
AnnotateTaskId: v.AnnotateTaskId,
CreatedAt: v.CreatedAt,
})
}
return &dto.AdminDatasetImagesRes{List: items}, nil
+67 -48
View File
@@ -87,6 +87,9 @@ func (s *labelTaskService) AdminStartLabelTask(ctx context.Context, req *dto.Adm
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
if dataset.Source == consts.DatasetSourceNegative {
return nil, gerror.New("负样本库图片无需标注(训练打包时以空标签作为背景参与)")
}
if common.LocalAiClient(ctx) == nil {
return nil, gerror.NewCode(common.CodeLocalAiNotConfigured)
}
@@ -158,46 +161,9 @@ func (s *labelTaskService) startDetection(ctx context.Context, dataset *entity.D
return taskId, nil
}
// AutoLabel 图片入库自动触发标注(上传/AI 生成成功后调用):只标本次新增图。
// localAi 未配置 → 报错(调用方拒绝/回滚,不允许产生无标注图);
// 已有 running 任务(忙)→ 不报错:本次跳过,由任务成功完成后的自动补标轮兜底
func (s *labelTaskService) AutoLabel(ctx context.Context, dataset *entity.Dataset, images []*entity.DatasetImage) error {
if len(images) == 0 {
return nil
}
if common.LocalAiClient(ctx) == nil {
return gerror.NewCode(common.CodeLocalAiNotConfigured)
}
names := make([]string, 0, len(images))
for _, img := range images {
names = append(names, img.Filename)
}
filenamesJSON := ""
if raw, jErr := json.Marshal(names); jErr == nil {
filenamesJSON = string(raw)
}
_, err := s.startDetection(ctx, dataset, images, filenamesJSON)
if err != nil && gerror.HasCode(err, common.CodeLabelTaskRunning) {
return nil // 忙:不报错
}
return err
}
// autoSupplement 自动补标:任务成功完成后,该数据集仍有从未扫描过的图(忙时入库等)则续一轮
// 只扫未扫描图;失败任务不续(防配置故障时无限重试)。
// 池语义 = labels 为 null/”(见 ListUnlabeledByDataset),已扫过的图(含空检出写 '[]')不入池——
// 空检出图若留池中,每轮完成续一轮只会轮到别的空图、轮完又轮回来(2026-09-02 兔子 3 张空检出
// 实测 {9}→{10,17}→{9}… 乒乓死循环,RNPHE_202 单张案例侥幸被上一版「排除本轮图」挡住);
// 空检出图重扫结果不变,确需重扫由人工触发(AdminStartLabelTask 指定文件名/全量)。
func (s *labelTaskService) autoSupplement(ctx context.Context, dataset *entity.Dataset) {
unlabeled, err := dao.DatasetImage.ListUnlabeledByDataset(ctx, dataset.Id)
if err != nil || len(unlabeled) == 0 {
return
}
if err := s.AutoLabel(ctx, dataset, unlabeled); err != nil {
g.Log().Infof(ctx, "自动补标未触发(数据集 %d: %v", dataset.Id, err)
}
}
// AutoLabel 已随自动标注退场删除(2026-09-04 用户定案):上传/生成入库不再触发标注,
// 标注唯一入口 = 管理端勾选图片「预标」(AdminStartLabelTask);
// 原 autoSupplement 自动补标机制一并删除(空检出乒乓问题随机制消亡)
// runDetection 预标注执行协程(生命周期任务):池内逐张检测,进度经 Serial 更新。
// 每张:读图 → 全图检测(等比缩放提交,坐标映射回原图归一化)→ 标注(conf≥confConfirmed 为 class 0)。
@@ -272,14 +238,19 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
_ = dao.LabelTask.Finish(bgCtx, taskId, failed)
return
}
// 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注,人工保存同走 UpdateLabels
// 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注)
// 有检出框 → 待审核(1),空检出 → 未标注(0)(无框可审,等人工画框)
err := common.Serial().Submit(bgCtx, func() error {
for i := range results {
raw, jErr := json.Marshal(results[i])
if jErr != nil {
return gerror.Wrap(jErr, "标注序列化失败")
}
if uErr := dao.DatasetImage.UpdateLabels(bgCtx, images[i].Id, string(raw)); uErr != nil {
status := consts.ReviewImageNone
if len(results[i]) > 0 {
status = consts.ReviewImagePending
}
if uErr := dao.DatasetImage.UpdateLabelsAndReview(bgCtx, images[i].Id, string(raw), status); uErr != nil {
return uErr
}
}
@@ -290,9 +261,6 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
return
}
_ = dao.LabelTask.Finish(bgCtx, taskId, "")
// 成功完成:自动补标该数据集从未扫描过的图(忙时入库的图等兜底),失败任务不续;
// 空检出 '[]' 图不入池(见 ListUnlabeledByDataset),每张图同链条最多被自动扫一次
s.autoSupplement(bgCtx, dataset)
}()
}
@@ -579,6 +547,7 @@ func (s *labelTaskService) buildWorkbenchItems(ctx context.Context, dataset *ent
item.Labeled = true
}
}
item.ReviewStatus = img.ReviewStatus
if data, rErr := os.ReadFile(filepath.Join(imgDir, img.Filename)); rErr == nil {
item.Width, item.Height = imageSize(data)
}
@@ -630,18 +599,20 @@ func (s *labelTaskService) AdminLabelSave(ctx context.Context, req *dto.AdminLab
return nil, gerror.New("标注框坐标非法(需 0~1 归一化)")
}
}
// 空框 = 清空标注
// 空框 = 清空标注;人工保存即审核定稿(有框→2 已审核,空框→0 未标注回池)
raw := ""
reviewStatus := consts.ReviewImageNone
if len(req.Boxes) > 0 {
b, err := json.Marshal(req.Boxes)
if err != nil {
return nil, gerror.Wrap(err, "标注序列化失败")
}
raw = string(b)
reviewStatus = consts.ReviewImageApproved
}
var labeled int64
if err := common.Serial().Submit(ctx, func() error {
if err := dao.DatasetImage.UpdateLabels(ctx, img.Id, raw); err != nil {
if err := dao.DatasetImage.UpdateLabelsAndReview(ctx, img.Id, raw, reviewStatus); err != nil {
return err
}
labeled, err = dao.DatasetImage.CountLabeledByDataset(ctx, dataset.Id)
@@ -677,8 +648,11 @@ func (s *labelTaskService) prepareYoloSet(ctx context.Context, dataset *entity.D
if img.CleanExcluded == 1 {
continue // 数据清洗排除图不进训练集(图片/标注保留,可恢复)
}
if img.ReviewStatus != consts.ReviewImageApproved {
continue // 训练集只收人工审核通过的图(标签质量闸门,2026-09-04 审核状态机)
}
if img.LabelsJson == "" || img.LabelsJson == "[]" {
continue // 空标注图剔除
continue // 空框(确认无目标)不进训练集:维持原负样本不入包行为
}
var boxes []*dto.AdminLabelBox
if json.Unmarshal([]byte(img.LabelsJson), &boxes) != nil || len(boxes) == 0 {
@@ -723,9 +697,54 @@ func (s *labelTaskService) prepareYoloSet(ctx context.Context, dataset *entity.D
}
addSplit("train", trainItems)
addSplit("val", valItems)
// 混入负样本库(技术设计.md「负样本库」):全部物种数据集共用一套统一背景样本,
// 库不存在/为空时行为与无负样本完全一致
if err := s.appendNegatives(ctx, pkg); err != nil {
return nil, err
}
return pkg, nil
}
// appendNegatives 训练包混入负样本库图片:空标签 txt = 背景图(ultralytics 自动按背景学习,
// 无需改训练脚本),文件名加 neg_ 前缀防与主数据集图重名覆盖;负样本无审核语义
// (不看 review_status/clean_excluded),同一 80/20 逻辑拆 train/valval 含负样本
// 可在训练指标中观测背景误检);label txt 写空内容。
func (s *labelTaskService) appendNegatives(ctx context.Context, pkg *common.YoloPackage) error {
lib, err := dao.Dataset.GetByName(ctx, consts.NegativeDatasetName)
if err != nil {
return err
}
if lib == nil {
return nil
}
images, err := dao.DatasetImage.ListByDataset(ctx, lib.Id)
if err != nil {
return err
}
if len(images) == 0 {
return nil
}
idx := make([]int, len(images))
for i := range idx {
idx[i] = i
}
randShuffle(idx)
nVal := len(idx) / 5
srcDir := common.DatasetImagesDir(ctx, lib.Name)
for i, j := range idx {
split := "train"
if i < nVal {
split = "val"
}
name := "neg_" + images[j].Filename
pkg.Files = append(pkg.Files,
common.YoloFile{Name: filepath.Join("images", split, name), ImagePath: filepath.Join(srcDir, images[j].Filename)},
common.YoloFile{Name: filepath.Join("labels", split, strings.TrimSuffix(name, filepath.Ext(name))+".txt"), Content: []byte{}},
)
}
return nil
}
// randShuffle Fisher-Yates 伪随机(固定种子,沿用原 prepare_yolo.py random.seed(42) 语义)
func randShuffle(n []int) {
state := uint32(42)
+3
View File
@@ -387,6 +387,9 @@ func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.Admin
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
if dataset.Source == consts.DatasetSourceNegative {
return nil, gerror.New("负样本库不能单独训练(打包时自动混入各数据集)")
}
variants, err := normalizeVariants(req.Variants, cfg)
if err != nil {
return nil, err
@@ -0,0 +1 @@
apk
+11
View File
@@ -110,6 +110,17 @@ labelTask:
# 预标注并发度(逐张调 RF-DETR,缺失或非法时回退默认值)
poolSize: 4
annotateReward:
# 标注众包时长激励(技术设计.md「App 标注众包与时长激励」;缺失/非法回退 consts 默认值)
claimSize: 10 # 每次领取图片数
claimTimeoutHour: 2 # 领取锁超时(pending 超时未提交视为放弃,领取时惰性释放)
rewardPerImages: 10 # 每累计提交 N 张发一次时长
rewardMinutes: 30 # 每次发放分钟数(license 到期时间分钟级顺延)
dailyCapMinutes: 120 # 每日发放上限(自然日)
freezeRatio: 0.8 # 通过比例(approved/(approved+rejected))低于该值触发冻结
freezeMinReviewed: 5 # 触发冻结判定的最小已审核样本数(防小样本误冻)
freezeHours: 24 # 冻结时长(license.annotate_frozen_until 时间戳对比自动解冻,基线重置后统计重新累计)
# 套餐定价(静态配置,改价 = 改本节点后重启服务;金额单位为分,展示名由 days 派生"N天"
plans:
- id: day
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)
common.BindController(group, controller.Order, controller.License, controller.ModelCatalog, controller.Annotate)
})
// 支付回调组:不做统一包装,按渠道应答格式直接返回(微信 SUCCESS/FAIL JSON、支付宝 success/failure 文本)
s.Group("/api/v1/payment", func(group *ghttp.RouterGroup) {
+13 -1
View File
@@ -407,8 +407,11 @@ 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(图重新进任务池);**训练集打包(prepareYoloSet)只收 review_status=2**(标签质量闸门,原「labels_json 非空即收」作废);管理端图片 tab 由三变四:未标注(0)/待审核(1)/已标注(2)/已清洗,另加「标注任务」「标注记录」两个管理面板 tab(同在详情页,无独立菜单,2026-09-07 用户定案)——任务面板=本数据集任务下发/进度/停用,记录面板=本数据集众包记录查询/解冻(两列表接口支持 `datasetId` 过滤;`annotate_record` 领取时冗余 `dataset_id` 列,单表过滤不跨表);labeled_count 与 dataset.status='labeled' 口径同步改为 review_status=2 计数(2 必然非空框,无歧义)
- **自动标注退场(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 入口
- **详情页布局(2026-09-02 卡片化改版后快照)**:图片以缩略图卡片网格展示(每页 18 张,点卡片进弹窗编辑器);页顶标注/AI 生成任务进度条;**图片列表三 tab(2026-09-02)**:「未标注」(默认,待处理优先)、「已标注」、**「已清洗」**各带计数——已标注判定 = labels_json 非空(空检出 `[]` 的图仍在未标注,等人工画框);**已清洗 tab = clean_excluded=1 的图(数据清洗执行后从已标注 tab 移入,卡片角标同 tab 语义二选一),内提供逐张/批量恢复(= 清除标记,回「已标注」)**,恢复仅动作无删除;「数据清洗」按钮在已标注 tab 工具条(分析/执行入口,见下节);切 tab/翻页均清空勾选(防跨 tab 误标),编辑器「上一张/下一张」仅在当前 tab 列表内导航(连续标注不串到另一分类);**卡片上不再叠「已标注/未标注」角标**(2026-09-02,分类由 tab 表达),仅保留 AI 来源角标(付费资产提示)
- **详情页布局(2026-09-02 卡片化改版后快照)**:图片以缩略图卡片网格展示(每页 18 张,点卡片进弹窗编辑器);页顶标注/AI 生成任务进度条;**图片列表三 tab(2026-09-02)**:「未标注」(默认,待处理优先)、「已标注」、**「已清洗」**各带计数——已标注判定 = labels_json 非空(空检出 `[]` 的图仍在未标注,等人工画框);**未标注 tab 只显示未下发的图(annotate_task_id=02026-09-07 众包图片粒度下发,已下发图从 tab 消失,停用任务释放后回来)**;**已清洗 tab = clean_excluded=1 的图(数据清洗执行后从已标注 tab 移入,卡片角标同 tab 语义二选一),内提供逐张/批量恢复(= 清除标记,回「已标注」)**,恢复仅动作无删除;「数据清洗」按钮在已标注 tab 工具条(分析/执行入口,见下节);切 tab/翻页均清空勾选(防跨 tab 误标),编辑器「上一张/下一张」仅在当前 tab 列表内导航(连续标注不串到另一分类);**卡片上不再叠「已标注/未标注」角标**(2026-09-02,分类由 tab 表达),仅保留 AI 来源角标(付费资产提示)
- **弹窗标注**:点击原图/标注图打开放大弹窗(大 canvas,1152x2048 原尺寸),支持两点画框/点框删除/清空/类别切换(确认 0/疑似 1)/上下一张/保存——**AI 自动标注与人工框同层可编辑**(含清理 AI 框);保存即整体覆写 `dataset_image.labels_json`JSON 数组,YOLO 归一化 xywh+类别+置信度,空=清空标注);未保存修改切换图片有确认
- 无历史标注任务时详情页工作台数据源为 `GET /admin/label-workbench?dataset_id=`(图片 + 全量标注框),避免依赖人工先跑一次任务
- 训练前自动整理(prepare_yolo 逻辑服务端,**内存组装不落本地盘**):数据集有 labels → 按 80/20 拆 train/val 生成 YOLO 训练集包(标注 txt 内存生成、原图只记数据集目录源路径)→ 随同步推训练机(subprocess 直写 `workdir/datasetDir/yolo/<name>/`ssh 走 **tar 流式管道**本地不落盘);**自动标注图直接进训练集**(人工修改/清理后即为训练数据),**2026-09-02 起跳过 `clean_excluded=1` 的图(数据清洗排除,见下)**;本地无暂存目录(旧 `workspace/datasets/yolo/``labels/` 已删除)
@@ -422,6 +425,15 @@ training:
- **执行语义(排除而非删除)**:候选清单人工过目后打 `dataset_image.clean_excluded=1`EnsureColumn 迁移)——**只影响训练集打包**(prepare_yolo 跳过),图片/标注不动、列表/标注/预标全流程照常可见;**可恢复**(清除标记即回训练集,付费资产原则:清洗默认不删文件,物理删除仍走人工逐删)
- 接口:`POST /admin/datasets/clean/preview`datasetId + 可选 quotas → 各档分布/配额/超配候选清单 + 已排除列表);`POST /admin/datasets/clean/apply`imageIds + exclude bool 批量置/清标记);**管理端详情页承载:「数据清洗」按钮在已标注 tab 工具条**(配额表可编辑 + 候选清单默认全选 → 执行排除),**已排除的图集中到「已清洗」tab 展示,逐张/批量恢复(apply exclude=false**,图片列表接口返回 cleanExcluded 供前端归类
### 负样本库(训练背景样本统一管理,2026-09-07)
- **背景**:训练集为合成图(每张必含目标),模型从未学过「这不是目标」,端侧实拍把非目标动物/纹理误识别为动物。根治 = 训练时混入**负样本**(无任何标注框的背景图,ultralytics 对空标签图自动按背景学习,无需改训练脚本),负样本对全部物种数据集**统一一套**(误报源与物种无关:猫狗/树桩/草丛/真实场景背景通用)。
- **实现决策:不建新表**——负样本库是一个特殊数据集:`dataset.source='negative'`、固定保留名 `__negative__`consts.NegativeDatasetName,目录名同名;AdminCreateDataset 拒绝用户使用该保留名),完全复用 `dataset`/`dataset_image` 表与上传/删除/图片网格能力。首次进入管理端「负样本」tab 时 `POST /admin/datasets/negative` 懒创建(Serial 内防并发重建)。
- **管理端「数据训练」页双 tab(2026-09-07 用户定案)**:「数据集」tab = 现有数据集卡片列表(列表接口排除负样本库),「负样本」tab = 负样本库图片网格(复用既有上传/删除接口,按负样本库 datasetId 调用;无标注/审核/清洗/预标/生成/众包流程)。
- **训练打包混入(prepareYoloSet)**:打包主数据集后查负样本库,存在且有图则全部图片(不看 review_status/clean_excluded——负样本库无审核语义)加入训练包:文件名统一加 `neg_` 前缀(防与主数据集图重名覆盖),label txt 写空内容(= 背景图),随主数据集按同一 80/20 逻辑拆 train/val(val 含负样本可观测背景误检率);`data.yaml` 类别不变(负样本不引入新类)。无负样本库/库为空 → 行为与原来完全一致。
- **入口防护**:预标注(AdminStartLabelTask)、AI 生成(AdminGenerateImages)、众包下发(AdminCreateTask)、发起训练(AdminStartTraining)对 `source='negative'` 数据集一律拒绝(负样本库单独训练无意义、其图片不需要标注)。
- **删除防护**AdminDeleteDataset 拒绝删除负样本库(防误删整库);单张图片删除照常可用。
### 模型热更新与多模型推理(客户端 Flutter)
- `GET /api/v1/app/update` 扩展:响应追加 `models` 数组(`GET /api/v1/models` 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl)——**多模型目录,不再有单一 modelUrl**;无发布模型时不返回该字段(旧 App 忽略、新 App 兼容旧服务器)