diff --git a/server/README.md b/server/README.md index 80502d2..a150dcc 100644 --- a/server/README.md +++ b/server/README.md @@ -214,7 +214,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow | 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 还有「下发标注任务」按钮),含数据集名/池余量/各状态记录数/状态 | +| GET | `/admin/annotate-tasks` | 标注任务列表:`datasetId` 可选过滤(**无独立菜单——「标注任务」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 同样内嵌详情页**) | diff --git a/server/data/observer.db b/server/data/observer.db index 61ad51c..b244138 100644 Binary files a/server/data/observer.db and b/server/data/observer.db differ diff --git a/server/技术设计.md b/server/技术设计.md index 1b22986..f2b8484 100644 --- a/server/技术设计.md +++ b/server/技术设计.md @@ -407,7 +407,7 @@ training: - `localAi` 配置节点:键与取值以 config.yml 为准,文档不复制具体数值(曾多次调整阈值致双源漂移,2026-09-02 起删除此类描述)——**部署前提:RF-DETR 服务须从 Go 服务器可达**(现跑在 Mac 上,部署时搬服务器/训练机;**未配置时图片上传/生成接口直接报错**——标注是强语义,不允许产生无标注图片;配置调不通则任务 failed,页顶错误条展示,不自动重试);**端点为配置唯一来源,无运行时覆盖** - **预标注自动触发(2026-08-26 决策,无手动按钮)**:手动上传/AI 生成图片入库成功后,自动对**本次新增图**发起标注;**`localAi` 未配置 → 上传/生成接口直接报错**(同步检查,生成场景在第一张生成前检查避免付费资产生成后标不了);**已有 running 标注任务(忙)→ 不报错**:上传/生成照常成功,新图由「任务成功完成后自动补标未标注图」机制兜底(每轮任务成功结束时检查该数据集**从未扫描过**的图(labels 为 null/''),有则自动续一轮——失败任务不续,防配置坏时无限重试;**2026-09-02 空检出图不入自动补标池**:检出为空的图写 `labels_json='[]'` 后仍被视作未标注(管理端未标注 tab 照常展示等人工画框),但不入自动池——空检出图若留池,每轮自动续一轮只会扫到别的空图、扫完又轮回来:RNPHE_202(阈值调高后 0 检出,低阈下 12 个候选全为噪声,无一过丢弃线)单张被上一版「排除本轮已扫图」恰好挡住未暴露,2026-09-02 兔子批量 3 张空检出实测 {9}→{10,17}→{9}… 无限交替、每轮白打一次 RF-DETR 方暴露;现自动链条只扫从未扫描的图,每张图至多被自动扫一次,空检出图确需重扫由人工触发(按文件名批量/全量任务,见下));service 逐张调 local-ai 推理(common 池并行,`label_task` 记录 total/done 进度)→ 扫描结果(YOLO 归一化 xywh + 置信度 + 建议类别)**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**;**置信度分档(2026-09-02 起按两档语义调定,具体数值以 config.yml 为准不在此复制)**:conf ≥ confConfirmed 落 class 0 真目标,threshold ≤ conf < confConfirmed 落 class 1 疑似待人工复核,低于 threshold 不入库;背景(RNPHE_257 实测):COCO 通用 rfdetr 对类鸟纹理误检置信度可达 0.46~0.5 且与真目标仅差 ~0.1(真目标 0.562、两误检 0.477/0.462 曾全落 class 0),低置信段是纯噪声候选(该图 21 个原始检出中 17 个落此区间);单阈值无法分离真/假目标(类别名也无用——全部输出 "bird");误检落 class 0 会作为雉鸡正样本直接污染训练集,危害大于真目标降级疑似(后者人工一键确认即可);**重叠去重(2026-08-26 修订)**:NMS 风格、按置信度降序依次保留,与已保留框**重叠比(minIoU = 交叠面积/两框较小面积)> `localAi.overlapThreshold`**的框剔除(同目标被重复检出只留置信度最高者,跨 class 去重;人工画框不参与去重)——**用 minIoU 而非 IoU**:RF-DETR 对同一目标常输出一大一小两个框(标准 IoU 仅 0.3~0.5 会漏杀,实测红黄重叠即此形态),大框套小框时小框被覆盖比例高,minIoU 命中;相邻目标两框互有外露,minIoU 通常 < 0.3;**全图扫描**(项目既定规则:不套用生成规格的位置裁剪,候选宁多勿漏);**单图框数上限(2026-09-02,定义于 consts.go `consts.MaxPrelabelPerImage`)**:去重后仍超上限按置信度取前 N(曾按生成声明数 animal_count 裁剪致漏目标——声明 1 个不代表图内只有 1 个;手动上传图同限);不设候选确认两阶段(2026-08-26 撤销 candidates_json);`POST /admin/label-tasks` 接口保留(服务端 `filenames` 参数支持指定图批量重标,2026-09-02 前端入口收敛后仍可全量——不传 filenames 即全量扫描) - **预标注四级漏斗(2026-09-04,树栖藏匿难例)**:树冠藏匿生成图(树栖物种目标仅占画面高 ~2%)在全图扫描下不可检——模型包固定 700x700 stretch 输入(见「预标注模型固定 700x700」,不支持动态分辨率,提高提交分辨率无增益),占图高 2% 的目标等比缩入后仅 ~15px 落在检出盲区。单张处理升级为逐级漏斗,**检测只信 RF-DETR,VLM 永不直接出框(只提议候选区)**:①**全图扫描**(现有 `Detect`,`localAi.threshold`)有候选即收(绝大多数图到此命中,不触发后级);②空检出 → **切片扫描**:`common.TileRegions` 滑窗切块(块长边/重叠比 = `localAi.tileSize`/`tileOverlap`,边缘块贴边收口保证全覆盖)逐块 `DetectRegion` 按低阈值(`localAi.tileThreshold`,小目标置信度天然偏低,量级取 RF-DETR-S 实测经验;低于全图丢弃线的候选仍入库落疑似 class 1,宁多勿漏)检测,块内坐标在 DetectRegion 内映射回原图像素,跨块重复检出由既有 minIoU 去重兜住;③仍空 → **VLM 提议候选区**:qwen3-vl-8b 按「宁可指错不可遗漏」提示词输出 ≤`consts.VlmLocateMaxRegions` 个归一化候选区(与手动藏匿位补检不同:此阶段图内无已确认框,无排除集;物种注入同 vlm-review——`gen_species` 空回退数据集名),逐区扩大 `consts.VlmRegionExpand` 倍(VLM 坐标偏粗,扩大给 RF-DETR 足够上下文,钳制图片边界)后 `DetectRegion` 精修,精修命中的框才采纳、未命中丢弃(VLM 误报被 RF-DETR 否掉);**VLM 失败/超时/输出非法一律跳过该级不阻塞任务**(三模型共存 + local-ai 服务端排队串行,无需显存互斥检查,2026-09-02 已放开);④全空 → 写 `'[]'`(语义不变:未标注 tab 展示等人工画框、不入自动补标池防乒乓)。漏斗各级候选同通道处理:conf ≥ confConfirmed 落 class 0、否则 class 1、minIoU 去重、单图上限 `consts.MaxPrelabelPerImage`,全图扫描不套生成规格的项目规则不变。单图超时 120s → `consts.LabelImageTimeoutSec`(切片 ~20 块串行 + VLM 兜底;切片在单图池任务内串行执行,不嵌套协程池) -- **标注审核状态机(2026-09-04)**:`dataset_image.review_status`(EnsureColumn 幂等补列):`0=未标注, 1=待审核, 2=已审核`;存量迁移——labels_json 非空数组→2(既有标注视为已审定稿)、null/''/'[]'→0。流转:预标注完成→1(**空检出保持 0**——无框可审,「确认无动物」应由人工画框/清洗表达,避免空图混入已审核口径)、App 用户提交→1、管理端工作台保存(AdminLabelSave)→2、审核「通过」→2、「拒绝」→ 清 labels_json + 回 0(图重新进任务池);**训练集打包(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)**:`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 用户定案)——**任务面板=任务图集网格**(任务即勾选下发的具体图片,选几张展示几张,每张角标=任务 id+状态;头部仅任务概要条与停用;下发入口在未标注 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 入口 diff --git a/server_admin/src/views/DatasetDetail.vue b/server_admin/src/views/DatasetDetail.vue index 9c7c96d..c68c8b6 100644 --- a/server_admin/src/views/DatasetDetail.vue +++ b/server_admin/src/views/DatasetDetail.vue @@ -429,6 +429,26 @@ const anStatusOptions = [ const anTagType = { pending: 'info', submitted: 'warning', approved: 'success', rejected: 'danger' } const anTagText = { pending: '领取中', submitted: '待审核', approved: '已通过', rejected: '已拒绝' } +// 任务图集(2026-09-07 图片粒度下发):任务 = 勾选的具体图片,选几张任务里就展示几张—— +// 面板直接网格展示被占用(annotateTaskId>0)的图片,每张角标 = 任务 id + 当前状态; +// 不再以任务记录表呈现。停用释放的图(annotateTaskId 清 0)自动移出网格 +const taskImages = computed(() => (detail.value?.images || []).filter((it) => meta(it).annotateTaskId > 0)) +const taskChips = computed(() => { + const occupied = new Set(taskImages.value.map((it) => meta(it).annotateTaskId)) + return anTasks.value.filter((t) => t.status === 'published' || occupied.has(t.id)) +}) +const taskImgTag = (it) => { + const rs = reviewStatusOf(it) + if (rs === 1) return { text: '待审核', type: 'warning' } + if (rs === 2) return { text: '已通过', type: 'success' } + return { text: '待标注', type: 'info' } +} + +// 刷新 = 任务概要 + 图片占用一起刷新(停用释放/下发后网格即时更新) +async function refreshTasks() { + await Promise.all([loadAnnotateTasks(), loadAll()]) +} + async function loadAnnotateTasks() { anLoading.value = true try { @@ -444,17 +464,17 @@ async function loadAnnotateTasks() { async function stopAnnotateTask(row) { try { - await ElMessageBox.confirm(`停用任务「${row.name}」?停用后 App 端不可再领取。`, '停用任务', { - type: 'warning', - confirmButtonText: '停用', - cancelButtonText: '取消', - }) + await ElMessageBox.confirm( + `停用任务「${row.name}」?停用后 App 端不可再领取,未被领取的图片释放回未标注。`, + '停用任务', + { type: 'warning', confirmButtonText: '停用', cancelButtonText: '取消' }, + ) } catch { return } await request.post('/annotate-tasks/stop', { id: row.id }) - ElMessage.success('已停用') - loadAnnotateTasks() + ElMessage.success('已停用,未被领取的图已释放回未标注') + await refreshTasks() } async function loadAnnotateRecords() { @@ -1253,43 +1273,37 @@ onBeforeUnmount(() => { - +
- 下发入口:在「未标注」tab 勾选图片后点顶栏「下发标注任务」 - 刷新 -
- - - - - - - - - - + 暂无任务。下发入口:在「未标注」tab 勾选图片后点顶栏「下发标注任务」 + 刷新
+
+
+
+ + #{{ meta(item).annotateTaskId }} + {{ taskImgTag(item).text }} +
+
{{ item.filename }}
+
+
+ @@ -1608,6 +1622,32 @@ onBeforeUnmount(() => { font-size: 12px; color: #909399; } +.task-chip { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid #ebeef5; + border-radius: 6px; + font-size: 12px; +} +.task-chip-name { + font-weight: 600; + white-space: nowrap; +} +.task-chip-stat { + color: #909399; + white-space: nowrap; +} +.task-refresh { + margin-left: auto; +} +.card-status { + position: absolute; + left: 6px; + bottom: 6px; + z-index: 2; +} .label-progress { display: flex; align-items: center;