Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a97655646 | ||
|
|
cc42045734 | ||
|
|
9e59cc4462 | ||
|
|
ba58f9e534 | ||
|
|
9b34426c9f | ||
|
|
5c8c0f6ad7 | ||
|
|
99263f89e5 | ||
|
|
eda9909ec8 | ||
|
|
4b56d0b87b | ||
|
|
c590f74b1e | ||
|
|
87ab06221c | ||
|
|
d8853ee2ab | ||
|
|
8c2c9ad77d | ||
|
|
e519e1d71d | ||
|
|
daf47ca385 | ||
|
|
2e31f33d0c | ||
|
|
3731583eb1 | ||
|
|
06c7554e08 | ||
|
|
f39045a9da | ||
|
|
a23afb25a6 | ||
|
|
c58f69cbe1 | ||
|
|
f4fe079703 | ||
|
|
b8dd969cb1 | ||
|
|
47a06af0d4 | ||
|
|
94aeb828ab | ||
|
|
8299432b20 | ||
|
|
dc8d783410 | ||
|
|
c3e800a630 | ||
|
|
ab527bcf1e | ||
|
|
99b1dd699d | ||
|
|
95863de113 | ||
|
|
58211aeb08 | ||
|
|
5883045583 | ||
|
|
30ff5b830e | ||
|
|
acd2a8f412 | ||
|
|
79b025e3e3 | ||
|
|
434cf85182 | ||
|
|
b3c43ef0fe | ||
|
|
8f5810f0d4 | ||
|
|
25846b133a | ||
|
|
19f16aaed3 | ||
|
|
17e5b01773 | ||
|
|
77d3aad6fc | ||
|
|
7bfafc7be3 | ||
|
|
71d042906c | ||
|
|
6f8c54c31d | ||
|
|
bb75c69d91 | ||
|
|
3597eb264c | ||
|
|
9dd90779c1 |
@@ -7,5 +7,19 @@
|
||||
.idea/
|
||||
flutter_app/android/build/
|
||||
|
||||
# 运行时数据集(图片量大,不进 git;trainings 模型产物保留跟踪,见 server/workspace/trainings)
|
||||
server/workspace/datasets/
|
||||
# 运行时 workspace(数据集图片/训练模型产物/APK 更新包,全部不进 git)
|
||||
server/admin_dist/
|
||||
/server/workspace/*
|
||||
|
||||
# 运行时缓存与本地实例标识
|
||||
server/cache/
|
||||
.local_user_id
|
||||
|
||||
# LocalAI 目录骨架(local-ai 以仓库目录为 cwd 启动时自建,非项目代码)
|
||||
/data/
|
||||
/models/
|
||||
/backends/
|
||||
/configuration/
|
||||
/server/models/
|
||||
/server/backends/
|
||||
/server/configuration/
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# 视野 · 生态摄影辅助与自然教育工具
|
||||
|
||||
**产品资料(投资人口径)**
|
||||
|
||||
---
|
||||
|
||||
## 一、项目概览
|
||||
|
||||
**「视野」是一款面向生态摄影与自然教育人群的端侧野生动物实时识别工具 App,以及支撑它的全套 AI 数据与模型生产线;同时为野猪防控、护农等致害动物治理场景提供物种辅助识别能力。**
|
||||
|
||||
- **定位口径**:生态摄影辅助 + 自然教育工具——帮助观鸟人、生态摄影师、自然研学人群"看到即认识、拍到即标注";在致害动物治理场景中,定位为物种辅助识别与科普工具,不提供任何猎捕辅助功能
|
||||
- **端侧实时识别**:手机取景框内实时框选目标、标注物种,无需网络,深山野外可用
|
||||
- **订阅 + 广告双变现**:按天/周/月购买使用时长,免费用户看广告也能赚时长;App 端已预留微信/支付宝支付入口,支付链路开发推进中,种子用户经运营发放授权持续使用中
|
||||
- **自建 AI 数据生产线**:AI 文生图造数据 + AI 预标注 + 人工审核,把野外采集与人工标注的成本降低一个数量级
|
||||
- **模型热更新**:模型迭代不需要重新发版,App 自动下载最新模型,识别能力持续进化
|
||||
- **数据飞轮**:用户通过众包标注换取使用时长,越用越准、越准越多人用
|
||||
|
||||
产品已于 2026 年 8 月上线,完成从数据生产、模型训练、支付变现到版本运营的**全链路闭环**,目前处于种子用户运营阶段。
|
||||
|
||||
---
|
||||
|
||||
## 二、市场与痛点
|
||||
|
||||
### 目标场景
|
||||
|
||||
野生动物实时识别是一项横跨多个场景的刚性需求:
|
||||
|
||||
- **生态摄影与自然教育**:观鸟、生态摄影、徒步、自然研学人群对"看到即认识、拍到即标注"的强需求
|
||||
- **野猪防控与护农的物种辅助识别**:在野猪等陆生野生动物危害防控、农区致害动物治理场景中,提供物种辅助识别与科普能力——该方向有政策背书(国家林草局等 15 部门联合部署野猪等陆生野生动物危害防控工作),辅助研判、减少误伤,定位为治理辅助工具而非猎捕工具
|
||||
- **野生动物监测与科研辅助**:红外相机影像筛查、野外调查记录等重复性识别工作
|
||||
|
||||
### 行业痛点
|
||||
|
||||
| 痛点 | 现状 |
|
||||
|---|---|
|
||||
| 识别靠经验 | 野外物种辨识门槛高,新手辨不清、认不准,学习成本大 |
|
||||
| 现有免费工具做不到「视频流 + 框选 + 离线 + 全品类」 | 免费识别工具多为"拍照-上传-出结果"的单张模式:没有实时视频流框选,依赖联网,物种覆盖窄;四项能力同时做到的产品在免费市场是空白 |
|
||||
| 小目标识别是硬骨头 | 远距离、草丛遮挡、林缘一瞥的目标,通用识别模型漏检率高 |
|
||||
| 长尾场景数据稀缺 | 野外真实采集数据成本极高,稀有场景(雪地、夜行、远距、遮挡)样本难凑齐 |
|
||||
| 专业设备贵 | 传统野外监测设备与定制识别方案价格高,难以覆盖个人用户 |
|
||||
|
||||
### 我们的判断
|
||||
|
||||
野生动物识别的本质难点不在模型结构,而在**数据**:目标小、遮挡多、场景长尾。谁能用低成本持续生产高质量训练数据,谁就拥有这个品类的定价权。
|
||||
|
||||
---
|
||||
|
||||
## 三、产品方案
|
||||
|
||||
### 3.1 App:打开相机即识别
|
||||
|
||||
- **实时取景识别**:摄像头画面逐帧推理,目标实时框选 + 物种名标注,识别到目标震动与提示音提醒,解放双眼
|
||||
- **离线可用**:模型全部跑在手机端(TFLite 量化模型,数 MB 级),无信号山区照常用
|
||||
- **双档位模型**:「高识别档」追求难目标不漏检,「高性能档」照顾低配手机流畅度,用户一键切换
|
||||
- **多模型并行**:支持同时挂载多个物种模型并行推理、结果合并,一个 App 覆盖多物种识别
|
||||
- **模型管理页**:用户自由下载、删除、启用模型,内置兜底模型,新物种即插即用
|
||||
|
||||
### 3.2 变现:时长 = 统一价值单位,订阅与广告双引擎
|
||||
|
||||
- 三档套餐:**日卡 ¥10 / 周卡 ¥56 / 月卡 ¥180**,微信、支付宝支付入口已在 App 端预留,支付链路开发推进中
|
||||
- **看广告赚时长(开发中)**:免费用户观看激励视频广告即可获取使用时长——用户体验零门槛,平台获得广告分成收入
|
||||
- 授权以服务端为准,识别入口强制校验,到期出付费墙,充值或看广告即恢复
|
||||
- 授权绑定手机号账号,多设备共享,时长自动顺延叠加
|
||||
|
||||
### 3.3 增长机制:标注众包赚时长
|
||||
|
||||
**用户给 AI 打工,时长当工资**——这是产品的原创设计:
|
||||
|
||||
- 管理端下发标注任务,用户在手机上画框提交,每有效提交 10 张**即时到账 30 分钟**使用时长,每日上限 2 小时
|
||||
- 质量约束内置:审核通过率低于 80% 自动冻结资格 24 小时,人工审核兜底
|
||||
- 对用户:免费用时长;对平台:**标注成本转化为用户留存**,提交记录、审核状态全程可追溯
|
||||
- **假目标一键上报(规划中)**:识别误报时用户点一下「假目标」,该目标即作为难负样本回流服务器——真实场景的误报正是模型最稀缺的训练素材,与 AI 生成数据管线互补,形成"越用越准"的部署反馈闭环
|
||||
|
||||
---
|
||||
|
||||
## 四、核心技术壁垒
|
||||
|
||||
### 4.1 自建 AI 数据生产线(核心护城河)
|
||||
|
||||
从"创建数据集"到"可训练数据打包",全程管理端点按钮完成:
|
||||
|
||||
```
|
||||
创建数据集 → VLM 自动生成提示词参数池 → AI 文生图批量造图
|
||||
→ RF-DETR 四级漏斗预标注 → 人工审核闸门 → 智能数据清洗 → 打包训练
|
||||
```
|
||||
|
||||
- **AI 文生图造数据**:按物种自动生成场景、动作、遮挡、距离、性别配比的训练图,单张约 40 秒,可批量上千张;长尾场景(雪地、密林、远距小目标)不再依赖野外蹲守拍摄
|
||||
- **预标注四级漏斗**:全图扫描 → 空检自动升级切片扫描 → VLM 推理藏匿位提议候选 → 精修复核,专治"小目标+遮挡"漏检;AI 预标 + 人工审核,标注效率数量级提升
|
||||
- **数据清洗**:按目标尺寸分档统计配额,超配桶内整图感知哈希去重、优先保留稀缺小目标样本,自动生成清洗候选清单
|
||||
- **负样本库**:自动生成无目标场景图并以 AI 检测质检(有误检即剔除),压低野外误报率
|
||||
|
||||
### 4.2 一键训练流水线
|
||||
|
||||
- 管理端一键发起,双档位并行训练,GPU 独占排队,训练进度、日志、指标逐轮可视
|
||||
- 训练产物自动自检(模型输入输出校验),失败即报、不带病发布
|
||||
- 训练完成一键发布,版本、校验和、指标自动登记
|
||||
|
||||
### 4.3 模型热更新:识别能力绕过应用商店进化
|
||||
|
||||
- App 启动自动检查新模型,下载、校验、原子替换,失败自动回退旧模型
|
||||
- **模型迭代周期以"天"计**,无需重新打包 APK、无需应用商店审核
|
||||
- 新物种模型上线 = 后台上传发布,全量用户次日即用
|
||||
|
||||
### 4.4 端侧工程能力
|
||||
|
||||
- 自研原生相机通道,竖屏帧处理,中低端安卓机流畅实时推理
|
||||
- 多模型并行推理 + 跨模型结果合并,准确率与流畅度可按机型自适配
|
||||
|
||||
---
|
||||
|
||||
## 五、商业模式与数据飞轮
|
||||
|
||||
### 收入模型:双引擎,免费用户也产生收入
|
||||
|
||||
**引擎一:订阅现金流**
|
||||
|
||||
时间授权订阅,现金流直接、无账期:
|
||||
|
||||
| 套餐 | 价格 | 定位 |
|
||||
|---|---|---|
|
||||
| 日卡 | ¥10 | 尝鲜/单次出行 |
|
||||
| 周卡 | ¥56 | 短期高频使用 |
|
||||
| 月卡 | ¥180 | 核心用户订阅 |
|
||||
|
||||
**引擎二:广告分成收入**
|
||||
|
||||
- 免费用户观看激励视频广告换取使用时长,平台按展示获得广告分成
|
||||
- 广告发放的"时长"是边际成本近乎为零的虚拟权益,却带来真实现金流——**免费用户规模越大,广告收入越高**
|
||||
- 广告时长与付费订阅共享同一套授权体系,形成"免费体验 → 广告换时长 → 付费订阅"的自然升级漏斗
|
||||
|
||||
### 成本结构优势
|
||||
|
||||
- **数据成本**:AI 生成替代野外采集,AI 预标注替代人工逐张标注 → 边际数据成本趋近电费
|
||||
- **标注成本**:众包机制把剩余标注工作转化为用户换取时长的行为 → 现金成本近乎为零
|
||||
- **发版成本**:模型热更新绕过应用商店 → 迭代零摩擦
|
||||
|
||||
### 数据飞轮
|
||||
|
||||
```
|
||||
更多用户 → 更多众包标注 + 更多真实场景反馈 → 模型更准
|
||||
↑ ↓
|
||||
└────── 识别能力强,口碑拉新 ←────────────┘
|
||||
```
|
||||
|
||||
每一个付费用户同时是收入来源和数据生产者。物种覆盖越广、模型越准,护城河越深。
|
||||
|
||||
---
|
||||
|
||||
## 六、产品进展
|
||||
|
||||
### 数据一览(截至 2026-09)
|
||||
|
||||
| 维度 | 数据 | 口径说明 |
|
||||
|---|---|---|
|
||||
| 物种覆盖 | **15 个物种数据集,4 个物种已完成模型交付** | 已交付(训练完成并发布):环颈雉、野兔、斑鸠、鹌鹑;一个物种 = 一条数据集 + 一组模型,扩张边际成本低 |
|
||||
| 识别指标 | **验证集 mAP50 最高 0.73 / precision 最高 0.96** | 分物种分档位,指标随数据飞轮持续迭代;端侧实测识别率统计【待补充】 |
|
||||
| 训练数据 | **3364 张训练图,100% AI 生成** | 零野外采集成本;生成图/实拍图对照实验掉点数据【待补充】 |
|
||||
| 种子用户 | **9 个授权用户,运营发放中** | 授权经运营发放,支付链路开通后转付费 |
|
||||
| 用户活跃度 | **日均在线 2~12 小时** | 重度使用特征显著,野外出行场景的高粘性工具属性 |
|
||||
| 团队构成 | **研发 1 人(项目创始人)** | 独立完成客户端、后端、AI 数据生产线与运营后台的全栈交付,产品本身即工程能力证明 |
|
||||
| 融资需求 | 【待补充:金额与资金用途】 | — |
|
||||
|
||||
- **产品上线**:Android App 已完成开发与线上分发,含应用内更新通道
|
||||
- **种子运营**:**种子用户经运营发放授权持续使用中**,处于种子运营阶段;微信/支付宝支付入口已预留、支付链路开发推进中;激励视频广告变现机制已设计完成,接入推进中
|
||||
- **闭环跑通**:数据生产线、训练流水线、模型发布与热更新全链路已进入日常运转,已交付多个物种识别模型并持续迭代版本
|
||||
- **管理后台完备**:数据集、标注、训练、发布、订单、账号、版本管理一站式后台,单人即可运营
|
||||
|
||||
---
|
||||
|
||||
## 七、发展路线图
|
||||
|
||||
| 阶段 | 方向 | 说明 |
|
||||
|---|---|---|
|
||||
| 短期 | 变现通道开通 | 微信/支付宝支付链路开发上线(App 端入口已预留);激励视频广告 SDK 接入上线,"看广告赚时长"面向全量用户 |
|
||||
| 短期 | 物种矩阵扩张 | 架构按"一个物种 = 一条数据集 + 一组模型"设计,新物种边际成本极低,逐个上线扩充可识别物种库 |
|
||||
| 短期 | 识别率持续提升 | 数据飞轮驱动,长尾场景(远距/遮挡/恶劣天气)样本持续补充 |
|
||||
| 短期 | iOS 上架 | 客户端为 Flutter 双端工程,Android 已上线,iOS 随 App Store 审核流程同步发布 |
|
||||
| 中期 | 场景延伸 | 野猪防控、护农治理、野外监测、科研辅助等 B 端场景的能力输出 |
|
||||
| 长期 | 识别能力平台化 | 以"数据生产线 + 热更新通道"为底座,向更多垂直识别品类复制方法论 |
|
||||
@@ -38,33 +38,126 @@ xcrun devicectl device process launch --console --terminate-existing \
|
||||
"In iOS 14+, debug mode Flutter apps can only be launched from Flutter tooling"。
|
||||
debug 调试必须用 `flutter run -d <设备ID>` 或 Xcode IDE 启动(`flutter devices` 查设备ID);
|
||||
从图标启动只对 release 构建有效。
|
||||
- **`flutter run` 真机 debug 附接失败(errno 49,多次复现)**:Xcode 构建、安装、
|
||||
启动都成功,attach 阶段报 `OS Error: Can't assign requested address, errno = 49`
|
||||
工具即退出——与本机 VPN(utun 隧道)环境相关,断 VPN 后可恢复。
|
||||
需要真机验证时一律用上方 release + devicectl 流程(不依赖 attach);只有需要
|
||||
热重载/看 debugPrint 才用 `flutter run`,遇 errno 49 先断 VPN 重试。
|
||||
## 端侧推理加速(GPU / CoreML)
|
||||
|
||||
识别慢的根因是 yolov8s@1280 推理量大(CPU 4 线程约每秒不到 1 帧),
|
||||
`TfliteDetector.fromBuffer` 加载模型时按平台挂加速 delegate,均为**浮点计算不降精度**
|
||||
(区别于 int8 量化掉点):
|
||||
|
||||
| 平台 | delegate | 说明 |
|
||||
|---|---|---|
|
||||
| Android | `GpuDelegateV2` | TFLite GPU delegate;依赖 `libtensorflowlite_gpu_jni.so`,已 vendor 到 `android/app/src/main/jniLibs/arm64-v8a/`(AAR 因 AGP 9 namespace 冲突保持排除,升级 tflite_flutter 时需同步换 .so,版本对齐 base 2.11.0) |
|
||||
| iOS | `CoreMlDelegate` | Core ML(苹果 ANE/GPU,插件 pod 自带 TensorFlowLiteSwift/CoreML,无需额外依赖) |
|
||||
|
||||
delegate 初始化失败(老设备/驱动/符号缺失)**自动回退纯 CPU 4 线程**,最后才返回 null
|
||||
(仅预览不识别)。生效与否看日志:加载模型时输出
|
||||
`[TfliteDetector] 加速生效 model=xxx (CoreML|GPU)`,回退输出 `回退 CPU` 及原因。
|
||||
GPU delegate 默认允许 FP16 计算(YOLO 类精度损失可忽略);如需全精度改为传
|
||||
`GpuDelegateOptionsV2(isPrecisionLossAllowed: false)`。
|
||||
|
||||
## 模型热更新(多数据集模型)
|
||||
|
||||
模型与 APK 更新走**独立通道**:启动时拉取 `GET /api/v1/app/update` 随附的
|
||||
`models` 目录(公开接口,无需登录),与 `UpdateChecker` 的 APK 检查并行。
|
||||
模型与 APK 更新走**独立通道**:进入视野页时后台同步 `GET /api/v1/app/update`
|
||||
随附的 `models` 目录(公开接口,无需登录;不阻塞相机预览启动),与
|
||||
`UpdateChecker` 的 APK 检查并行。
|
||||
|
||||
- **目录条目**:`{datasetId, datasetName, version, labels[], sizeBytes, sha256,
|
||||
downloadUrl, coverUrl}`;服务器未发布模型时不返回 `models` 字段,App 无模型可用,
|
||||
- **目录条目**:`{datasetId, datasetName, variant, version, labels[], sizeBytes, sha256,
|
||||
downloadUrl, coverUrl}`——**双档位(2026-09-03)**:每数据集至多 2 条 = 高精度 s
|
||||
(@1280 精度优先,默认)+ 高性能 n(@704 速度优先)各自的当前版本,条目带 `variant`
|
||||
(s/n);服务器未发布模型时不返回 `models` 字段,App 无模型可用,
|
||||
相机页仅预览不识别。
|
||||
- **下载入口**:相机页设置弹层「模型清单」按需下载/使用(封面缩略图 2 列网格,
|
||||
未下载点击「使用」显示进度,完成自动激活;已激活再次点击取消;下载中可取消)。
|
||||
- **存储**:应用私有目录 `models/<datasetId>/`,含 `model.tflite`、
|
||||
`labels.json`、`meta.json`(meta 记录 `{version, sha256}`)。版本与摘要都
|
||||
未变化时跳过下载;变化则下载到 `.part` 临时文件、sha256 校验通过后
|
||||
原子 rename 替换,失败重试一次并保留旧模型,下次启动再试。
|
||||
- **清理**:服务器下线的数据集下次同步时删除本地对应目录。
|
||||
- **并行推理合并**:识别时加载全部已激活模型(`DetectorWorker` isolate 内
|
||||
逐模型加载,单个失败不影响其他),同帧各模型独立推理后按类别分组做
|
||||
**跨模型 NMS**(同类别不同模型检出同一目标取高分去重,不同类别互不压制),
|
||||
结果叠加 `modelName` 标注来源。
|
||||
**同一物种合并一张卡**,卡上**不显示版本号、也不设档位状态行**
|
||||
(2026-09-10 删除「高精度/高性能 + 版本」灰字行,版本仅作内部记账,
|
||||
档位状态看主按钮);点
|
||||
「下载」一次性补下该动物高精度+高性能两个已训练文件——各档独立进度逐条展示、
|
||||
可取消(已下过的档不重复下),**目标档落地即自动启用、伴档备好**;目标档已
|
||||
启用显示「使用中」点击取消使用。弹层高度上限 70% 屏高(防盖满全屏无法关闭)。
|
||||
**档位(2026-09-03 修订)**:同一物种一次只运行一个档位——激活某档会自动停用
|
||||
同物种另一档,**不同物种可用不同档位并行识别**(如雉鸡用高性能、斑鸠用高精度)。
|
||||
弹层顶部「识别模式」分段控件(高性能 / 高精度,默认高精度,持久化本地;
|
||||
2026-09-03 高性能移到左位)是**目标档偏好**:不直接切换运行中的模型,
|
||||
只决定卡片按钮面向哪个档。卡片主按钮(2026-09-03 简化:不再出现「改用X」、
|
||||
不再显示当前运行档提示):目标档未下载 →「下载」(落地自动启用、伴档备好;
|
||||
同一动物另一档在使用时补下目标档后自动切换过去);已下载未启用 →「使用」
|
||||
点击即用(另一档在使用会被自动停用);启用中 →「使用中」点击取消使用。
|
||||
- **存储**:应用私有目录 `models/<datasetId>/<variant>/`(双档位 2026-09-03,
|
||||
原无 variant 目录与存量 s 档一致——s 档复用 `models/<datasetId>/` 同级读取,
|
||||
目录键 = 档位标识符),含 `model.tflite`、`labels.json`、`meta.json`
|
||||
(meta 记录 `{version, sha256}`)。版本与摘要都未变化时跳过下载;变化则下载到
|
||||
`.part` 临时文件、sha256 校验通过后原子 rename 替换,失败重试一次并保留旧模型,
|
||||
下次启动再试——检查记账按 `(datasetId, variant)` 独立。
|
||||
- **目录缓存(2026-09-03)**:最近一次成功拉取的模型目录落盘应用私有目录
|
||||
`catalog.json`;每次刷新先载入缓存、展示立即可用(弹层离线/弱网也有内容),
|
||||
网络成功后再以权威目录覆盖缓存。清理与自动更新只在网络拉取成功时执行:
|
||||
离线降级不清文件、不触发下载——升级后断网首启不会误删已下载模型。
|
||||
- **清理**:服务器下线的数据集下次同步时删除本地对应目录(两档都无条目时才删)。
|
||||
- **识别会话制(2026-09-03 修订)**:每次进入视野页即开始新识别会话——从
|
||||
**仅预览**开始、**不自动恢复上次使用的模型**(上次崩溃/坏模型不会在下一次打开时
|
||||
自动复现,用户总能进入模型清单调整;这也是打开即慢的根因消除项:进入页面不再
|
||||
读模型/重建推理器)。识别需在模型清单**手动启用**,启用状态只在本会话内生效
|
||||
(不跨会话持久化);已下载文件常驻本地,「使用」即点即用不重新下载。
|
||||
服务器新版本仅对**已下载文件**后台自动补齐,补齐不改变启用状态。
|
||||
- **并行推理合并**:识别时加载**本会话已启用**的全部模型(激活集即实际运行集,
|
||||
会话内每数据集至多一个档位;`DetectorWorker` isolate 内逐模型加载,单个失败
|
||||
不影响其他;worker 构建完成后原地挂到已启动的相机流,不重启相机),
|
||||
同帧各模型独立推理后做**跨模型 NMS**(所有模型的框统一按 IoU 去重取高分——
|
||||
异类别重叠也压制,实测多个模型会对同一目标检出不同类别),结果叠加 `modelName`
|
||||
(数据集名)标注来源。
|
||||
|
||||
实现:`lib/models/model_manager.dart`(下载/校验/持久化,`ModelManager`
|
||||
单例 + ChangeNotifier)、`lib/detection/detector_worker.dart`(多模型并行
|
||||
推理与 `mergeAcrossModels`)、`lib/camera/camera_screen.dart`(启动同步 +
|
||||
诊断行展示模型列表)。
|
||||
实现:`lib/models/model_manager.dart`(下载/校验/清单/会话制单档激活收敛,
|
||||
`ModelManager` 单例 + ChangeNotifier,条目身份含档位)、
|
||||
`lib/detection/detector_worker.dart`(多模型并行推理与 `mergeAcrossModels`)、
|
||||
`lib/camera/camera_screen.dart`(会话重置从仅预览开始 + 先出预览后台同步 +
|
||||
设置弹层「识别模式」目标档切换 + 底部状态胶囊:识别延迟)、
|
||||
`lib/camera/model_catalog_section.dart`(清单网格:按数据集合并卡片、
|
||||
双档下载与「使用/使用中/下载」主按钮动作,2026-09-03 简化文案)。
|
||||
|
||||
- **模型输入是 NHWC**:训练导出的模型需做字节级手术(开头 TRANSPOSE→RESHAPE,
|
||||
输入 [1,320,320,3])再发布给 App,否则 iOS 报
|
||||
"Node number 0 (TRANSPOSE) failed to prepare"。
|
||||
- **模拟器黑屏**:本机 iOS 模拟器 Impeller 渲染黑屏,验证 UI 用 VM service
|
||||
(`flutter run` 输出里的 DevTools 地址),或直接真机验证。
|
||||
|
||||
## 识别链路约束(踩过的坑)
|
||||
|
||||
### 归一化坐标不得与像素量纲阈值直接比较(2026-09-12 漏检根因)
|
||||
|
||||
**现象**:验证图 `RNPHE_2030`(麦田平卧雉鸡)离线推理 0.8845、与真值 IoU 0.88,
|
||||
App 端对准画面却一个框都不出;换张图/换个握持方向又偶尔能出——"有时能识别、
|
||||
有时识别不出来"。
|
||||
|
||||
**根因**:`camera_view_model.dart` 的 `_plausible()` 拿 `w / h` 与固定窗口
|
||||
`0.3 ~ 3.0` 比较,但 `left/top/right/bottom` 是**按各自轴归一化**的(w 除以图宽、
|
||||
h 除以图高),于是:
|
||||
|
||||
```
|
||||
归一化宽高比 = 像素宽高比 × (图高 / 图宽)
|
||||
```
|
||||
|
||||
竖屏画幅(图高/图宽 ≈ 1.78)下 `0.3 ~ 3.0` 实际只剩像素宽高比 `0.17 ~ 1.69`;
|
||||
雉鸡是长尾鸟(本例 199×89px,像素宽高比 2.24),归一化后 3.99 > 3.0 →
|
||||
**在建轨迹之前被 `continue` 丢掉,置信度再高也不显示**。横屏时窗口是 0.53~5.33
|
||||
所以能出框——这就是"有时能、有时不能"随握持方向/目标姿态变化的来源。
|
||||
|
||||
**修法(2026-09-12 定案)**:几何门**整体删除**(过近/过远目标同样不能丢),
|
||||
只保留"零宽/零高"的退化数据兜底;质量交给置信度(minScore)+ VisualPrior
|
||||
(仅作用于 < 0.35 的框)+ 轨迹确认(3 帧 / ≥ 0.35 / 运动证据)把关。
|
||||
|
||||
**规矩(改识别链路时照做)**:
|
||||
|
||||
1. 任何宽高比/尺寸判断必须先换算回**像素空间**再比阈值:
|
||||
`aspectPx = (w * frameW) / (h * frameH)`。直接拿 `w/h` 比固定常量,在非方图上必然错。
|
||||
2. 显示链路上会**静默丢弃真框**的过滤(`continue` 不留痕)必须满足其一:
|
||||
只作用于低分框(同 VisualPrior 的分工)、或有可观测口径(诊断层/日志)——
|
||||
否则"模型检出了但没显示"无从定位。
|
||||
3. 回归用例已锁死:`test/camera_view_model_test.dart` 的「平卧长条框不被几何过滤」
|
||||
与「近距离大目标与远距离小目标均可显示」——两条在旧逻辑下必然失败,动这块先跑它们。
|
||||
|
||||
**定位手法**:诊断层(状态胶囊 3 秒内连点 5 次)看「最高分」——
|
||||
**高分无框 = 卡在过滤链路;最高分也低 = 卡在采集/推理链路**。
|
||||
|
||||
@@ -45,8 +45,10 @@ kotlin {
|
||||
// 不依赖任何相机三方库(含 CameraX)。
|
||||
|
||||
// tflite_flutter 依赖的 tensorflow-lite / tensorflow-lite-gpu / tensorflow-lite-api 三个 AAR
|
||||
// 声明了相同 namespace(org.tensorflow.lite),新 AGP 视作冲突直接报错;
|
||||
// 本项目仅用 CPU 推理,GPU delegate 未使用,排除 gpu 及其传递依赖的 api 即可。
|
||||
// 声明了相同 namespace(org.tensorflow.lite),新 AGP 视作冲突直接报错,故仍整体排除;
|
||||
// GPU delegate 所需 libtensorflowlite_gpu_jni.so 已手工抽取 vendor 到
|
||||
// src/main/jniLibs/arm64-v8a/(tflite_flutter 经 FFI 直调 .so,不用 AAR 内 Java 类),
|
||||
// 与 base tensorflow-lite 同为 2.11.0 版本;升级 tflite_flutter 时需同步更新 .so。
|
||||
configurations.all {
|
||||
exclude(group = "org.tensorflow", module = "tensorflow-lite-gpu")
|
||||
exclude(group = "org.tensorflow", module = "tensorflow-lite-api")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- App 内更新安装 APK(PackageInstaller 会话安装,见 InstallerChannel.kt) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<application
|
||||
android:label="视野"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.example.observer
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* 原生 APK 安装通道(App 内更新安装):PackageInstaller 会话安装,
|
||||
* 安装进度经 EventChannel 实时回传 Flutter(下载进度由 Flutter 侧 http 流式下载自算)。
|
||||
*
|
||||
* 通道:
|
||||
* - MethodChannel "observer/installer":install(path)
|
||||
* - result.success("installing"):已提交安装
|
||||
* - result.success("permission_required"):未开「安装未知应用」,已拉起系统设置页
|
||||
* - EventChannel "observer/installer/progress":{event: progress/finished/failed, ...}
|
||||
*/
|
||||
class InstallerChannel(
|
||||
private val activity: FlutterActivity,
|
||||
private val engine: FlutterEngine,
|
||||
) {
|
||||
private val messenger = engine.dartExecutor.binaryMessenger
|
||||
private val method = MethodChannel(messenger, "observer/installer")
|
||||
private val progress = EventChannel(messenger, "observer/installer/progress")
|
||||
|
||||
private var progressSink: EventChannel.EventSink? = null
|
||||
private var activeSession: PackageInstaller.Session? = null
|
||||
|
||||
/// 安装完成轮询兜底开关:部分 ROM(如 MIUI)点确认后不回调
|
||||
/// onProgressChanged/onFinished,仅靠回调会永久停在「安装中」。
|
||||
@Volatile
|
||||
private var installSettled = false
|
||||
|
||||
fun register() {
|
||||
method.setMethodCallHandler { call: MethodCall, result: MethodChannel.Result ->
|
||||
when (call.method) {
|
||||
"install" -> install(call.argument<String>("path") ?: "", result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
progress.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
progressSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
progressSink = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun install(path: String, result: MethodChannel.Result) {
|
||||
installSettled = false
|
||||
val file = File(path)
|
||||
Log.d(TAG, "install: path=$path exists=${file.exists()} len=${file.length()}")
|
||||
if (!file.exists()) {
|
||||
result.error("FILE_NOT_FOUND", "APK 文件不存在: $path", null)
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
!activity.packageManager.canRequestPackageInstalls()
|
||||
) {
|
||||
Log.d(TAG, "install: permission_required")
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${activity.packageName}"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
activity.startActivity(intent)
|
||||
result.success("permission_required")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val pm = activity.packageManager
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
setSize(file.length())
|
||||
}
|
||||
val sessionId = pm.packageInstaller.createSession(params)
|
||||
val session = pm.packageInstaller.openSession(sessionId)
|
||||
activeSession = session
|
||||
Log.d(TAG, "install: session=$sessionId opened")
|
||||
// API 36 起 registerSessionCallback(int, ...) 变体被移除,只剩全局注册形式,
|
||||
// 回调按 sessionId 过滤,避免响应其他会话事件
|
||||
val callback = object : PackageInstaller.SessionCallback() {
|
||||
override fun onCreated(id: Int) {}
|
||||
|
||||
override fun onBadgingChanged(id: Int) {}
|
||||
|
||||
override fun onActiveChanged(id: Int, active: Boolean) {}
|
||||
|
||||
override fun onProgressChanged(id: Int, progressPercent: Float) {
|
||||
if (id != sessionId) return
|
||||
Log.d(TAG, "callback onProgressChanged=$progressPercent")
|
||||
emit("progress", "progress" to progressPercent.toInt())
|
||||
}
|
||||
|
||||
override fun onFinished(id: Int, success: Boolean) {
|
||||
if (id != sessionId) return
|
||||
Log.d(TAG, "callback onFinished success=$success")
|
||||
installSettled = true
|
||||
emit("finished", "success" to success)
|
||||
pm.packageInstaller.unregisterSessionCallback(this)
|
||||
activeSession = null
|
||||
}
|
||||
}
|
||||
pm.packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper()))
|
||||
// 安装前已装版本号(更新安装成功后必然变化,轮询兜底依据)
|
||||
val installedCode = try {
|
||||
pm.getPackageInfo(activity.packageName, 0).versionCode
|
||||
} catch (e: Exception) {
|
||||
-1
|
||||
}
|
||||
// 写 APK 到会话:1MB 缓冲流式拷贝,完成后 commit 弹系统确认框
|
||||
Thread {
|
||||
try {
|
||||
Log.d(TAG, "writeThread: start")
|
||||
session.openWrite("apk", 0, file.length()).use { out ->
|
||||
file.inputStream().use { input ->
|
||||
val buf = ByteArray(1 shl 20)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
out.write(buf, 0, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "writeThread: written, commit")
|
||||
// commit 的 status receiver 必须 MUTABLE:系统要向 intent 写入安装结果,
|
||||
// FLAG_IMMUTABLE 直接抛 "The commit() status receiver should come from a
|
||||
// mutable PendingIntent"(模拟器 AOSP 严格校验;部分 ROM 不校验但回调丢失)
|
||||
val sender = PendingIntent.getActivity(
|
||||
activity,
|
||||
0,
|
||||
Intent(activity, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||
).intentSender
|
||||
session.commit(sender)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "writeThread: failed", e)
|
||||
session.abandon()
|
||||
emit("failed", "error" to (e.message ?: "安装会话写入失败"))
|
||||
activeSession = null
|
||||
}
|
||||
}.start()
|
||||
// 完成轮询兜底:回调缺失时靠版本号变化判定安装成功(2s 间隔,最长 120s)
|
||||
Thread {
|
||||
var waited = 0
|
||||
while (!installSettled && waited < 120_000) {
|
||||
Thread.sleep(2000)
|
||||
waited += 2000
|
||||
val now = try {
|
||||
pm.getPackageInfo(activity.packageName, 0).versionCode
|
||||
} catch (e: Exception) {
|
||||
-1
|
||||
}
|
||||
if (now != installedCode && now != -1) {
|
||||
Log.d(TAG, "poll: versionCode changed $installedCode->$now, settled")
|
||||
installSettled = true
|
||||
emit("finished", "success" to true)
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
if (!installSettled) {
|
||||
Log.d(TAG, "poll: timeout after 120s, not settled")
|
||||
emit("failed", "error" to "安装超时,请重试")
|
||||
}
|
||||
}.start()
|
||||
result.success("installing")
|
||||
} catch (e: Exception) {
|
||||
result.error("INSTALL_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(event: String, vararg pairs: Pair<String, Any>) {
|
||||
// EventSink.success 内部 dispatchPlatformMessage 要求主线程(@UiThread 校验),
|
||||
// 写 APK 线程 / 轮询线程直接调用会崩溃,必须投递主线程
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(TAG, "emit: $event $pairs")
|
||||
progressSink?.success(mapOf("event" to event, *pairs))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InstallerChannel"
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,10 @@
|
||||
package com.example.observer
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var cameraChannel: CameraChannel? = null
|
||||
private var installerChannel: InstallerChannel? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleInstallIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleInstallIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 14+ 安装确认回调:PackageInstaller 会话 commit 后如需要用户确认
|
||||
* (确认对话框/安装未知来源授权),系统不直接弹 packageinstaller 界面,
|
||||
* 而是经 commit 传入的 statusReceiver(指向本 Activity 的 PendingIntent)
|
||||
* 回传 STATUS_PENDING_USER_ACTION,并在 EXTRA_INTENT 里附上确认界面 Intent
|
||||
* (ACTION_CONFIRM_INSTALL → InstallStart),由调用方负责启动。
|
||||
* 不处理则确认框永不出现,安装卡在 0.8% 直至超时(AOSP 见
|
||||
* PackageInstallerSession.sendOnUserActionRequired)。
|
||||
*/
|
||||
private fun handleInstallIntent(intent: Intent?) {
|
||||
val status = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) ?: return
|
||||
if (status != PackageInstaller.STATUS_PENDING_USER_ACTION) return
|
||||
val confirm = if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
}
|
||||
if (confirm == null) return
|
||||
try {
|
||||
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(confirm)
|
||||
Log.d(TAG, "handleInstallIntent: started confirm ${confirm.component}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "handleInstallIntent: start confirm failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -57,12 +13,6 @@ class MainActivity : FlutterActivity() {
|
||||
// configureFlutterEngine(onCreate 阶段)只注册通道与 viewFactory,
|
||||
// 实际 bindToLifecycle 由 Flutter 相机页 start 时触发(此时已 RESUMED)
|
||||
cameraChannel = CameraChannel(this, flutterEngine).also { it.register() }
|
||||
// App 内更新安装 APK(PackageInstaller 会话安装 + 进度回传)
|
||||
installerChannel = InstallerChannel(this, flutterEngine).also { it.register() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 879 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 11 KiB |
@@ -57,6 +57,8 @@
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机进行动物实时识别</string>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:cupertino_http/cupertino_http.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../auth/session_store.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 标注众包 API 客户端(契约见 server/README.md「标注众包」)。
|
||||
/// 异常语义同 OrderApi:失败抛 AnnotateApiException,登录失效抛 SessionExpired。
|
||||
class AnnotateApiException implements Exception {
|
||||
final String message;
|
||||
const AnnotateApiException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class SessionExpiredException extends AnnotateApiException {
|
||||
const SessionExpiredException() : super('登录已失效,请重新登录');
|
||||
}
|
||||
|
||||
/// 众包任务条目
|
||||
class AnnotateTaskInfo {
|
||||
final int id;
|
||||
final String name;
|
||||
final String datasetName;
|
||||
final String species;
|
||||
final int poolRemain;
|
||||
|
||||
const AnnotateTaskInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.datasetName,
|
||||
required this.species,
|
||||
required this.poolRemain,
|
||||
});
|
||||
|
||||
factory AnnotateTaskInfo.fromJson(Map<String, dynamic> j) => AnnotateTaskInfo(
|
||||
id: (j['id'] as num).toInt(),
|
||||
name: (j['name'] as String?) ?? '',
|
||||
datasetName: (j['datasetName'] as String?) ?? '',
|
||||
species: (j['species'] as String?) ?? '',
|
||||
poolRemain: (j['poolRemain'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 我的标注统计(进度/奖励/冻结)
|
||||
class AnnotateStats {
|
||||
final int submittedTotal;
|
||||
final int approved;
|
||||
final int rejected;
|
||||
final double approveRatio;
|
||||
final int progressDone;
|
||||
final int rewardPerImages;
|
||||
final int rewardMinutes;
|
||||
final int totalEarnedMinutes;
|
||||
final int todayEarnedMinutes;
|
||||
final int dailyCapMinutes;
|
||||
final DateTime? frozenUntil;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
const AnnotateStats({
|
||||
required this.submittedTotal,
|
||||
required this.approved,
|
||||
required this.rejected,
|
||||
required this.approveRatio,
|
||||
required this.progressDone,
|
||||
required this.rewardPerImages,
|
||||
required this.rewardMinutes,
|
||||
required this.totalEarnedMinutes,
|
||||
required this.todayEarnedMinutes,
|
||||
required this.dailyCapMinutes,
|
||||
required this.frozenUntil,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
bool get frozen => frozenUntil != null && frozenUntil!.isAfter(DateTime.now());
|
||||
|
||||
factory AnnotateStats.fromJson(Map<String, dynamic> j) => AnnotateStats(
|
||||
submittedTotal: (j['submittedTotal'] as num?)?.toInt() ?? 0,
|
||||
approved: (j['approved'] as num?)?.toInt() ?? 0,
|
||||
rejected: (j['rejected'] as num?)?.toInt() ?? 0,
|
||||
approveRatio: (j['approveRatio'] as num?)?.toDouble() ?? 1,
|
||||
progressDone: (j['progressDone'] as num?)?.toInt() ?? 0,
|
||||
rewardPerImages: (j['rewardPerImages'] as num?)?.toInt() ?? 10,
|
||||
rewardMinutes: (j['rewardMinutes'] as num?)?.toInt() ?? 30,
|
||||
totalEarnedMinutes: (j['totalEarnedMinutes'] as num?)?.toInt() ?? 0,
|
||||
todayEarnedMinutes: (j['todayEarnedMinutes'] as num?)?.toInt() ?? 0,
|
||||
dailyCapMinutes: (j['dailyCapMinutes'] as num?)?.toInt() ?? 120,
|
||||
frozenUntil: j['frozenUntil'] == null
|
||||
? null
|
||||
: DateTime.tryParse(j['frozenUntil'] as String),
|
||||
expiresAt: j['expiresAt'] == null
|
||||
? null
|
||||
: DateTime.tryParse(j['expiresAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// 领取到的单张图
|
||||
class AnnotateClaimImage {
|
||||
final int imageId;
|
||||
final String url; // 相对路径,需拼 baseUrl;访问需 Bearer 头
|
||||
final int width;
|
||||
final int height;
|
||||
|
||||
const AnnotateClaimImage({
|
||||
required this.imageId,
|
||||
required this.url,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
factory AnnotateClaimImage.fromJson(Map<String, dynamic> j) =>
|
||||
AnnotateClaimImage(
|
||||
imageId: (j['imageId'] as num).toInt(),
|
||||
url: (j['url'] as String?) ?? '',
|
||||
width: (j['width'] as num?)?.toInt() ?? 0,
|
||||
height: (j['height'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 提交结果(是否触发奖励发放)
|
||||
class AnnotateSubmitResult {
|
||||
final bool granted;
|
||||
final int minutes;
|
||||
final int todayEarnedMinutes;
|
||||
final int progressDone;
|
||||
|
||||
const AnnotateSubmitResult({
|
||||
required this.granted,
|
||||
required this.minutes,
|
||||
required this.todayEarnedMinutes,
|
||||
required this.progressDone,
|
||||
});
|
||||
|
||||
factory AnnotateSubmitResult.fromJson(Map<String, dynamic> j) =>
|
||||
AnnotateSubmitResult(
|
||||
granted: j['granted'] == true,
|
||||
minutes: (j['minutes'] as num?)?.toInt() ?? 0,
|
||||
todayEarnedMinutes: (j['todayEarnedMinutes'] as num?)?.toInt() ?? 0,
|
||||
progressDone: (j['progressDone'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class AnnotateApi {
|
||||
final String baseUrl;
|
||||
final SessionStore sessionStore;
|
||||
final http.Client _client;
|
||||
|
||||
// iOS 本地网络 socket 拦截绕行,同 OrderApi(见其注释)
|
||||
AnnotateApi({String? baseUrl, required this.sessionStore, http.Client? client})
|
||||
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
|
||||
_client = client ?? _defaultHttpClient();
|
||||
|
||||
static http.Client _defaultHttpClient() {
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
return CupertinoClient.defaultSessionConfiguration();
|
||||
}
|
||||
return http.Client();
|
||||
}
|
||||
|
||||
/// 可领任务列表 + 我的统计
|
||||
Future<(List<AnnotateTaskInfo>, AnnotateStats)> tasks() async {
|
||||
final json = await _get('/api/v1/annotate/tasks');
|
||||
final list = (json['list'] as List? ?? [])
|
||||
.map((e) => AnnotateTaskInfo.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
final stats = AnnotateStats.fromJson(
|
||||
((json['stats'] as Map?) ?? const {}).cast<String, dynamic>());
|
||||
return (list, stats);
|
||||
}
|
||||
|
||||
/// 领取一批标注图片
|
||||
Future<List<AnnotateClaimImage>> claim(int taskId) async {
|
||||
final json = await _post(
|
||||
'/api/v1/annotate/claim', jsonEncode({'taskId': taskId}));
|
||||
return (json['images'] as List? ?? [])
|
||||
.map((e) =>
|
||||
AnnotateClaimImage.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 提交单张标注(boxes 元素:{class,cx,cy,w,h,confidence},归一化坐标)
|
||||
Future<AnnotateSubmitResult> submit(
|
||||
int imageId, List<Map<String, dynamic>> boxes) async {
|
||||
final json = await _post(
|
||||
'/api/v1/annotate/submit', jsonEncode({'imageId': imageId, 'boxes': boxes}));
|
||||
return AnnotateSubmitResult.fromJson(json);
|
||||
}
|
||||
|
||||
/// 我的统计
|
||||
Future<AnnotateStats> me() async {
|
||||
final json = await _get('/api/v1/annotate/me');
|
||||
return AnnotateStats.fromJson(
|
||||
((json['stats'] as Map?) ?? const {}).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// 图片完整 URL(Image.network 用,需带 Bearer 头)
|
||||
Uri imageUrl(String relative) => Uri.parse('$baseUrl$relative');
|
||||
|
||||
/// 图片请求头(标注器 Image.network 加载用)
|
||||
Future<Map<String, String>> imageHeaders() => _authHeaders();
|
||||
|
||||
Future<Map<String, String>> _authHeaders() async {
|
||||
final token = await sessionStore.readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
return {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _get(String path) async {
|
||||
try {
|
||||
final res = await _client
|
||||
.get(Uri.parse('$baseUrl$path'), headers: await _authHeaders())
|
||||
.timeout(const Duration(seconds: 30));
|
||||
return _decode(res);
|
||||
} catch (e) {
|
||||
if (e is AnnotateApiException) rethrow;
|
||||
throw AnnotateApiException('网络请求失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _post(String path, String body) async {
|
||||
try {
|
||||
final res = await _client
|
||||
.post(Uri.parse('$baseUrl$path'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...await _authHeaders(),
|
||||
},
|
||||
body: body)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
return _decode(res);
|
||||
} catch (e) {
|
||||
if (e is AnnotateApiException) rethrow;
|
||||
throw AnnotateApiException('网络请求失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _decode(http.Response res) {
|
||||
final Map<String, dynamic> json;
|
||||
try {
|
||||
json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw AnnotateApiException('服务端响应异常 (${res.statusCode})');
|
||||
}
|
||||
if (res.statusCode != 200 || json['code'] != 0) {
|
||||
final message = json['message'] as String? ?? '服务端错误 (${res.statusCode})';
|
||||
if (json['code'] == 61) {
|
||||
throw const SessionExpiredException();
|
||||
}
|
||||
throw AnnotateApiException(message);
|
||||
}
|
||||
return json['data'] as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'annotate_api.dart';
|
||||
import 'annotator_screen.dart';
|
||||
|
||||
String _fmt(DateTime t) {
|
||||
String p(int n) => n.toString().padLeft(2, '0');
|
||||
return '${t.month}/${t.day} ${p(t.hour)}:${p(t.minute)}';
|
||||
}
|
||||
|
||||
/// 标注赚时长首页:我的统计(进度/今日已得/冻结态)+ 可领任务列表。
|
||||
/// 领取后进入标注器逐张画框提交,提交满 [AnnotateStats.rewardPerImages] 张自动到账时长。
|
||||
class AnnotateTasksScreen extends StatefulWidget {
|
||||
final AnnotateApi api;
|
||||
|
||||
const AnnotateTasksScreen({super.key, required this.api});
|
||||
|
||||
@override
|
||||
State<AnnotateTasksScreen> createState() => _AnnotateTasksScreenState();
|
||||
}
|
||||
|
||||
class _AnnotateTasksScreenState extends State<AnnotateTasksScreen> {
|
||||
late Future<(List<AnnotateTaskInfo>, AnnotateStats)> _future;
|
||||
bool _claiming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.api.tasks();
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
setState(() => _future = widget.api.tasks());
|
||||
}
|
||||
|
||||
Future<void> _claim(AnnotateTaskInfo task) async {
|
||||
if (_claiming) return;
|
||||
setState(() => _claiming = true);
|
||||
try {
|
||||
final images = await widget.api.claim(task.id);
|
||||
if (!mounted) return;
|
||||
if (images.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('任务池暂时没有可领取的图片,稍后再来')));
|
||||
_reload();
|
||||
return;
|
||||
}
|
||||
final earned = await Navigator.of(context).push<bool>(MaterialPageRoute(
|
||||
builder: (_) => AnnotatorScreen(api: widget.api, task: task, images: images),
|
||||
));
|
||||
if (earned == true) _reload(); // 本批有提交:回列表刷新进度/奖励
|
||||
} on SessionExpiredException {
|
||||
if (mounted) _sessionExpired();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e'.replaceFirst('Exception: ', ''))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _claiming = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _sessionExpired() {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('登录已失效,请重新登录')));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('标注赚时长')),
|
||||
body: FutureBuilder<(List<AnnotateTaskInfo>, AnnotateStats)>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('加载失败:${snap.error}'.replaceFirst('Exception: ', '')),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: _reload, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final (tasks, stats) = snap.data!;
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _reload(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_StatsCard(stats: stats),
|
||||
const SizedBox(height: 16),
|
||||
Text('可领任务', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
if (tasks.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: Text('暂无可领任务,敬请期待')),
|
||||
)
|
||||
else
|
||||
...tasks.map((t) => Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: ListTile(
|
||||
title: Text(t.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(
|
||||
'物种:${t.species.isEmpty ? t.datasetName : t.species} · 池余量 ${t.poolRemain} 张'),
|
||||
trailing: FilledButton(
|
||||
onPressed: _claiming || stats.frozen || t.poolRemain <= 0
|
||||
? null
|
||||
: () => _claim(t),
|
||||
child: const Text('领取'),
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsCard extends StatelessWidget {
|
||||
final AnnotateStats stats;
|
||||
|
||||
const _StatsCard({required this.stats});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.emoji_events,
|
||||
color: Colors.amber.shade700, size: 28),
|
||||
const SizedBox(width: 8),
|
||||
Text('标 ${stats.rewardPerImages} 张得 ${stats.rewardMinutes} 分钟',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: stats.rewardPerImages <= 0
|
||||
? 0
|
||||
: (stats.progressDone % stats.rewardPerImages) /
|
||||
stats.rewardPerImages,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'本档进度 ${stats.progressDone}/${stats.rewardPerImages} · 今日已得 ${stats.todayEarnedMinutes}/${stats.dailyCapMinutes} 分钟 · 累计 ${stats.totalEarnedMinutes} 分钟',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
Text(
|
||||
'通过 ${stats.approved} / 拒绝 ${stats.rejected}(通过率 ${(stats.approveRatio * 100).toStringAsFixed(0)}%)',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey.shade600),
|
||||
),
|
||||
if (stats.frozen) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.block, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'标注质量未达标,资格冻结中(${_fmt(stats.frozenUntil!)} 解冻)',
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'annotate_api.dart';
|
||||
|
||||
/// 标注器:逐张画框提交。交互:空白处拖拽画框、点框选中(高亮)、
|
||||
/// 撤销/删除选中;每张提交进「待审核」,满 rewardPerImages 张自动到账时长。
|
||||
class AnnotatorScreen extends StatefulWidget {
|
||||
final AnnotateApi api;
|
||||
final AnnotateTaskInfo task;
|
||||
final List<AnnotateClaimImage> images;
|
||||
|
||||
const AnnotatorScreen({
|
||||
super.key,
|
||||
required this.api,
|
||||
required this.task,
|
||||
required this.images,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnnotatorScreen> createState() => _AnnotatorScreenState();
|
||||
}
|
||||
|
||||
/// 归一化框(cx,cy 中心 + w,h,0~1,同服务端 AdminLabelBox)
|
||||
class _NormBox {
|
||||
double cx;
|
||||
double cy;
|
||||
double w;
|
||||
double h;
|
||||
_NormBox(this.cx, this.cy, this.w, this.h);
|
||||
|
||||
bool hit(Offset p) =>
|
||||
p.dx >= cx - w / 2 && p.dx <= cx + w / 2 && p.dy >= cy - h / 2 && p.dy <= cy + h / 2;
|
||||
}
|
||||
|
||||
class _AnnotatorScreenState extends State<AnnotatorScreen> {
|
||||
int _index = 0;
|
||||
final List<_NormBox> _boxes = [];
|
||||
int? _selected;
|
||||
Offset? _dragStart; // 拖拽画框进行中(归一化坐标)
|
||||
Offset? _dragCur;
|
||||
bool _submitting = false;
|
||||
bool _earnedThisBatch = false;
|
||||
|
||||
AnnotateClaimImage get _current => widget.images[_index];
|
||||
|
||||
void _clamp(_NormBox b) {
|
||||
// 边界钳制 + 防越界(归一化 0~1,同服务端校验)
|
||||
b.cx = b.cx.clamp(0.0, 1.0);
|
||||
b.cy = b.cy.clamp(0.0, 1.0);
|
||||
b.w = b.w.clamp(0.001, 1.0);
|
||||
b.h = b.h.clamp(0.001, 1.0);
|
||||
}
|
||||
|
||||
void _onPanStart(DragStartDetails d) {
|
||||
_dragStart = d.localPosition;
|
||||
_dragCur = d.localPosition;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails d) {
|
||||
_dragCur = d.localPosition;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPanEnd(DragEndDetails d) {
|
||||
final s = _dragStart;
|
||||
final e = _dragCur;
|
||||
_dragStart = null;
|
||||
_dragCur = null;
|
||||
if (s == null || e == null) {
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
final dx = (e.dx - s.dx).abs();
|
||||
final dy = (e.dy - s.dy).abs();
|
||||
if (dx < 8 || dy < 8) {
|
||||
setState(() {}); // 过小视为误触
|
||||
return;
|
||||
}
|
||||
final box = _NormBox(
|
||||
(s.dx + e.dx) / 2,
|
||||
(s.dy + e.dy) / 2,
|
||||
dx,
|
||||
dy,
|
||||
);
|
||||
final size = _canvasSize;
|
||||
if (size == null || size.width <= 0 || size.height <= 0) {
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
box.cx /= size.width;
|
||||
box.cy /= size.height;
|
||||
box.w /= size.width;
|
||||
box.h /= size.height;
|
||||
_clamp(box);
|
||||
setState(() {
|
||||
_boxes.add(box);
|
||||
_selected = _boxes.length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
Size? _canvasSize;
|
||||
|
||||
void _onTapUp(TapUpDetails d) {
|
||||
final p = d.localPosition;
|
||||
final size = _canvasSize;
|
||||
if (size == null) return;
|
||||
final n = Offset(p.dx / size.width, p.dy / size.height);
|
||||
int? hit;
|
||||
for (var i = _boxes.length - 1; i >= 0; i--) {
|
||||
if (_boxes[i].hit(n)) {
|
||||
hit = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setState(() => _selected = hit);
|
||||
}
|
||||
|
||||
void _deleteSelected() {
|
||||
if (_selected == null) return;
|
||||
setState(() {
|
||||
_boxes.removeAt(_selected!);
|
||||
_selected = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _undo() {
|
||||
if (_boxes.isEmpty) return;
|
||||
setState(() {
|
||||
_boxes.removeLast();
|
||||
_selected = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit({required bool noTarget}) async {
|
||||
if (_submitting) return;
|
||||
if (!noTarget && _boxes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请先画框,或使用「画面无目标」提交')));
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final payload = noTarget
|
||||
? <Map<String, dynamic>>[]
|
||||
: _boxes
|
||||
.map((b) => <String, dynamic>{
|
||||
'class': 0,
|
||||
'cx': b.cx,
|
||||
'cy': b.cy,
|
||||
'w': b.w,
|
||||
'h': b.h,
|
||||
'confidence': 1,
|
||||
})
|
||||
.toList();
|
||||
final res = await widget.api.submit(_current.imageId, payload);
|
||||
if (res.granted) _earnedThisBatch = true;
|
||||
if (!mounted) return;
|
||||
if (res.granted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
'恭喜!累计提交满档,${res.minutes} 分钟时长已到账(今日已得 ${res.todayEarnedMinutes} 分钟)'),
|
||||
backgroundColor: Colors.green,
|
||||
));
|
||||
}
|
||||
_next();
|
||||
} on SessionExpiredException {
|
||||
if (mounted) Navigator.of(context).pop(false);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('$e'.replaceFirst('Exception: ', ''))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _next() {
|
||||
if (_index + 1 >= widget.images.length) {
|
||||
// 本批完成:回任务列表刷新进度
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(_earnedThisBatch ? '本批完成,奖励已到账' : '本批完成,继续加油!')));
|
||||
Navigator.of(context).pop(true);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_index++;
|
||||
_boxes.clear();
|
||||
_selected = null;
|
||||
_dragStart = null;
|
||||
_dragCur = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final img = _current;
|
||||
final aspect =
|
||||
img.width > 0 && img.height > 0 ? img.width / img.height : 4 / 3;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${widget.task.name}(${_index + 1}/${widget.images.length})'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: FutureBuilder<Map<String, String>>(
|
||||
future: widget.api.imageHeaders(),
|
||||
builder: (context, snap) {
|
||||
final headers = snap.data ?? const <String, String>{};
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final maxW = constraints.maxWidth;
|
||||
final maxH = constraints.maxHeight.isInfinite
|
||||
? MediaQuery.of(context).size.height * 0.55
|
||||
: constraints.maxHeight;
|
||||
var w = maxW;
|
||||
var h = w / aspect;
|
||||
if (h > maxH) {
|
||||
h = maxH;
|
||||
w = h * aspect;
|
||||
}
|
||||
_canvasSize = Size(w, h);
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
onPanEnd: _onPanEnd,
|
||||
onTapUp: _onTapUp,
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: w,
|
||||
height: h,
|
||||
child: Image.network(
|
||||
widget.api.imageUrl(img.url).toString(),
|
||||
headers: headers,
|
||||
fit: BoxFit.fill,
|
||||
loadingBuilder: (c, child, progress) =>
|
||||
progress == null
|
||||
? child
|
||||
: const Center(
|
||||
child: CircularProgressIndicator()),
|
||||
errorBuilder: (c, e, st) => const Center(
|
||||
child: Text('图片加载失败',
|
||||
style: TextStyle(color: Colors.red))),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _BoxesPainter(
|
||||
boxes: _boxes,
|
||||
selected: _selected,
|
||||
dragStart: _dragStart == null
|
||||
? null
|
||||
: Offset(_dragStart!.dx / w,
|
||||
_dragStart!.dy / h),
|
||||
dragCur: _dragCur == null
|
||||
? null
|
||||
: Offset(
|
||||
_dragCur!.dx / w, _dragCur!.dy / h),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _boxes.isEmpty ? null : _undo,
|
||||
icon: const Icon(Icons.undo),
|
||||
label: const Text('撤销'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _selected == null ? null : _deleteSelected,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('删除选中'),
|
||||
),
|
||||
Text(
|
||||
'${_boxes.length} 框',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _submitting ? null : () => _submit(noTarget: true),
|
||||
child: const Text('画面无目标'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _submitting ? null : () => _submit(noTarget: false),
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.check),
|
||||
label: Text(_submitting
|
||||
? '提交中…'
|
||||
: '提交${_boxes.isEmpty ? '' : '(${_boxes.length} 框)'}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BoxesPainter extends CustomPainter {
|
||||
final List<_NormBox> boxes;
|
||||
final int? selected;
|
||||
final Offset? dragStart; // 归一化
|
||||
final Offset? dragCur;
|
||||
|
||||
_BoxesPainter({
|
||||
required this.boxes,
|
||||
required this.selected,
|
||||
required this.dragStart,
|
||||
required this.dragCur,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final border = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
final b = boxes[i];
|
||||
border.color = i == selected ? Colors.orange : Colors.green;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH((b.cx - b.w / 2) * size.width, (b.cy - b.h / 2) * size.height,
|
||||
b.w * size.width, b.h * size.height),
|
||||
border,
|
||||
);
|
||||
}
|
||||
final s = dragStart;
|
||||
final e = dragCur;
|
||||
if (s != null && e != null) {
|
||||
border.color = Colors.blue;
|
||||
canvas.drawRect(
|
||||
Rect.fromPoints(Offset(s.dx * size.width, s.dy * size.height),
|
||||
Offset(e.dx * size.width, e.dy * size.height)),
|
||||
border,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _BoxesPainter old) =>
|
||||
old.boxes != boxes || old.selected != selected || old.dragCur != dragCur;
|
||||
}
|
||||
@@ -70,24 +70,19 @@ class _StartupGateState extends State<StartupGate> {
|
||||
Navigator.of(context).pushReplacementNamed('/terms');
|
||||
return;
|
||||
}
|
||||
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于
|
||||
// 「已装版本与已确认接受版本」中的较大者即强制更新,弹全屏阻塞页。
|
||||
// APK 版本号不递增时,点过「立即更新」的 accepted 版本参与比较,
|
||||
// 更新完成后再次启动不反复提示。
|
||||
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于已装
|
||||
// 版本即强制更新,弹全屏阻塞页 → 浏览器下载 APK 手动安装
|
||||
// (2026-09-03:App 内安装在小米等 ROM 失败,改浏览器下载)
|
||||
final info = await UpdateChecker().fetch();
|
||||
final current = await PackageInfo.fromPlatform();
|
||||
if (!mounted) return;
|
||||
final session = context.read<SessionStore>();
|
||||
final acceptedUpdate = await session.readAcceptedUpdateVersion();
|
||||
if (!mounted) return;
|
||||
if (UpdateChecker.needsUpdate(info.version, current.version, acceptedUpdate)) {
|
||||
if (UpdateChecker.needsUpdate(info.version, current.version)) {
|
||||
Navigator.of(context).pushReplacement(MaterialPageRoute(
|
||||
builder: (_) => UpdateScreen(
|
||||
version: info.version,
|
||||
url: UpdateChecker.downloadUrl(),
|
||||
notes: info.notes,
|
||||
onUpdateAccepted: () =>
|
||||
session.saveAcceptedUpdateVersion(info.version),
|
||||
),
|
||||
));
|
||||
return;
|
||||
|
||||
@@ -2,17 +2,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// 登录会话持久化:token + 手机号存 secure storage。
|
||||
/// 启动时读取判断是否已登录;登出/401 时清除回登录页。
|
||||
/// 另存「已确认更新版本」:用户点过「立即更新」后记录服务器版本号,
|
||||
/// 与 APK 内 versionName 取较大者参与更新判断(APK 版本号不递增也不会反复提示)。
|
||||
class SessionStore {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _tokenKey = 'auth_token';
|
||||
static const _phoneKey = 'auth_phone';
|
||||
static const _acceptedUpdateKey = 'accepted_update_version';
|
||||
|
||||
static String? _cachedToken;
|
||||
static String? _cachedPhone;
|
||||
static String? _cachedAcceptedUpdate;
|
||||
|
||||
Future<String?> readToken() async {
|
||||
if (_cachedToken != null) return _cachedToken;
|
||||
@@ -24,18 +20,6 @@ class SessionStore {
|
||||
return _cachedPhone = await _storage.read(key: _phoneKey);
|
||||
}
|
||||
|
||||
/// 用户点过「立即更新」的服务器版本号(空串 = 从未接受过更新提示)
|
||||
Future<String> readAcceptedUpdateVersion() async {
|
||||
if (_cachedAcceptedUpdate != null) return _cachedAcceptedUpdate!;
|
||||
return _cachedAcceptedUpdate =
|
||||
(await _storage.read(key: _acceptedUpdateKey)) ?? '';
|
||||
}
|
||||
|
||||
Future<void> saveAcceptedUpdateVersion(String version) async {
|
||||
_cachedAcceptedUpdate = version;
|
||||
await _storage.write(key: _acceptedUpdateKey, value: version);
|
||||
}
|
||||
|
||||
Future<void> save(String phone, String token) async {
|
||||
_cachedPhone = phone;
|
||||
_cachedToken = token;
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert' show jsonEncode;
|
||||
import 'dart:ui' as ui show PlatformDispatcher;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../auth/session_store.dart';
|
||||
import '../detection/detection_result.dart';
|
||||
import '../detection/detector_worker.dart';
|
||||
import '../feedback/feedback_api.dart';
|
||||
import '../feedback/false_target_capture.dart';
|
||||
import '../models/model_manager.dart';
|
||||
import '../reminder/reminder.dart';
|
||||
import 'app_camera_controller.dart';
|
||||
@@ -32,7 +39,7 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
String? _initError;
|
||||
|
||||
/// 置信度阈值(设置页滑块调整,worker 内实时生效)
|
||||
double _minScore = 0.10;
|
||||
double _minScore = 0.015;
|
||||
|
||||
/// 原生侧帧状态轮询结果(诊断用;无帧时诊断行也能实时刷新)
|
||||
Map<dynamic, dynamic> _nativeStats = const {};
|
||||
@@ -47,6 +54,84 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
/// ModelManager revision 快照:模型文件更新(自动更新下载新版)也需重建 worker
|
||||
int _lastRevision = -1;
|
||||
|
||||
/// C 端状态胶囊:3 秒内连点 5 次切换完整开发诊断(远程收截图报障仍可取数)
|
||||
bool _devInfo = false;
|
||||
final List<DateTime> _devTaps = [];
|
||||
|
||||
/// 推理 worker 后台重建中(启用/停用模型触发):期间 modelReady 仍为 false,
|
||||
/// 胶囊与顶部横幅显示「识别准备中」,不误报「模型加载失败」
|
||||
bool _workerPending = false;
|
||||
|
||||
/// 假目标上报进行中(防重复触发)
|
||||
bool _reporting = false;
|
||||
|
||||
static const _falseTargetConsent = FlutterSecureStorage();
|
||||
|
||||
void _onDevTaps() {
|
||||
final now = DateTime.now();
|
||||
_devTaps.add(now);
|
||||
_devTaps
|
||||
.removeWhere((t) => now.difference(t) > const Duration(seconds: 3));
|
||||
if (_devTaps.length < 5) return;
|
||||
_devTaps.clear();
|
||||
setState(() => _devInfo = !_devInfo);
|
||||
}
|
||||
|
||||
/// 识别延迟分档(C 端直观读法:流畅 / 一般 / 偏慢)
|
||||
({String label, Color color}) _latencyBucket(double avgMs) {
|
||||
if (avgMs <= 200) return (label: '流畅', color: Colors.greenAccent);
|
||||
if (avgMs <= 600) return (label: '一般', color: Colors.amberAccent);
|
||||
return (label: '偏慢', color: Colors.redAccent);
|
||||
}
|
||||
|
||||
/// C 端底部状态胶囊内容:设备识别延迟(近帧滚动平均)+ 当前识别模型
|
||||
List<Widget> _statusCapsuleLines(CameraUiState s) {
|
||||
if (!s.modelReady) {
|
||||
if (_workerPending) {
|
||||
// worker 后台构建中(用户刚启用模型):给个进行时状态,别误读为未启用
|
||||
return const [
|
||||
Text(
|
||||
'识别准备中…',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
];
|
||||
}
|
||||
return const [
|
||||
Text(
|
||||
'仅预览 · 未启用识别',
|
||||
style: TextStyle(color: Colors.orangeAccent, fontSize: 12),
|
||||
),
|
||||
];
|
||||
}
|
||||
final avg = s.latencyAvgMs;
|
||||
final lines = <Widget>[];
|
||||
if (avg == null) {
|
||||
lines.add(const Text(
|
||||
'识别延迟 --',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
));
|
||||
} else {
|
||||
final b = _latencyBucket(avg);
|
||||
lines.add(Text(
|
||||
'识别延迟 ${(avg / 1000).toStringAsFixed(2)} 秒 · ${b.label}',
|
||||
style: TextStyle(
|
||||
color: b.color,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
));
|
||||
}
|
||||
final names = ModelManager.instance.modelsLabel;
|
||||
if (names.isNotEmpty && names != '未下载') {
|
||||
lines.add(Text(
|
||||
'识别模型:$names',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 10.5),
|
||||
));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
void _openSettings() {
|
||||
final vm = _viewModel;
|
||||
if (vm == null) return;
|
||||
@@ -56,53 +141,66 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.black87,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSheetState) => SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('识别设置',
|
||||
// 弹层高度 ≤ 70% 屏高:超过会盖满全屏使遮罩无处点按,弹层无法关闭
|
||||
builder: (ctx, setSheetState) => ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(ctx).size.height * 0.7,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'识别设置',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text('置信度阈值',
|
||||
style:
|
||||
TextStyle(color: Colors.white70, fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text('${(_minScore * 100).toStringAsFixed(0)}%',
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'置信度阈值',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${(_minScore * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.greenAccent,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _minScore,
|
||||
min: 0.05,
|
||||
max: 0.50,
|
||||
divisions: 45,
|
||||
activeColor: Colors.greenAccent,
|
||||
onChanged: (v) {
|
||||
setSheetState(() => _minScore = v);
|
||||
_analyzer?.worker?.setMinScore(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'阈值越低识别越灵敏(低分框越多,误报也可能增加)',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(color: Colors.white12),
|
||||
const SizedBox(height: 8),
|
||||
ModelCatalogSection(manager: ModelManager.instance),
|
||||
],
|
||||
color: Colors.greenAccent,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: _minScore,
|
||||
min: 0.01,
|
||||
max: 0.50,
|
||||
divisions: 49,
|
||||
activeColor: Colors.greenAccent,
|
||||
onChanged: (v) {
|
||||
setSheetState(() => _minScore = v);
|
||||
_analyzer?.worker?.setMinScore(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'阈值越低识别越灵敏(低分框越多,误报也可能增加)',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(color: Colors.white12),
|
||||
const SizedBox(height: 8),
|
||||
ModelCatalogSection(manager: ModelManager.instance),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -110,20 +208,118 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 假目标上报:一键上报当前画面——按一次快照帧(与喂给 YOLO 的帧同源同尺寸)
|
||||
/// + 当前全部检测框快照,单独同意(首次)→ isolate 内整帧 jpg 重编码
|
||||
/// (剥离全部元数据)→ 上传。无任何额外选择步骤,取消/失败不影响识别。
|
||||
Future<void> _startFalseTargetReport() async {
|
||||
final analyzer = _analyzer;
|
||||
final vm = _viewModel;
|
||||
if (_reporting || analyzer == null || vm == null || !vm.state.modelReady) {
|
||||
return;
|
||||
}
|
||||
_reporting = true;
|
||||
try {
|
||||
final snap = await analyzer
|
||||
.takeSnapshot()
|
||||
.timeout(const Duration(seconds: 2), onTimeout: () => null);
|
||||
if (!mounted || snap == null) {
|
||||
_toast('未获取到画面,请稍后重试');
|
||||
return;
|
||||
}
|
||||
final ok = await _ensureFalseTargetConsent();
|
||||
if (!mounted || !ok) return;
|
||||
_toast('正在上报…');
|
||||
final boxes = List<DetectionResult>.of(vm.state.results)
|
||||
.where((b) => b.width > 0 && b.height > 0)
|
||||
.toList();
|
||||
final detections = jsonEncode([
|
||||
for (final b in boxes)
|
||||
{
|
||||
'label': b.label,
|
||||
'class': b.classId,
|
||||
'score': double.parse(b.score.toStringAsFixed(4)),
|
||||
'model': b.modelName,
|
||||
'cx': double.parse(b.centerX.toStringAsFixed(6)),
|
||||
'cy': double.parse(b.centerY.toStringAsFixed(6)),
|
||||
'w': double.parse(b.width.toStringAsFixed(6)),
|
||||
'h': double.parse(b.height.toStringAsFixed(6)),
|
||||
},
|
||||
]);
|
||||
final jpeg = await compute(encodeFrameJpeg, snap);
|
||||
await FeedbackApi(sessionStore: SessionStore()).reportFalseTarget(
|
||||
jpeg: jpeg,
|
||||
detectionsJson: detections,
|
||||
sourceW: snap.width,
|
||||
sourceH: snap.height,
|
||||
);
|
||||
_toast('感谢反馈!我们会用它改进识别');
|
||||
} catch (e) {
|
||||
_toast('上报失败: $e');
|
||||
} finally {
|
||||
_reporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 单独同意(PIPL):首次上报前弹说明,可拒绝且不影响识别;同意后记录不再弹
|
||||
Future<bool> _ensureFalseTargetConsent() async {
|
||||
if (await _falseTargetConsent.read(key: falseTargetConsentKey) == '1') {
|
||||
return true;
|
||||
}
|
||||
if (!mounted) return false;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('上报说明'),
|
||||
content: const Text(
|
||||
'将上传当前画面一帧(与识别使用的画面一致,上传前已在本地重新编码、'
|
||||
'移除位置等全部照片信息)及当时的识别框,用于改进识别模型。\n\n'
|
||||
'该功能完全自愿,拒绝不影响任何其他功能。已上传的图片可联系客服删除。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('暂不上报'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('同意并继续'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await _falseTargetConsent.write(key: falseTargetConsentKey, value: '1');
|
||||
}
|
||||
return ok == true;
|
||||
}
|
||||
|
||||
void _toast(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text(msg), duration: const Duration(seconds: 2)));
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final oldPlatform = ui.PlatformDispatcher.instance.onError;
|
||||
ui.PlatformDispatcher.instance.onError = (error, stack) {
|
||||
setState(() => _globalError =
|
||||
'Platform: $error\n${stack.toString().split('\n').take(3).join('\n')}');
|
||||
setState(
|
||||
() => _globalError =
|
||||
'Platform: $error\n${stack.toString().split('\n').take(3).join('\n')}',
|
||||
);
|
||||
return oldPlatform?.call(error, stack) ?? false;
|
||||
};
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
|
||||
// 相机页常亮:野外观察时保持屏幕不熄(离开页面时关闭)
|
||||
WakelockPlus.enable();
|
||||
// 每秒轮询原生侧帧状态:无帧时诊断行也能实时刷新(camErr/计数)
|
||||
_statsTimer = Timer.periodic(const Duration(seconds: 1), (_) => _pollStats());
|
||||
_statsTimer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => _pollStats(),
|
||||
);
|
||||
// 模型清单激活集变化(下载完成自动激活/取消激活)时重建推理 worker
|
||||
ModelManager.instance.addListener(_onModelsChanged);
|
||||
}
|
||||
@@ -142,29 +338,37 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
setState(() => _permissionGranted = granted);
|
||||
if (!granted) return;
|
||||
|
||||
// 模型热更新:启动拉取目录(只拉不下载),兜底等待;已下载/已激活模型
|
||||
// 有新版本时 autoUpdate 自动重下,变化经 _onModelsChanged 重建 worker
|
||||
try {
|
||||
await ModelManager.instance
|
||||
.refresh()
|
||||
.timeout(const Duration(seconds: 30));
|
||||
} catch (_) {}
|
||||
await _reloadWorker();
|
||||
// 新识别会话(2026-09-03):不恢复上次启用的模型——每次进入都从仅预览开始,
|
||||
// 识别需用户在模型清单手动启用;崩溃/坏模型不会自动复现。激活集变化经
|
||||
// _onModelsChanged 重建 worker 挂载
|
||||
ModelManager.instance.resetForSession();
|
||||
// 目录同步先行(缓存优先,2026-09-03):进页面立即触发——refresh 开头先把
|
||||
// 上次成功目录从磁盘载入并 notify,设置弹层打开即有内容(不完全依赖网络);
|
||||
// 网络拉取/自动更新在后台继续,全程不阻塞预览
|
||||
unawaited(ModelManager.instance.refresh());
|
||||
// 先出预览:占位 analyzer(无 worker)让相机立即启动,识别加载完再原地挂上
|
||||
if (_analyzer == null) {
|
||||
final vm = CameraViewModel(reminder: Reminder());
|
||||
_viewModel = vm;
|
||||
_analyzer = FrameAnalyzer(worker: null, viewModel: vm);
|
||||
}
|
||||
await _startCamera();
|
||||
}
|
||||
|
||||
/// 用当前激活模型重建推理 worker(激活集变化/启动时调用);
|
||||
/// worker 为 null(无激活模型或加载失败)时仅预览并提示。
|
||||
/// 并发调用共享同一进行中的重建:refresh 通知触发的重建与 _init 的等待
|
||||
/// 共用一个 Future,相机等重建完成后再启动(避免绑定旧 analyzer)。
|
||||
/// 用当前激活模型重建推理 worker(启用/停用模型、模型文件更新时调用);
|
||||
/// worker 为 null(无启用模型或加载失败)时仅预览并提示。
|
||||
/// 并发调用共享同一进行中的重建:进行中时先等完成再复查,期间变化不丢失。
|
||||
Future<void> _reloadWorker() {
|
||||
final inFlight = _reloadInFlight;
|
||||
if (inFlight != null) {
|
||||
// 重建进行中:完成后按最新状态复查,期间的变化不丢失
|
||||
return inFlight.then((_) => _reloadWorker()).catchError((_) {});
|
||||
}
|
||||
_reloadInFlight =
|
||||
_doReloadWorker().whenComplete(() => _reloadInFlight = null);
|
||||
if (mounted) setState(() => _workerPending = true);
|
||||
_reloadInFlight = _doReloadWorker().whenComplete(() {
|
||||
_reloadInFlight = null;
|
||||
if (mounted) setState(() => _workerPending = false);
|
||||
});
|
||||
return _reloadInFlight!;
|
||||
}
|
||||
|
||||
@@ -182,24 +386,31 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
_lastRevision = mgr.revision;
|
||||
final worker = await DetectorWorker.create(models: models);
|
||||
_loadedModelIds = ids;
|
||||
if (!mounted) {
|
||||
worker?.dispose();
|
||||
return; // 页面已关闭:不触碰可能已 dispose 的 vm/analyzer
|
||||
}
|
||||
final vm = _viewModel ?? CameraViewModel(reminder: Reminder());
|
||||
vm.setModelReady(worker != null);
|
||||
final analyzer = FrameAnalyzer(worker: worker, viewModel: vm);
|
||||
final old = _analyzer;
|
||||
if (!mounted) {
|
||||
analyzer.dispose();
|
||||
if (vm != _viewModel) vm.dispose();
|
||||
// 重建后把设置页调过的阈值落到新 worker(避免静默回到默认值)
|
||||
worker?.setMinScore(_minScore);
|
||||
final existing = _analyzer;
|
||||
if (existing == null) {
|
||||
// 相机尚未绑定 analyzer(冷启动竞态兜底):直接以新 worker 建 analyzer
|
||||
final analyzer = FrameAnalyzer(worker: worker, viewModel: vm);
|
||||
setState(() {
|
||||
_viewModel = vm;
|
||||
_analyzer = analyzer;
|
||||
});
|
||||
if (_cameraController != null) {
|
||||
await _cameraController!.start(analyzer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_viewModel = vm;
|
||||
_analyzer = analyzer;
|
||||
});
|
||||
old?.worker?.dispose();
|
||||
// 相机已启动:重启帧流绑定新 analyzer(start 内部先 stop 再订阅)
|
||||
if (mounted && _cameraController != null) {
|
||||
await _cameraController!.start(analyzer);
|
||||
}
|
||||
// 相机已绑定(预览中):worker 原地挂到既有 analyzer——帧流回调闭包捕获的
|
||||
// 是 analyzer 对象本身,替换其 worker 双平台即时生效;重启相机会重新
|
||||
// initialize(iOS ~1s+)且预览闪断,一律避免(2026-09-03)
|
||||
existing.attachWorker(worker);
|
||||
}
|
||||
|
||||
void _onModelsChanged() {
|
||||
@@ -310,7 +521,10 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('相机初始化失败', style: TextStyle(color: Colors.white)),
|
||||
const Text(
|
||||
'相机初始化失败',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
if (_initError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
@@ -320,7 +534,9 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent, fontSize: 11),
|
||||
color: Colors.redAccent,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
@@ -361,8 +577,7 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
right: 16,
|
||||
top: MediaQuery.of(context).padding.top + 56,
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -375,8 +590,9 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠)
|
||||
if (!vm.state.modelReady)
|
||||
// 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠);
|
||||
// worker 后台构建中不提示「加载失败」(modelReady 就绪前的一瞬)
|
||||
if (!vm.state.modelReady && !_workerPending)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
@@ -397,6 +613,34 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// 假目标上报入口:识别就绪且有框时出现(完全自愿,可忽略)
|
||||
if (vm.state.modelReady && vm.state.results.isNotEmpty && !_workerPending)
|
||||
Positioned(
|
||||
left: 12,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 72,
|
||||
child: Material(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: _startFalseTargetReport,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.flag_outlined,
|
||||
color: Colors.orangeAccent, size: 16),
|
||||
SizedBox(width: 5),
|
||||
Text('误报上报',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 8,
|
||||
@@ -405,56 +649,87 @@ class _CameraScreenState extends State<CameraScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'阈值:${(_minScore * 100).toStringAsFixed(0)}% 模型:${vm.state.modelReady ? ModelManager.instance.modelsLabel : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 传感:${camera?.sensorOrientation ?? '-'} 屏转:${camera?.displayDegrees ?? '-'} 旋:${camera?.rotationDegrees ?? 0} turn:${camera?.quarterTurns ?? '-'}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
// C 端状态胶囊:识别延迟 + 识别模型(连点 5 次展开完整开发诊断)
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _onDevTaps,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: _statusCapsuleLines(vm.state),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_nativeStats.isNotEmpty)
|
||||
Text(
|
||||
'原生:回调${_nativeStats['callbacks'] ?? '-'} 发出${_nativeStats['emitOk'] ?? '-'} 异常${_nativeStats['emitErr'] ?? '-'} 无订阅${_nativeStats['sinkNull'] ?? '-'} sink:${_nativeStats['sink'] ?? '-'} 配置:${_nativeStats['size'] ?? '-'} 发帧:${_nativeStats['emitSize'] ?? '-'} 错误:${_nativeStats['error'] ?? '无'} 轮询:${_nativeStats['pollErr'] ?? 'ok'}${vm.state.results.isEmpty ? '' : ' 框1:(${vm.state.results.first.left.toStringAsFixed(2)},${vm.state.results.first.top.toStringAsFixed(2)},${vm.state.results.first.right.toStringAsFixed(2)},${vm.state.results.first.bottom.toStringAsFixed(2)})'}',
|
||||
style: const TextStyle(
|
||||
color: Colors.amberAccent, fontSize: 11),
|
||||
// —— 开发诊断(默认隐藏,远程排查用)——
|
||||
if (_devInfo) ...[
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'开发诊断(连点上方状态胶囊 5 次收起)',
|
||||
style: TextStyle(color: Colors.white24, fontSize: 10),
|
||||
),
|
||||
if (vm.state.debugYuv.isNotEmpty)
|
||||
Text(
|
||||
'yuv:${vm.state.debugYuv}',
|
||||
style: const TextStyle(
|
||||
color: Colors.yellowAccent, fontSize: 11),
|
||||
),
|
||||
if (camera != null)
|
||||
Text(
|
||||
'streaming:${camera.isStreaming} '
|
||||
'camErr:${camera.errorDescription ?? '无'}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.cyanAccent, fontSize: 11),
|
||||
),
|
||||
if (vm.state.debugLastError != null)
|
||||
Text(
|
||||
vm.state.debugLastError!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent, fontSize: 11),
|
||||
),
|
||||
if (_globalError != null)
|
||||
Text(
|
||||
_globalError!,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent, fontSize: 11),
|
||||
'阈值:${(_minScore * 100).toStringAsFixed(0)}% 模型:${vm.state.modelReady ? ModelManager.instance.modelsLabel : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 平均:${vm.state.latencyAvgMs?.toStringAsFixed(0) ?? '-'}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 传感:${camera?.sensorOrientation ?? '-'} 屏转:${camera?.displayDegrees ?? '-'} 旋:${camera?.rotationDegrees ?? 0} turn:${camera?.quarterTurns ?? '-'}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
if (_nativeStats.isNotEmpty)
|
||||
Text(
|
||||
'原生:回调${_nativeStats['callbacks'] ?? '-'} 发出${_nativeStats['emitOk'] ?? '-'} 异常${_nativeStats['emitErr'] ?? '-'} 无订阅${_nativeStats['sinkNull'] ?? '-'} sink:${_nativeStats['sink'] ?? '-'} 配置:${_nativeStats['size'] ?? '-'} 发帧:${_nativeStats['emitSize'] ?? '-'} 错误:${_nativeStats['error'] ?? '无'} 轮询:${_nativeStats['pollErr'] ?? 'ok'}${vm.state.results.isEmpty ? '' : ' 框1:(${vm.state.results.first.left.toStringAsFixed(2)},${vm.state.results.first.top.toStringAsFixed(2)},${vm.state.results.first.right.toStringAsFixed(2)},${vm.state.results.first.bottom.toStringAsFixed(2)})'}',
|
||||
style: const TextStyle(
|
||||
color: Colors.amberAccent,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
if (vm.state.debugYuv.isNotEmpty)
|
||||
Text(
|
||||
'yuv:${vm.state.debugYuv}',
|
||||
style: const TextStyle(
|
||||
color: Colors.yellowAccent,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
if (camera != null)
|
||||
Text(
|
||||
'streaming:${camera.isStreaming} '
|
||||
'camErr:${camera.errorDescription ?? '无'}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.cyanAccent,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
if (vm.state.debugLastError != null)
|
||||
Text(
|
||||
vm.state.debugLastError!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
const TextStyle(color: Colors.redAccent, fontSize: 11),
|
||||
),
|
||||
if (_globalError != null)
|
||||
Text(
|
||||
_globalError!,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
const TextStyle(color: Colors.redAccent, fontSize: 11),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// 双指捏合缩放预览;overlay 与纹理同几何(Stack 内同尺寸)
|
||||
@@ -464,10 +739,7 @@ class _ZoomablePreview extends StatefulWidget {
|
||||
/// 检测框 overlay(随帧更新,与纹理同区域)
|
||||
final Widget? overlay;
|
||||
|
||||
const _ZoomablePreview({
|
||||
required this.controller,
|
||||
this.overlay,
|
||||
});
|
||||
const _ZoomablePreview({required this.controller, this.overlay});
|
||||
|
||||
@override
|
||||
State<_ZoomablePreview> createState() => _ZoomablePreviewState();
|
||||
@@ -497,8 +769,7 @@ class _ZoomablePreviewState extends State<_ZoomablePreview> {
|
||||
return GestureDetector(
|
||||
onScaleStart: (_) => _gestureStartZoom = _currentZoom,
|
||||
onScaleUpdate: (d) {
|
||||
final target =
|
||||
(_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom);
|
||||
final target = (_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom);
|
||||
if ((target - _currentZoom).abs() < 0.01) return;
|
||||
_currentZoom = target;
|
||||
widget.controller.setZoomLevel(target);
|
||||
@@ -518,10 +789,7 @@ class _CameraTopBar extends StatelessWidget {
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onOpenSettings;
|
||||
|
||||
const _CameraTopBar({
|
||||
required this.onClose,
|
||||
required this.onOpenSettings,
|
||||
});
|
||||
const _CameraTopBar({required this.onClose, required this.onOpenSettings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -558,10 +826,7 @@ class _PermissionGuide extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'需要相机权限才能进行实时识别',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
const Text('需要相机权限才能进行实时识别', style: TextStyle(color: Colors.white)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: onRequest, child: const Text('授权相机')),
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../detection/detection_result.dart';
|
||||
import '../detection/motion_aggregator.dart';
|
||||
import '../detection/nms.dart';
|
||||
import '../reminder/reminder.dart';
|
||||
|
||||
@immutable
|
||||
@@ -21,6 +22,10 @@ class CameraUiState {
|
||||
final int debugLastMs;
|
||||
final String debugYuv;
|
||||
|
||||
/// 近 [CameraViewModel.latencyWindow] 帧推理耗时滚动平均(毫秒;无样本为 null)。
|
||||
/// 给 C 端用户看的设备识别延迟。
|
||||
final double? latencyAvgMs;
|
||||
|
||||
const CameraUiState({
|
||||
this.modelReady = false,
|
||||
this.results = const [],
|
||||
@@ -34,15 +39,19 @@ class CameraUiState {
|
||||
this.framesReceived = 0,
|
||||
this.debugLastMs = 0,
|
||||
this.debugYuv = '',
|
||||
this.latencyAvgMs,
|
||||
});
|
||||
}
|
||||
|
||||
/// 检测结果置信度分级与轨迹确认。
|
||||
///
|
||||
/// - [highConf](0.35):高于此分直接确认显示;真实环颈雉鸡多为 0.1~0.2,
|
||||
/// - [highConf](0.35):高于此分直接确认显示;真实目标多为 0.1~0.2,
|
||||
/// 高于 0.35 视为强证据。
|
||||
/// - 低于 0.35 的框:需要多帧稳定([confirmFrames] 帧)或 活动证据
|
||||
/// (运动区域/背景新出现区域重叠)才确认显示。
|
||||
/// - 同位置去重(2026-09-03):多模型对同一目标交替检出时各轨迹都会在
|
||||
/// 忘记窗内持续显示 → 轨迹层跨标签同目标补挂 + 显示层
|
||||
/// [dedupeVisibleOverlaps] 同类别强重叠只保留高置信度框。
|
||||
class CameraViewModel extends ChangeNotifier {
|
||||
static const int maxTracks = 30;
|
||||
static const double motionBoost = 0.15;
|
||||
@@ -52,6 +61,10 @@ class CameraViewModel extends ChangeNotifier {
|
||||
static const int displayAgeMs = 500;
|
||||
static const int forgetMs = 2000;
|
||||
|
||||
/// 延迟滚动平均窗口(帧数):单帧抖动大,取近期均值给用户展示
|
||||
static const int latencyWindow = 30;
|
||||
final List<int> _procWindow = [];
|
||||
|
||||
/// 推理后台 isolate 是否就绪(由相机页创建 worker 后设置)
|
||||
bool modelReady = false;
|
||||
|
||||
@@ -68,6 +81,8 @@ class CameraViewModel extends ChangeNotifier {
|
||||
void setModelReady(bool ready) {
|
||||
if (modelReady == ready) return;
|
||||
modelReady = ready;
|
||||
// 换 worker / 停识别:旧窗口样本作废,从零起算
|
||||
_procWindow.clear();
|
||||
_state = CameraUiState(modelReady: ready);
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -105,9 +120,10 @@ class CameraViewModel extends ChangeNotifier {
|
||||
visible.add(r.copyWith(confirmed: t.confirmed));
|
||||
}
|
||||
|
||||
// 提醒:仅新确认的目标物种轨迹(class 0,如环颈雉鸡;确认瞬间触发一次,10s 同类冷却在 Reminder 内)
|
||||
// 提醒:仅新确认的目标物种轨迹(label 非 suspect 即目标——单物种模型
|
||||
// class 0、综合模型各物种索引 0..N-1;确认瞬间触发一次,10s 冷却在 Reminder 内)
|
||||
for (final t in _tracks.values) {
|
||||
final isSuspect = t.result.classId > 0 || t.label == 'suspect';
|
||||
final isSuspect = t.result.label == 'suspect';
|
||||
if (isSuspect || !t.confirmed || t.reminded) continue;
|
||||
final age = now - t.firstSeenMs;
|
||||
if (age >= displayAgeMs && age <= displayAgeMs + 1600 &&
|
||||
@@ -121,9 +137,18 @@ class CameraViewModel extends ChangeNotifier {
|
||||
for (final r in results) {
|
||||
if (r.score > highest) highest = r.score;
|
||||
}
|
||||
if (lastProcessMs > 0) {
|
||||
_procWindow.add(lastProcessMs);
|
||||
if (_procWindow.length > latencyWindow) {
|
||||
_procWindow.removeAt(0);
|
||||
}
|
||||
}
|
||||
final latencyAvg = _procWindow.isEmpty
|
||||
? null
|
||||
: _procWindow.reduce((a, b) => a + b) / _procWindow.length;
|
||||
_state = CameraUiState(
|
||||
modelReady: modelReady,
|
||||
results: visible,
|
||||
results: dedupeVisibleOverlaps(visible),
|
||||
rotation: rotation,
|
||||
imageWidthPx: imageWidthPx,
|
||||
imageHeightPx: imageHeightPx,
|
||||
@@ -134,6 +159,7 @@ class CameraViewModel extends ChangeNotifier {
|
||||
framesReceived: framesReceived,
|
||||
debugLastMs: lastProcessMs,
|
||||
debugYuv: yuvDiag.isNotEmpty ? yuvDiag : _state.debugYuv,
|
||||
latencyAvgMs: latencyAvg,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -147,19 +173,40 @@ class CameraViewModel extends ChangeNotifier {
|
||||
int now) {
|
||||
final matched = <int>{};
|
||||
for (final r in results) {
|
||||
if (!_plausible(r)) continue;
|
||||
// 仅挡退化数据(零宽/零高):尺寸与宽高比不设限——过近/过远的真实
|
||||
// 目标同样要显示(2026-09-12 用户定案:原几何门把远处小鸟和近处
|
||||
// 大目标一并丢弃;质量交给置信度 + 轨迹确认把关)
|
||||
if (r.width <= 0 || r.height <= 0) continue;
|
||||
_Track? best;
|
||||
var bestD = associateRadius;
|
||||
for (final t in _tracks.values) {
|
||||
if (matched.contains(t.id)) continue;
|
||||
final d = _centerDist(t.result, r);
|
||||
// 同标签宽松匹配;跨标签(环颈雉鸡↔疑似 抖动)收紧到 60%
|
||||
// 同标签宽松匹配;跨标签(目标↔疑似 抖动)收紧到 60%
|
||||
final limit = t.label == r.label ? bestD : associateRadius * 0.6;
|
||||
if (d < limit) {
|
||||
bestD = d;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
// 跨标签同目标补挂(2026-09-03 用户实测修订:重叠位置只保留高置信度框):
|
||||
// 中心距超过跨标签收紧半径、但几何强重叠指向同一位置(不同模型对同一
|
||||
// 目标的框偏移/紧致度不同)的检测并入既有轨迹,防止同目标两条轨迹并存
|
||||
// → 同位置双名常驻/重复提醒。疑似↔目标(不同类别预警)不并入。
|
||||
if (best == null) {
|
||||
_Track? adopt;
|
||||
var adoptD = double.infinity;
|
||||
for (final t in _tracks.values) {
|
||||
if (matched.contains(t.id)) continue;
|
||||
if (t.isSuspect != r.isSuspect || !sameTarget(t.result, r)) continue;
|
||||
final d = _centerDist(t.result, r);
|
||||
if (d < adoptD) {
|
||||
adoptD = d;
|
||||
adopt = t;
|
||||
}
|
||||
}
|
||||
best = adopt;
|
||||
}
|
||||
if (best != null) {
|
||||
matched.add(best.id);
|
||||
best.update(r, now);
|
||||
@@ -182,10 +229,10 @@ class CameraViewModel extends ChangeNotifier {
|
||||
|
||||
/// 显示判定(按类别策略):
|
||||
/// - 疑似(生境预警):设计意图是常驻静态预警,始终显示(渲染侧弱化)
|
||||
/// - 环颈雉鸡:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示
|
||||
/// - 目标物种:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示
|
||||
bool _shouldDisplay(_Track t, List<MotionRegion> motionRegions,
|
||||
List<MotionRegion> noveltyRegions) {
|
||||
if (t.result.classId > 0 || t.label == 'suspect') return true;
|
||||
if (t.label == 'suspect') return true;
|
||||
if (t.confirmed) return true;
|
||||
return t.result.score >= highConf ||
|
||||
_hasActivity(t.result, motionRegions, noveltyRegions);
|
||||
@@ -197,17 +244,6 @@ class CameraViewModel extends ChangeNotifier {
|
||||
motionRegions.any((m) => MotionAggregator.centerInRegion(r, m)) ||
|
||||
noveltyRegions.any((m) => MotionAggregator.centerInRegion(r, m));
|
||||
|
||||
/// 物理合理性过滤:宽高比与相对尺寸(环颈雉鸡 20-100px@720 量级,参照标注脚本)
|
||||
bool _plausible(DetectionResult r) {
|
||||
final h = r.height;
|
||||
final w = r.width;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
final aspect = w / h;
|
||||
if (aspect < 0.3 || aspect > 3.0) return false;
|
||||
if (r.classId > 0 || r.label == 'suspect') return h >= 0.01 && h <= 0.5;
|
||||
return h >= 0.01 && h <= 0.3;
|
||||
}
|
||||
|
||||
double _centerDist(DetectionResult a, DetectionResult b) =>
|
||||
math.sqrt(math.pow(a.centerX - b.centerX, 2) +
|
||||
math.pow(a.centerY - b.centerY, 2));
|
||||
@@ -233,8 +269,30 @@ class _Track {
|
||||
: lastSeenMs = firstSeenMs,
|
||||
label = result.label;
|
||||
|
||||
/// 当前框是否疑似类别(随关联的最新检测更新——轨迹框在跨标签补挂后
|
||||
/// 会换成其他标签的框;轨迹 label 仅创建时记账)
|
||||
bool get isSuspect => result.isSuspect;
|
||||
|
||||
void update(DetectionResult r, int now) {
|
||||
lastSeenMs = now;
|
||||
result = r;
|
||||
}
|
||||
}
|
||||
|
||||
/// 同屏抑制(2026-09-03 用户实测修订:同一位置/重叠位置出现同/异模型检出的
|
||||
/// 物种只保留高置信度者)。根因:多个模型对同一目标**交替**检出时,每帧合并
|
||||
/// 层只压掉当帧低分框,但各自轨迹都落在 2s 忘记窗内持续显示 → 同位置双名
|
||||
/// 常驻。显示层兜底:可见框内同类别(都目标/都疑似)、且 [sameTarget] 强
|
||||
/// 重叠指向同一位置的框每帧只保留最高分者。异类别(目标×疑似生境预警)是
|
||||
/// 两种语义不同的框,同位置也各自保留。
|
||||
List<DetectionResult> dedupeVisibleOverlaps(List<DetectionResult> visible) {
|
||||
if (visible.length <= 1) return visible;
|
||||
final sorted = [...visible]..sort((a, b) => b.score.compareTo(a.score));
|
||||
final kept = <DetectionResult>[];
|
||||
for (final r in sorted) {
|
||||
if (!kept.any((k) => k.isSuspect == r.isSuspect && sameTarget(k, r))) {
|
||||
kept.add(r);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ import '../detection/coordinate_mapper.dart';
|
||||
import '../detection/detection_result.dart';
|
||||
|
||||
/// 检测框绘制分级:
|
||||
/// - 环颈雉鸡 confirmed:红色实线 3px(强证据)
|
||||
/// - 环颈雉鸡 candidate:红色虚线 2px 半透明(待确认,弱提示)
|
||||
/// - 目标物种 confirmed:红色实线 3px(强证据)
|
||||
/// - 目标物种 candidate:红色虚线 2px 半透明(待确认,弱提示)
|
||||
/// - 疑似(生境预警):黄色虚线 2px 半透明(常驻静态预警,弱化渲染)
|
||||
/// 标签附带距离估计(针孔模型 焦距px×参考体型/框高px)。
|
||||
/// 名称文本只取模型输出的 label(数据集训练决定,App 不内置物种名)。
|
||||
class DetectionOverlay extends StatelessWidget {
|
||||
final List<DetectionResult> results;
|
||||
final int rotation;
|
||||
@@ -44,9 +45,11 @@ class _OverlayPainter extends CustomPainter {
|
||||
_OverlayPainter(this.results, this.rotation, this.imageWidthPx,
|
||||
this.imageHeightPx);
|
||||
|
||||
static const _labels = {'pheasant': '环颈雉鸡', 'suspect': '疑似'};
|
||||
// 仅保留通用「疑似」中文翻译;物种名由模型 labels 直接展示
|
||||
// (识别什么物种由训练好的模型决定,App 不内置物种名)
|
||||
static const _labels = {'suspect': '疑似'};
|
||||
|
||||
/// 参考体型(米):目标物种(class 0,如环颈雉鸡)身高 / suspect 植被高度
|
||||
/// 参考体型(米):目标物种(class 0)身高 / suspect 植被高度
|
||||
static const double _refSizeSpeciesM = 0.45;
|
||||
static const double _refSizeSuspectM = 0.50;
|
||||
|
||||
@@ -69,9 +72,9 @@ class _OverlayPainter extends CustomPainter {
|
||||
size.width,
|
||||
size.height,
|
||||
);
|
||||
// 颜色按类别索引而非 label 文本:模型类别名可能为中文(环颈雉)或
|
||||
// 随数据集变化,class 0 恒为目标物种(红),其余类恒为 suspect(黄)
|
||||
final isSuspect = r.classId > 0 || r.label == 'suspect';
|
||||
// 颜色按 label 文本而非类别索引(2026-09-09 综合模型多类:0..N-1 均为
|
||||
// 目标物种红框,仅类名 suspect 走生境预警黄框;单物种模型语义同前)
|
||||
final isSuspect = r.label == 'suspect';
|
||||
final color = isSuspect ? Color(0xFFFDD835) : Color(0xFFE53935);
|
||||
final confirmed = r.confirmed && !isSuspect;
|
||||
final paint = Paint()
|
||||
@@ -112,9 +115,7 @@ class _OverlayPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
String _distanceLabel(DetectionResult r) {
|
||||
final refH = (r.classId > 0 || r.label == 'suspect')
|
||||
? _refSizeSuspectM
|
||||
: _refSizeSpeciesM;
|
||||
final refH = r.label == 'suspect' ? _refSizeSuspectM : _refSizeSpeciesM;
|
||||
final hPx = r.height * imageHeightPx;
|
||||
if (hPx < 8) return '';
|
||||
final m = focalPx * refH / hPx;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
|
||||
import '../detection/detection_result.dart';
|
||||
import '../detection/detector_worker.dart';
|
||||
import '../feedback/false_target_capture.dart';
|
||||
import 'camera_view_model.dart';
|
||||
|
||||
/// 抽帧节流 + 后台推理(对应 Kotlin FrameAnalyzer)。
|
||||
@@ -12,8 +14,12 @@ class FrameAnalyzer {
|
||||
/// 连续检测:100ms 一帧
|
||||
int intervalMs = 100;
|
||||
|
||||
/// null = 模型加载失败,仅预览不分析
|
||||
final DetectorWorker? worker;
|
||||
DetectorWorker? _worker;
|
||||
|
||||
/// 当前推理 worker(null = 仅预览不分析);相机启动后可用 [attachWorker]
|
||||
/// 原地替换(相机帧流回调闭包捕获的是本 analyzer 对象,无需重启相机)
|
||||
DetectorWorker? get worker => _worker;
|
||||
|
||||
final CameraViewModel viewModel;
|
||||
|
||||
int _lastDetectMs = 0;
|
||||
@@ -29,9 +35,52 @@ class FrameAnalyzer {
|
||||
/// 图像流回调是否到达(诊断用)
|
||||
int framesReceived = 0;
|
||||
|
||||
FrameAnalyzer({required this.worker, required this.viewModel}) {
|
||||
worker?.onResult = _onResult;
|
||||
worker?.onError = _onError;
|
||||
/// 假目标上报快照:armed 时下一帧(节流前)拷贝单平面像素并交付。
|
||||
/// Completer 泛型必须可空:运行时 future 类型决定 .timeout(onTimeout) 的
|
||||
/// 回调签名,非空 Completer 会让 `() => null` 触发运行时子类型错误
|
||||
Completer<FrameSnapshot?>? _snapCompleter;
|
||||
|
||||
FrameAnalyzer({DetectorWorker? worker, required this.viewModel}) {
|
||||
attachWorker(worker);
|
||||
}
|
||||
|
||||
/// 取下一帧快照(假目标上报用;在节流判定之前捕获,~33ms 内必有帧)
|
||||
Future<FrameSnapshot?> takeSnapshot() {
|
||||
final existing = _snapCompleter;
|
||||
if (existing != null && !existing.isCompleted) {
|
||||
return existing.future;
|
||||
}
|
||||
final c = Completer<FrameSnapshot?>();
|
||||
_snapCompleter = c;
|
||||
return c.future;
|
||||
}
|
||||
|
||||
/// 帧到达(节流判定前):armed 则拷贝像素完成快照
|
||||
void _captureIfNeeded(
|
||||
Uint8List plane, int bytesPerRow, int width, int height, bool bgra) {
|
||||
final c = _snapCompleter;
|
||||
if (c == null || c.isCompleted) return;
|
||||
_snapCompleter = null;
|
||||
c.complete(FrameSnapshot(
|
||||
plane: Uint8List.fromList(plane),
|
||||
bytesPerRow: bytesPerRow,
|
||||
width: width,
|
||||
height: height,
|
||||
bgra: bgra,
|
||||
));
|
||||
}
|
||||
|
||||
/// 替换推理 worker(null = 停识别仅预览)。旧 worker 在此释放;若相机已
|
||||
/// 启动则新 worker 立即接管后续帧——重启相机会重新 initialize
|
||||
/// (iOS ~1s+)且预览闪断,原地替换即时生效(2026-09-03)。
|
||||
void attachWorker(DetectorWorker? w) {
|
||||
if (identical(_worker, w)) return;
|
||||
final old = _worker;
|
||||
_worker = w;
|
||||
w?.onResult = _onResult;
|
||||
w?.onError = _onError;
|
||||
old?.dispose();
|
||||
_lastDetectMs = 0;
|
||||
}
|
||||
|
||||
void recordStreamError(String msg) {
|
||||
@@ -98,6 +147,11 @@ class FrameAnalyzer {
|
||||
|
||||
void analyze(CameraImage image, int rotationDegrees,
|
||||
{bool rgbaOrder = false}) {
|
||||
final p = image.planes.isNotEmpty ? image.planes.first : null;
|
||||
if (p != null) {
|
||||
_captureIfNeeded(
|
||||
p.bytes, p.bytesPerRow, image.width, image.height, !rgbaOrder);
|
||||
}
|
||||
if (!_canSend()) return;
|
||||
worker!.analyze(image, rotationDegrees, rgbaOrder: rgbaOrder);
|
||||
}
|
||||
@@ -113,6 +167,9 @@ class FrameAnalyzer {
|
||||
required bool rgbaOrder,
|
||||
int rotationDegrees = 0,
|
||||
}) {
|
||||
if (planes.isNotEmpty) {
|
||||
_captureIfNeeded(planes.first, strides.first, width, height, isBgra);
|
||||
}
|
||||
if (!_canSend()) return;
|
||||
worker!.analyzeRaw(
|
||||
planes: planes,
|
||||
@@ -127,5 +184,5 @@ class FrameAnalyzer {
|
||||
|
||||
void reset() => _lastDetectMs = 0;
|
||||
|
||||
void dispose() => worker?.dispose();
|
||||
void dispose() => _worker?.dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
|
||||
/// 陀螺仪全局运动追踪(主 isolate):对角速度按 dt 积分得到两次运动分析帧
|
||||
/// 之间的累计转角,折算为缩略图像素位移,供 MotionDetector 做全局运动补偿——
|
||||
/// 补偿后差分 = 目标独立运动(手持/走动的旋转分量被抵消)。
|
||||
///
|
||||
/// 符号约定(背部相机、竖屏、假设无镜像):绕设备竖轴(y)偏航 → 画面水平位移;
|
||||
/// 绕设备横轴(x)俯仰 → 画面垂直位移。kPanSign/kTiltSign 待真机校准:
|
||||
/// 若补偿后误报反而变多(差分被放大),把对应符号取反即可。
|
||||
/// 位移超限(缩略图短边 1/4)时由消费方丢弃该帧——运动过快时像素级补偿不可靠。
|
||||
class GyroTracker {
|
||||
static const double assumedVfovDeg = 52; // 与测距口径一致
|
||||
static const double kPanSign = 1;
|
||||
static const double kTiltSign = 1;
|
||||
|
||||
StreamSubscription<GyroscopeEvent>? _gyroSub;
|
||||
StreamSubscription<AccelerometerEvent>? _accSub;
|
||||
|
||||
double _panRad = 0; // 两次 takeShift 之间的累计偏航
|
||||
double _tiltRad = 0; // 累计俯仰
|
||||
DateTime? _lastGyroMs;
|
||||
|
||||
double _accLowZ = 9.8; // 加速度计低通(重力在设备 z 轴分量)
|
||||
double _accLowY = 0; // 低通 y 轴分量(算俯仰用)
|
||||
bool _hasAcc = false;
|
||||
|
||||
/// 当前分析窗口内的角速度峰值(|x|+|y|+|z|,rad/s):走动/车载时显著抬升。
|
||||
/// takeShift 消费时清零(按分析窗口计量),供曝光联动等判定
|
||||
double _recentOmega = 0;
|
||||
|
||||
bool _running = false;
|
||||
bool get hasData => _hasGyro;
|
||||
bool _hasGyro = false;
|
||||
|
||||
/// 角速度峰值超阈值(rad/s)→ 机位正在明显运动(手持快走/车载)
|
||||
bool get recentlyMoving => _recentOmega > 0.5;
|
||||
|
||||
void start() {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
_gyroSub = gyroscopeEventStream().listen((e) {
|
||||
final now = DateTime.now();
|
||||
final last = _lastGyroMs;
|
||||
if (last != null) {
|
||||
final dt =
|
||||
(now.millisecondsSinceEpoch - last.millisecondsSinceEpoch) / 1000.0;
|
||||
if (dt > 0 && dt < 0.5) {
|
||||
_panRad += e.y * dt;
|
||||
_tiltRad += e.x * dt;
|
||||
_hasGyro = true;
|
||||
final mag = e.x.abs() + e.y.abs() + e.z.abs();
|
||||
if (mag > _recentOmega) _recentOmega = mag;
|
||||
}
|
||||
}
|
||||
_lastGyroMs = now;
|
||||
});
|
||||
_accSub = accelerometerEventStream().listen((e) {
|
||||
// 低通估重力方向 → 俯仰角(镜头朝上为正)
|
||||
const a = 0.1;
|
||||
_accLowZ = _accLowZ * (1 - a) + e.z * a;
|
||||
_accLowY = _accLowY * (1 - a) + e.y * a;
|
||||
_hasAcc = true;
|
||||
});
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_running = false;
|
||||
_gyroSub?.cancel();
|
||||
_accSub?.cancel();
|
||||
_gyroSub = null;
|
||||
_accSub = null;
|
||||
}
|
||||
|
||||
/// 消费累计转角 → 缩略图像素位移 (dx, dy)。
|
||||
/// dx>0 表示画面内容向右移动(前帧采样点左移补偿)。
|
||||
(int, int) takeShift(double thumbW, double thumbH) {
|
||||
final pan = _panRad;
|
||||
final tilt = _tiltRad;
|
||||
_panRad = 0;
|
||||
_tiltRad = 0;
|
||||
_recentOmega = 0;
|
||||
if (!_hasGyro || (pan == 0 && tilt == 0)) return (0, 0);
|
||||
final focalPx = (thumbH / 2) / math.tan(assumedVfovDeg * math.pi / 180 / 2);
|
||||
final limit = math.max(thumbW, thumbH) / 4;
|
||||
final dx = (kPanSign * pan * focalPx).clamp(-limit, limit).round();
|
||||
final dy = (kTiltSign * tilt * focalPx).clamp(-limit, limit).round();
|
||||
return (dx, dy);
|
||||
}
|
||||
|
||||
/// 俯仰角(度):0=平举,正=镜头朝上。无加速度计数据返回 0。
|
||||
double get pitchDeg {
|
||||
if (!_hasAcc) return 0;
|
||||
return math.atan2(_accLowY, _accLowZ) * 180 / math.pi;
|
||||
}
|
||||
|
||||
/// 角速度是否持续偏大(走动/车载判定,供曝光联动等使用)
|
||||
bool get moving => recentlyMoving;
|
||||
}
|
||||
@@ -2,9 +2,25 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/model_manager.dart';
|
||||
|
||||
/// 设置弹层「模型清单」区块:2 列封面缩略图网格。
|
||||
/// 未下载 →「使用」点击后显示下载进度,完成自动激活;已下载未激活 → 直接激活;
|
||||
/// 已激活 → 再次点击取消;下载失败 → 失败提示 + 重试。
|
||||
/// 档位展示名(档位码 s/n 仅内部记账,不对用户展示)
|
||||
String _variantLabel(String v) => v == kVariantN ? '高性能' : '高精度';
|
||||
|
||||
/// 设置弹层「模型清单」区块:顶部「识别档位」分段控件(高性能/高精度,默认
|
||||
/// 高性能,持久化本地)选择**目标档位**——只记录偏好,不直接切换运行中的
|
||||
/// 模型,决定卡片主按钮面向哪一档(2026-09-11 回归单按钮制:卡内不再同列
|
||||
/// 两档按钮)。多物种综合识别 App 端下线(2026-09-11):目录中 combined 条目
|
||||
/// 直接忽略、不展示,服务端/管理端综合训练功能保持不变。下方 2 列封面网格,
|
||||
/// 同一数据集合并一张卡(s/n 两档内部记账,各自下载独立进度),卡片只对
|
||||
/// **目标档**给一个主按钮:
|
||||
/// - 任一档下载中 → 逐档进度条 + 取消(中止全部进行中的下载);
|
||||
/// - 任一档失败 → 错误提示 + 重试(只补下未成功的档);
|
||||
/// - 目标档已激活 →「使用中」点击取消使用;
|
||||
/// - 目标档已下载未激活 →「使用」直接启用(同数据集另一档在运行会自动停用);
|
||||
/// - 目标档未下载 →「下载」只取回目标档(双档分别下载,伴档不随下;另一档
|
||||
/// 正在运行而缺目标档时,下载完自动切换过去)。
|
||||
/// 卡片不展示版本号与档位状态行(2026-09-10 删「高精度/高性能 + 版本」灰字行,
|
||||
/// 版本仅作内部记账),档位状态看主按钮。新版本由用户在卡片上手动重新
|
||||
/// 下载(使用中重下会原地生效,2026-09-09 自动更新/待办横幅已退场)。
|
||||
class ModelCatalogSection extends StatelessWidget {
|
||||
final ModelManager manager;
|
||||
|
||||
@@ -15,26 +31,67 @@ class ModelCatalogSection extends StatelessWidget {
|
||||
return ListenableBuilder(
|
||||
listenable: manager,
|
||||
builder: (context, _) {
|
||||
final items = manager.catalog;
|
||||
// 目录按数据集分组:同一数据集 s/n 合成一张卡;组内 s(高精度)前 n 后。
|
||||
// 综合(combined,datasetId=0)条目 App 端忽略(2026-09-11 多物种下线)
|
||||
final byDataset = <int, List<ModelCatalogItem>>{};
|
||||
for (final c in manager.catalog) {
|
||||
if (c.isCombined) continue;
|
||||
byDataset.putIfAbsent(c.datasetId, () => []).add(c);
|
||||
}
|
||||
final groups = byDataset.values.toList()
|
||||
..sort((a, b) => a.first.datasetId.compareTo(b.first.datasetId));
|
||||
const order = {kVariantS: 0, kVariantN: 1};
|
||||
for (final g in groups) {
|
||||
g.sort((a, b) =>
|
||||
(order[a.variant] ?? 9).compareTo(order[b.variant] ?? 9));
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('模型清单',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold)),
|
||||
const Text(
|
||||
'识别档位',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const Spacer(),
|
||||
_SegmentSwitch(
|
||||
selected: manager.mode,
|
||||
// 高性能在左、高精度在右(档位码仅内部记账)
|
||||
options: const [
|
||||
(value: kVariantN, label: '高性能'),
|
||||
(value: kVariantS, label: '高精度'),
|
||||
],
|
||||
onSelect: (v) => manager.setMode(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'卡片操作面向所选档位;同一数据集一次只运行一档,切换会自动停用另一档',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'模型清单',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () => manager.refresh(),
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('刷新'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
visualDensity: VisualDensity.compact),
|
||||
foregroundColor: Colors.white70,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -42,27 +99,29 @@ class ModelCatalogSection extends StatelessWidget {
|
||||
if (manager.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(manager.error!,
|
||||
style: const TextStyle(
|
||||
color: Colors.orange, fontSize: 12)),
|
||||
child: Text(
|
||||
manager.error!,
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (items.isEmpty)
|
||||
const Text('暂无已发布模型',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13))
|
||||
if (groups.isEmpty)
|
||||
const Text(
|
||||
'暂无已发布模型',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 13),
|
||||
)
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.72,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemCount: groups.length,
|
||||
itemBuilder: (context, i) =>
|
||||
_ModelCard(item: items[i], manager: manager),
|
||||
_SpeciesCard(items: groups[i], manager: manager),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -71,132 +130,128 @@ class ModelCatalogSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ModelCard extends StatelessWidget {
|
||||
final ModelCatalogItem item;
|
||||
final ModelManager manager;
|
||||
/// 通用分段开关:白底圆角外框,选中段绿色高亮;[options] = (值, 展示名) 列表,
|
||||
/// 点某段回调 [onSelect](识别档位行使用)
|
||||
class _SegmentSwitch extends StatelessWidget {
|
||||
final String selected;
|
||||
final List<({String value, String label})> options;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
const _ModelCard({required this.item, required this.manager});
|
||||
const _SegmentSwitch({
|
||||
required this.selected,
|
||||
required this.options,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = manager.isActive(item.datasetId);
|
||||
final downloaded = manager.isDownloaded(item.datasetId);
|
||||
final progress = manager.progressOf(item.datasetId);
|
||||
final error = manager.errorOf(item.datasetId);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < options.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 2),
|
||||
_seg(options[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _seg(({String value, String label}) o) {
|
||||
final sel = selected == o.value;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => onSelect(o.value),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? Colors.greenAccent : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
o.label,
|
||||
style: TextStyle(
|
||||
color: sel ? Colors.black : Colors.white70,
|
||||
fontSize: 12,
|
||||
fontWeight: sel ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单物种卡片:同数据集 s/n 两档合并展示但**只给目标档一个主按钮**;下载/激活
|
||||
/// 状态按 (数据集, 档位) 独立记账,同物种同时至多一个档位被激活(目标档为准)
|
||||
class _SpeciesCard extends StatelessWidget {
|
||||
final List<ModelCatalogItem> items;
|
||||
final ModelManager manager;
|
||||
|
||||
const _SpeciesCard({required this.items, required this.manager});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 目标条目:当前目标档优先;目录缺目标档(存量单档物种)时取 s 档
|
||||
final target = items.firstWhere(
|
||||
(i) => i.variant == manager.mode,
|
||||
orElse: () => items.first,
|
||||
);
|
||||
final tActive = manager.isActive(target.datasetId, target.variant);
|
||||
final tDownloaded = manager.isDownloaded(target.datasetId, target.variant);
|
||||
// 同物种另一档正在使用的条目(至多一个——每数据集单档激活约束)
|
||||
final activeOther = items
|
||||
.where((i) =>
|
||||
i.variant != target.variant &&
|
||||
manager.isActive(i.datasetId, i.variant))
|
||||
.toList();
|
||||
|
||||
// 任一档进行中/失败标志
|
||||
final downloading =
|
||||
items.any((i) => manager.progressOf(i.datasetId, i.variant) != null);
|
||||
final hasError =
|
||||
items.any((i) => manager.errorOf(i.datasetId, i.variant) != null);
|
||||
|
||||
final thumb = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: Image.network(
|
||||
'${manager.baseUrl}${item.coverUrl}',
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, chunk) => chunk == null
|
||||
? child
|
||||
: Container(
|
||||
color: Colors.white12,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.network(
|
||||
'${manager.baseUrl}${target.coverUrl}',
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, chunk) => chunk == null
|
||||
? child
|
||||
: Container(
|
||||
color: Colors.white12,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2)))),
|
||||
errorBuilder: (context, error, stack) => Container(
|
||||
color: Colors.white12,
|
||||
child: const Icon(Icons.image_not_supported_outlined,
|
||||
color: Colors.white38),
|
||||
),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
errorBuilder: (context, error, stack) => Container(
|
||||
color: Colors.white12,
|
||||
child: const Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Colors.white38,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget action;
|
||||
if (progress != null) {
|
||||
action = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.white12,
|
||||
color: Colors.greenAccent),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${(progress * 100).toStringAsFixed(0)}%',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => manager.cancelDownload(item.datasetId),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white54,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: const Size(0, 24),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('取消', style: TextStyle(fontSize: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (error != null) {
|
||||
action = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(error,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.redAccent, fontSize: 10)),
|
||||
TextButton(
|
||||
onPressed: () => manager.downloadModel(item),
|
||||
child: const Text('重试', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (active) {
|
||||
action = SizedBox(
|
||||
height: 30,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => manager.setActive(item.datasetId, false),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.greenAccent,
|
||||
side: const BorderSide(color: Colors.greenAccent)),
|
||||
child: const Text('已使用', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
} else if (downloaded) {
|
||||
action = SizedBox(
|
||||
height: 30,
|
||||
child: FilledButton(
|
||||
onPressed: () => manager.setActive(item.datasetId, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.greenAccent,
|
||||
foregroundColor: Colors.black,
|
||||
visualDensity: VisualDensity.compact),
|
||||
child: const Text('使用', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
action = SizedBox(
|
||||
height: 30,
|
||||
child: FilledButton(
|
||||
onPressed: () => manager.downloadModel(item),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.greenAccent,
|
||||
foregroundColor: Colors.black,
|
||||
visualDensity: VisualDensity.compact),
|
||||
child: const Text('使用', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
@@ -208,18 +263,201 @@ class _ModelCard extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(child: Center(child: thumb)),
|
||||
const SizedBox(height: 6),
|
||||
Text(item.datasetName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text('v${item.version}',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 10)),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
target.datasetName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
action,
|
||||
_actionArea(
|
||||
items: items,
|
||||
target: target,
|
||||
downloading: downloading,
|
||||
hasError: hasError,
|
||||
tActive: tActive,
|
||||
tDownloaded: tDownloaded,
|
||||
activeOther: activeOther,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 动作区:进度/错误优先级最高;其次按目标档的下载/激活状态给唯一主按钮
|
||||
Widget _actionArea({
|
||||
required List<ModelCatalogItem> items,
|
||||
required ModelCatalogItem target,
|
||||
required bool downloading,
|
||||
required bool hasError,
|
||||
required bool tActive,
|
||||
required bool tDownloaded,
|
||||
required List<ModelCatalogItem> activeOther,
|
||||
}) {
|
||||
if (downloading) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final i in items)
|
||||
if (manager.progressOf(i.datasetId, i.variant) != null)
|
||||
_progressRow(item: i),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
for (final i in items) {
|
||||
if (manager.progressOf(i.datasetId, i.variant) != null) {
|
||||
manager.cancelDownload(i.datasetId, i.variant);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white54,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: const Size(0, 24),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('取消', style: TextStyle(fontSize: 11)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (hasError) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final i in items)
|
||||
if (manager.errorOf(i.datasetId, i.variant) != null)
|
||||
Text(
|
||||
'${_variantLabel(i.variant)}下载失败',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent, fontSize: 10),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
for (final i in items) {
|
||||
if (manager.errorOf(i.datasetId, i.variant) != null) {
|
||||
manager.downloadModel(i);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minimumSize: const Size(0, 24),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text('重试', style: TextStyle(fontSize: 11)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final Widget mainBtn;
|
||||
if (tActive) {
|
||||
// 目标档使用中:点击取消使用
|
||||
mainBtn = SizedBox(
|
||||
height: 30,
|
||||
child: OutlinedButton(
|
||||
onPressed: () =>
|
||||
manager.setActive(target.datasetId, target.variant, false),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.greenAccent,
|
||||
side: const BorderSide(color: Colors.greenAccent),
|
||||
),
|
||||
child: const Text('使用中', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
} else if (tDownloaded) {
|
||||
// 已下载未激活(目标档):点「使用」直接启用——同物种另一档在使用会
|
||||
// 自动停用(每数据集至多一档运行)
|
||||
mainBtn = SizedBox(
|
||||
height: 30,
|
||||
child: FilledButton(
|
||||
onPressed: () =>
|
||||
manager.setActive(target.datasetId, target.variant, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.greenAccent,
|
||||
foregroundColor: Colors.black,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: const Text('使用', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 目标档未下载:只下载目标档(双档分别下载——伴档不随下,缺失档由
|
||||
// 切档后按钮补下);另一档在使用时补下目标档后自动切换过去
|
||||
final use = activeOther.isNotEmpty;
|
||||
mainBtn = SizedBox(
|
||||
height: 30,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
if (!manager.isDownloaded(target.datasetId, target.variant)) {
|
||||
if (use) {
|
||||
final ok = await manager.downloadModel(target);
|
||||
if (!ok) return; // 下载失败/取消:错误分支展示,保持现状
|
||||
} else {
|
||||
manager.downloadModel(target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await manager.setActive(target.datasetId, target.variant, true);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.greenAccent,
|
||||
foregroundColor: Colors.black,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: const Text('下载', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [mainBtn],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _progressRow({required ModelCatalogItem item}) {
|
||||
final p = manager.progressOf(item.datasetId, item.variant) ?? 0.0;
|
||||
final label = _variantLabel(item.variant);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'$label ${(p * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
LinearProgressIndicator(
|
||||
value: p,
|
||||
backgroundColor: Colors.white12,
|
||||
color: Colors.greenAccent,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ class AppConfig {
|
||||
/// 后端服务器地址(订单创建/授权查询/套餐价格)
|
||||
/// 默认指向线上域名;本地联调构建时用 --dart-define=API_BASE_URL=http://127.0.0.1:8080 覆盖
|
||||
static const String apiBaseUrl = String.fromEnvironment('API_BASE_URL',
|
||||
defaultValue: 'http://observer.redpowerfuture.com');
|
||||
defaultValue: 'https://animal-spot.icu');
|
||||
|
||||
/// 微信开放平台 AppID(需在微信开放平台注册包名+签名)
|
||||
static const String wechatAppId = 'wx0000000000000000';
|
||||
static const String wechatUniversalLink =
|
||||
'https://YOUR_DOMAIN.com/wechat/';
|
||||
'https://animal-spot.icu/wechat/';
|
||||
|
||||
/// 支付宝开放平台 AppID
|
||||
static const String alipayAppId = '2020000000000000';
|
||||
static const String alipayUniversalLink =
|
||||
'https://YOUR_DOMAIN.com/alipay/';
|
||||
'https://animal-spot.icu/alipay/';
|
||||
|
||||
/// iOS URL Scheme(微信/支付宝拉起回调,需与 Info.plist 一致)
|
||||
static const String wechatUrlScheme = 'wx0000000000000000';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'annotate/annotate_api.dart';
|
||||
import 'auth/auth_view_model.dart';
|
||||
import 'auth/session_store.dart';
|
||||
import 'home/home_view_model.dart';
|
||||
@@ -15,6 +16,7 @@ class AppContainer {
|
||||
late final AuthViewModel authViewModel;
|
||||
late final HomeViewModel homeViewModel;
|
||||
late final PaywallViewModel paywallViewModel;
|
||||
late final AnnotateApi annotateApi;
|
||||
|
||||
AppContainer() {
|
||||
sessionStore = SessionStore();
|
||||
@@ -31,5 +33,6 @@ class AppContainer {
|
||||
PayChannel.alipay: AlipayService(orderApi: orderApi),
|
||||
},
|
||||
);
|
||||
annotateApi = AnnotateApi(sessionStore: sessionStore);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'motion_aggregator.dart';
|
||||
/// 分块聚合为新颖区域(novelty)。
|
||||
///
|
||||
/// 固定机位下,常驻物体(键盘/石头/文字)永远属于背景、不产生新颖区域;
|
||||
/// 走进画面的目标(环颈雉鸡移动/新出现)才会触发。比相邻帧差分更强的证据:
|
||||
/// 走进画面的目标(动物移动/新出现)才会触发。比相邻帧差分更强的证据:
|
||||
/// 风吹草动是持续的背景更新,不会长期标记为新颖。
|
||||
class BackgroundModel {
|
||||
final int maxWidth;
|
||||
|
||||
@@ -9,7 +9,8 @@ class DetectionResult {
|
||||
/// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线
|
||||
final bool confirmed;
|
||||
|
||||
/// 类别索引:0 = 目标物种(红色框),>0 = suspect(黄色框);-1 = 未知
|
||||
/// 类别索引:单物种模型 0 = 目标物种、1 = suspect;综合模型 0..N-1 = 各物种、
|
||||
/// 末位 = suspect。仅作展示索引,类别语义看 label 文本(2026-09-09 综合模型)
|
||||
final int classId;
|
||||
|
||||
/// 产出该框的模型(数据集 id 与名称;无来源为 -1/空)
|
||||
@@ -34,6 +35,11 @@ class DetectionResult {
|
||||
double get centerX => (left + right) / 2;
|
||||
double get centerY => (top + bottom) / 2;
|
||||
|
||||
/// 疑似(生境预警)类别:训练标签为 suspect。单物种模型 suspect 在索引 1,
|
||||
/// 综合模型在末位——统一按标签名判定,不能按 classId>0(综合模型其他物种
|
||||
/// 索引也 >0,2026-09-09)
|
||||
bool get isSuspect => label == 'suspect';
|
||||
|
||||
DetectionResult copyWith({
|
||||
double? score,
|
||||
double? left,
|
||||
|
||||
@@ -61,7 +61,14 @@ class DetectorWorker {
|
||||
try {
|
||||
if (models == null || models.isEmpty) return null;
|
||||
final payload = <List<Object?>>[
|
||||
for (final m in models) [m.bytes, m.labels, m.datasetId, m.datasetName],
|
||||
for (final m in models)
|
||||
[
|
||||
m.bytes,
|
||||
m.labels,
|
||||
m.datasetId,
|
||||
m.datasetName,
|
||||
m.variant,
|
||||
],
|
||||
];
|
||||
|
||||
final responses = ReceivePort();
|
||||
@@ -222,6 +229,7 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
for (final entry in list[1] as List) {
|
||||
final e = entry as List;
|
||||
final name = e.length > 3 ? e[3] as String : '';
|
||||
// modelName 仅标注来源数据集名;档位码 s/n 内部记账,不展示给用户
|
||||
final d = await TfliteDetector.fromBuffer(
|
||||
e[0] as Uint8List,
|
||||
(e[1] as List).cast<String>(),
|
||||
@@ -369,7 +377,7 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
results.addAll(dets);
|
||||
}
|
||||
results = mergeAcrossModels(results, TfliteDetector.iouThreshold);
|
||||
// 低分环颈雉鸡框过视觉先验(颜色/位置),减少户外误报
|
||||
// 低分目标框过视觉先验(颜色/位置),减少户外误报
|
||||
results = VisualPrior.filter(
|
||||
results,
|
||||
planes: planes,
|
||||
@@ -464,8 +472,25 @@ Future<void> _workerMain(SendPort mainPort) async {
|
||||
/// 实测多个模型会对同一目标检出不同类别(误检/歧义),若异类别互不压制
|
||||
/// 会出现重叠框;2026-09-01 用户实测定案:所有模型的框统一按 IoU 去重,
|
||||
/// 重叠时取高分(远处真实的多目标互不重叠,正常保留)。
|
||||
///
|
||||
/// 2026-09-03 补跨模型同目标窗口:小框被大框覆盖 > [iouThreshold] 直接去重;
|
||||
/// 覆盖不足、但两框来自**不同模型**且 [sameTarget](小框中心在大框内、
|
||||
/// 覆盖 ≥ 30%)也去重——不同输入分辨率模型对同一目标的框几何有系统性偏移,
|
||||
/// 纯阈值会漏判。同模型框对(模型内已做过类内 NMS)不套用该窗口。
|
||||
List<DetectionResult> mergeAcrossModels(
|
||||
List<DetectionResult> all, double iouThreshold) {
|
||||
if (all.length <= 1) return all;
|
||||
return nms(all, iouThreshold);
|
||||
final sorted = [...all]..sort((a, b) => b.score.compareTo(a.score));
|
||||
final kept = <DetectionResult>[];
|
||||
for (final b in sorted) {
|
||||
final dup = kept.any((k) {
|
||||
if (boxOverlap(k, b) > iouThreshold) return true;
|
||||
if (k.modelId == b.modelId || k.modelId < 0 || b.modelId < 0) {
|
||||
return false; // 同模型/无来源:类内 NMS 已处理,不补窗
|
||||
}
|
||||
return sameTarget(k, b);
|
||||
});
|
||||
if (!dup) kept.add(b);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
@@ -35,3 +35,22 @@ List<DetectionResult> nms(List<DetectionResult> boxes, double iouThreshold) {
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/// 同目标判定(2026-09-03 用户实测修订:同一标注位置/重叠位置,同/异模型
|
||||
/// 检出的物种只保留高置信度框)。不同模型对同一目标的框紧致度/偏移系统性
|
||||
/// 不同,纯 boxOverlap 阈值(0.45)会漏判「几何明显指向同一位置」的偏移框;
|
||||
/// 补判条件:小框被大框覆盖 ≥ [sameTargetMinCover] 且小框中心落在大框内
|
||||
/// (相邻独立目标的中心不会落在对方框内,不会被误并)。
|
||||
const double sameTargetMinCover = 0.3;
|
||||
|
||||
bool sameTarget(DetectionResult a, DetectionResult b) {
|
||||
final cover = boxOverlap(a, b);
|
||||
if (cover < sameTargetMinCover) return false;
|
||||
final aBigger = a.width * a.height >= b.width * b.height;
|
||||
final big = aBigger ? a : b;
|
||||
final small = aBigger ? b : a;
|
||||
return small.centerX >= big.left &&
|
||||
small.centerX <= big.right &&
|
||||
small.centerY >= big.top &&
|
||||
small.centerY <= big.bottom;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
@@ -6,17 +7,18 @@ import 'package:tflite_flutter/tflite_flutter.dart';
|
||||
import 'detection_result.dart';
|
||||
import 'nms.dart';
|
||||
|
||||
/// YOLOv8s 端侧推理实现(对应 Kotlin TFLiteDetector)。
|
||||
/// YOLO 端侧推理实现(对应 Kotlin TFLiteDetector)。
|
||||
/// 模型输出布局(ultralytics litert 导出):[1, 4 + nc, anchors],
|
||||
/// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。
|
||||
/// 输入为 NCHW [1, 3, 1280, 1280](litert 导出保留 torch 布局)。
|
||||
/// 输入为 NCHW [1, 3, H, W](litert 导出保留 torch 布局),H/W 随模型档位:
|
||||
/// s 高精度 @1280、n 高性能 @704,输入尺寸取自模型自身。
|
||||
class TfliteDetector {
|
||||
// 输入尺寸取自模型本身(ultralytics litert 导出 NCHW [1,3,H,W],各数据集
|
||||
// 训练 imgsz 可不同),默认 1280 兜底
|
||||
static const int defaultInputSize = 1280;
|
||||
// 环颈雉鸡数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升;
|
||||
// 目标数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升;
|
||||
// 可运行时调整(设置页滑块),默认 0.10
|
||||
double minScore = 0.10;
|
||||
double minScore = 0.015;
|
||||
static const double iouThreshold = 0.45;
|
||||
static const int maxDetections = 20;
|
||||
|
||||
@@ -44,12 +46,28 @@ class TfliteDetector {
|
||||
|
||||
/// 模型缺失或加载失败返回 null(App 降级为仅预览)。
|
||||
/// 在后台 isolate 内调用(模型字节由主 isolate 读取后传入)。
|
||||
/// 加速:Android 挂 TFLite GPU delegate、iOS 挂 CoreML delegate(ANE/GPU),
|
||||
/// 均为浮点计算不降精度(对比 int8 量化);delegate 初始化失败自动回退纯 CPU 4 线程。
|
||||
static Future<TfliteDetector?> fromBuffer(
|
||||
Uint8List bytes,
|
||||
List<String> labels, {
|
||||
int modelId = -1,
|
||||
String modelName = '',
|
||||
}) async {
|
||||
final delegate = _createAccelDelegate();
|
||||
if (delegate != null) {
|
||||
try {
|
||||
final options = InterpreterOptions()..threads = 4;
|
||||
options.addDelegate(delegate);
|
||||
final interpreter = Interpreter.fromBuffer(bytes, options: options);
|
||||
debugPrint('[TfliteDetector] 加速生效 model=$modelName '
|
||||
'(${Platform.isIOS ? 'CoreML' : 'GPU'})');
|
||||
return TfliteDetector._fromModel(
|
||||
interpreter, labels, modelId, modelName);
|
||||
} catch (e) {
|
||||
debugPrint('[TfliteDetector] 加速 delegate 初始化失败,回退 CPU: $e');
|
||||
}
|
||||
}
|
||||
try {
|
||||
final interpreter = Interpreter.fromBuffer(
|
||||
bytes,
|
||||
@@ -62,6 +80,26 @@ class TfliteDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台加速 delegate:Android=TFLite GPU(libtensorflowlite_gpu_jni.so 已 vendor 到
|
||||
/// android/app/src/main/jniLibs,AAR 因 AGP namespace 冲突保持排除)、iOS=CoreML。
|
||||
/// 老设备/驱动/符号缺失时创建失败返回 null,走 CPU。
|
||||
static Delegate? _createAccelDelegate() {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
return CoreMlDelegate();
|
||||
} catch (e) {
|
||||
debugPrint('[TfliteDetector] CoreML delegate 创建失败: $e');
|
||||
}
|
||||
} else if (Platform.isAndroid) {
|
||||
try {
|
||||
return GpuDelegateV2();
|
||||
} catch (e) {
|
||||
debugPrint('[TfliteDetector] GPU delegate 创建失败: $e');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 输出布局 [1, 4+nc, anchors] 取自模型本身,类别数不与 labels 文件长度耦合。
|
||||
factory TfliteDetector._fromModel(Interpreter interpreter,
|
||||
List<String> labels, int modelId, String modelName) {
|
||||
|
||||
@@ -3,14 +3,15 @@ import 'dart:typed_data';
|
||||
|
||||
import 'detection_result.dart';
|
||||
|
||||
/// 运行时视觉先验:对低置信度环颈雉鸡框做多线索过滤,降低户外误报。
|
||||
/// 运行时视觉先验:对低置信度目标物种框(class 0)做多线索过滤,降低户外误报。
|
||||
/// 识别哪些类为目标物种由模型训练决定,本先验不依赖任何具体物种名。
|
||||
///
|
||||
/// 仅对 score < [maxScore](0.35)的 pheasant 框生效;高分框与
|
||||
/// 仅对 score < [maxScore](0.35)的目标框生效;高分框与
|
||||
/// suspect(生境预警)不参与过滤,避免误杀。
|
||||
///
|
||||
/// 线索:
|
||||
/// - 颜色:绿色主导(草/叶)、蓝色主导(天空/水)、平坦低饱和(键盘/石头/文字)
|
||||
/// - 位置:中心在画面上部 15%(天空区)——环颈雉鸡是地栖动物,不会出现在天空
|
||||
/// - 位置:中心在画面上部 15%(天空区)——目标物种为地面活动,不会出现在天空
|
||||
///
|
||||
/// 采样在原始 planes 上进行(后台 isolate 内,不依赖 UI 线程)。
|
||||
class VisualPrior {
|
||||
@@ -39,9 +40,10 @@ class VisualPrior {
|
||||
if (results.isEmpty || width <= 0 || height <= 0) return results;
|
||||
final kept = <DetectionResult>[];
|
||||
for (final r in results) {
|
||||
final lowConfPheasant =
|
||||
(r.classId == 0 || r.label == 'pheasant') && r.score < maxScore;
|
||||
if (lowConfPheasant &&
|
||||
// 仅目标物种做视觉先验剔除(suspect=生境区域不受限;综合模型各物种
|
||||
// 索引 0..N-1 均目标,按 label 而非 classId 判定,2026-09-09)
|
||||
final lowConfTarget = !r.isSuspect && r.score < maxScore;
|
||||
if (lowConfTarget &&
|
||||
_reject(r, planes, strides, width, height, isBgra, rgbaOrder)) {
|
||||
continue;
|
||||
}
|
||||
@@ -86,7 +88,7 @@ class VisualPrior {
|
||||
/// 读取单像素 RGB(0~255)。
|
||||
/// 8888 单平面按实际字节序取通道:BGRA=[b,g,r,a](iOS 插件)、
|
||||
/// RGBA=[r,g,b,a](Android 自写原生通道)——字节序写死会让 Android
|
||||
/// 低分框采样到 R/B 互换的颜色(橙色环颈雉鸡身被误判成"蓝色")整批误杀;
|
||||
/// 低分框采样到 R/B 互换的颜色(橙色目标躯体被误判成"蓝色")整批误杀;
|
||||
/// YUV:y 平面 + 4:2:0 半分辨率 U/V(NV12 交错或 I420 分离)。
|
||||
static (double, double, double) _pixel(List<Uint8List> planes,
|
||||
List<int> strides, int x, int y, int width, int height, bool isBgra,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
/// 按一次快照帧:单平面 BGRA/RGBA 像素(两平台分析帧均为竖屏单平面 4 通道)。
|
||||
/// 即喂给 YOLO 推理的同一帧,尺寸与推理输入同源。
|
||||
class FrameSnapshot {
|
||||
final Uint8List plane;
|
||||
final int bytesPerRow;
|
||||
final int width;
|
||||
final int height;
|
||||
|
||||
/// true = BGRA 字节序(Android 原生通道默认 / iOS bgra8888),false = RGBA
|
||||
final bool bgra;
|
||||
|
||||
const FrameSnapshot({
|
||||
required this.plane,
|
||||
required this.bytesPerRow,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.bgra,
|
||||
});
|
||||
}
|
||||
|
||||
/// 在 isolate 执行:整帧像素行拷贝 → 解码为 Image → jpg q85 重编码。
|
||||
/// 与推理帧同源同尺寸、重编码天然剥离全部元数据(合规硬要求)
|
||||
Uint8List encodeFrameJpeg(FrameSnapshot snap) {
|
||||
final packed = Uint8List(snap.width * snap.height * 4);
|
||||
for (var y = 0; y < snap.height; y++) {
|
||||
final srcStart = y * snap.bytesPerRow;
|
||||
final dstStart = y * snap.width * 4;
|
||||
packed.setRange(dstStart, dstStart + snap.width * 4,
|
||||
snap.plane.sublist(srcStart, srcStart + snap.width * 4));
|
||||
}
|
||||
final image = img.Image.fromBytes(
|
||||
width: snap.width,
|
||||
height: snap.height,
|
||||
bytes: packed.buffer,
|
||||
numChannels: 4,
|
||||
order: snap.bgra ? img.ChannelOrder.bgra : img.ChannelOrder.rgba,
|
||||
);
|
||||
return Uint8List.fromList(img.encodeJpg(image, quality: 85));
|
||||
}
|
||||
|
||||
/// 单独同意弹窗结果存储 key(FlutterSecureStorage,与 terms_accepted 同库)
|
||||
const falseTargetConsentKey = 'false_target_consent';
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cupertino_http/cupertino_http.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../auth/session_store.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 假目标上报 API 客户端(契约见 server/README.md「假目标上报」)。
|
||||
/// 仅上传误报框裁剪图(客户端已重编码剥离元数据)+ 检测框快照字段。
|
||||
class FeedbackApi {
|
||||
final String baseUrl;
|
||||
final SessionStore sessionStore;
|
||||
final http.Client _client;
|
||||
|
||||
FeedbackApi({String? baseUrl, required this.sessionStore, http.Client? client})
|
||||
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
|
||||
_client = client ?? _defaultHttpClient();
|
||||
|
||||
static http.Client _defaultHttpClient() {
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
return CupertinoClient.defaultSessionConfiguration();
|
||||
}
|
||||
return http.Client();
|
||||
}
|
||||
|
||||
/// 上报假目标:[jpeg] 为上报瞬间的分析帧整图(与推理帧同源同尺寸),
|
||||
/// [detectionsJson] 为当前全部检测框快照 JSON 数组(可为空串)
|
||||
Future<int> reportFalseTarget({
|
||||
required Uint8List jpeg,
|
||||
required String detectionsJson,
|
||||
required int sourceW,
|
||||
required int sourceH,
|
||||
}) async {
|
||||
final token = await sessionStore.readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw Exception('登录已失效');
|
||||
}
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$baseUrl/api/v1/feedback/false-target'),
|
||||
)
|
||||
..headers['Authorization'] = 'Bearer $token'
|
||||
..fields['detections'] = detectionsJson
|
||||
..fields['sourceW'] = '$sourceW'
|
||||
..fields['sourceH'] = '$sourceH'
|
||||
..files.add(
|
||||
http.MultipartFile.fromBytes('file', jpeg, filename: 'frame.jpg'));
|
||||
final streamed =
|
||||
await _client.send(req).timeout(const Duration(seconds: 30));
|
||||
final res = await http.Response.fromStream(streamed);
|
||||
final Map<String, dynamic> json;
|
||||
try {
|
||||
json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw Exception('服务端响应异常 (${res.statusCode})');
|
||||
}
|
||||
if (res.statusCode != 200 || json['code'] != 0) {
|
||||
throw Exception(json['message'] as String? ?? '上报失败 (${res.statusCode})');
|
||||
}
|
||||
return ((json['data'] as Map?)?['id'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../annotate/annotate_tasks_screen.dart';
|
||||
import '../auth/session_store.dart';
|
||||
import '../config/app_version.dart';
|
||||
import '../container.dart';
|
||||
import 'home_view_model.dart';
|
||||
|
||||
/// 主界面:当前账号到期时间 + 搜索按钮(强制服务端校验后进相机)+ 充值入口
|
||||
@@ -49,6 +51,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
if (mounted) context.read<HomeViewModel>().refresh();
|
||||
}
|
||||
|
||||
/// 标注赚时长:处理众包标注任务获取使用时长
|
||||
Future<void> _onAnnotate() async {
|
||||
final api = context.read<AppContainer>().annotateApi;
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => AnnotateTasksScreen(api: api),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _toLogin() async {
|
||||
final container = context.read<SessionStore>();
|
||||
await container.clear();
|
||||
@@ -148,9 +158,20 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _onAnnotate,
|
||||
icon: const Icon(Icons.edit_note),
|
||||
label: const Text('标注赚时长',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: TextButton.icon(
|
||||
onPressed: _onRecharge,
|
||||
icon: const Icon(Icons.payment),
|
||||
label: const Text('充值', style: TextStyle(fontSize: 16)),
|
||||
icon: const Icon(Icons.payment, size: 18),
|
||||
label:
|
||||
const Text('充值', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
),
|
||||
if (vm.error != null && vm.license != null) ...[
|
||||
|
||||
@@ -46,7 +46,10 @@ class _TermsScreenState extends State<TermsScreen> {
|
||||
五、识别结果说明
|
||||
本应用的动物识别结果基于人工智能模型,可能存在误差或漏检,识别结果仅供参考,不构成科学鉴定或法律依据。
|
||||
|
||||
六、其他
|
||||
六、反馈数据条款
|
||||
当您主动使用「假目标上报」功能时,本应用仅上传您确认上报瞬间的当前画面一帧(与识别使用的画面一致,上传前已在设备本地重编码、移除照片元数据等所有附加信息)及当时的识别框信息,用于改进识别模型。该功能完全自愿,您可以拒绝使用且不影响识别等其他功能;您可联系客服删除已上传的反馈图片。
|
||||
|
||||
七、其他
|
||||
本协议内容可能适时更新,更新后您继续使用即视为接受。''';
|
||||
|
||||
Future<void> _accept() async {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -9,11 +8,31 @@ import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 识别档位标识:s = 高精度(@1280 精度优先,默认),n = 高性能(@704 速度优先)
|
||||
const String kVariantS = 's';
|
||||
const String kVariantN = 'n';
|
||||
|
||||
/// 模型类型(2026-09-09 综合训练):species = 单物种(缺省,老目录兼容),combined = 多物种综合
|
||||
/// (datasetId=0、datasetIds=覆盖物种列表;与其覆盖的单物种模型激活互斥)
|
||||
const String kKindSpecies = 'species';
|
||||
const String kKindCombined = 'combined';
|
||||
|
||||
/// 识别档位偏好取值 s/n 同 [kVariantS]/[kVariantN](2026-09-11 多物种综合识别
|
||||
/// 在 App 端下线:目录中 combined 条目直接忽略,历史 multi_* / 两维识别方式
|
||||
/// 配置自动归并为对应档位,不再有 multi_* 取值)
|
||||
|
||||
/// 模型身份键:同一数据集不同档位是两个独立条目(下载/激活/记账互不影响)
|
||||
typedef ModelKey = ({int datasetId, String variant});
|
||||
|
||||
/// 模型目录条目(GET /api/v1/app/update 响应 data.models[])。
|
||||
/// 服务器发布模型后随版本检查一同下发,App 按目录逐数据集下载/更新。
|
||||
/// 双档位(2026-09-03):每数据集至多 2 条(s/n 各一),[variant] 标识档位。
|
||||
class ModelCatalogItem {
|
||||
final int datasetId;
|
||||
final String datasetName;
|
||||
final String variant;
|
||||
final String kind; // species 单物种(缺省)| combined 多物种综合
|
||||
final List<int> datasetIds; // combined:覆盖的数据集 id 列表
|
||||
final String version;
|
||||
final List<String> labels;
|
||||
final int sizeBytes;
|
||||
@@ -24,6 +43,9 @@ class ModelCatalogItem {
|
||||
const ModelCatalogItem({
|
||||
required this.datasetId,
|
||||
required this.datasetName,
|
||||
this.variant = kVariantS,
|
||||
this.kind = kKindSpecies,
|
||||
this.datasetIds = const [],
|
||||
required this.version,
|
||||
required this.labels,
|
||||
required this.sizeBytes,
|
||||
@@ -32,10 +54,19 @@ class ModelCatalogItem {
|
||||
this.coverUrl = '',
|
||||
});
|
||||
|
||||
bool get isCombined => kind == kKindCombined;
|
||||
|
||||
factory ModelCatalogItem.fromJson(Map<String, dynamic> j) =>
|
||||
ModelCatalogItem(
|
||||
datasetId: (j['datasetId'] as num?)?.toInt() ?? 0,
|
||||
datasetName: j['datasetName'] as String? ?? '',
|
||||
// 旧目录无 variant 字段(2026-09-03 前发布的单档 s)→ 归为 s
|
||||
variant: j['variant'] as String? ?? kVariantS,
|
||||
// 老目录无 kind 字段 → species(2026-09-09 综合训练)
|
||||
kind: j['kind'] as String? ?? kKindSpecies,
|
||||
datasetIds: (j['datasetIds'] as List? ?? const [])
|
||||
.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
version: j['version'] as String? ?? '',
|
||||
labels: (j['labels'] as List? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
@@ -51,6 +82,7 @@ class ModelCatalogItem {
|
||||
class ModelBundle {
|
||||
final int datasetId;
|
||||
final String datasetName;
|
||||
final String variant;
|
||||
final String version;
|
||||
final List<String> labels;
|
||||
final Uint8List bytes;
|
||||
@@ -58,6 +90,7 @@ class ModelBundle {
|
||||
const ModelBundle({
|
||||
required this.datasetId,
|
||||
required this.datasetName,
|
||||
required this.variant,
|
||||
required this.version,
|
||||
required this.labels,
|
||||
required this.bytes,
|
||||
@@ -67,8 +100,26 @@ class ModelBundle {
|
||||
/// 模型热更新管理:启动时拉取模型目录(随 /app/update 公开接口下发,无需登录态),
|
||||
/// 按需下载/校验/持久化各数据集模型,供相机页多模型并行推理。
|
||||
///
|
||||
/// 存储:应用私有目录 `models/<datasetId>/`(model.tflite + labels.json + meta.json),
|
||||
/// meta 记录 {version, sha256},服务器发布新版本时按版本+摘要重下,不重复下载旧模型。
|
||||
/// 双档位存储(2026-09-03):`models/<datasetId>/` 存放 s 档(legacy 布局,目录键 =
|
||||
/// 档位标识符的「无子目录」形态,存量设备无需迁移),n 档存 `models/<datasetId>/n/`;
|
||||
/// 各目录含 model.tflite + labels.json + meta.json,meta 记录 {version, sha256},
|
||||
/// 版本与摘要都未变化时跳过下载。记账键一律是 (datasetId, variant) 二元组。
|
||||
/// 识别档位 [mode] 只是用户偏好(持久化:s 高精度默认 / n 高性能,切换不直接
|
||||
/// 热换运行中模型):作为各卡默认目标档与「下载完成自动启用」的判定依据
|
||||
/// (2026-09-11 多物种综合识别 App 端下线——不再展示综合卡,目录中 combined
|
||||
/// 条目直接忽略,历史 multi_* / 两维识别方式配置自动归并到对应档位)。
|
||||
/// 实际运行由激活集驱动——每个数据集**至多一个档位**在使用:激活某档会自动停用
|
||||
/// 同数据集另一档,不同数据集可用不同档位并行识别(2026-09-03)。
|
||||
/// 激活集是**会话态**(2026-09-03 修订):每次进入视野页 [resetForSession] 清空、
|
||||
/// 不跨会话持久化——识别需用户在模型清单手动启用(显式「下载」落地即启用目标档
|
||||
/// 属于用户动作);上次崩溃/坏模型不会在下次打开时自动复现,用户总能看到仅预览
|
||||
/// 界面并自行调整。
|
||||
/// 目录**缓存优先**(2026-09-03):最近一次成功拉取的 models 目录落盘
|
||||
/// catalog.json,[refresh] 开头先载入缓存并通知(弹层离线也有内容展示),网络
|
||||
/// 成功后再以权威目录覆盖并落盘;清理/激活同步只在网络成功(fetched)后执行
|
||||
/// ——缓存降级时不清文件不下载,离线首启不误删已下载模型。
|
||||
/// 模型更新**手动制**(2026-09-09):无自动更新/待办横幅,新版本由用户在
|
||||
/// 卡片上重新下载(使用中重下会原地生效)。
|
||||
class ModelManager extends ChangeNotifier {
|
||||
static final ModelManager instance = ModelManager._();
|
||||
|
||||
@@ -78,12 +129,14 @@ class ModelManager extends ChangeNotifier {
|
||||
|
||||
List<ModelBundle> _models = const [];
|
||||
List<ModelCatalogItem> _catalog = const [];
|
||||
final Set<int> _activeIds = {};
|
||||
final Set<int> _downloadedIds = {};
|
||||
final Map<int, double> _progress = {};
|
||||
final Map<int, String> _errors = {};
|
||||
final Set<int> _cancelRequested = {};
|
||||
bool _activeLoaded = false;
|
||||
final Set<ModelKey> _active = {};
|
||||
final Set<ModelKey> _downloaded = {};
|
||||
final Map<ModelKey, double> _progress = {};
|
||||
final Map<ModelKey, String> _errors = {};
|
||||
final Set<ModelKey> _cancelRequested = {};
|
||||
// 默认高性能(n):未持久化偏好时的新装默认档(2026-09-11 用户定案)
|
||||
String _mode = kVariantN;
|
||||
bool _modeLoaded = false;
|
||||
bool _ready = false;
|
||||
bool _refreshing = false;
|
||||
String? _error;
|
||||
@@ -94,27 +147,32 @@ class ModelManager extends ChangeNotifier {
|
||||
int _revision = 0;
|
||||
int get revision => _revision;
|
||||
|
||||
/// 服务器目录(弹层模型清单展示用)
|
||||
/// 服务器目录(弹层模型清单展示用;同一数据集可能 s/n 两行)
|
||||
List<ModelCatalogItem> get catalog => _catalog;
|
||||
|
||||
/// 激活模型 id 集合(多选叠加)
|
||||
Set<int> get activeDatasetIds => Set.unmodifiable(_activeIds);
|
||||
/// 识别档位偏好(s 高精度 / n 高性能):作为各卡默认目标档
|
||||
/// (不直接切换运行——运行由激活集驱动)
|
||||
String get mode => _mode;
|
||||
|
||||
bool isActive(int datasetId) => _activeIds.contains(datasetId);
|
||||
bool isActive(int datasetId, String variant) =>
|
||||
_active.contains((datasetId: datasetId, variant: variant));
|
||||
|
||||
/// 该数据集模型文件是否已下载到本地(同步判断,内存态)
|
||||
bool isDownloaded(int datasetId) => _downloadedIds.contains(datasetId);
|
||||
/// 该 (数据集, 档位) 模型文件是否已下载到本地(同步判断,内存态)
|
||||
bool isDownloaded(int datasetId, String variant) =>
|
||||
_downloaded.contains((datasetId: datasetId, variant: variant));
|
||||
|
||||
/// 下载进度 0..1(无下载/已完成为 null)
|
||||
double? progressOf(int datasetId) => _progress[datasetId];
|
||||
double? progressOf(int datasetId, String variant) =>
|
||||
_progress[(datasetId: datasetId, variant: variant)];
|
||||
|
||||
/// 下载失败原因(失败后可重试)
|
||||
String? errorOf(int datasetId) => _errors[datasetId];
|
||||
String? errorOf(int datasetId, String variant) =>
|
||||
_errors[(datasetId: datasetId, variant: variant)];
|
||||
|
||||
/// 中断进行中的下载:下一个数据块到达时终止(丢弃 .part),卡片恢复「使用」。
|
||||
/// 取消不记错误,可再次下载。
|
||||
void cancelDownload(int datasetId) {
|
||||
_cancelRequested.add(datasetId);
|
||||
void cancelDownload(int datasetId, String variant) {
|
||||
_cancelRequested.add((datasetId: datasetId, variant: variant));
|
||||
}
|
||||
|
||||
ModelManager._({String? baseUrl, http.Client? client})
|
||||
@@ -130,7 +188,7 @@ class ModelManager extends ChangeNotifier {
|
||||
_client = client ?? http.Client(),
|
||||
_rootDirOverride = rootDir;
|
||||
|
||||
/// 已激活且已下载的模型列表(空 = 未下载任何模型,相机页仅预览)
|
||||
/// 已激活且已下载的模型列表(每个数据集至多一个档位;空 = 未加载任何模型,仅预览)
|
||||
List<ModelBundle> get models => _models;
|
||||
|
||||
/// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空")
|
||||
@@ -141,12 +199,21 @@ class ModelManager extends ChangeNotifier {
|
||||
|
||||
bool get refreshing => _refreshing;
|
||||
|
||||
/// 模型名摘要(诊断行展示):未下载 / 数据集名×n
|
||||
/// 模型名摘要(诊断行展示):数据集名(同数据集的档位码不外显)
|
||||
String get modelsLabel {
|
||||
if (_models.isEmpty) return '未下载';
|
||||
return _models.map((m) => m.datasetName).join(',');
|
||||
}
|
||||
|
||||
/// 切换识别档位偏好(s 高精度 / n 高性能):只改默认目标档并持久化。
|
||||
Future<void> setMode(String mode) async {
|
||||
if (mode != kVariantS && mode != kVariantN) return;
|
||||
if (_mode == mode) return;
|
||||
_mode = mode;
|
||||
await _saveMode();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。
|
||||
Future<void> refresh() {
|
||||
if (_refreshing) return _inFlight ?? Future.value();
|
||||
@@ -159,131 +226,162 @@ class ModelManager extends ChangeNotifier {
|
||||
return _inFlight!;
|
||||
}
|
||||
|
||||
/// 开始新识别会话(进入视野页时调用):清空激活集与已加载模型。
|
||||
/// 激活集为会话态、不做跨会话持久化——上次使用的模型不自动恢复,识别需
|
||||
/// 用户在模型清单手动启用(2026-09-03 会话制修订)。
|
||||
void resetForSession() {
|
||||
if (_active.isEmpty) return;
|
||||
_active.clear();
|
||||
_models = const [];
|
||||
_revision++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _doRefresh() async {
|
||||
try {
|
||||
await _loadActive();
|
||||
final res = await _client
|
||||
.get(Uri.parse('$baseUrl/api/v1/app/update'))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
// 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8
|
||||
final body =
|
||||
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
final data = body['data'] as Map<String, dynamic>? ?? const {};
|
||||
final list = data['models'] as List? ?? const [];
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示
|
||||
final downloaded = <int>{};
|
||||
for (final item in _catalog) {
|
||||
if (await _isLocal(item)) downloaded.add(item.datasetId);
|
||||
await _loadMode();
|
||||
// 缓存优先(2026-09-03):网络返回前先载入上次成功拉取的目录并提前 notify
|
||||
// ——设置弹层打开即有内容展示,不依赖网络请求;网络成功后再以权威目录覆盖
|
||||
if (_catalog.isEmpty) {
|
||||
await _loadCatalogCache();
|
||||
if (_catalog.isNotEmpty) notifyListeners();
|
||||
}
|
||||
_downloadedIds
|
||||
var fetched = false;
|
||||
try {
|
||||
final res = await _client
|
||||
.get(Uri.parse('$baseUrl/api/v1/app/update'))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
// 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8
|
||||
final body =
|
||||
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
final data = body['data'] as Map<String, dynamic>? ?? const {};
|
||||
final list = data['models'] as List? ?? const [];
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
await _saveCatalogCache(list);
|
||||
fetched = true;
|
||||
} catch (e) {
|
||||
// 拉取失败:保留缓存/旧目录继续展示;本实例从未拉取成功过才记错误
|
||||
// (有缓存兜底时同样提示,说明当前展示的目录未经最新网络确认)
|
||||
if (!_ready) _error = '模型目录拉取失败:$e';
|
||||
}
|
||||
// 无缓存且未拉取成功(目录确为空):无从同步,等下次刷新
|
||||
if (_catalog.isEmpty && !fetched) return;
|
||||
|
||||
// 只拉目录不下载;扫描本地已有模型文件供清单展示(版本是否落后由
|
||||
// 卡片对照目录版本提示「更新」)。缓存目录同样扫描:离线重开也能正确
|
||||
// 标出已下载档位
|
||||
final downloaded = <ModelKey>{};
|
||||
for (final item in _catalog) {
|
||||
if (await _hasFile(item)) {
|
||||
downloaded.add((datasetId: item.datasetId, variant: item.variant));
|
||||
}
|
||||
}
|
||||
_downloaded
|
||||
..clear()
|
||||
..addAll(downloaded);
|
||||
|
||||
// 清理/激活同步只认网络拉到的权威目录:缓存降级时不清文件——
|
||||
// 离线首启不会误删已下载模型(2026-09-03)
|
||||
if (!fetched) return;
|
||||
await _prune(_catalog);
|
||||
// 服务器已下线的数据集移出激活集
|
||||
final catalogIds = _catalog.map((c) => c.datasetId).toSet();
|
||||
if (_activeIds.any((id) => !catalogIds.contains(id))) {
|
||||
_activeIds.removeWhere((id) => !catalogIds.contains(id));
|
||||
await _saveActive();
|
||||
}
|
||||
// 服务器已下线的 (数据集, 档位) 移出激活集
|
||||
final catalogKeys = _catalog
|
||||
.map((c) => (datasetId: c.datasetId, variant: c.variant))
|
||||
.toSet();
|
||||
_active.removeWhere((k) => !catalogKeys.contains(k));
|
||||
|
||||
_models = await _loadBundles(_catalog);
|
||||
_ready = true;
|
||||
_error = null;
|
||||
// 自动更新:已下载/已激活模型发现新版本后台重下(不阻塞目录刷新)
|
||||
unawaited(autoUpdate());
|
||||
} catch (e) {
|
||||
if (!_ready) _error = '模型目录拉取失败:$e';
|
||||
// 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示)
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在)
|
||||
Future<bool> _isLocal(ModelCatalogItem item) async {
|
||||
final dir = await _modelDir(item.datasetId);
|
||||
try {
|
||||
final meta = await _readMeta(dir);
|
||||
final file = File('${dir.path}/model.tflite');
|
||||
return meta != null &&
|
||||
meta['version'] == item.version &&
|
||||
meta['sha256'] == item.sha256 &&
|
||||
await file.exists();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta);
|
||||
/// 成功自动加入激活集(下载完成即使用)。失败重试一次并记录错误。
|
||||
/// 按需下载:流式下载 + sha256 校验 + 落盘(labels/meta)。
|
||||
/// [autoActivate](默认 true,用户显式下载)该数据集此前无任何档位在使用且
|
||||
/// 本档为目录中唯一可选/匹配目标档时自动激活(下载即有识别);
|
||||
/// 使用中的档位原地更新则字节生效(重建推理 worker)。失败重试一次并记录错误。
|
||||
Future<bool> downloadModel(ModelCatalogItem item,
|
||||
{void Function(int received, int total)? onProgress}) async {
|
||||
// 并发保护:同一数据集已有进行中的下载则直接短路(预置 0 先占位,
|
||||
// 使 onProgress 首次回调前的双击/refresh 交错也被 containsKey 拦下)
|
||||
if (_progress.containsKey(item.datasetId)) return false;
|
||||
_cancelRequested.remove(item.datasetId);
|
||||
_progress[item.datasetId] = 0;
|
||||
final dir = await _modelDir(item.datasetId);
|
||||
{bool autoActivate = true,
|
||||
void Function(int received, int total)? onProgress}) async {
|
||||
final key = (datasetId: item.datasetId, variant: item.variant);
|
||||
// 并发保护:同一 (数据集, 档位) 已有进行中的下载则直接短路
|
||||
if (_progress.containsKey(key)) return false;
|
||||
_cancelRequested.remove(key);
|
||||
_progress[key] = 0;
|
||||
final dir = await _modelDir(item.datasetId, item.variant);
|
||||
final file = File('${dir.path}/model.tflite');
|
||||
try {
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
if (_cancelRequested.contains(item.datasetId)) break;
|
||||
if (_cancelRequested.contains(key)) break;
|
||||
final ok = await _downloadAndVerify(item, dir, file,
|
||||
onProgress: (r, t) {
|
||||
_progress[item.datasetId] = t == 0 ? 0 : r / t;
|
||||
_progress[key] = t == 0 ? 0 : r / t;
|
||||
onProgress?.call(r, t);
|
||||
notifyListeners();
|
||||
});
|
||||
if (ok) {
|
||||
_progress.remove(item.datasetId);
|
||||
_errors.remove(item.datasetId);
|
||||
_downloadedIds.add(item.datasetId);
|
||||
_revision++;
|
||||
// 新版本字节立即生效:自动更新时已激活模型 setActive 会因状态未变
|
||||
// 提前返回,不在此重载则重建 worker 仍读到旧模型
|
||||
_models = await _loadBundles(_catalog);
|
||||
_progress.remove(key);
|
||||
_errors.remove(key);
|
||||
final wasActive = _active.contains(key);
|
||||
_downloaded.add(key);
|
||||
if (wasActive) {
|
||||
// 使用中的模型原地更新:字节已替换,重建 worker 读新文件
|
||||
_revision++;
|
||||
_models = await _loadBundles(_catalog);
|
||||
} else if (autoActivate &&
|
||||
!_active.any((k) => k.datasetId == item.datasetId)) {
|
||||
// 用户显式下载且该数据集尚无档位在使用:自动激活识别档位默认档
|
||||
// 条目;目录没有默认档(存量单档物种)时激活本条,保证下载即有
|
||||
// 识别。走 setActive 统一做同档覆盖互斥(同数据集至多一档运行)
|
||||
final hasTarget = _catalog.any((c) =>
|
||||
c.datasetId == item.datasetId && c.variant == mode);
|
||||
if (item.variant == mode || !hasTarget) {
|
||||
await setActive(item.datasetId, item.variant, true);
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
await setActive(item.datasetId, true);
|
||||
return true;
|
||||
}
|
||||
if (_cancelRequested.contains(item.datasetId)) break;
|
||||
if (_cancelRequested.contains(key)) break;
|
||||
await file.delete().catchError((_) => file);
|
||||
await File('${dir.path}/model.tflite.part')
|
||||
.delete()
|
||||
.catchError((_) => file);
|
||||
}
|
||||
if (_cancelRequested.contains(item.datasetId)) {
|
||||
if (_cancelRequested.contains(key)) {
|
||||
// 用户取消:清理残留,不记错误
|
||||
await file.delete().catchError((_) => file);
|
||||
await File('${dir.path}/model.tflite.part')
|
||||
.delete()
|
||||
.catchError((_) => file);
|
||||
_progress.remove(item.datasetId);
|
||||
_progress.remove(key);
|
||||
notifyListeners();
|
||||
debugPrint('[ModelManager] 下载已取消: ${item.datasetName}');
|
||||
return false;
|
||||
}
|
||||
_progress.remove(item.datasetId);
|
||||
_errors[item.datasetId] = '下载失败,请重试';
|
||||
_progress.remove(key);
|
||||
_errors[key] = '下载失败,请重试';
|
||||
notifyListeners();
|
||||
debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (_cancelRequested.contains(item.datasetId)) {
|
||||
if (_cancelRequested.contains(key)) {
|
||||
await file.delete().catchError((_) => file);
|
||||
await File('${dir.path}/model.tflite.part')
|
||||
.delete()
|
||||
.catchError((_) => file);
|
||||
_progress.remove(item.datasetId);
|
||||
_progress.remove(key);
|
||||
notifyListeners();
|
||||
debugPrint('[ModelManager] 下载已取消: ${item.datasetName}');
|
||||
return false;
|
||||
}
|
||||
_progress.remove(item.datasetId);
|
||||
_errors[item.datasetId] = '下载异常:$e';
|
||||
_progress.remove(key);
|
||||
_errors[key] = '下载异常:$e';
|
||||
notifyListeners();
|
||||
debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e');
|
||||
return false;
|
||||
@@ -309,12 +407,16 @@ class ModelManager extends ChangeNotifier {
|
||||
final total = res.contentLength ?? item.sizeBytes;
|
||||
await for (final chunk
|
||||
in res.stream.timeout(const Duration(seconds: 30))) {
|
||||
if (_cancelRequested.contains(item.datasetId)) break; // 用户取消
|
||||
if (_cancelRequested.contains(
|
||||
(datasetId: item.datasetId, variant: item.variant))) {
|
||||
break; // 用户取消
|
||||
}
|
||||
sink.add(chunk);
|
||||
received += chunk.length;
|
||||
onProgress?.call(received, total);
|
||||
}
|
||||
if (_cancelRequested.contains(item.datasetId)) {
|
||||
if (_cancelRequested.contains(
|
||||
(datasetId: item.datasetId, variant: item.variant))) {
|
||||
await sink.close();
|
||||
return false;
|
||||
}
|
||||
@@ -334,7 +436,7 @@ class ModelManager extends ChangeNotifier {
|
||||
'version': item.version,
|
||||
'sha256': item.sha256,
|
||||
}));
|
||||
debugPrint('[ModelManager] 已下载 ${item.datasetName} '
|
||||
debugPrint('[ModelManager] 已下载 ${item.datasetName}(${item.variant}) '
|
||||
'${bytes.length}B -> ${file.path}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -344,89 +446,166 @@ class ModelManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理服务器目录中已下线的数据集模型(不再发布则删本地)
|
||||
/// 清理本地目录:数据集整体下线(s/n 两档都无目录条目)删整目录;
|
||||
/// 数据集仍在但某档已下线时清该档子目录(s 档为同级文件,无独立目录,
|
||||
/// 残留文件不再被引用,仅占用磁盘,不做细粒度清除)。
|
||||
Future<void> _prune(List<ModelCatalogItem> catalog) async {
|
||||
final root = await _rootDir();
|
||||
if (!await root.exists()) return;
|
||||
final keep = catalog.map((c) => '${c.datasetId}').toSet();
|
||||
final dsIds = catalog.map((c) => c.datasetId).toSet();
|
||||
final nDsIds = catalog
|
||||
.where((c) => c.variant == kVariantN)
|
||||
.map((c) => c.datasetId)
|
||||
.toSet();
|
||||
await for (final e in root.list()) {
|
||||
if (e is Directory) {
|
||||
// 目录 URI 末尾带 '/',pathSegments 末位为空串 → 过滤后取目录名
|
||||
final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last;
|
||||
if (!keep.contains(name)) {
|
||||
await e.delete(recursive: true).catchError((_) => e);
|
||||
if (e is! Directory) continue;
|
||||
// 目录 URI 末尾带 '/',pathSegments 末位为空串 → 过滤后取目录名
|
||||
final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last;
|
||||
final dsId = int.tryParse(name);
|
||||
if (dsId == null) continue;
|
||||
if (!dsIds.contains(dsId)) {
|
||||
await e.delete(recursive: true).catchError((_) => e);
|
||||
continue;
|
||||
}
|
||||
if (!nDsIds.contains(dsId)) {
|
||||
final sub = Directory('${e.path}/$kVariantN');
|
||||
if (await sub.exists()) {
|
||||
await sub.delete(recursive: true).catchError((_) => e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置激活状态(true=使用,false=取消);持久化到 `root/active.json`。
|
||||
/// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。
|
||||
Future<void> setActive(int datasetId, bool active) async {
|
||||
final changed =
|
||||
active ? _activeIds.add(datasetId) : _activeIds.remove(datasetId);
|
||||
if (!changed) return;
|
||||
/// 设置激活状态(true=使用,false=取消;仅本次会话内生效,不持久化)。
|
||||
/// 同一数据集至多一个档位在使用:激活某档时若同数据集另一档在使用则先停用
|
||||
/// (2026-09-03:不同动物可跑不同档位,同一种动物一次只跑一档)。
|
||||
/// **覆盖互斥(2026-09-09 综合模型)**:激活综合模型自动停用其 datasetIds 覆盖
|
||||
/// 物种的单物种模型;激活某单物种自动停用覆盖它的综合模型(兜底:并存时跨模型
|
||||
/// NMS 按类别名合并不会重复框,互斥只为省算力)。变化即重建推理 worker。
|
||||
Future<void> setActive(int datasetId, String variant, bool active) async {
|
||||
final key = (datasetId: datasetId, variant: variant);
|
||||
if (!active) {
|
||||
if (!_active.remove(key)) return;
|
||||
} else {
|
||||
final keyActive = _active.contains(key);
|
||||
final conflicts = <ModelKey>{
|
||||
..._active.where(
|
||||
(k) => k.datasetId == datasetId && k.variant != variant),
|
||||
};
|
||||
// 覆盖互斥:找到本条目目录信息,按 kind 判定冲突集
|
||||
ModelCatalogItem? catItem;
|
||||
for (final c in _catalog) {
|
||||
if (c.datasetId == datasetId && c.variant == variant) {
|
||||
catItem = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (catItem != null && catItem.isCombined) {
|
||||
// 综合:停用其覆盖物种的全部单物种激活
|
||||
for (final k in _active) {
|
||||
if (k.datasetId != 0 && catItem.datasetIds.contains(k.datasetId)) {
|
||||
conflicts.add(k);
|
||||
}
|
||||
}
|
||||
} else if (catItem != null) {
|
||||
// 单物种:停用覆盖本物种的综合模型(同档位才冲突)
|
||||
for (final k in _active) {
|
||||
if (k.datasetId != 0) continue;
|
||||
for (final c in _catalog) {
|
||||
if (c.datasetId == 0 &&
|
||||
c.variant == k.variant &&
|
||||
c.isCombined &&
|
||||
c.datasetIds.contains(datasetId)) {
|
||||
conflicts.add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (keyActive && conflicts.isEmpty) return; // 状态未变化
|
||||
_active.removeAll(conflicts);
|
||||
_active.add(key);
|
||||
}
|
||||
_revision++;
|
||||
_models = await _loadBundles(_catalog);
|
||||
await _saveActive();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 自动更新:已下载/已激活的模型,目录出现新版本时自动重下(保持原激活状态;
|
||||
/// 未下载的模型不自动拉取,避免无谓流量)。下载进度经 downloadModel 通知。
|
||||
/// 2026-09-01 用户需求:发布新模型后 App 端自动更新,无需手动触发。
|
||||
Future<void> autoUpdate() async {
|
||||
if (_catalog.isEmpty) return;
|
||||
final tracked = {..._downloadedIds, ..._activeIds};
|
||||
for (final item in _catalog) {
|
||||
if (!tracked.contains(item.datasetId)) continue;
|
||||
if (await _isLocal(item)) continue;
|
||||
final wasActive = _activeIds.contains(item.datasetId);
|
||||
try {
|
||||
final ok = await downloadModel(item);
|
||||
// 原本未激活:下载完成自动激活后恢复原状态
|
||||
if (ok && !wasActive) await setActive(item.datasetId, false);
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 自动更新失败: ${item.datasetName} $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveActive() async {
|
||||
Future<void> _saveMode() async {
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
await root.create(recursive: true);
|
||||
await File('${root.path}/active.json')
|
||||
.writeAsString(jsonEncode({'active': _activeIds.toList()}));
|
||||
await File('${root.path}/mode.json')
|
||||
.writeAsString(jsonEncode({'mode': _mode}));
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 激活集持久化失败: $e');
|
||||
debugPrint('[ModelManager] 识别偏好持久化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadActive() async {
|
||||
if (_activeLoaded) return;
|
||||
_activeLoaded = true;
|
||||
Future<void> _loadMode() async {
|
||||
if (_modeLoaded) return;
|
||||
_modeLoaded = true;
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
final f = File('${root.path}/active.json');
|
||||
final f = File('${root.path}/mode.json');
|
||||
if (!await f.exists()) return;
|
||||
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||
_activeIds
|
||||
..clear()
|
||||
..addAll((data['active'] as List? ?? const [])
|
||||
.map((e) => (e as num).toInt()));
|
||||
final r = data['recognition'] as String?;
|
||||
if (r != null) {
|
||||
// 四选一时期(2026-09-11 定稿后、多物种下线前)的配置:后缀即档位
|
||||
if (r.endsWith(kVariantN)) {
|
||||
_mode = kVariantN;
|
||||
} else if (r.endsWith(kVariantS)) {
|
||||
_mode = kVariantS;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 更早 {mode 档位, source 单/多源} 两维结构:只看档位(multi 已下线)
|
||||
if (data['mode'] == kVariantN) _mode = kVariantN;
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 激活集读取失败: $e');
|
||||
debugPrint('[ModelManager] 识别偏好读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 载入上次成功拉取的目录缓存(catalog.json,模型根目录下)——离线/弱网时
|
||||
/// 设置弹层也能先展示模型清单。无缓存文件/损坏/空列表则保持目录为空。
|
||||
Future<void> _loadCatalogCache() async {
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
final f = File('${root.path}/catalog.json');
|
||||
if (!await f.exists()) return;
|
||||
final data = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||
final list = data['models'] as List? ?? const [];
|
||||
if (list.isEmpty) return;
|
||||
_catalog = list
|
||||
.map((e) => ModelCatalogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 目录缓存读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 落盘最近一次成功拉取的 models 原始列表(含服务器可能新增的字段),
|
||||
/// 供下次离线/网络慢时先展示;缓存仅作展示降级,不参与清理/自动更新决策。
|
||||
Future<void> _saveCatalogCache(List<dynamic> rawModels) async {
|
||||
try {
|
||||
final root = await _rootDir();
|
||||
await root.create(recursive: true);
|
||||
await File('${root.path}/catalog.json')
|
||||
.writeAsString(jsonEncode({'models': rawModels}));
|
||||
} catch (e) {
|
||||
debugPrint('[ModelManager] 目录缓存保存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取全部激活条目(不再按目标档过滤——激活集即实际运行集,每数据集一档)
|
||||
Future<List<ModelBundle>> _loadBundles(
|
||||
List<ModelCatalogItem> catalog) async {
|
||||
final bundles = <ModelBundle>[];
|
||||
for (final item in catalog) {
|
||||
if (!_activeIds.contains(item.datasetId)) continue;
|
||||
final key = (datasetId: item.datasetId, variant: item.variant);
|
||||
if (!_active.contains(key)) continue;
|
||||
try {
|
||||
final dir = await _modelDir(item.datasetId);
|
||||
final dir = await _modelDir(item.datasetId, item.variant);
|
||||
final file = File('${dir.path}/model.tflite');
|
||||
if (!await file.exists()) continue;
|
||||
final labels = await File('${dir.path}/labels.json').exists()
|
||||
@@ -439,6 +618,7 @@ class ModelManager extends ChangeNotifier {
|
||||
bundles.add(ModelBundle(
|
||||
datasetId: item.datasetId,
|
||||
datasetName: item.datasetName,
|
||||
variant: item.variant,
|
||||
version: item.version,
|
||||
labels: labels,
|
||||
bytes: await file.readAsBytes(),
|
||||
@@ -450,26 +630,30 @@ class ModelManager extends ChangeNotifier {
|
||||
return bundles;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _readMeta(Directory dir) async {
|
||||
final f = File('${dir.path}/meta.json');
|
||||
if (!await f.exists()) return null;
|
||||
try {
|
||||
return jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _rootDir() async {
|
||||
if (_rootDirOverride != null) return _rootDirOverride();
|
||||
final support = await getApplicationSupportDirectory();
|
||||
return Directory('${support.path}/models');
|
||||
}
|
||||
|
||||
Future<Directory> _modelDir(int datasetId) async {
|
||||
/// 档位子路径(相对模型根目录):s 档 `models/<datasetId>/`(legacy 无子目录,
|
||||
/// 目录键 = 档位标识符的 s 形态,存量设备零迁移);n 档 `models/<datasetId>/n/`。
|
||||
String _subPath(int datasetId, String variant) =>
|
||||
variant == kVariantS ? '$datasetId' : '$datasetId/$variant';
|
||||
|
||||
/// 档位目录(不存在则创建)
|
||||
Future<Directory> _modelDir(int datasetId, String variant) async {
|
||||
final root = await _rootDir();
|
||||
final dir = Directory('${root.path}/$datasetId');
|
||||
final dir = Directory('${root.path}/${_subPath(datasetId, variant)}');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// 目录条目对应的模型文件是否已存在本地(不校验版本:文件已存在即标已下载,
|
||||
/// 是否落后由卡片端对照目录版本提示「更新」)
|
||||
Future<bool> _hasFile(ModelCatalogItem item) async {
|
||||
final root = await _rootDir();
|
||||
return File('${root.path}/${_subPath(item.datasetId, item.variant)}/model.tflite')
|
||||
.exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import 'package:vibration/vibration.dart';
|
||||
/// 提醒:同类目标 10s 内只提醒一次。
|
||||
/// 震动/提示音默认开启,不提供关闭入口。
|
||||
class Reminder {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
// 惰性创建:AudioPlayer 构造即发起平台初始化(无插件环境下未处理错误会
|
||||
// 泄漏为 unhandled async error),首响时才建
|
||||
AudioPlayer? _player;
|
||||
String? _lastAlertLabel;
|
||||
int _lastAlertAt = 0;
|
||||
|
||||
@@ -24,11 +26,12 @@ class Reminder {
|
||||
void _vibrate() => Vibration.vibrate(duration: 200);
|
||||
|
||||
Future<void> _playTone() async {
|
||||
await _player.stop();
|
||||
await _player.play(AssetSource('beep.wav'));
|
||||
final p = _player ??= AudioPlayer();
|
||||
await p.stop();
|
||||
await p.play(AssetSource('beep.wav'));
|
||||
}
|
||||
|
||||
void release() {
|
||||
_player.dispose();
|
||||
_player?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 安装进度事件(原生 PackageInstaller 会话回调经 EventChannel 回传)
|
||||
class InstallEvent {
|
||||
/// progress / finished / failed
|
||||
final String event;
|
||||
|
||||
/// event=progress 时的安装进度 0-100
|
||||
final int progress;
|
||||
|
||||
/// event=finished 时是否安装成功
|
||||
final bool? success;
|
||||
|
||||
/// event=failed 时的错误描述
|
||||
final String? error;
|
||||
|
||||
const InstallEvent({
|
||||
required this.event,
|
||||
this.progress = 0,
|
||||
this.success,
|
||||
this.error,
|
||||
});
|
||||
}
|
||||
|
||||
/// App 内安装 APK:原生侧 PackageInstaller 会话安装(InstallerChannel.kt)
|
||||
class ApkInstaller {
|
||||
static const _method = MethodChannel('observer/installer');
|
||||
static const _progress = EventChannel('observer/installer/progress');
|
||||
|
||||
/// 提交安装。返回 installing(已进入安装流程)/ permission_required
|
||||
/// (未允许「安装未知应用」,原生侧已拉起系统设置页)。
|
||||
static Future<String> install(String path) =>
|
||||
_method.invokeMethod<String>('install', {'path': path}).then(
|
||||
(v) => v ?? 'installing',
|
||||
);
|
||||
|
||||
/// 安装进度流:progress(0-100) → finished(success) / failed(error)
|
||||
static Stream<InstallEvent> progress() {
|
||||
return _progress.receiveBroadcastStream().map((e) {
|
||||
final m = e as Map;
|
||||
return InstallEvent(
|
||||
event: m['event'] as String? ?? '',
|
||||
progress: (m['progress'] as num?)?.toInt() ?? 0,
|
||||
success: m['success'] as bool?,
|
||||
error: m['error'] as String?,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class AppUpdateInfo {
|
||||
}
|
||||
|
||||
/// 启动时版本更新检查:仅 Android 检查;服务器版本高于本地版本即强制更新
|
||||
/// (无普通/强制之分)。
|
||||
/// (无普通/强制之分)。2026-09-03 起更新走浏览器下载 APK 手动安装。
|
||||
class UpdateChecker {
|
||||
final String baseUrl;
|
||||
final http.Client _client;
|
||||
@@ -52,12 +52,11 @@ class UpdateChecker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否需要更新:服务器版本高于「已装版本与已确认接受版本」中的较大者。
|
||||
/// APK 版本号不递增时,用户点过「立即更新」后 accepted 追上服务器版本,
|
||||
/// 已更新完成再次启动也不会反复提示。
|
||||
static bool needsUpdate(String server, String installed, String accepted) {
|
||||
/// 是否需要更新:服务器版本高于已装版本(2026-09-03 起更新改浏览器下载,
|
||||
/// 安装结果由系统安装器完成,App 无法感知,故不再记录「已确认接受版本」)
|
||||
static bool needsUpdate(String server, String installed) {
|
||||
if (server.isEmpty || installed.isEmpty) return false;
|
||||
return isNewer(server, installed) || isNewer(server, accepted);
|
||||
return isNewer(server, installed);
|
||||
}
|
||||
|
||||
/// 语义化版本号比较:a > b 返回 true。按数字段比较(1.10.0 > 1.9.9),
|
||||
|
||||
@@ -1,219 +1,53 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'installer.dart';
|
||||
|
||||
/// 强制更新页:检测到新版本时的全屏阻塞页。
|
||||
/// PopScope 禁返回(Android 系统返回 / iOS 边缘滑动均不可退出)。
|
||||
/// 进页自动在 App 内流式下载 APK(显示下载进度)→ PackageInstaller
|
||||
/// 会话安装(显示安装进度),失败可手动重试,不再跳浏览器。
|
||||
/// 安装成功时回调 onUpdateAccepted(调用方持久化服务器版本号,
|
||||
/// 使 APK 版本号不递增时也不反复提示)。
|
||||
/// 2026-09-03 改为浏览器下载:App 内 PackageInstaller 会话安装在小米等
|
||||
/// ROM 上点系统确认框后失败(下载/写入正常但最终安装被拒),而系统浏览器
|
||||
/// 下载 APK 后经通知栏走 ROM 自己的安装器可正常完成,故下载链接改由浏览器
|
||||
/// 打开,不再 App 内下载/安装。仅点「立即更新」按钮时打开浏览器,不自动
|
||||
/// 跳转;安装完成(versionName 追上服务器)后下次启动不再提示。
|
||||
class UpdateScreen extends StatefulWidget {
|
||||
final String version;
|
||||
final String url;
|
||||
final String notes;
|
||||
final VoidCallback? onUpdateAccepted;
|
||||
|
||||
const UpdateScreen({
|
||||
super.key,
|
||||
required this.version,
|
||||
required this.url,
|
||||
this.notes = '',
|
||||
this.onUpdateAccepted,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UpdateScreen> createState() => _UpdateScreenState();
|
||||
}
|
||||
|
||||
enum _Stage { idle, downloading, installing, finished, failed }
|
||||
|
||||
class _UpdateScreenState extends State<UpdateScreen> {
|
||||
final http.Client _client = http.Client();
|
||||
final File _apkFile = File('${Directory.systemTemp.path}/observer-latest.apk');
|
||||
final File _apkPart =
|
||||
File('${Directory.systemTemp.path}/observer-latest.apk.part');
|
||||
|
||||
_Stage _stage = _Stage.idle;
|
||||
|
||||
/// 进度百分比 0-100;null = 总量未知(不确定进度条)
|
||||
double? _progress;
|
||||
String? _message;
|
||||
StreamSubscription<InstallEvent>? _installSub;
|
||||
|
||||
/// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
|
||||
Timer? _installTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 自动更新:进页即自动下载并安装,无需手动点击(2026-09-01 用户需求)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _launch());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_installTimer?.cancel();
|
||||
_installSub?.cancel();
|
||||
_client.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _launch() async {
|
||||
if (_stage == _Stage.downloading ||
|
||||
_stage == _Stage.installing ||
|
||||
_stage == _Stage.finished) {
|
||||
Future<void> _openBrowser() async {
|
||||
final uri = Uri.tryParse(widget.url);
|
||||
if (uri == null) {
|
||||
setState(() => _message = '下载链接无效,请联系管理员');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_stage = _Stage.idle;
|
||||
_message = null;
|
||||
});
|
||||
if (!Platform.isAndroid) {
|
||||
// 更新检查本就仅 Android 触发,这里兜底非 Android 走浏览器
|
||||
final uri = Uri.tryParse(widget.url);
|
||||
if (uri == null) return;
|
||||
try {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
// APK 已下载完成(下载是原子落盘,.part 改名后文件才存在)→ 跳过下载直接安装
|
||||
if (_apkFile.existsSync()) {
|
||||
await _install();
|
||||
return;
|
||||
}
|
||||
await _download();
|
||||
}
|
||||
|
||||
Future<void> _download() async {
|
||||
setState(() {
|
||||
_stage = _Stage.downloading;
|
||||
_progress = 0;
|
||||
});
|
||||
try {
|
||||
if (_apkPart.existsSync()) _apkPart.deleteSync();
|
||||
// 下载无总时长上限(APK 几十 MB 慢网可能数分钟);连接/响应头与
|
||||
// 数据流分别做 30s 停滞判定,避免断流黑洞永久卡死
|
||||
final res = await _client
|
||||
.send(http.Request('GET', Uri.parse(widget.url)))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (res.statusCode != 200) {
|
||||
throw HttpException('HTTP ${res.statusCode}');
|
||||
}
|
||||
final total = res.contentLength ?? -1;
|
||||
final sink = _apkPart.openWrite();
|
||||
var received = 0;
|
||||
await for (final chunk
|
||||
in res.stream.timeout(const Duration(seconds: 30))) {
|
||||
sink.add(chunk);
|
||||
received += chunk.length;
|
||||
if (mounted && total > 0) {
|
||||
setState(() => _progress = received / total * 100);
|
||||
}
|
||||
}
|
||||
await sink.close();
|
||||
// 原子落盘:下载完成后才重命名为正式文件,避免残留半包被当成完整 APK
|
||||
_apkPart.renameSync(_apkFile.path);
|
||||
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!mounted) return;
|
||||
await _install();
|
||||
setState(() {
|
||||
_message = ok ? '已打开浏览器下载,完成后点击通知栏的安装提示即可更新' : '打开浏览器失败,请点「立即更新」重试';
|
||||
});
|
||||
} catch (_) {
|
||||
if (_apkPart.existsSync()) _apkPart.deleteSync();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_progress = null;
|
||||
_message = '下载失败,请检查网络后重试';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _install() async {
|
||||
setState(() {
|
||||
_stage = _Stage.installing;
|
||||
_progress = 0;
|
||||
_message = null;
|
||||
});
|
||||
await _installSub?.cancel();
|
||||
// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
|
||||
_installTimer?.cancel();
|
||||
_installTimer = Timer(const Duration(seconds: 120), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = '安装超时,请重试';
|
||||
});
|
||||
});
|
||||
_installSub = ApkInstaller.progress().listen((e) {
|
||||
if (!mounted) return;
|
||||
switch (e.event) {
|
||||
case 'progress':
|
||||
setState(() => _progress = e.progress.toDouble());
|
||||
break;
|
||||
case 'finished':
|
||||
_installTimer?.cancel();
|
||||
final ok = e.success == true;
|
||||
// 安装成功才记录已接受版本:失败/取消时下次启动仍提示重试
|
||||
if (ok) widget.onUpdateAccepted?.call();
|
||||
setState(() {
|
||||
_stage = ok ? _Stage.finished : _Stage.failed;
|
||||
_message = ok ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试';
|
||||
});
|
||||
break;
|
||||
case 'failed':
|
||||
_installTimer?.cancel();
|
||||
debugPrint('UpdateScreen: install failed: ${e.error}');
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = e.error ?? '安装失败,请重试';
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, onError: (Object _) {
|
||||
_installTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = '安装失败,请重试';
|
||||
});
|
||||
});
|
||||
final String result;
|
||||
try {
|
||||
result = await ApkInstaller.install(_apkFile.path);
|
||||
} catch (_) {
|
||||
// 原生安装通道异常(如会话创建失败):不捕获则 UI 永久停在「安装中」
|
||||
_installTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = '安装启动失败,请重试';
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (result == 'permission_required') {
|
||||
// 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装
|
||||
_installTimer?.cancel();
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)';
|
||||
});
|
||||
setState(() => _message = '打开浏览器失败,请点「立即更新」重试');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final busy = _stage == _Stage.downloading || _stage == _Stage.installing;
|
||||
final progressText = _stage == _Stage.downloading
|
||||
? (_progress == null ? '下载中…' : '下载中 ${_progress!.round()}%')
|
||||
: (_progress == null ? '安装中…' : '安装中 ${_progress!.round()}%');
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
@@ -234,26 +68,22 @@ class _UpdateScreenState extends State<UpdateScreen> {
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(height: 1.6)),
|
||||
const SizedBox(height: 24),
|
||||
if (busy) ...[
|
||||
LinearProgressIndicator(
|
||||
value: _progress == null ? null : _progress! / 100,
|
||||
minHeight: 6,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(progressText),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: busy || _stage == _Stage.finished
|
||||
? null
|
||||
: _launch,
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text(_stage == _Stage.finished ? '已完成' : '立即更新'),
|
||||
onPressed: _openBrowser,
|
||||
icon: const Icon(Icons.open_in_browser),
|
||||
label: const Text('立即更新'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(200, 48),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'新版本需在浏览器下载 APK 后手动安装\n安装完成后请重新打开应用',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.error),
|
||||
),
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -261,16 +91,12 @@ class _UpdateScreenState extends State<UpdateScreen> {
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
color: _stage == _Stage.failed
|
||||
? theme.colorScheme.error
|
||||
: Colors.green,
|
||||
color: _message!.startsWith('已打开')
|
||||
? Colors.green
|
||||
: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text('不更新将无法继续使用',
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -367,6 +375,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image
|
||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -639,6 +655,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: observer
|
||||
description: "视野 - 动物实时识别 (环颈雉鸡/生境), YOLOv8 + 充值付费"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.24+29
|
||||
version: 1.0.42+47
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@@ -32,6 +32,8 @@ dependencies:
|
||||
# 模型热更新:多模型下载(crypto 校验 sha256;path_provider 取应用私有目录持久化)
|
||||
crypto: ^3.0.0
|
||||
path_provider: ^2.1.4
|
||||
# 假目标上报:纯 Dart jpg 重编码(裁剪 + 剥离全部元数据,合规硬要求)
|
||||
image: ^4.5.4
|
||||
|
||||
# 微信/支付宝原生 SDK 配置(占位值,与 lib/config/app_config.dart 一致;接入真实支付时替换。
|
||||
# 注意:fluwx 的 universal_link 占位符会被其 pod 脚本注入 Associated Domains,
|
||||
@@ -42,7 +44,7 @@ fluwx:
|
||||
tobias:
|
||||
url_scheme: alipay0000000000
|
||||
ios:
|
||||
universal_link: https://YOUR_DOMAIN.com/alipay/
|
||||
universal_link: https://animal-spot.icu/alipay/
|
||||
|
||||
# camera_android_camerax 屏蔽:Android 相机走自写 CameraChannel,不需要 CameraX;
|
||||
# 插件注册即初始化 CameraX,无相机环境(模拟器)下回调线程直接
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:observer/camera/camera_view_model.dart';
|
||||
import 'package:observer/detection/detection_result.dart';
|
||||
import 'package:observer/reminder/reminder.dart';
|
||||
|
||||
DetectionResult box(
|
||||
String label,
|
||||
double score,
|
||||
double l,
|
||||
double t,
|
||||
double r,
|
||||
double b, {
|
||||
int modelId = -1,
|
||||
int classId = 0,
|
||||
}) =>
|
||||
DetectionResult(
|
||||
label: label,
|
||||
score: score,
|
||||
left: l,
|
||||
top: t,
|
||||
right: r,
|
||||
bottom: b,
|
||||
modelId: modelId,
|
||||
classId: classId,
|
||||
);
|
||||
|
||||
/// 真实 Reminder 会触碰音频/震动插件通道,测试用记录桩(onDetected 同步记录)。
|
||||
/// AudioPlayer 构造里的 _create 平台调用在测试环境抛 MissingPluginException,
|
||||
/// 已被插件内部 catch,不影响用例。
|
||||
class _RecordingReminder extends Reminder {
|
||||
final List<String> alerts = [];
|
||||
@override
|
||||
void onDetected(String label) => alerts.add(label);
|
||||
}
|
||||
|
||||
void feed(CameraViewModel vm, List<DetectionResult> results) {
|
||||
vm.onFramesAnalyzed(results, 90, 1280, 720, const [], const [],
|
||||
lastProcessMs: 8);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('dedupeVisibleOverlaps', () {
|
||||
test('同类别同位置重复框:只保留最高分', () {
|
||||
final visible = [
|
||||
box('雉鸡', 0.6, 0.3, 0.3, 0.5, 0.5, modelId: 1),
|
||||
box('斑鸠', 0.5, 0.3, 0.3, 0.5, 0.5, modelId: 2),
|
||||
];
|
||||
final out = dedupeVisibleOverlaps(visible);
|
||||
expect(out.length, 1);
|
||||
expect(out.single.label, '雉鸡');
|
||||
expect(out.single.score, 0.6);
|
||||
});
|
||||
|
||||
test('不同类别同位置(目标×疑似):不同语义各自保留', () {
|
||||
final visible = [
|
||||
box('雉鸡', 0.6, 0.3, 0.3, 0.5, 0.5, classId: 0),
|
||||
box('生境', 0.8, 0.2, 0.2, 0.6, 0.6, classId: 1),
|
||||
];
|
||||
final out = dedupeVisibleOverlaps(visible);
|
||||
expect(out.length, 2);
|
||||
});
|
||||
|
||||
test('中心不在对方框内的偏移重叠:两个独立目标不误并', () {
|
||||
// 覆盖 ~40% 但中心互不在对方框内(并肩两个目标)
|
||||
final visible = [
|
||||
box('雉鸡', 0.7, 0.10, 0.10, 0.40, 0.40),
|
||||
box('雉鸡', 0.5, 0.28, 0.10, 0.58, 0.40),
|
||||
];
|
||||
expect(dedupeVisibleOverlaps(visible).length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('跨标签同目标补挂:交替检出同一目标只一条轨迹/一次提醒', () async {
|
||||
final reminder = _RecordingReminder();
|
||||
final vm = CameraViewModel(reminder: reminder);
|
||||
feed(vm, [box('雉鸡', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 1)]);
|
||||
// 越过 500ms 显示窗(确认轨迹开始显示、提醒窗口开启)
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
// 模型 2 检出同一目标:细长小框贴大框边角(中心距 ~0.2 > 跨标签收紧半径
|
||||
// 0.072、覆盖 ~0.31 也不到 0.45),但小框中心在大框内 → 补挂进雉鸡
|
||||
// 轨迹而不是新建第二条
|
||||
feed(vm, [box('斑鸠', 0.55, 0.38, 0.445, 0.62, 0.535, modelId: 2)]);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
expect(reminder.alerts, ['雉鸡'],
|
||||
reason: '补挂进原轨迹:提醒按轨迹原标签只触发一次;若新建第二条轨迹'
|
||||
'(斑鸠未达 500ms 显示窗)本帧不会触发提醒');
|
||||
expect(vm.state.results.length, 1, reason: '同位置只有一条轨迹可见');
|
||||
expect(vm.state.results.single.label, '斑鸠', reason: '框内容跟随后到的检测');
|
||||
});
|
||||
|
||||
test('平卧长条框不被几何过滤(RNPHE_2030 实测框:归一化宽高比 3.99)', () async {
|
||||
final vm = CameraViewModel(reminder: _RecordingReminder());
|
||||
// 实测模型 0.8845 检出,框 0.283×0.071(像素 199×89,长尾雉鸡),
|
||||
// 旧逻辑 aspect > 3.0 在建轨迹前丢弃 → 高分也无框
|
||||
final flat = box('雉鸡', 0.88, 0.405, 0.419, 0.688, 0.490);
|
||||
feed(vm, [flat]);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
feed(vm, [flat]);
|
||||
expect(vm.state.results.length, 1);
|
||||
expect(vm.state.results.single.score, 0.88);
|
||||
});
|
||||
|
||||
test('近距离大目标(占画面高 45%)与远距离小目标(高 0.8%)均可显示', () async {
|
||||
final vm = CameraViewModel(reminder: _RecordingReminder());
|
||||
final near = box('雉鸡', 0.9, 0.15, 0.05, 0.85, 0.50);
|
||||
final far = box('雉鸡', 0.9, 0.45, 0.80, 0.48, 0.808);
|
||||
feed(vm, [near, far]);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
feed(vm, [near, far]);
|
||||
expect(vm.state.results.length, 2);
|
||||
});
|
||||
}
|
||||
@@ -20,15 +20,27 @@ Stream<List<int>> _delayedChunks() async* {
|
||||
yield [5, 6, 7, 8];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _item() => {
|
||||
'datasetId': 7,
|
||||
'datasetName': '环颈雉鸡数据集',
|
||||
Map<String, dynamic> _item({
|
||||
int datasetId = 7,
|
||||
String variant = kVariantS,
|
||||
String name = '数据集A',
|
||||
}) =>
|
||||
{
|
||||
'datasetId': datasetId,
|
||||
'datasetName': name,
|
||||
if (variant.isNotEmpty) 'variant': variant,
|
||||
'version': 'v1.0.0',
|
||||
'labels': ['pheasant', 'suspect'],
|
||||
'labels': ['target', 'suspect'],
|
||||
'sizeBytes': _modelBytes.length,
|
||||
'sha256': _shaHex(_modelBytes),
|
||||
'downloadUrl': '/download/models/7/latest.tflite',
|
||||
'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE',
|
||||
'downloadUrl': '/download/models/$datasetId/$variant.tflite',
|
||||
'coverUrl': '/api/v1/app/cover?namePrefix=DS001',
|
||||
};
|
||||
|
||||
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) => {
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'models': models}
|
||||
};
|
||||
|
||||
/// 真实流式下载客户端:send 立即返回分块流(MockClient 的 Response.fromStream
|
||||
@@ -38,11 +50,8 @@ class _StreamingClient extends http.BaseClient {
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
if (request.url.path == '/api/v1/app/update') {
|
||||
return http.StreamedResponse(
|
||||
http.ByteStream.fromBytes(utf8.encode(jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'models': [_item()]}
|
||||
}))),
|
||||
http.ByteStream.fromBytes(utf8.encode(
|
||||
jsonEncode(_catalog([_item()])))),
|
||||
200);
|
||||
}
|
||||
return http.StreamedResponse(_delayedChunks(), 200,
|
||||
@@ -59,17 +68,12 @@ void main() {
|
||||
|
||||
tearDown(() => root.delete(recursive: true));
|
||||
|
||||
ModelManager manager() => ModelManager(
|
||||
ModelManager manager(List<Map<String, dynamic>> models) => ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: MockClient((req) async {
|
||||
if (req.url.path == '/api/v1/app/update') {
|
||||
return http.Response.bytes(
|
||||
utf8.encode(jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'models': [_item()]}
|
||||
})),
|
||||
200);
|
||||
utf8.encode(jsonEncode(_catalog(models))), 200);
|
||||
}
|
||||
if (req.url.path.startsWith('/download/models/')) {
|
||||
return http.Response.bytes(_modelBytes, 200);
|
||||
@@ -99,44 +103,55 @@ void main() {
|
||||
}
|
||||
|
||||
Future<void> pumpSection(WidgetTester tester, ModelManager m) async {
|
||||
// 与设置弹层一致:区块置于可滚动容器(区块含识别模式行后超出测试视口,
|
||||
// 点按前需 ensureVisible 滚动到目标)
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(body: ModelCatalogSection(manager: m))));
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: ModelCatalogSection(manager: m)))));
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 50),
|
||||
EnginePhase.sendSemanticsUpdate, const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
testWidgets('未下载:显示使用按钮,点击后下载完成自动变为已使用', (tester) async {
|
||||
final m = manager();
|
||||
/// 滚动到目标可见后点按(区块可超出视口)
|
||||
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.pump();
|
||||
await tester.tap(finder);
|
||||
}
|
||||
|
||||
testWidgets('未下载:显示下载按钮,点击后下载完成自动变为使用中', (tester) async {
|
||||
final m = manager([_item()]);
|
||||
await tester.runAsync(() => m.refresh());
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(find.text('环颈雉鸡数据集'), findsOneWidget);
|
||||
expect(find.text('使用'), findsOneWidget);
|
||||
expect(find.text('数据集A'), findsOneWidget);
|
||||
expect(find.text('下载'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('使用'));
|
||||
await tapVisible(tester, find.text('下载'));
|
||||
await tester.pump(); // 下载启动,进度条出现
|
||||
// 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「已使用」
|
||||
await pumpUntilFound(tester, find.text('已使用'));
|
||||
// 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「使用中」
|
||||
await pumpUntilFound(tester, find.text('使用中'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isDownloaded(7), isTrue);
|
||||
expect(m.isActive(7), isTrue);
|
||||
expect(find.text('已使用'), findsOneWidget);
|
||||
expect(m.isDownloaded(7, kVariantS), isTrue);
|
||||
expect(m.isActive(7, kVariantS), isTrue);
|
||||
expect(find.text('使用中'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('已激活:再次点击取消使用', (tester) async {
|
||||
final m = manager();
|
||||
testWidgets('已激活:点击使用中取消使用', (tester) async {
|
||||
final m = manager([_item()]);
|
||||
await tester.runAsync(() => m.refresh());
|
||||
await tester.runAsync(() => m.downloadModel(m.catalog.first));
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(find.text('已使用'), findsOneWidget);
|
||||
await tester.tap(find.text('已使用'));
|
||||
expect(find.text('使用中'), findsOneWidget);
|
||||
await tapVisible(tester, find.text('使用中'));
|
||||
// 排空 setActive 的激活集重载与落盘 IO,直至 UI 呈现「使用」
|
||||
await pumpUntilFound(tester, find.text('使用'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isActive(7), isFalse);
|
||||
expect(m.isActive(7, kVariantS), isFalse);
|
||||
expect(find.text('使用'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -144,8 +159,7 @@ void main() {
|
||||
final m = ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: MockClient((_) async => http.Response.bytes(
|
||||
utf8.encode(jsonEncode(
|
||||
{'code': 0, 'message': 'ok', 'data': {'models': []}})),
|
||||
utf8.encode(jsonEncode(_catalog([]))),
|
||||
200)),
|
||||
rootDir: () async => root,
|
||||
);
|
||||
@@ -155,7 +169,7 @@ void main() {
|
||||
expect(find.text('暂无已发布模型'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('下载中:显示进度和取消按钮,取消后恢复使用', (tester) async {
|
||||
testWidgets('下载中:显示进度和取消按钮,取消后恢复下载', (tester) async {
|
||||
final m = ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
client: _StreamingClient(),
|
||||
@@ -164,20 +178,156 @@ void main() {
|
||||
await tester.runAsync(() => m.refresh());
|
||||
await pumpSection(tester, m);
|
||||
|
||||
await tester.tap(find.text('使用'));
|
||||
await tapVisible(tester, find.text('下载'));
|
||||
// 首个分块到达,进度条与取消按钮出现(IO 链需交替驱动)
|
||||
await pumpUntilFound(tester, find.text('取消'),
|
||||
fake: const Duration(milliseconds: 400));
|
||||
expect(find.text('取消'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('取消'));
|
||||
// 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「使用」
|
||||
await pumpUntilFound(tester, find.text('使用'),
|
||||
await tapVisible(tester, find.text('取消'));
|
||||
// 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「下载」
|
||||
await pumpUntilFound(tester, find.text('下载'),
|
||||
fake: const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isDownloaded(7), isFalse);
|
||||
expect(m.isActive(7), isFalse);
|
||||
expect(m.isDownloaded(7, kVariantS), isFalse);
|
||||
expect(m.isActive(7, kVariantS), isFalse);
|
||||
expect(find.text('下载'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('双档位:同物种合并一张卡,下载只启用目标档,使用即停旧档', (tester) async {
|
||||
final m = manager([_item(), _item(variant: kVariantN)]);
|
||||
await tester.runAsync(() => m.refresh());
|
||||
await pumpSection(tester, m);
|
||||
|
||||
// 合并卡:同一数据集只一张卡,无双卡/无档位角标;双档行标注两档
|
||||
expect(find.text('数据集A'), findsOneWidget);
|
||||
expect(find.text('下载'), findsOneWidget, reason: '合并卡只有一个主按钮');
|
||||
expect(find.textContaining('高精度 v1.0.0'), findsOneWidget);
|
||||
expect(find.textContaining('高性能 v1.0.0'), findsOneWidget);
|
||||
|
||||
// 一次下载取回双档:同物种只启用目标档(默认高精度 s),n 档备好不启用。
|
||||
// 双档下载收尾链交错,等 models 真正重载完成再断言
|
||||
await tapVisible(tester, find.text('下载'));
|
||||
await pumpUntilFound(tester, find.text('使用中'));
|
||||
for (var i = 0;
|
||||
i < 50 &&
|
||||
!(m.isDownloaded(7, kVariantS) &&
|
||||
m.isDownloaded(7, kVariantN) &&
|
||||
m.isActive(7, kVariantS) &&
|
||||
!m.isActive(7, kVariantN) &&
|
||||
m.models.length == 1 &&
|
||||
m.models.first.variant == kVariantS);
|
||||
i++) {
|
||||
await pumpRealIo(tester);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isDownloaded(7, kVariantS), isTrue);
|
||||
expect(m.isDownloaded(7, kVariantN), isTrue);
|
||||
expect(m.isActive(7, kVariantS), isTrue);
|
||||
expect(m.isActive(7, kVariantN), isFalse,
|
||||
reason: '同物种同时只运行目标档,另一档备好不叠加启用');
|
||||
expect(m.mode, kVariantS, reason: '下载双档不应改变目标档');
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.variant, kVariantS);
|
||||
expect(find.text('使用中'), findsOneWidget);
|
||||
|
||||
// 目标档切到高性能:运行中的 s 不受影响;n 已下载未启用 → 主按钮「使用」
|
||||
// (保存目标档偏好是真实 IO,等按钮呈现才算 UI 落定;2026-09-03 简化:
|
||||
// 不再叫「改用X」、不显示「当前使用 X」提示)
|
||||
await tapVisible(tester, find.text('高性能'));
|
||||
await pumpUntilFound(tester, find.text('使用'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(m.mode, kVariantN);
|
||||
expect(m.isActive(7, kVariantS), isTrue,
|
||||
reason: '切换目标档不应改变运行中的模型');
|
||||
expect(find.text('使用'), findsOneWidget);
|
||||
expect(find.textContaining('当前使用'), findsNothing);
|
||||
|
||||
// 点「使用」:同物种 s 自动停用,n 热启用(setActive 尾部的落盘
|
||||
// 与 notify 是真实 IO,等 UI 呈现「使用中」才算整链落定)
|
||||
await tapVisible(tester, find.text('使用'));
|
||||
await pumpUntilFound(tester, find.text('使用中'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用另一档');
|
||||
expect(m.isActive(7, kVariantN), isTrue);
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.variant, kVariantN);
|
||||
expect(find.text('使用中'), findsOneWidget, reason: '主按钮跟随当前档');
|
||||
});
|
||||
|
||||
testWidgets('不同动物可用不同档位:使用只作用于点击的动物', (tester) async {
|
||||
final m = manager([
|
||||
_item(datasetId: 7, name: '数据集A'),
|
||||
_item(datasetId: 7, variant: kVariantN, name: '数据集A'),
|
||||
_item(datasetId: 8, name: '数据集B'),
|
||||
_item(datasetId: 8, variant: kVariantN, name: '数据集B'),
|
||||
]);
|
||||
await tester.runAsync(() => m.refresh());
|
||||
// 两个物种都下载并运行默认档(高精度 s),各自 n 档备好
|
||||
for (final it in m.catalog) {
|
||||
await tester.runAsync(() => m.downloadModel(it));
|
||||
}
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(m.models.length, 2);
|
||||
expect(find.text('使用中'), findsNWidgets(2));
|
||||
|
||||
// 目标档切高性能:两卡主按钮都变「使用」(各自 s 仍在运行、n 已备好)
|
||||
await tapVisible(tester, find.text('高性能'));
|
||||
await pumpUntilFound(tester, find.text('使用'));
|
||||
expect(find.text('使用'), findsNWidgets(2));
|
||||
|
||||
// 只点第一张卡(数据集 7,目录序在前):该动物切到高性能,另一动物保持高精度
|
||||
await tapVisible(tester, find.text('使用').first);
|
||||
await pumpUntilFound(tester, find.text('使用中')); // 数据集 7 的卡先翻为使用中
|
||||
for (var i = 0;
|
||||
i < 50 &&
|
||||
!(m.models.length == 2 &&
|
||||
m.models.firstWhere((b) => b.datasetId == 7).variant ==
|
||||
kVariantN &&
|
||||
m.models.firstWhere((b) => b.datasetId == 8).variant ==
|
||||
kVariantS);
|
||||
i++) {
|
||||
await pumpRealIo(tester);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(m.isActive(7, kVariantN), isTrue);
|
||||
expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用同动物另一档');
|
||||
expect(m.isActive(8, kVariantS), isTrue, reason: '未点击的动物保持原档位');
|
||||
expect(m.isActive(8, kVariantN), isFalse);
|
||||
expect(m.models.length, 2, reason: '两个动物仍同时识别');
|
||||
expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantN);
|
||||
expect(m.models.firstWhere((b) => b.datasetId == 8).variant, kVariantS);
|
||||
expect(find.text('使用中'), findsOneWidget, reason: '只有数据集 7 的卡使用中');
|
||||
expect(find.text('使用'), findsOneWidget,
|
||||
reason: '数据集 8 目标档已备好:按钮就是使用(不叫改用)');
|
||||
expect(find.textContaining('当前使用'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('识别模式分段控件:只改目标档偏好,不影响已在运行的模型', (tester) async {
|
||||
final m = manager([_item(), _item(variant: kVariantN)]);
|
||||
await tester.runAsync(() => m.refresh());
|
||||
await tester.runAsync(() => m.downloadModel(m.catalog.first)); // s 启用
|
||||
await pumpSection(tester, m);
|
||||
|
||||
expect(find.text('高精度'), findsOneWidget, reason: '分段控件选中默认档');
|
||||
expect(find.text('高性能'), findsOneWidget);
|
||||
expect(find.text('使用中'), findsOneWidget);
|
||||
|
||||
await tapVisible(tester, find.text('高性能'));
|
||||
// 目标档 n 尚未下载(之前只下了 s)→ 主按钮变为「下载」:仍不切运行模型
|
||||
await pumpUntilFound(tester, find.text('下载'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(m.mode, kVariantN, reason: '点击分段控件应改目标档');
|
||||
expect(m.isActive(7, kVariantS), isTrue, reason: '只改偏好不切运行模型');
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.variant, kVariantS, reason: '没有热加载/切换');
|
||||
expect(find.text('下载'), findsOneWidget, reason: '卡片按钮面向新目标档');
|
||||
expect(find.textContaining('当前使用'), findsNothing);
|
||||
// 目标档偏好持久化由 model_manager_test「识别目标档位偏好持久化」覆盖;
|
||||
// widget 测试内 tap 触发的文件写与真实 IO 交错不可控,不在本层断言重启
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,19 +26,21 @@ Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) => {
|
||||
|
||||
Map<String, dynamic> _item({
|
||||
int datasetId = 7,
|
||||
String name = '环颈雉鸡数据集',
|
||||
String variant = kVariantS,
|
||||
String name = '数据集A',
|
||||
String version = 'v1.0.0',
|
||||
String sha = '',
|
||||
}) =>
|
||||
{
|
||||
'datasetId': datasetId,
|
||||
'datasetName': name,
|
||||
if (variant.isNotEmpty) 'variant': variant,
|
||||
'version': version,
|
||||
'labels': ['pheasant', 'suspect'],
|
||||
'labels': ['target', 'suspect'],
|
||||
'sizeBytes': _modelBytes.length,
|
||||
'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha,
|
||||
'downloadUrl': '/download/models/$datasetId/latest.tflite',
|
||||
'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE',
|
||||
'downloadUrl': '/download/models/$datasetId/$variant.tflite',
|
||||
'coverUrl': '/api/v1/app/cover?namePrefix=DS001',
|
||||
};
|
||||
|
||||
void main() {
|
||||
@@ -50,7 +52,20 @@ void main() {
|
||||
downloadHits = 0;
|
||||
});
|
||||
|
||||
tearDown(() => root.delete(recursive: true));
|
||||
tearDown(() async {
|
||||
// refresh 末尾的 autoUpdate 为不阻塞目录刷新的 fire-and-forget:其真实
|
||||
// 文件 IO 可能晚于 test body 结束,delete 撞上迟到写入会 Directory not
|
||||
// empty → 等待后重试清根(迟到链结束后即可删净)
|
||||
for (var i = 0; i < 40; i++) {
|
||||
try {
|
||||
await root.delete(recursive: true);
|
||||
return;
|
||||
} on FileSystemException {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
await root.delete(recursive: true);
|
||||
});
|
||||
|
||||
ModelManager manager(MockClient client) => ModelManager(
|
||||
baseUrl: 'http://test.local',
|
||||
@@ -81,7 +96,9 @@ void main() {
|
||||
expect(downloadHits, 0, reason: 'refresh 不应触发下载');
|
||||
expect(m.models, isEmpty, reason: '未激活的模型不应出现在 models');
|
||||
expect(m.catalog.length, 1);
|
||||
expect(m.catalog.first.coverUrl, '/api/v1/app/cover?namePrefix=RNPHE');
|
||||
expect(m.catalog.first.coverUrl, '/api/v1/app/cover?namePrefix=DS001');
|
||||
expect(m.catalog.first.variant, kVariantS,
|
||||
reason: '旧目录无 variant 字段(单档 s)应归为 s');
|
||||
});
|
||||
|
||||
test('downloadModel:下载+校验+落盘+自动激活+进度回调', () async {
|
||||
@@ -95,11 +112,12 @@ void main() {
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(downloadHits, 1);
|
||||
expect(m.isDownloaded(7), isTrue);
|
||||
expect(m.isActive(7), isTrue, reason: '下载完成应自动使用');
|
||||
expect(m.isDownloaded(7, kVariantS), isTrue);
|
||||
expect(m.isActive(7, kVariantS), isTrue, reason: '下载完成应自动使用');
|
||||
expect(progresses.last, 1.0);
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.datasetName, '环颈雉鸡数据集');
|
||||
expect(m.models.first.datasetName, '数据集A');
|
||||
expect(m.models.first.variant, kVariantS);
|
||||
|
||||
final dir = Directory('${root.path}/7');
|
||||
expect(await File('${dir.path}/model.tflite').exists(), isTrue);
|
||||
@@ -111,13 +129,13 @@ void main() {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
await m.downloadModel(m.catalog.first);
|
||||
await m.setActive(7, false);
|
||||
expect(m.isActive(7), isFalse);
|
||||
await m.setActive(7, kVariantS, false);
|
||||
expect(m.isActive(7, kVariantS), isFalse);
|
||||
expect(m.models, isEmpty);
|
||||
|
||||
final before = downloadHits;
|
||||
await m.setActive(7, true);
|
||||
expect(m.isActive(7), isTrue);
|
||||
await m.setActive(7, kVariantS, true);
|
||||
expect(m.isActive(7, kVariantS), isTrue);
|
||||
expect(downloadHits, before, reason: '已下载直接使用不应重新下载');
|
||||
expect(m.models.length, 1);
|
||||
});
|
||||
@@ -135,23 +153,28 @@ void main() {
|
||||
expect(m2.models.first.version, 'v2.0.0');
|
||||
});
|
||||
|
||||
test('autoUpdate:已激活模型出新版本,refresh 后自动重下并立即生效', () async {
|
||||
test('autoUpdate:已下载模型出新版本,自动补齐文件但不改变激活状态', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
await m.downloadModel(m.catalog.first);
|
||||
await m.downloadModel(m.catalog.first); // (7, s) v1 下载并激活
|
||||
expect(downloadHits, 1);
|
||||
|
||||
// 重启 + 服务器目录出 v2:refresh 内部 autoUpdate 自动重下(无需手动)
|
||||
// 模拟新会话(新实例 + 服务器目录出 v2):文件仍在 → autoUpdate 只补文件
|
||||
final m2 = manager(client([_item(version: 'v2.0.0')]));
|
||||
await m2.refresh();
|
||||
for (var i = 0; i < 50 && downloadHits < 2; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
|
||||
expect(downloadHits, 2, reason: '新版本应自动重下');
|
||||
expect(m2.isActive(7), isTrue, reason: '自动更新应保持激活');
|
||||
expect(m2.models.first.version, 'v2.0.0',
|
||||
reason: '自动更新后立即生效新版本字节');
|
||||
expect(downloadHits, 2, reason: '新版本应自动补齐(只更新文件不激活)');
|
||||
expect(m2.isDownloaded(7, kVariantS), isTrue);
|
||||
expect(m2.isActive(7, kVariantS), isFalse,
|
||||
reason: '会话制:自动补齐不激活任何模型');
|
||||
expect(m2.models, isEmpty, reason: '新会话从仅预览开始');
|
||||
|
||||
// 手动启用后跑的是新版本
|
||||
await m2.setActive(7, kVariantS, true);
|
||||
expect(m2.models.first.version, 'v2.0.0');
|
||||
expect(m2.models.first.bytes, _modelBytes);
|
||||
});
|
||||
|
||||
@@ -166,22 +189,30 @@ void main() {
|
||||
final ok = await m2.downloadModel(m2.catalog.first);
|
||||
expect(ok, isFalse);
|
||||
expect(downloadHits, 2, reason: '校验失败应重试一次');
|
||||
expect(m2.isDownloaded(7), isFalse);
|
||||
expect(m2.isActive(7), isFalse);
|
||||
expect(m2.errorOf(7), isNotNull);
|
||||
expect(m2.isDownloaded(7, kVariantS), isFalse);
|
||||
expect(m2.isActive(7, kVariantS), isFalse);
|
||||
expect(m2.errorOf(7, kVariantS), isNotNull);
|
||||
});
|
||||
|
||||
test('激活集持久化:重启后恢复激活且已下载的模型', () async {
|
||||
test('会话制:进入新会话清空激活,文件保留,再启用不重新下载', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
await m.downloadModel(m.catalog.first);
|
||||
expect(m.isActive(7, kVariantS), isTrue);
|
||||
|
||||
// 同一 root 新建 manager 模拟重启
|
||||
final m2 = manager(client([_item()]));
|
||||
await m2.refresh();
|
||||
expect(m2.isActive(7), isTrue, reason: '激活集应持久化');
|
||||
expect(m2.models.length, 1);
|
||||
expect(downloadHits, 1, reason: '重启不应触发下载');
|
||||
// 每次进入视野页 = 新会话:清空激活与已加载模型(不恢复上次使用的模型)
|
||||
m.resetForSession();
|
||||
expect(m.isActive(7, kVariantS), isFalse,
|
||||
reason: '新会话不自动恢复上次使用的模型');
|
||||
expect(m.models, isEmpty);
|
||||
expect(m.isDownloaded(7, kVariantS), isTrue, reason: '文件保留:清单显示已下载');
|
||||
|
||||
// 手动启用:直接用本地文件,不重新下载
|
||||
final before = downloadHits;
|
||||
await m.setActive(7, kVariantS, true);
|
||||
expect(m.isActive(7, kVariantS), isTrue);
|
||||
expect(m.models.length, 1);
|
||||
expect(downloadHits, before, reason: '已下载启用不应重新下载');
|
||||
});
|
||||
|
||||
test('目录下线:清理本地并移除激活', () async {
|
||||
@@ -193,14 +224,14 @@ void main() {
|
||||
final m2 = manager(client([]));
|
||||
await m2.refresh();
|
||||
expect(m2.models, isEmpty);
|
||||
expect(m2.isActive(7), isFalse, reason: '下线的模型应移出激活集');
|
||||
expect(m2.isActive(7, kVariantS), isFalse, reason: '下线的模型应移出激活集');
|
||||
expect(await Directory('${root.path}/7').exists(), isFalse,
|
||||
reason: '下线的数据集模型目录应被清理');
|
||||
});
|
||||
|
||||
test('多模型:只激活其一则只加载其一', () async {
|
||||
final m = manager(
|
||||
client([_item(datasetId: 7, name: '环颈雉'), _item(datasetId: 8, name: '斑鸠')]));
|
||||
client([_item(datasetId: 7, name: '数据集A'), _item(datasetId: 8, name: '数据集B')]));
|
||||
await m.refresh();
|
||||
await m.downloadModel(m.catalog.first); // 只下载并激活 7
|
||||
expect(m.models.length, 1);
|
||||
@@ -241,16 +272,226 @@ void main() {
|
||||
final fut = m.downloadModel(m.catalog.first);
|
||||
// 首个分块到达后取消(模拟用户在下载中点取消)
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
m.cancelDownload(7);
|
||||
m.cancelDownload(7, kVariantS);
|
||||
final ok = await fut;
|
||||
|
||||
expect(ok, isFalse);
|
||||
expect(downloadHits, 1);
|
||||
expect(m.isDownloaded(7), isFalse);
|
||||
expect(m.isActive(7), isFalse);
|
||||
expect(m.progressOf(7), isNull);
|
||||
expect(m.errorOf(7), isNull, reason: '取消不记错误');
|
||||
expect(m.isDownloaded(7, kVariantS), isFalse);
|
||||
expect(m.isActive(7, kVariantS), isFalse);
|
||||
expect(m.progressOf(7, kVariantS), isNull);
|
||||
expect(m.errorOf(7, kVariantS), isNull, reason: '取消不记错误');
|
||||
expect(await File('${root.path}/7/model.tflite.part').exists(), isFalse,
|
||||
reason: '取消后 .part 残留应被清理');
|
||||
});
|
||||
|
||||
test('双档位:n 档独立目录 models/7/n/,同物种只运行目标档', () async {
|
||||
final m = manager(client(
|
||||
[_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')]));
|
||||
await m.refresh();
|
||||
expect(m.catalog.length, 2);
|
||||
|
||||
// 默认目标档 s:下载 s 自动激活并加载
|
||||
await m.downloadModel(m.catalog.first); // (7, s)
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.variant, kVariantS);
|
||||
|
||||
// 同物种已有 s 在使用时下载 n:备好不启用(同物种一次只运行一档)
|
||||
final nItem = m.catalog.last;
|
||||
expect(nItem.variant, kVariantN);
|
||||
await m.downloadModel(nItem);
|
||||
expect(m.isDownloaded(7, kVariantN), isTrue);
|
||||
expect(m.isActive(7, kVariantN), isFalse, reason: '同物种运行中不叠加启用');
|
||||
expect(m.models.length, 1, reason: '备好的 n 档不进加载列表');
|
||||
expect(m.models.first.variant, kVariantS);
|
||||
|
||||
// n 档文件在独立子目录(目录键 = 档位),s 档仍为同级文件
|
||||
expect(await File('${root.path}/7/n/model.tflite').exists(), isTrue);
|
||||
expect(await File('${root.path}/7/n/meta.json').exists(), isTrue);
|
||||
expect(await File('${root.path}/7/model.tflite').exists(), isTrue,
|
||||
reason: 's 档 legacy 同级布局保持不变');
|
||||
|
||||
// 切目标档:只改偏好,不切换运行中的模型
|
||||
final before = downloadHits;
|
||||
await m.setMode(kVariantN);
|
||||
expect(m.mode, kVariantN);
|
||||
expect(downloadHits, before, reason: '切目标档不应触发下载');
|
||||
expect(m.isActive(7, kVariantS), isTrue, reason: '切档不改运行状态');
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.variant, kVariantS);
|
||||
|
||||
// 改用 n:同物种 s 自动停用,n 启用(无需重新下载)
|
||||
await m.setActive(7, kVariantN, true);
|
||||
expect(m.isActive(7, kVariantS), isFalse, reason: '改用自动停用另一档');
|
||||
expect(m.isActive(7, kVariantN), isTrue);
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.variant, kVariantN);
|
||||
expect(m.models.first.datasetName, '数据集A');
|
||||
|
||||
// 切回 s 档恢复 s 模型
|
||||
await m.setActive(7, kVariantS, true);
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.variant, kVariantS);
|
||||
expect(m.isActive(7, kVariantN), isFalse);
|
||||
});
|
||||
|
||||
test('识别目标档位偏好持久化;激活集为会话态不跨重启', () async {
|
||||
final m = manager(client(
|
||||
[_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')]));
|
||||
await m.refresh();
|
||||
await m.downloadModel(m.catalog.first); // (7, s) 下载自动激活
|
||||
await m.downloadModel(m.catalog.last); // (7, n) 备好不启用
|
||||
await m.setMode(kVariantN);
|
||||
|
||||
// 同一 root 新建 manager 模拟重启:目标档偏好持久化,激活集不恢复
|
||||
final m2 = manager(client(
|
||||
[_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')]));
|
||||
await m2.refresh();
|
||||
expect(m2.mode, kVariantN, reason: '识别目标档位是用户偏好,应持久化');
|
||||
expect(m2.isActive(7, kVariantS), isFalse, reason: '激活集不跨会话恢复');
|
||||
expect(m2.isActive(7, kVariantN), isFalse);
|
||||
expect(m2.models, isEmpty, reason: '重启/新会话从仅预览开始');
|
||||
expect(m2.isDownloaded(7, kVariantS), isTrue, reason: '文件保留可即点即用');
|
||||
expect(m2.isDownloaded(7, kVariantN), isTrue);
|
||||
expect(downloadHits, 2, reason: '重启不应触发下载');
|
||||
});
|
||||
|
||||
test('多物种混合档位:改用一物种不影响其它物种的档位', () async {
|
||||
final m = manager(client([
|
||||
_item(datasetId: 7, name: '数据集A'),
|
||||
_item(datasetId: 7, variant: kVariantN, name: '数据集A'),
|
||||
_item(datasetId: 8, name: '数据集B'),
|
||||
_item(datasetId: 8, variant: kVariantN, name: '数据集B'),
|
||||
]));
|
||||
await m.refresh();
|
||||
for (final it in m.catalog) {
|
||||
await m.downloadModel(it);
|
||||
}
|
||||
expect(m.models.length, 2);
|
||||
expect(m.isActive(7, kVariantS), isTrue);
|
||||
expect(m.isActive(8, kVariantS), isTrue);
|
||||
|
||||
// 只把 7 改到 n:8 保持 s —— 两物种不同档位并行运行
|
||||
await m.setActive(7, kVariantN, true);
|
||||
expect(m.models.length, 2);
|
||||
final byId = {for (final b in m.models) b.datasetId: b.variant};
|
||||
expect(byId, {7: kVariantN, 8: kVariantS});
|
||||
expect(m.isActive(7, kVariantS), isFalse);
|
||||
expect(m.isActive(8, kVariantS), isTrue);
|
||||
expect(m.isActive(8, kVariantN), isFalse);
|
||||
|
||||
// 7 改回 s
|
||||
await m.setActive(7, kVariantS, true);
|
||||
expect(m.models.length, 2);
|
||||
expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantS);
|
||||
});
|
||||
|
||||
test('目标档决定首次下载自动启用:非目标档先落地不启用,目标档落地启用', () async {
|
||||
final m = manager(client(
|
||||
[_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')]));
|
||||
await m.refresh();
|
||||
await m.setMode(kVariantN); // 目标高性能
|
||||
|
||||
// 先下 s(非目标档):数据集尚无档位在使用,但不应启用 s
|
||||
await m.downloadModel(m.catalog.first);
|
||||
expect(m.isDownloaded(7, kVariantS), isTrue);
|
||||
expect(m.isActive(7, kVariantS), isFalse, reason: '非目标档落地不自动启用');
|
||||
expect(m.models, isEmpty);
|
||||
|
||||
// 目标档 n 落地:自动启用(s 保持备好)
|
||||
await m.downloadModel(m.catalog.last);
|
||||
expect(m.isActive(7, kVariantN), isTrue);
|
||||
expect(m.isActive(7, kVariantS), isFalse);
|
||||
expect(m.models.length, 1);
|
||||
expect(m.models.first.variant, kVariantN);
|
||||
});
|
||||
|
||||
test('单档目录(无目标档条目):下载即自动启用仅有的档', () async {
|
||||
final m = manager(client([_item()])); // 目录只有 s
|
||||
await m.refresh();
|
||||
await m.setMode(kVariantN); // 目标高性能但目录没有 n
|
||||
await m.downloadModel(m.catalog.first);
|
||||
expect(m.isActive(7, kVariantS), isTrue, reason: '无目标档时启用仅有的档');
|
||||
expect(m.models.length, 1);
|
||||
});
|
||||
|
||||
test('数据集仍在但 n 档下线:清理 n 档子目录,保留数据集目录', () async {
|
||||
final m = manager(client([_item(variant: kVariantN)]));
|
||||
await m.refresh();
|
||||
await m.downloadModel(m.catalog.first); // (7, n)
|
||||
expect(await File('${root.path}/7/n/model.tflite').exists(), isTrue);
|
||||
|
||||
final m2 = manager(client([_item()])); // 目录只剩 s 档
|
||||
await m2.refresh();
|
||||
expect(await File('${root.path}/7/n/model.tflite').exists(), isFalse,
|
||||
reason: '数据集仍在但 n 档下线:应清理 n 档子目录');
|
||||
expect(await Directory('${root.path}/7').exists(), isTrue,
|
||||
reason: '数据集仍在(s 档):不应删整目录');
|
||||
});
|
||||
|
||||
test('目录缓存:断网重启后展示上次目录、标出已下载、保留文件且不恢复激活', () async {
|
||||
final m = manager(client([_item()]));
|
||||
await m.refresh();
|
||||
await m.downloadModel(m.catalog.first); // (7, s) 下载并激活
|
||||
expect(m.isDownloaded(7, kVariantS), isTrue);
|
||||
|
||||
// 模拟重启 + 断网:同 root 新实例、目录接口失败
|
||||
final offline =
|
||||
manager(MockClient((_) async => http.Response('boom', 500)));
|
||||
await offline.refresh();
|
||||
|
||||
expect(offline.catalog.length, 1, reason: '无网络也应以缓存目录展示模型清单');
|
||||
expect(offline.catalog.first.datasetId, 7);
|
||||
expect(offline.error, isNotNull, reason: '未拉取成功过仍提示目录刷新失败');
|
||||
expect(offline.ready, isFalse);
|
||||
expect(offline.isDownloaded(7, kVariantS), isTrue,
|
||||
reason: '缓存目录同样扫描本地文件:按钮不误导为未下载');
|
||||
expect(offline.isActive(7, kVariantS), isFalse, reason: '会话制:不恢复激活');
|
||||
expect(offline.models, isEmpty);
|
||||
expect(await Directory('${root.path}/7').exists(), isTrue,
|
||||
reason: '离线降级(非权威目录)不得清理本地模型');
|
||||
});
|
||||
|
||||
test('目录缓存:网络成功覆盖缓存,再离线读到最近一次目录', () async {
|
||||
final m = manager(client([_item(datasetId: 7, name: '数据集A')]));
|
||||
await m.refresh(); // 缓存 v1:只有数据集 7 的 s 档
|
||||
|
||||
final m2 = manager(client([
|
||||
_item(datasetId: 7, name: '数据集A'),
|
||||
_item(datasetId: 7, variant: kVariantN, name: '数据集A'),
|
||||
_item(datasetId: 8, name: '数据集B'),
|
||||
]));
|
||||
await m2.refresh();
|
||||
expect(m2.catalog.length, 3);
|
||||
|
||||
final offline =
|
||||
manager(MockClient((_) async => http.Response('boom', 500)));
|
||||
await offline.refresh();
|
||||
expect(offline.catalog.length, 3, reason: '缓存应为最近一次成功拉取的目录');
|
||||
expect(offline.catalog.map((c) => c.datasetId).toSet(), {7, 8});
|
||||
expect(
|
||||
offline.catalog
|
||||
.where((c) => c.datasetId == 7)
|
||||
.map((c) => c.variant)
|
||||
.toSet(),
|
||||
{kVariantS, kVariantN});
|
||||
});
|
||||
|
||||
test('升级后断网首启(无缓存文件):拉取失败不执行任何清理', () async {
|
||||
// 旧版本下载的文件直接落盘(无 catalog.json):升级首启即断网的最坏情况
|
||||
final dir = Directory('${root.path}/9');
|
||||
await dir.create(recursive: true);
|
||||
await File('${dir.path}/model.tflite').writeAsBytes(_modelBytes);
|
||||
await File('${dir.path}/meta.json')
|
||||
.writeAsString(jsonEncode({'version': 'v1.0.0'}));
|
||||
|
||||
final offline =
|
||||
manager(MockClient((_) async => http.Response('boom', 500)));
|
||||
await offline.refresh();
|
||||
|
||||
expect(offline.error, isNotNull);
|
||||
expect(offline.catalog, isEmpty, reason: '无缓存可兜底:无可展示目录');
|
||||
expect(await dir.exists(), isTrue,
|
||||
reason: '清理只在网络拉取成功后执行,离线首启不得误删已下载模型');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,36 +13,50 @@ DetectionResult box(String label, double score, double x, double y,
|
||||
bottom: y + 0.1,
|
||||
modelId: modelId,
|
||||
modelName: modelName,
|
||||
classId: 0,
|
||||
);
|
||||
|
||||
DetectionResult rect(String label, double score, double l, double t, double r,
|
||||
double b, {int modelId = -1}) =>
|
||||
DetectionResult(
|
||||
label: label,
|
||||
score: score,
|
||||
left: l,
|
||||
top: t,
|
||||
right: r,
|
||||
bottom: b,
|
||||
modelId: modelId,
|
||||
classId: 0,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('不同模型同标签重复框:NMS 去重取高分', () {
|
||||
// 环颈雉鸡模型与野兔模型都检出了同一只"环颈雉鸡"(不同模型对同一目标的重复框)
|
||||
// 两个模型都对同一目标检出同标签框(重复框需去重)
|
||||
final all = [
|
||||
box('pheasant', 0.18, 0.3, 0.3, modelId: 1, modelName: '环颈雉鸡模型'),
|
||||
box('pheasant', 0.55, 0.31, 0.3, modelId: 2, modelName: '野兔模型'),
|
||||
box('target', 0.18, 0.3, 0.3, modelId: 1, modelName: '模型A'),
|
||||
box('target', 0.55, 0.31, 0.3, modelId: 2, modelName: '模型B'),
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 1);
|
||||
expect(merged.first.score, 0.55);
|
||||
expect(merged.first.modelName, '野兔模型');
|
||||
expect(merged.first.modelName, '模型B');
|
||||
});
|
||||
|
||||
test('不同类别重叠:去重取高分(实测多模型对同一目标检异类别)', () {
|
||||
final all = [
|
||||
box('pheasant', 0.3, 0.5, 0.5, modelId: 1),
|
||||
box('hare', 0.7, 0.5, 0.5, modelId: 2), // 同位置但不同类别
|
||||
box('target', 0.3, 0.5, 0.5, modelId: 1),
|
||||
box('second', 0.7, 0.5, 0.5, modelId: 2), // 同位置但不同类别
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 1);
|
||||
expect(merged.first.label, 'hare');
|
||||
expect(merged.first.label, 'second');
|
||||
expect(merged.first.score, 0.7);
|
||||
});
|
||||
|
||||
test('不同类别不重叠:都保留', () {
|
||||
final all = [
|
||||
box('pheasant', 0.3, 0.1, 0.1, modelId: 1),
|
||||
box('hare', 0.7, 0.8, 0.8, modelId: 2), // 远处互不重叠
|
||||
box('target', 0.3, 0.1, 0.1, modelId: 1),
|
||||
box('second', 0.7, 0.8, 0.8, modelId: 2), // 远处互不重叠
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 2);
|
||||
@@ -50,8 +64,8 @@ void main() {
|
||||
|
||||
test('同模型内部与跨模型合并一致:远处不重叠保留', () {
|
||||
final all = [
|
||||
box('pheasant', 0.2, 0.1, 0.1, modelId: 1, modelName: '环颈雉鸡模型'),
|
||||
box('pheasant', 0.3, 0.8, 0.8, modelId: 1, modelName: '环颈雉鸡模型'),
|
||||
box('target', 0.2, 0.1, 0.1, modelId: 1, modelName: '模型A'),
|
||||
box('target', 0.3, 0.8, 0.8, modelId: 1, modelName: '模型A'),
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 2);
|
||||
@@ -63,4 +77,35 @@ void main() {
|
||||
final merged = mergeAcrossModels(single, 0.45);
|
||||
expect(identical(merged, single), isTrue);
|
||||
});
|
||||
|
||||
test('不同模型偏移框(小框中心在大框内):0.45 阈值外的窗口也去重', () {
|
||||
// 覆盖 ≈ 0.31 < 0.45:纯阈值会漏;小框中心在大框内 → 同一目标取高分
|
||||
// (2026-09-03:不同输入分辨率模型对同一目标的框几何系统性偏移)
|
||||
final all = [
|
||||
rect('target', 0.5, 0.38, 0.445, 0.62, 0.535, modelId: 1),
|
||||
rect('second', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 2),
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 1);
|
||||
expect(merged.first.label, 'second');
|
||||
expect(merged.first.score, 0.6);
|
||||
});
|
||||
|
||||
test('同模型偏移框:不套用跨模型窗口,正常保留', () {
|
||||
final all = [
|
||||
rect('target', 0.5, 0.38, 0.445, 0.62, 0.535, modelId: 1),
|
||||
rect('second', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 1),
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 2, reason: '同模型框对由模型内类内 NMS 负责');
|
||||
});
|
||||
|
||||
test('无来源模型(modelId -1):不套用跨模型窗口(存量语义不变)', () {
|
||||
final all = [
|
||||
rect('target', 0.5, 0.38, 0.445, 0.62, 0.535),
|
||||
rect('second', 0.6, 0.2, 0.2, 0.5, 0.5),
|
||||
];
|
||||
final merged = mergeAcrossModels(all, 0.45);
|
||||
expect(merged.length, 2);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ void main() {
|
||||
|
||||
test('centerInRegion_matches', () {
|
||||
final box = DetectionResult(
|
||||
label: 'hare',
|
||||
label: 'target',
|
||||
score: 0.30,
|
||||
left: 0.2,
|
||||
top: 0.3,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 运行时数据目录(compose 挂载卷,不进构建上下文,workspace 含 1.3GB APK 上传)
|
||||
workspace/
|
||||
data/
|
||||
cache/
|
||||
training/
|
||||
@@ -1,5 +1,7 @@
|
||||
# 视野后端构建:多阶段编译 → 精简运行镜像(纯 Go + SQLite,无 CGO 依赖)
|
||||
FROM golang:1.26 AS builder
|
||||
# 基础镜像仓库:国内直连 docker.io 不通,默认走 DaoCloud 镜像站;构建时可用 --build-arg REGISTRY=docker.io/library 覆盖
|
||||
ARG REGISTRY=docker.m.daocloud.io/library
|
||||
FROM ${REGISTRY}/golang:1.26 AS builder
|
||||
|
||||
# 国内网络 proxy.golang.org 不通,默认走 goproxy.cn;构建时可用 --build-arg 覆盖
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
@@ -15,9 +17,14 @@ RUN http_proxy= https_proxy= HTTP_PROXY= HTTPS_PROXY= go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /out/observer-server .
|
||||
|
||||
FROM alpine:3.20
|
||||
ARG REGISTRY=docker.m.daocloud.io/library
|
||||
FROM ${REGISTRY}/alpine:3.20
|
||||
# 国内网络 dl-cdn.alpinelinux.org 不通,默认走阿里云镜像;构建时可用 --build-arg 覆盖
|
||||
ARG APK_MIRROR=https://mirrors.aliyun.com
|
||||
# 时区对齐宿主(自然日授权语义依赖本地时区)
|
||||
RUN apk add --no-cache tzdata && \
|
||||
# apk 走 http_proxy 环境变量:清空 buildkit 注入的宿主代理(同上方 go mod download,容器内代理不通)
|
||||
RUN sed -i "s#https://dl-cdn.alpinelinux.org#${APK_MIRROR}#g" /etc/apk/repositories && \
|
||||
http_proxy= https_proxy= HTTP_PROXY= HTTPS_PROXY= apk add --no-cache tzdata && \
|
||||
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" > /etc/timezone
|
||||
WORKDIR /app
|
||||
@@ -29,5 +36,5 @@ COPY --from=builder /build/admin_dist /app/admin_dist
|
||||
COPY --from=builder /build/h5 /app/h5
|
||||
# 运行时数据目录(SQLite 库),由 docker-compose 挂载持久化
|
||||
RUN mkdir -p /app/data
|
||||
EXPOSE 18080
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/observer-server"]
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
| 支付回调 | 微信支付 / 支付宝异步回调验签落授权(`/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` 记录每用户每张图的处理明细(领取/提交快照/审核结果) |
|
||||
| 假目标上报(负样本回流,2026-09-08) | App 识别页一键「误报上报」:上报瞬间按一次快照帧(**与喂给 YOLO 的帧同源同尺寸**,整图,jpg q85 重编码剥离全部元数据)+ 当前全部检测框快照(归一化坐标/类别/置信度/来源模型,`detections` JSON),无任何额外选择步骤;服务端落 `false_target_report` 待审(每用户**每日上限 50 条**防灌水);管理端「数据训练 → 假目标上报」tab 审核(radio 带待审数)——**通过 = 整图迁移入负样本库**(`__negative__` 数据集,训练打包自动混入当背景学习,压制同类误报),拒绝 = 删文件;**上报查重(2026-09-09)**:整帧 dHash 与已上报记录近重复(汉明 ≤8)直接拒绝入库不占日限额;**RF-DETR 疑似真目标预判**:上报后异步检测,高分检出标记「疑似真目标」供审核把关;**合规**:首次使用单独同意弹窗(可拒绝且不影响识别),用户协议含反馈图训练使用授权条款 |
|
||||
| 套餐 | `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 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗为**审核视图(不做手动画框)**:点击框选中,列表可确认疑似框/删除误检框/清空并保存——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 |
|
||||
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置):进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1(GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
|
||||
| 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/trainings/<文件名前缀>.tflite`(前缀空回退数据集名) + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
|
||||
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/trainings/<文件名前缀>.tflite`(前缀空回退数据集名);**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 |
|
||||
| 标注 | **图片入库自动触发**:手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 全图扫描(`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 逻辑在服务端) |
|
||||
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程,**双 tab(2026-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` 快照;**综合训练(2026-09-09)**:`POST /admin/trainings/combined` 勾选 ≥2 个数据集 + 档位,多物种合并训练出**一个综合模型**(全类一张 tflite:类别表 = 各物种名按数据集 id 升序 + 共享 suspect 置末位,打包时类别 id 重映射、负样本只混一份、图片名加 d<id>_ 前缀防跨数据集重名),产物/发布/目录下发走现有链路,文件基名 `combined`(combined.tflite / combined_n.tflite);单物种训练流程不变,两种模式并存(详见技术设计.md「综合训练」);**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` 逻辑内嵌脚本),自检失败任务置失败并带出原因;**增量训练(2026-09-09)**:单物种/综合任务按档位 lineage 自动热启动——上一次成功的 best.pt 存档于 `workspace/trainings/weights/<基名>.pt`(基名同 tflite:单物种 `<前缀或数据集名>[_n]`、综合 `combined[_n]`),下次训练存在即推训练机作基座、不存在回落 config 基座(首次全量),类别数变化自动重建检测头;删该文件即从零重训;`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 模型兜底 |
|
||||
| 标注 | **无自动标注(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 +48,18 @@ 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/''/'[]'=未标注)、`created_at` |
|
||||
| `model_training` | 训练任务 | `id`(PK)、`name`、`status`(running/success/failed)、`dataset`(训练机数据集名)、`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`、`version`(m1.0.0 递增, 同数据集 UNIQUE)、`training_id`、`metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256`、`size_bytes`、`is_latest`、`notes`、`created_at`(模型文件不落表:发布即写 `trainings/<文件名前缀>.tflite`(前缀空回退数据集名),客户端固定下载,无存档回退) |
|
||||
| `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/1,2026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`annotate_task_id`(2026-09-07 众包下发的任务占用标记,0=未下发;下发即从「未标注」tab 消失,停用任务释放未领取图回 0)、`created_at` |
|
||||
| `model_training` | 训练任务 | `id`(PK)、`name`、`status`(queued/running/success/failed;queued=GPU 忙排队中,2026-09-03)、`dataset_id`(综合任务=0)、`variant`(s/n 档位,default s,2026-09-03)、`kind`(species/combined,2026-09-09 综合)、`dataset_ids`(JSON,综合任务覆盖的数据集列表)、`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/n,default s;存量行迁移为 s)、`version`(m1.0.0 递增, 同数据集 UNIQUE,s/n 交替发布共用序列)、`training_id`、`metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256`、`size_bytes`、`is_latest`(按 (数据集,档位) 各记一条)、`kind`(species/combined,2026-09-09 综合,综合行 dataset_id=0)、`dataset_ids`(JSON,综合覆盖的数据集)、`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-04;2026-09-07 图片粒度下发) | `id`(PK)、`dataset_id`、`name`、`status`(published/stopped)、`created_at`(任务图集 = `dataset_image.annotate_task_id` 占用本任务 id 的图,不落任务表) |
|
||||
| `annotate_record` | 用户标注记录(2026-09-04) | `id`(PK)、`phone_num`、`task_id`、`dataset_id`(领取时冗余,详情页过滤用)、`image_id`、`labels_json`(提交快照)、`status`(pending 领取锁/submitted/approved/rejected)、`created_at`、`submitted_at`、`reviewed_at`;UNIQUE(phone_num,image_id),partial unique(image_id) WHERE status='pending' |
|
||||
| `reward_log` | 标注时长发放流水(2026-09-04) | `id`(PK)、`phone_num`、`minutes`、`task_id`、`granted_at`(日上限 = 自然日求和) |
|
||||
| `false_target_report` | 假目标上报(2026-09-08) | `id`(PK)、`phone_num`、`file`(uuid.jpg 分析帧整图)、`labels_json`(检测框快照:label/class/score/model/归一化 xywh)、`species`(来源模型名去重拼接)、`status`(pending/approved;**拒绝即删记录与文件,无 rejected 存量**)、`image_hash`(整帧 dHash 64 位,上报查重用,0=未算/存量回填前)、`suspect_real`(0/1,RF-DETR 高分检出疑似真目标预判标记,2026-09-09)、`created_at`、`reviewed_at`(文件落 `datasetDir/false_targets/`,审核通过迁移入负样本库图片目录) |
|
||||
|
||||
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
|
||||
|
||||
@@ -180,12 +186,14 @@ 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/etaMinutes——预计剩余分钟,running 且已完成 ≥1 轮才 >0);**排除负样本库**(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/negative/generate` | 负样本批量生成 `{"count":100}`(异步任务,2026-09-07):config.yml 内置场景池按序循环组装提示词(negativeScenes 空场景 + negativeHumans 人物/衣物两池两模板;**动物不进统一负样本**——它们将来可能是正式识别目标),空场景图**自动过 RF-DETR 空检、有检出即剔除不入库**(人物图不做空检);进度复用 GET `/admin/datasets/gen-task`(rejected=剔除数),入口在管理端「负样本」tab |
|
||||
| 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 |
|
||||
| POST | `/admin/datasets/images/vlm-review` | VLM 藏匿位补检 `{"datasetId":1,"imageId":5}`(两阶段标注第二阶段,**须与图像生成显存互斥**):qwen3.6-35b-a3b(+mmproj) 排除已确认框,按环境/季节/时间/天气/光线/地形/习性综合判读画面推理藏身位,追加 ≤3 个疑似框(class 1)进同一 labels_json |
|
||||
| POST | `/admin/datasets/cover` | 上传数据集封面(multipart:`datasetId`+`file`,jpg/jpeg/png ≤10MB):**自动缩放 1248x704 + 转 jpg + UUID 命名**落盘并覆盖旧封面 |
|
||||
| POST | `/admin/datasets/cover/generate` | 生成数据集封面 `{"datasetId":1}`(z-image 文生图:16:9(1248x704)、1 雄 1 雌,物种取 gen_species 空回退数据集名):覆盖旧封面,返回 `{cover}` 新文件名;`datasetId=0` 时传 `{"name":"家鸽"}` 新建预生成(数据集未创建,封面仅落盘不写库,创建请求带 cover 回传写库);与生成任务显存互斥,失败不覆盖旧封面 |
|
||||
| POST | `/admin/datasets/cover/generate` | 生成数据集封面 `{"datasetId":1}`(z-image 文生图:16:9(1248x704)、1 雄 1 雌,物种取 gen_species 空回退数据集名):覆盖旧封面,返回 `{cover}` 新文件名;`datasetId=0` 时传 `{"name":"家鸽"}` 新建预生成(数据集未创建,封面仅落盘不写库,创建请求带 cover 回传写库);生成任务进行中亦可直接调用(与任务图片同走 z-image、由 local-ai 服务端排队串行,2026-09-02 放开整任务拒绝),失败不覆盖旧封面 |
|
||||
| GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位;`datasetId=0` 时按 `name`+`filename` 直读——新建对话框预生成封面回显) |
|
||||
| POST | `/admin/datasets/cover/delete` | 删除数据集封面 `{"datasetId":1}`:删文件 + 清 cover 字段 |
|
||||
| POST | `/admin/datasets/upload` | 上传图片(multipart/form-data:`datasetId` + `files` 多张,仅接受 `.jpg/.jpeg/.png`),存 `app.datasetDir`/`datasets/<name>/`,逐张入库 |
|
||||
@@ -196,19 +204,49 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
||||
| POST | `/admin/datasets/images/delete` | 删除图片 `{"datasetId":1,"ids":[1,2]}`:删文件 + 删记录(AI 生成图是付费资产,前端确认文案提示) |
|
||||
| GET | `/admin/datasets/export` | 导出数据集 zip:`datasetId`,打包图片目录为 zip 下载(标注衔接用) |
|
||||
| POST | `/admin/datasets/sync` | 同步数据集到训练机 `{"id":1}`:按 `training` 通道推送图片到训练机 `datasetDir/<name>/`(subprocess 同机 cp、ssh 异机 scp/rsync);训练启动前自动执行 |
|
||||
| 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/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"]}`(详情页勾选图片「预标」按钮入口,**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` |
|
||||
| POST | `/admin/trainings` | 发起训练 `{"dataset":"yolo","imgsz":1280,"epochs":150,"batch":16,"device":"0","name":"..."}`(imgsz/model 实际以 `config.yml` training 节点为准):先同步数据集到训练机 → 校验目录存在 → runner 启动训练;**并发度 1**,已有 running 任务时返回错误 |
|
||||
| GET | `/admin/trainings` | 训练任务列表:`page/size` 分页,按下发时间倒序,含 status/进度/指标 |
|
||||
| POST | `/admin/trainings` | 发起训练 `{"datasetId":1,"name":"...","variants":["s","n"]}`:`variants` 限定档位(省略=双档 s+n 各建一条任务;只补跑高性能档传 `["n"]`;请求的档位 n 未配置时报错);校验数据集有标注 → 落任务返回 `{id}`(首条任务 id);**GPU 独占排队**:并发度 1 不变——已有 running 时不拒绝、新任务落 queued,running 结束后轮询自动按创建顺序晋级启动(一次一个);同数据集同档位已有任务(running/queued)时拒绝(防重复提交) |
|
||||
| POST | `/admin/trainings/combined` | 综合训练发起 `{"datasetIds":[1,2],"variants":["s","n"]}`:勾选 ≥2 个数据集合并训练一个多物种模型;类别表 = 各物种名(按数据集 id 升序,gen_species 回退数据集名)+ 共享 suspect 置末位;每档位一条 `kind=combined` 任务(dataset_id=0、dataset_ids 快照),与单物种任务同队列排队;产物基名 `combined`(combined.tflite / combined_n.tflite) |
|
||||
| GET | `/admin/trainings` | 训练任务列表:`page/size` 分页,按下发时间倒序,含 status(queued/running/success/failed)/variant/进度/指标;`status` 过滤参数支持 queued |
|
||||
| GET | `/admin/trainings/detail` | 任务详情 `{"id":1}`:参数快照 + 进度 + 指标 + 日志尾部 |
|
||||
| POST | `/admin/trainings/cancel` | 取消训练 `{"id":1}`(仅 running):杀训练进程,状态置 failed(记录 error) |
|
||||
| POST | `/admin/trainings/publish` | 发布为最新模型 `{"id":1,"notes":"..."}`(仅 success):tflite 拷为 `workspace/model-latest.tflite`(原子覆盖)+ sha256/大小 → 插入 `model_version`(版本号递增 m1.0.0 → m1.0.1)+ 旧版 `is_latest=0` |
|
||||
| POST | `/admin/trainings/cancel` | 取消训练 `{"id":1}`:running=杀训练进程置 failed;queued=无进程直接置 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 内嵌数据训练详情页,任务图集网格展示勾选下发的具体图片,头部任务概要条/停用**),含数据集名/池余量/各状态记录数/状态 |
|
||||
| POST | `/admin/annotate-tasks/stop` | 停用任务 `{"id":1}`:整体不可再领取(已领取未提交的可继续提交);**释放未被领取过的图**(清 `annotate_task_id`,回到「未标注」tab 可再次下发) |
|
||||
| POST | `/admin/annotate-review` | 批量审核 `{"imageIds":[...],"approve":true}`(详情页「待审核」tab):通过 → review_status=2 已标注(**全部框原样保留,含疑似框——通过即人工背书**,2026-09-07 定案);拒绝 → 清 labels_json 回未标注池 + 对应 submitted 记录置 rejected(计入该用户低质统计:通过比例 <80% 且已审核样本 ≥5 → 冻结 24h,自动解冻) |
|
||||
| GET | `/admin/annotate-records` | 用户标注记录分页:`datasetId/phone/taskId/status` 过滤,每用户每张图的领取/提交快照/审核结果(**「标注记录」tab 同样内嵌详情页**) |
|
||||
| POST | `/admin/annotate-unfreeze` | 手动解冻 `{"phone":"..."}`:清冻结标记与统计基线 |
|
||||
|
||||
### 标注众包(App,Bearer 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 转元。
|
||||
|
||||
### POST /api/v1/feedback/false-target
|
||||
|
||||
假目标上报(需 Bearer token,`multipart/form-data`):App 识别页一键上报当前画面。字段:`file`(上报瞬间的分析帧整图 jpg,与推理帧同源同尺寸,客户端重编码已剥离元数据)+ `detections`(当前全部检测框快照 JSON 数组 `[{label,class,score,model,cx,cy,w,h}]`,归一化坐标,可空)+ `sourceW`/`sourceH`(分析帧宽高)。每用户每日上限 50 条,超限报错;与已上报记录整帧 dHash 近重复(汉明 ≤8)报「相似样本已存在,无需重复上报」(2026-09-09;查重先于落盘——重复上报不产生文件、不占限额,2026-09-10)。响应 `data: {id}`。
|
||||
|
||||
### 管理端假目标上报(`/api/v1/admin`,X-Admin-Token 鉴权;页面入口 = 数据训练页第三个 tab)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/admin/false-targets` | 上报分页列表,筛选 `phoneNum/status`(pending/approved),条目含画面预览地址/检测框快照/来源模型名 |
|
||||
| GET | `/admin/false-targets/image` | 上报画面预览(静态字节流,`id` 定位) |
|
||||
| POST | `/admin/false-targets/review` | 审核 `{"ids":[1],"approve":true}`:通过 = 画面迁移入负样本库(`__negative__` 数据集当背景图训练)+ 置 approved;**拒绝 = 删除记录与图片文件**;仅 pending 可审 |
|
||||
|
||||
### GET /api/v1/models
|
||||
|
||||
模型目录(需 Bearer token,客户端模型管理页拉取):返回**全部数据集当前生效模型**。响应 `data`:
|
||||
@@ -216,19 +254,23 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{"datasetId": 1, "datasetName": "pheasant", "version": "m1.2.0", "labels": ["pheasant", "suspect"],
|
||||
{"datasetId": 1, "datasetName": "pheasant", "variant": "s", "version": "m1.2.0", "labels": ["pheasant", "suspect"],
|
||||
"sizeBytes": 6400000, "sha256": "ab12...", "notes": "修复小目标漏检", "publishedAt": "2026-08-26T10:00:00+08:00",
|
||||
"downloadUrl": "/download/models/pheasant/latest.tflite"}
|
||||
"downloadUrl": "/download/trainings/pheasant.tflite"},
|
||||
{"datasetId": 1, "datasetName": "pheasant", "variant": "n", "version": "m1.3.0", "labels": ["pheasant", "suspect"],
|
||||
"sizeBytes": 3500000, "sha256": "cd34...", "notes": "", "publishedAt": "2026-09-03T10:00:00+08:00",
|
||||
"downloadUrl": "/download/trainings/pheasant_n.tflite"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 只返回 `is_latest=1` 的模型(每数据集至多一条);无任何发布模型时 `list` 为空数组
|
||||
- 下载地址由客户端拼 `apiBaseUrl` 访问;下载文件 sha256 校验,类别名数组 `labels` 用于多模型合并推理展示
|
||||
- 只返回 `is_latest=1` 的模型:**每 (数据集, 档位) 至多一条**(每数据集 s/n 各一条,variant 标识档位;n 档文件名带 `_n` 后缀);无任何发布模型时 `list` 为空数组
|
||||
- **综合模型条目(2026-09-09)**:`kind:"combined"` + `datasetIds`(覆盖的数据集 id 列表)+ `datasetId:0`、`datasetName:"综合"`,下载地址 `/download/trainings/combined(_n).tflite`;单物种条目 `kind:"species"`(缺省视为 species,老 App 兼容);App 激活综合模型时自动停用其覆盖物种的单物种模型(反之亦然,覆盖互斥)
|
||||
- 下载地址由客户端拼 `apiBaseUrl` 访问;下载文件 sha256 校验,类别名数组 `labels` 用于多模型合并推理展示;条目带 `variant`(2026-09-03),App 按识别档位(s 高识别/n 高性能)筛选加载
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时)、`imageGen`(AI 生成图片 provider:`dashscope` 通义万相(apiKey + model qwen-image-3.0)/ `localai` 本地 local-ai(baseUrl + model 如 qwen-image;每张不设调用超时,失败由 provider 真实返回判定))、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化
|
||||
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时;**双档位 2026-09-03**:s 档基座/分辨率 `model`/`imgsz`、n 档 `modelN`/`imgszN`——n 档未配置时发起 n 档训练报错,epochs/batch/device 双档共用)、`imageGen`(AI 生成图片 provider:`dashscope` 通义万相(apiKey + model qwen-image-3.0)/ `localai` 本地 local-ai(baseUrl + model 如 qwen-image;每张不设调用超时,失败由 provider 真实返回判定))、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化
|
||||
2. `go build ./...` 编译验证
|
||||
3. 本地运行 `go run main.go`;服务层白盒测试:`GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/`(独立测试库,见 `biz/service/testdata/`)
|
||||
4. 后台管理端:`cd server_admin && npm run build`(构建产物输出到 `server/admin_dist/`,由后端 `/admin/` 托管);开发联调 `npm run dev`(Vite 代理 `/api` → `:8080`)。首次访问 `/admin/` 进入登录页,输入 `config.yml admin.token` 对应的管理 token(存浏览器 localStorage,随请求携带;token 不内嵌构建产物)。**硬性要求:只要修改了 `server_admin/` 源码,必须同步重新构建 `admin_dist/` 并提交产物**(后端托管的是构建产物,不重新构建则线上/部署版本不生效;禁止只改源码不构建)
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>视野管理端</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-CHT1qyKR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-aSE0N1Mp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,6 +13,31 @@ const (
|
||||
TableLabelTask = "label_task"
|
||||
TableGenTask = "gen_task"
|
||||
|
||||
TableAnnotateTask = "annotate_task"
|
||||
TableAnnotateRecord = "annotate_record"
|
||||
TableRewardLog = "reward_log"
|
||||
|
||||
TableFalseTargetReport = "false_target_report"
|
||||
|
||||
// 假目标上报状态(技术设计.md「假目标上报」):App 上报 → 管理端审核 →
|
||||
// 通过 = 迁移入负样本库当背景图;拒绝 = 记录与文件同删(无 rejected 存量状态)
|
||||
FalseTargetPending = "pending"
|
||||
FalseTargetApproved = "approved"
|
||||
|
||||
// 假目标上报每用户每日上限(防灌水/滥用)
|
||||
FalseTargetDailyLimit = 50
|
||||
|
||||
// 假目标上报整帧 dHash 查重阈值:与已上报记录最小汉明距离 ≤ 该值视为近重复拒绝
|
||||
// (同 CleanHashHamming;同机位连续帧近距通常 <8,2026-09-09)
|
||||
FalseTargetDupHamming = 8
|
||||
|
||||
// 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"
|
||||
@@ -30,16 +55,36 @@ const (
|
||||
// 预标注(逐张调 RF-DETR)池默认并发度(被 config.yml labelTask.poolSize 覆盖)
|
||||
LabelPoolDefaultSize = 4
|
||||
|
||||
// 训练任务状态机 running → success/failed
|
||||
// 训练任务状态机 queued → running → success/failed(queued=GPU 忙排队,2026-09-03 双档位串行)
|
||||
TrainingStatusQueued = "queued"
|
||||
TrainingStatusRunning = "running"
|
||||
TrainingStatusSuccess = "success"
|
||||
TrainingStatusFailed = "failed"
|
||||
|
||||
// 训练档位:s=高识别(yolov8s@1280,精度优先,默认) | n=高性能(yolov8n@704,速度优先)
|
||||
TrainingVariantS = "s"
|
||||
TrainingVariantN = "n"
|
||||
// n 档模型文件名后缀:trainings/<基名>_n.tflite(s 档无后缀 = 旧版唯一位,向后兼容)
|
||||
TrainingVariantNFileSuffix = "_n"
|
||||
|
||||
// 训练/模型类型(2026-09-09 综合训练):species=单物种(存量默认)| combined=多物种综合模型
|
||||
// (dataset_id=0、dataset_ids=覆盖数据集列表,文件基名 combined)
|
||||
TrainingKindSpecies = "species"
|
||||
TrainingKindCombined = "combined"
|
||||
// 综合模型文件基名与训练机目录名
|
||||
TrainingCombinedBase = "combined"
|
||||
|
||||
// 数据集状态 building → labeled → synced(synced = 已同步训练机)
|
||||
DatasetStatusBuilding = "building"
|
||||
DatasetStatusLabeled = "labeled"
|
||||
DatasetStatusSynced = "synced"
|
||||
|
||||
// 负样本库(技术设计.md「负样本库」):source=negative 的特殊数据集,统一背景样本
|
||||
// 训练打包时混入全部物种数据集(空标签 = 背景);固定保留名同是目录名,
|
||||
// AdminCreateDataset 拒绝用户使用该名,AdminDeleteDataset 拒绝删除整库
|
||||
DatasetSourceNegative = "negative"
|
||||
NegativeDatasetName = "__negative__"
|
||||
|
||||
// 标注任务状态
|
||||
LabelTaskRunning = "running"
|
||||
LabelTaskDone = "done"
|
||||
@@ -55,6 +100,71 @@ const (
|
||||
// 单图 VLM 藏匿位补检疑似框(class=1)上限;每次调用可追加数 = 上限 - 图内已有疑似框数,≤0 跳过调用
|
||||
VlmMaxSuspectPerImage = 3
|
||||
|
||||
// 单图预标(RF-DETR)框数上限:去重后检出仍超过该数按置信度取前 N——
|
||||
// 生成声明 1 个目标不代表图内只有 1 个,曾按声明数量裁剪漏目标,改固定上限
|
||||
// (与 VlmMaxSuspectPerImage 同量级,单图人工复核工作量可控)
|
||||
MaxPrelabelPerImage = 3
|
||||
|
||||
// 预标注四级漏斗(技术设计.md「预标注四级漏斗」):
|
||||
// 单图漏斗总超时秒数——L2 切片 ~20 块串行 + L3 VLM 兜底,原 120s 只够单次全图检测
|
||||
LabelImageTimeoutSec = 300
|
||||
// L3 VLM 提议候选区数上限(图全空时让 VLM 指可疑位置,RF-DETR 精修确认,宁可指错不可遗漏)
|
||||
VlmLocateMaxRegions = 3
|
||||
// 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"
|
||||
|
||||
// 桶内多样性保留的整图 dHash 相似阈值:与已保留图最小汉明距离 > 该值才保留
|
||||
// (同场地连拍帧整图哈希近距通常 <8,异场景帧通常 >12,2026-09-02 实测校准)
|
||||
CleanHashHamming = 8
|
||||
)
|
||||
|
||||
// CleanBuckets 数据清洗目标尺寸档(训练集去冗余,见技术设计.md「数据清洗」):
|
||||
// 按最大 class 0 框的归一化框高 h 划分,边界为占图高百分比,下含上不含。
|
||||
// <2% 极远档豁免——recall 瓶颈档只缺不多,不进候选清单(配额 0 固定不适用);
|
||||
// 其余档 Quota 为「目标保留张数」缺省值,preview 请求可覆盖(管理端配额表默认展示即此表)。
|
||||
// 配额随目标尺寸单调递减(2026-09-02 定案;2026-09-04 按每物种 800 张训练规模放大为方案B,
|
||||
// 覆盖 5m-100m 目标——<2% 档跨 26m→100m+,主动供给 ~300 且集中在 1-2% 子带,
|
||||
// 0.5% 以下低于 s档可检线不供给;档位↔距离映射与物理上限见技术设计.md):
|
||||
// 尺寸越大越易检出、冗余切得越狠,小档护稀缺硬样本;可控档合计 520 + <2% 豁免档 ≈ 800。
|
||||
// 旧小数据集各档总量 < 配额时配额不生效——超配档才裁剪,不受影响。
|
||||
type CleanBucket struct {
|
||||
Label string
|
||||
MinPct float64
|
||||
MaxPct float64
|
||||
Quota int
|
||||
}
|
||||
|
||||
var CleanBuckets = []CleanBucket{
|
||||
{"<2%", 0, 2, 0},
|
||||
{"2-3%", 2, 3, 110},
|
||||
{"3-4%", 3, 4, 100},
|
||||
{"4-6%", 4, 6, 90},
|
||||
{"6-8%", 6, 8, 70},
|
||||
{"8-12%", 8, 12, 60},
|
||||
{"12-20%", 12, 20, 50},
|
||||
{">20%", 20, 100, 40},
|
||||
}
|
||||
|
||||
@@ -82,6 +82,16 @@ 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)
|
||||
}
|
||||
|
||||
// NegativeGenerate 负样本批量生成(异步任务,含 RF-DETR 空检剔除)
|
||||
func (c *cAdmin) NegativeGenerate(ctx context.Context, req *dto.AdminNegativeGenerateReq) (*dto.AdminNegativeGenerateRes, error) {
|
||||
return service.Dataset.AdminNegativeGenerate(ctx, req)
|
||||
}
|
||||
|
||||
// UploadImages 上传图片
|
||||
func (c *cAdmin) UploadImages(ctx context.Context, req *dto.AdminDatasetUploadReq) (*dto.AdminDatasetUploadRes, error) {
|
||||
return service.Dataset.AdminUploadImages(ctx, req)
|
||||
@@ -112,6 +122,16 @@ func (c *cAdmin) DeleteImages(ctx context.Context, req *dto.AdminDatasetImagesDe
|
||||
return service.Dataset.AdminDeleteImages(ctx, req)
|
||||
}
|
||||
|
||||
// CleanPreview 数据清洗预览(桶分布/候选清单/已排除清单)
|
||||
func (c *cAdmin) CleanPreview(ctx context.Context, req *dto.AdminDatasetCleanPreviewReq) (*dto.AdminDatasetCleanPreviewRes, error) {
|
||||
return service.Dataset.AdminCleanPreview(ctx, req)
|
||||
}
|
||||
|
||||
// CleanApply 数据清洗执行(批量置/清排除标记)
|
||||
func (c *cAdmin) CleanApply(ctx context.Context, req *dto.AdminDatasetCleanApplyReq) (*dto.AdminDatasetCleanApplyRes, error) {
|
||||
return service.Dataset.AdminCleanApply(ctx, req)
|
||||
}
|
||||
|
||||
// Image 图片访问(直写响应体:service 校验归属返回路径,controller 输出二进制)
|
||||
func (c *cAdmin) Image(ctx context.Context, req *dto.AdminDatasetImageReq) (*dto.AdminDatasetImageRes, error) {
|
||||
path, err := service.Dataset.ImageFile(ctx, req.DatasetId, req.Filename)
|
||||
@@ -209,3 +229,61 @@ 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)
|
||||
}
|
||||
|
||||
// StartCombined 综合训练发起(多物种合并模型)
|
||||
func (c *cAdmin) StartCombined(ctx context.Context, req *dto.AdminTrainingCombinedStartReq) (*dto.AdminTrainingCombinedStartRes, error) {
|
||||
return service.Training.AdminStartCombined(ctx, req)
|
||||
}
|
||||
|
||||
// ListFalseTargets 假目标上报列表
|
||||
func (c *cAdmin) ListFalseTargets(ctx context.Context, req *dto.AdminFalseTargetListReq) (*dto.AdminFalseTargetListRes, error) {
|
||||
return service.FalseTarget.AdminList(ctx, req)
|
||||
}
|
||||
|
||||
// FalseTargetImage 假目标裁剪图预览(直写响应体)
|
||||
func (c *cAdmin) FalseTargetImage(ctx context.Context, req *dto.AdminFalseTargetImageReq) (*dto.AdminFalseTargetImageRes, error) {
|
||||
path, err := service.FalseTarget.ImageFile(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := ghttp.RequestFromCtx(ctx)
|
||||
r.Response.ServeFile(path)
|
||||
return &dto.AdminFalseTargetImageRes{}, nil
|
||||
}
|
||||
|
||||
// ReviewFalseTargets 审核假目标上报(通过=入负样本库;拒绝=删文件)
|
||||
func (c *cAdmin) ReviewFalseTargets(ctx context.Context, req *dto.AdminFalseTargetReviewReq) (*dto.AdminFalseTargetReviewRes, error) {
|
||||
return service.FalseTarget.AdminReview(ctx, req)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/service"
|
||||
)
|
||||
|
||||
// cFalseTarget 假目标上报接口(登录态,手机号取自 token)
|
||||
type cFalseTarget struct{}
|
||||
|
||||
var FalseTarget = &cFalseTarget{}
|
||||
|
||||
// Report 上报假目标(multipart:误报框裁剪图 + 检测框快照)
|
||||
func (c *cFalseTarget) Report(ctx context.Context, req *dto.FalseTargetReportReq) (*dto.FalseTargetReportRes, error) {
|
||||
return service.FalseTarget.AppReport(ctx, req)
|
||||
}
|
||||
@@ -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 用户标注记录表 DAO:pending 即领取锁(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 BIGSERIAL PRIMARY KEY,
|
||||
phone_num VARCHAR(20) NOT NULL,
|
||||
task_id BIGINT NOT NULL,
|
||||
dataset_id BIGINT NOT NULL DEFAULT 0,
|
||||
image_id BIGINT NOT NULL,
|
||||
labels_json JSONB,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
submitted_at TIMESTAMP,
|
||||
reviewed_at TIMESTAMP,
|
||||
UNIQUE (phone_num, image_id)
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// 存量表补列(领取时冗余数据集 id,管理端详情页按数据集过滤记录)
|
||||
common.EnsureColumn(ctx, consts.TableAnnotateRecord, "dataset_id", "dataset_id BIGINT NOT NULL DEFAULT 0")
|
||||
// 领取锁:同一张图同时只允许一条 pending(partial unique index,SQLite 原生支持)
|
||||
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": common.NilIfEmpty(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/rejected;pending 是未处理的锁不算)
|
||||
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
|
||||
}
|
||||
@@ -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 BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'published',
|
||||
created_at TIMESTAMP 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
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
@@ -20,29 +19,22 @@ var AppVersion = &appVersionDao{}
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS app_version (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version TEXT NOT NULL UNIQUE,
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
version VARCHAR(32) NOT NULL UNIQUE,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// v6 迁移:删除 url 列(下载地址改为固定文件 app.apkDir/observer-latest.apk,
|
||||
// 表内不再记录;新库建表已无此列直接跳过)
|
||||
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(app_version)")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, col := range cols {
|
||||
if gconv.String(col["name"]) == "url" {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE app_version DROP COLUMN url"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
g.Log().Warningf(ctx, "存量表 app_version 已迁移:删除 url 列")
|
||||
break
|
||||
if common.HasColumn(ctx, "app_version", "url") {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE app_version DROP COLUMN url"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
g.Log().Warningf(ctx, "存量表 app_version 已迁移:删除 url 列")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,42 +22,42 @@ var Dataset = &datasetDao{}
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS dataset (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL UNIQUE,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
image_count INTEGER NOT NULL DEFAULT 0,
|
||||
labeled_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'building',
|
||||
cover TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'building',
|
||||
cover VARCHAR(200),
|
||||
description TEXT,
|
||||
name_prefix TEXT NOT NULL DEFAULT '',
|
||||
name_prefix VARCHAR(50) NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
gen_species TEXT,
|
||||
gen_tone TEXT,
|
||||
gen_heights TEXT,
|
||||
gen_scenes TEXT,
|
||||
gen_actions TEXT,
|
||||
gen_occlusions TEXT,
|
||||
gen_classes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
gen_species VARCHAR(100),
|
||||
gen_tone VARCHAR(50),
|
||||
gen_heights VARCHAR(100),
|
||||
gen_scenes JSONB,
|
||||
gen_actions JSONB,
|
||||
gen_occlusions JSONB,
|
||||
gen_classes VARCHAR(200),
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// 存量库迁移:生成图文件名前缀列(EnsureColumn 的 ddl 须自带列名)
|
||||
common.EnsureColumn(ctx, consts.TableDataset, "name_prefix", "name_prefix TEXT NOT NULL DEFAULT ''")
|
||||
common.EnsureColumn(ctx, consts.TableDataset, "name_prefix", "name_prefix VARCHAR(50) NOT NULL DEFAULT ''")
|
||||
// 存量库迁移:序号列(列表排序主键,升序;同号按创建时间倒序)
|
||||
common.EnsureColumn(ctx, consts.TableDataset, "sort_order", "sort_order INTEGER NOT NULL DEFAULT 0")
|
||||
// 存量库迁移:生成参数池列(VLM 自动生成,2026-08-28)
|
||||
for _, c := range []struct{ name, ddl string }{
|
||||
{"gen_species", "gen_species TEXT"},
|
||||
{"gen_tone", "gen_tone TEXT"},
|
||||
{"gen_heights", "gen_heights TEXT"},
|
||||
{"gen_scenes", "gen_scenes TEXT"},
|
||||
{"gen_actions", "gen_actions TEXT"},
|
||||
{"gen_occlusions", "gen_occlusions TEXT"},
|
||||
{"gen_classes", "gen_classes TEXT"},
|
||||
{"gen_species", "gen_species VARCHAR(100)"},
|
||||
{"gen_tone", "gen_tone VARCHAR(50)"},
|
||||
{"gen_heights", "gen_heights VARCHAR(100)"},
|
||||
{"gen_scenes", "gen_scenes JSONB"},
|
||||
{"gen_actions", "gen_actions JSONB"},
|
||||
{"gen_occlusions", "gen_occlusions JSONB"},
|
||||
{"gen_classes", "gen_classes VARCHAR(200)"},
|
||||
} {
|
||||
common.EnsureColumn(ctx, consts.TableDataset, c.name, c.ddl)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
@@ -19,13 +18,13 @@ var DatasetImage = &datasetImageDao{}
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS dataset_image (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
animal_count INTEGER NOT NULL DEFAULT 0,
|
||||
labels_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
labels_json JSONB,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE (dataset_id, filename)
|
||||
)`)
|
||||
if err != nil {
|
||||
@@ -33,21 +32,25 @@ func init() {
|
||||
}
|
||||
// 存量库迁移:标注列缺失时追加(新库建表已含列,幂等跳过);candidates_json 已随 v10 删除
|
||||
// 注意 EnsureColumn 的 ddl 参数须自带列名(拼接为 ALTER TABLE ADD COLUMN <ddl>)
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json TEXT")
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json JSONB")
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "animal_count", "animal_count INTEGER NOT NULL DEFAULT 0")
|
||||
// 存量库迁移:删除 prompt 列(生成提示词不再存储,2026-09-02 决策)
|
||||
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(dataset_image)")
|
||||
if err != nil {
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "clean_excluded", "clean_excluded SMALLINT NOT NULL DEFAULT 0")
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "review_status", "review_status SMALLINT NOT NULL DEFAULT 0")
|
||||
// 存量库迁移:众包任务图片粒度下发占用标记(2026-09-07,0=未下发)
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "annotate_task_id", "annotate_task_id BIGINT 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 != '[]'::jsonb`); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, col := range cols {
|
||||
if gconv.String(col["name"]) == "prompt" {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE dataset_image DROP COLUMN prompt"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
g.Log().Warningf(ctx, "存量表 dataset_image 已迁移:删除 prompt 列")
|
||||
break
|
||||
// 存量库迁移:删除 prompt 列(生成提示词不再存储,2026-09-02 决策)
|
||||
if common.HasColumn(ctx, "dataset_image", "prompt") {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE dataset_image DROP COLUMN prompt"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
g.Log().Warningf(ctx, "存量表 dataset_image 已迁移:删除 prompt 列")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,12 +81,15 @@ func (d *datasetImageDao) GetById(ctx context.Context, id int64) (*entity.Datase
|
||||
|
||||
// ListByDataset 某数据集全部图片(按文件名编号倒序,详情页逐行展示用)。
|
||||
// 文件名形如 <前缀>_<编号>.jpg(无前缀为时间戳数字名),须提取下划线后的数字按数值倒序:
|
||||
// 字符串序会把 pheasant_101.jpg 排在 pheasant_2.jpg 之前('1'<'9'),导致最大编号落到底部
|
||||
// 字符串序会把 pheasant_101.jpg 排在 pheasant_2.jpg 之前('1'<'9'),导致最大编号落到底部。
|
||||
// pg 方言:regexp_match 提取「_ 后到扩展名前的数字段」,无下划线回退取前导数字
|
||||
// (对齐 SQLite CAST 的前导数字解析语义);无法提取时 NULLS LAST 排底(对齐 SQLite CAST 非
|
||||
// 数字返回 0 的排序效果)
|
||||
func (d *datasetImageDao) ListByDataset(ctx context.Context, datasetId int64) ([]*entity.DatasetImage, error) {
|
||||
var list []*entity.DatasetImage
|
||||
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).
|
||||
Order("CAST(substr(filename, instr(filename, '_') + 1) AS INTEGER) DESC").Scan(&list)
|
||||
Order(`COALESCE((regexp_match(filename, '_([0-9]+)\.'))[1], (regexp_match(filename, '^([0-9]+)'))[1])::BIGINT DESC NULLS LAST`).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.DatasetImage{}, nil
|
||||
@@ -93,12 +99,15 @@ func (d *datasetImageDao) ListByDataset(ctx context.Context, datasetId int64) ([
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ListUnlabeledByDataset 未标注图片(labels_json 为空/null/'[]';自动补标轮次用)
|
||||
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 = '' 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) {
|
||||
@@ -109,6 +118,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_id(Serial 内调用,防与并发下发竞态;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
|
||||
@@ -142,18 +211,30 @@ func (d *datasetImageDao) CountByDataset(ctx context.Context, datasetId int64) (
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
// UpdateLabels 覆写标注 JSON(整图粒度;空串=清空标注;AI 自动标注与人工保存共用)
|
||||
func (d *datasetImageDao) UpdateLabels(ctx context.Context, id int64, labelsJson string) error {
|
||||
// UpdateLabelsAndReview 覆写标注 JSON 并同步审核状态(预标/VLM 补标/AdminLabelSave/App 提交共用):
|
||||
// 预标检出/VLM 追加疑似框/App 提交 → 1(含疑似待人工过目);管理端人工保存全部框 class 0→2、仍含疑似保持 1、空框→0(回到未标注池)
|
||||
func (d *datasetImageDao) UpdateLabelsAndReview(ctx context.Context, id int64, labelsJson string, reviewStatus int) error {
|
||||
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"labels_json": labelsJson}).Update()
|
||||
Data(g.Map{"labels_json": common.NilIfEmpty(labelsJson), "review_status": reviewStatus}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// CountLabeledByDataset 某数据集已标注图片数(labels_json 非空数组的图片行数)
|
||||
// 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=2;2026-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
|
||||
}
|
||||
@@ -179,7 +260,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 {
|
||||
@@ -203,3 +284,26 @@ func (d *datasetImageDao) DeleteByDataset(ctx context.Context, datasetId int64)
|
||||
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("dataset_id", datasetId).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCleanExcluded 批量置/清数据清洗排除标记(excluded: 1=出训练集, 0=恢复;IN ≤100 分批)
|
||||
func (d *datasetImageDao) UpdateCleanExcluded(ctx context.Context, ids []int64, excluded int) 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]).
|
||||
Data(g.Map{"clean_excluded": excluded}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountExcludedByDataset 某数据集已排除(clean_excluded=1)图数(数据清洗后角标/计数刷新)
|
||||
func (d *datasetImageDao) CountExcludedByDataset(ctx context.Context, datasetId int64) (int64, error) {
|
||||
n, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Where("clean_excluded", 1).Count()
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// FalseTargetReport 假目标上报表 DAO(技术设计.md「假目标上报」)。
|
||||
type falseTargetReportDao struct{}
|
||||
|
||||
var FalseTargetReport = &falseTargetReportDao{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS false_target_report (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
phone_num VARCHAR(20) NOT NULL,
|
||||
file VARCHAR(255) NOT NULL,
|
||||
labels_json JSONB,
|
||||
species VARCHAR(50) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/approved(拒绝即删记录,无 rejected 存量)
|
||||
image_hash BIGINT NOT NULL DEFAULT 0, -- 整帧 dHash 64 位(上报查重;0=未算/存量回填前)
|
||||
suspect_real SMALLINT NOT NULL DEFAULT 0, -- RF-DETR 高分检出疑似真目标预判标记(0/1)
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
reviewed_at TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// 存量库迁移:查重/预判两列(2026-09-09;新库建表已含列,幂等跳过)
|
||||
common.EnsureColumn(ctx, consts.TableFalseTargetReport, "image_hash", "image_hash BIGINT NOT NULL DEFAULT 0")
|
||||
common.EnsureColumn(ctx, consts.TableFalseTargetReport, "suspect_real", "suspect_real SMALLINT NOT NULL DEFAULT 0")
|
||||
}
|
||||
|
||||
// Insert 插入上报记录并返回自增 id
|
||||
func (d *falseTargetReportDao) Insert(ctx context.Context, m *entity.FalseTargetReport) (int64, error) {
|
||||
res, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Data(g.Map{
|
||||
"phone_num": m.PhoneNum,
|
||||
"file": m.File,
|
||||
"labels_json": m.LabelsJson,
|
||||
"species": m.Species,
|
||||
"status": m.Status,
|
||||
"image_hash": m.ImageHash,
|
||||
"created_at": m.CreatedAt,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// CountTodayByPhone 某用户当日(自然日)上报条数(日限额判定)
|
||||
func (d *falseTargetReportDao) CountTodayByPhone(ctx context.Context, phone string, dayStart *gtime.Time) (int, error) {
|
||||
return g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).
|
||||
Where("phone_num", phone).
|
||||
WhereGTE("created_at", dayStart).
|
||||
Count()
|
||||
}
|
||||
|
||||
// PageByFilter 管理端分页(phoneNum 精确过滤、status 过滤,按上报时间倒序)
|
||||
func (d *falseTargetReportDao) PageByFilter(ctx context.Context, phone, status string, page, size int) ([]*entity.FalseTargetReport, int64, error) {
|
||||
base := func() *gdb.Model {
|
||||
m := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx)
|
||||
if phone != "" {
|
||||
m = m.Where("phone_num", phone)
|
||||
}
|
||||
if status != "" {
|
||||
m = m.Where("status", status)
|
||||
}
|
||||
return m
|
||||
}
|
||||
total, err := base().Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.FalseTargetReport
|
||||
err = base().OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
|
||||
return list, int64(total), err
|
||||
}
|
||||
|
||||
// GetByIds 按 id 批量取(审核前校验)
|
||||
func (d *falseTargetReportDao) GetByIds(ctx context.Context, ids []int64) ([]*entity.FalseTargetReport, error) {
|
||||
var list []*entity.FalseTargetReport
|
||||
err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).WhereIn("id", ids).Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
// SetStatus 审核通过落状态(仅 pending 可迁转,重复审核由调用方先查后写保证幂等)
|
||||
func (d *falseTargetReportDao) SetStatus(ctx context.Context, id int64, status string, reviewedAt *gtime.Time) error {
|
||||
_, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"status": status, "reviewed_at": reviewedAt}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByIds 删除记录(拒绝即删:记录与文件同删,不留 rejected 存量)
|
||||
func (d *falseTargetReportDao) DeleteByIds(ctx context.Context, ids []int64) error {
|
||||
_, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).WhereIn("id", ids).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// ListHashes 全表非零 dHash(上报查重比对集;0=未算/回填前不参与比对)。
|
||||
// 条件必须是 != 0 而非 > 0:dHash 最高位为 1 时 int64 存为负数(SQLite INTEGER 有符号),
|
||||
// 用 > 0 会把约一半的 hash 排除在比对集外,查重静默失效(2026-09-10 实测)
|
||||
func (d *falseTargetReportDao) ListHashes(ctx context.Context) ([]int64, error) {
|
||||
all, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).
|
||||
Fields("image_hash").Where("image_hash != 0").Array("image_hash")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]int64, 0, len(all))
|
||||
for _, v := range all {
|
||||
out = append(out, v.Int64())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListHashMissing 存量回填待办:image_hash=0 的记录
|
||||
func (d *falseTargetReportDao) ListHashMissing(ctx context.Context) ([]*entity.FalseTargetReport, error) {
|
||||
var list []*entity.FalseTargetReport
|
||||
err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).
|
||||
Where("image_hash", 0).OrderAsc("id").Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
// UpdateHash 回填整帧 dHash
|
||||
func (d *falseTargetReportDao) UpdateHash(ctx context.Context, id int64, hash int64) error {
|
||||
_, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Where("id", id).
|
||||
Data("image_hash", hash).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// SetSuspectReal 预判命中置疑似真目标标记(幂等,只置 1 不清 0)
|
||||
func (d *falseTargetReportDao) SetSuspectReal(ctx context.Context, id int64) error {
|
||||
_, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Where("id", id).
|
||||
Data("suspect_real", 1).Update()
|
||||
return err
|
||||
}
|
||||
@@ -19,18 +19,21 @@ var GenTask = &genTaskDao{}
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS gen_task (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'running',
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
rejected INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
finished_at TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// 存量库迁移:负样本生成空检剔除计数列(2026-09-07)
|
||||
common.EnsureColumn(ctx, consts.TableGenTask, "rejected", "rejected INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
|
||||
// Insert 创建生成任务,返回自增 id
|
||||
@@ -98,6 +101,13 @@ func (d *genTaskDao) UpdateProgress(ctx context.Context, id int64, done int) err
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateRejected 更新空检剔除计数(负样本生成收尾时一次性覆盖写)
|
||||
func (d *genTaskDao) UpdateRejected(ctx context.Context, id int64, rejected int) error {
|
||||
_, err := g.DB().Model(consts.TableGenTask).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"rejected": rejected}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// Finish 完成任务(done 状态 + 完成时间 + 失败原因;errMsg 非空时为 failed)
|
||||
func (d *genTaskDao) Finish(ctx context.Context, id int64, errMsg string) error {
|
||||
status := consts.GenTaskDone
|
||||
|
||||
@@ -19,15 +19,15 @@ var LabelTask = &labelTaskDao{}
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS label_task (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'running',
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
filenames TEXT,
|
||||
filenames JSONB,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
finished_at TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -41,7 +41,7 @@ func (d *labelTaskDao) Insert(ctx context.Context, m *entity.LabelTask) (int64,
|
||||
"status": m.Status,
|
||||
"total": m.Total,
|
||||
"done": m.Done,
|
||||
"filenames": m.Filenames,
|
||||
"filenames": common.NilIfEmpty(m.Filenames),
|
||||
"error": m.Error,
|
||||
"created_at": m.CreatedAt,
|
||||
"finished_at": m.FinishedAt,
|
||||
|
||||
@@ -21,17 +21,20 @@ func init() {
|
||||
ctx := context.Background()
|
||||
common.DropLegacyTableIfHasColumn(ctx, consts.TableLicense, "device_id")
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS license (
|
||||
phone_num TEXT PRIMARY KEY,
|
||||
password TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
phone_num VARCHAR(20) PRIMARY KEY,
|
||||
password VARCHAR(100) NOT NULL,
|
||||
expires_at TIMESTAMP,
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
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 TIMESTAMP")
|
||||
common.EnsureColumn(ctx, consts.TableLicense, "annotate_stats_since", "annotate_stats_since TIMESTAMP")
|
||||
}
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -2,6 +2,7 @@ package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
@@ -20,23 +21,26 @@ var Training = &modelTrainingDao{}
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS model_training (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
dataset_id INTEGER NOT NULL,
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'running',
|
||||
variant VARCHAR(5) NOT NULL DEFAULT 's',
|
||||
dataset_id BIGINT NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL DEFAULT 'species',
|
||||
dataset_ids JSONB,
|
||||
imgsz INTEGER NOT NULL DEFAULT 1280,
|
||||
epochs INTEGER NOT NULL DEFAULT 150,
|
||||
batch INTEGER NOT NULL DEFAULT 16,
|
||||
device TEXT NOT NULL DEFAULT '0',
|
||||
device VARCHAR(20) NOT NULL DEFAULT '0',
|
||||
current_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
total_epochs INTEGER NOT NULL DEFAULT 0,
|
||||
metrics TEXT,
|
||||
metrics JSONB,
|
||||
log_tail TEXT,
|
||||
pid INTEGER,
|
||||
error TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
finished_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -48,14 +52,17 @@ func (d *modelTrainingDao) Insert(ctx context.Context, m *entity.ModelTraining)
|
||||
res, err := g.DB().Model(consts.TableTraining).Ctx(ctx).Data(g.Map{
|
||||
"name": m.Name,
|
||||
"status": m.Status,
|
||||
"variant": m.Variant,
|
||||
"dataset_id": m.DatasetId,
|
||||
"kind": m.Kind,
|
||||
"dataset_ids": common.NilIfEmpty(m.DatasetIds),
|
||||
"imgsz": m.Imgsz,
|
||||
"epochs": m.Epochs,
|
||||
"batch": m.Batch,
|
||||
"device": m.Device,
|
||||
"current_epoch": m.CurrentEpoch,
|
||||
"total_epochs": m.TotalEpochs,
|
||||
"metrics": m.Metrics,
|
||||
"metrics": common.NilIfEmpty(m.Metrics),
|
||||
"log_tail": m.LogTail,
|
||||
"pid": m.Pid,
|
||||
"error": m.Error,
|
||||
@@ -170,6 +177,57 @@ func (d *modelTrainingDao) RunningByDataset(ctx context.Context, datasetId int64
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// ActiveByDatasetVariant 某 (数据集,档位) 未终态任务(running/queued;发起训练防重检查用)
|
||||
func (d *modelTrainingDao) ActiveByDatasetVariant(ctx context.Context, datasetId int64, variant string) (*entity.ModelTraining, error) {
|
||||
var e entity.ModelTraining
|
||||
err := g.DB().Model(consts.TableTraining).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Where("variant", variant).
|
||||
WhereIn("status", []string{consts.TrainingStatusRunning, consts.TrainingStatusQueued}).
|
||||
OrderAsc("id").Limit(1).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// PeekQueued 最老 queued 任务(串行晋级调度:无 running 时取一条)
|
||||
func (d *modelTrainingDao) PeekQueued(ctx context.Context) (*entity.ModelTraining, error) {
|
||||
var e entity.ModelTraining
|
||||
err := g.DB().Model(consts.TableTraining).Ctx(ctx).
|
||||
Where("status", consts.TrainingStatusQueued).OrderAsc("id").Limit(1).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// Promote 晋级排队任务为 running(CAS:仅 queued 可晋级,防与取消/删除竞态;开始时间取晋级时刻)
|
||||
func (d *modelTrainingDao) Promote(ctx context.Context, id int64, startedAt *gtime.Time) (bool, error) {
|
||||
res, err := g.DB().Model(consts.TableTraining).Ctx(ctx).
|
||||
Where("id", id).Where("status", consts.TrainingStatusQueued).
|
||||
Data(g.Map{"status": consts.TrainingStatusRunning, "started_at": startedAt}).Update()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// FailQueuedByDataset 某数据集全部排队任务置 failed(删数据集时调用:排队任务引用已删目录,
|
||||
// 晋级必失败,直接失败并带出原因,避免列表残留「排队中」)
|
||||
func (d *modelTrainingDao) FailQueuedByDataset(ctx context.Context, datasetId int64, reason string) error {
|
||||
_, err := g.DB().Model(consts.TableTraining).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Where("status", consts.TrainingStatusQueued).
|
||||
Data(g.Map{"status": consts.TrainingStatusFailed, "error": reason, "finished_at": gtime.Now()}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// ListRunning 全部 running 任务(Go 重启后恢复扫描用)
|
||||
func (d *modelTrainingDao) ListRunning(ctx context.Context) ([]*entity.ModelTraining, error) {
|
||||
var list []*entity.ModelTraining
|
||||
@@ -196,10 +254,11 @@ func (d *modelTrainingDao) FailUnstarted(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// LatestByDatasets 批量取各数据集最新一条训练记录(列表卡片训练状态用;
|
||||
// IN 一次取回按 id 倒序,应用层按 dataset_id 去重;数据集表小、记录少,单次查询足够)
|
||||
func (d *modelTrainingDao) LatestByDatasets(ctx context.Context, datasetIds []int64) (map[int64]*entity.ModelTraining, error) {
|
||||
out := make(map[int64]*entity.ModelTraining)
|
||||
// LatestByDatasets 批量取各数据集各档位(s/n)最新一条训练记录(列表卡片训练状态用;
|
||||
// IN 一次取回按 id 倒序,应用层按 (dataset_id, variant) 去重;数据集表小、记录少,单次查询足够)。
|
||||
// 返回 map[dataset_id][]train,每数据集 ≤2 条(s 在前 n 在后)。
|
||||
func (d *modelTrainingDao) LatestByDatasets(ctx context.Context, datasetIds []int64) (map[int64][]*entity.ModelTraining, error) {
|
||||
out := make(map[int64][]*entity.ModelTraining)
|
||||
if len(datasetIds) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
@@ -217,12 +276,22 @@ func (d *modelTrainingDao) LatestByDatasets(ctx context.Context, datasetIds []in
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[int64]map[string]bool) // dataset_id → 已收档位
|
||||
for _, t := range list {
|
||||
if _, ok := out[t.DatasetId]; !ok {
|
||||
out[t.DatasetId] = t
|
||||
if seen[t.DatasetId] == nil {
|
||||
seen[t.DatasetId] = map[string]bool{}
|
||||
}
|
||||
if seen[t.DatasetId][t.Variant] {
|
||||
continue
|
||||
}
|
||||
seen[t.DatasetId][t.Variant] = true
|
||||
out[t.DatasetId] = append(out[t.DatasetId], t)
|
||||
}
|
||||
}
|
||||
// 同数据集内固定 s 前 n 后(乱序展示无意义)
|
||||
for ds := range out {
|
||||
sort.Slice(out[ds], func(i, j int) bool { return out[ds][i].Variant < out[ds][j].Variant })
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,17 +18,20 @@ var ModelVersion = &modelVersionDao{}
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS model_version (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dataset_id INTEGER NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
training_id INTEGER,
|
||||
metrics TEXT,
|
||||
labels TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
is_latest INTEGER NOT NULL DEFAULT 0,
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL,
|
||||
variant VARCHAR(5) NOT NULL DEFAULT 's',
|
||||
version VARCHAR(50) NOT NULL,
|
||||
training_id BIGINT,
|
||||
kind VARCHAR(20) NOT NULL DEFAULT 'species',
|
||||
dataset_ids JSONB,
|
||||
metrics JSONB,
|
||||
labels JSONB NOT NULL,
|
||||
sha256 VARCHAR(64) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
is_latest SMALLINT NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE (dataset_id, version)
|
||||
)`)
|
||||
if err != nil {
|
||||
@@ -40,9 +43,12 @@ func init() {
|
||||
func (d *modelVersionDao) Insert(ctx context.Context, m *entity.ModelVersion) (int64, error) {
|
||||
res, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Data(g.Map{
|
||||
"dataset_id": m.DatasetId,
|
||||
"kind": m.Kind,
|
||||
"dataset_ids": common.NilIfEmpty(m.DatasetIds),
|
||||
"variant": m.Variant,
|
||||
"version": m.Version,
|
||||
"training_id": m.TrainingId,
|
||||
"metrics": m.Metrics,
|
||||
"metrics": common.NilIfEmpty(m.Metrics),
|
||||
"labels": m.Labels,
|
||||
"sha256": m.Sha256,
|
||||
"size_bytes": m.SizeBytes,
|
||||
@@ -56,10 +62,10 @@ func (d *modelVersionDao) Insert(ctx context.Context, m *entity.ModelVersion) (i
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// ClearLatest 某数据集所有版本置 is_latest=0(发布前调用)
|
||||
func (d *modelVersionDao) ClearLatest(ctx context.Context, datasetId int64) error {
|
||||
// ClearLatest 某 (数据集,档位) 所有版本置 is_latest=0(发布前调用,s/n 两档互不影响)
|
||||
func (d *modelVersionDao) ClearLatest(ctx context.Context, datasetId int64, variant string) error {
|
||||
_, err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Data(g.Map{"is_latest": 0}).Update()
|
||||
Where("dataset_id", datasetId).Where("variant", variant).Data(g.Map{"is_latest": 0}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -22,16 +22,16 @@ func init() {
|
||||
ctx := context.Background()
|
||||
common.DropLegacyTableIfHasColumn(ctx, consts.TablePaymentOrder, "device_id")
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS payment_order (
|
||||
order_id TEXT PRIMARY KEY,
|
||||
phone_num TEXT NOT NULL,
|
||||
plan_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
order_id VARCHAR(64) PRIMARY KEY,
|
||||
phone_num VARCHAR(20) NOT NULL,
|
||||
plan_id VARCHAR(20) NOT NULL,
|
||||
channel VARCHAR(20) NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'created',
|
||||
wx_trade_no TEXT UNIQUE,
|
||||
alipay_trade_no TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
paid_at TEXT
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'created',
|
||||
wx_trade_no VARCHAR(64) UNIQUE,
|
||||
alipay_trade_no VARCHAR(64) UNIQUE,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
paid_at TIMESTAMP
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -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 BIGSERIAL PRIMARY KEY,
|
||||
phone_num VARCHAR(20) NOT NULL,
|
||||
minutes INTEGER NOT NULL,
|
||||
task_id BIGINT NOT NULL DEFAULT 0,
|
||||
granted_at TIMESTAMP 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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
// ---------- App(Bearer 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{}
|
||||
@@ -0,0 +1,96 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流负样本库。
|
||||
// 合规:客户端 jpg 重编码剥离全部元数据,首用单独同意由 App 承担。
|
||||
|
||||
// ---------- App ----------
|
||||
|
||||
// FalseTargetReportReq 上报假目标(multipart):file = 上报瞬间的分析帧整图
|
||||
// (与喂给 YOLO 的帧同源同尺寸,jpg q85 重编码),detections = 当前全部检测框
|
||||
// 快照 JSON([{label,class,score,model,cx,cy,w,h}],归一化坐标,供审核参考)。
|
||||
type FalseTargetReportReq struct {
|
||||
g.Meta `path:"/feedback/false-target" method:"post" summary:"上报假目标" tags:"客户端" mime:"multipart/form-data"`
|
||||
File *ghttp.UploadFile `json:"file" v:"required" dc:"分析帧整图(jpg,与推理帧同尺寸)"`
|
||||
Detections string `json:"detections" dc:"检测框快照JSON数组,空=上报时无框"`
|
||||
SourceW int `json:"sourceW" v:"min:0" dc:"分析帧宽"`
|
||||
SourceH int `json:"sourceH" v:"min:0" dc:"分析帧高"`
|
||||
}
|
||||
|
||||
type FalseTargetReportRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// ---------- 管理端 ----------
|
||||
|
||||
// AdminFalseTargetListReq 上报分页列表
|
||||
type AdminFalseTargetListReq struct {
|
||||
g.Meta `path:"/false-targets" method:"get" summary:"假目标上报列表" tags:"管理端"`
|
||||
PhoneNum string `json:"phoneNum" v:"length:0,20" dc:"手机号精确过滤"`
|
||||
Status string `json:"status" v:"in:,pending,approved" dc:"状态过滤,空=全部"`
|
||||
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
|
||||
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
|
||||
}
|
||||
|
||||
// FalseTargetBox 检测框快照(归一化坐标相对原分析帧)
|
||||
type FalseTargetBox struct {
|
||||
Label string `json:"label"`
|
||||
Class int `json:"class"`
|
||||
Score float64 `json:"score"`
|
||||
Model string `json:"model"`
|
||||
Cx float64 `json:"cx"`
|
||||
Cy float64 `json:"cy"`
|
||||
W float64 `json:"w"`
|
||||
H float64 `json:"h"`
|
||||
}
|
||||
|
||||
// FalseTargetSnapshot labels_json 序列化结构:框快照 + 原分析帧尺寸
|
||||
type FalseTargetSnapshot struct {
|
||||
SourceW int `json:"sourceW"`
|
||||
SourceH int `json:"sourceH"`
|
||||
Boxes []*FalseTargetBox `json:"boxes"`
|
||||
}
|
||||
|
||||
// AdminFalseTargetItem 上报条目(附裁剪图预览地址与检测框快照)
|
||||
type AdminFalseTargetItem struct {
|
||||
Id int64 `json:"id"`
|
||||
PhoneNum string `json:"phoneNum"`
|
||||
Species string `json:"species"`
|
||||
Status string `json:"status"`
|
||||
ImageUrl string `json:"imageUrl"`
|
||||
Boxes []*FalseTargetBox `json:"boxes"`
|
||||
SourceW int `json:"sourceW"`
|
||||
SourceH int `json:"sourceH"`
|
||||
SuspectReal int `json:"suspectReal"` // RF-DETR 高分检出疑似真目标预判(0/1,审核辅助标记)
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
ReviewedAt *gtime.Time `json:"reviewedAt"`
|
||||
}
|
||||
|
||||
type AdminFalseTargetListRes struct {
|
||||
Total int64 `json:"total"`
|
||||
List []*AdminFalseTargetItem `json:"list"`
|
||||
}
|
||||
|
||||
// AdminFalseTargetImageReq 裁剪图预览(静态字节流)
|
||||
type AdminFalseTargetImageReq struct {
|
||||
g.Meta `path:"/false-targets/image" method:"get" summary:"假目标裁剪图" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"上报 id"`
|
||||
}
|
||||
|
||||
type AdminFalseTargetImageRes struct{}
|
||||
|
||||
// AdminFalseTargetReviewReq 审核:通过 = 迁移入负样本库当背景图;拒绝 = 删文件
|
||||
type AdminFalseTargetReviewReq struct {
|
||||
g.Meta `path:"/false-targets/review" method:"post" summary:"审核假目标上报" tags:"管理端"`
|
||||
Ids []int64 `json:"ids" v:"required|min-length:1" dc:"上报 id 列表"`
|
||||
Approve bool `json:"approve" dc:"true=通过 false=拒绝"`
|
||||
}
|
||||
|
||||
type AdminFalseTargetReviewRes struct {
|
||||
Affected int64 `json:"affected"` // 实际审核条数(过滤掉不存在/非 pending)
|
||||
}
|
||||
@@ -21,33 +21,41 @@ type AdminDatasetListReq struct {
|
||||
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
|
||||
}
|
||||
|
||||
// AdminDatasetItem 数据集条目(卡片展示用;AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh))
|
||||
// AdminDatasetTrainBrief 数据集卡片训练状态:各档位(s/n)最新一条训练记录
|
||||
type AdminDatasetTrainBrief struct {
|
||||
TrainingId int64 `json:"trainingId"`
|
||||
Variant string `json:"variant"` // s(高识别) | n(高性能)
|
||||
Status string `json:"status"` // queued | running | success | failed
|
||||
Error string `json:"error"`
|
||||
Published bool `json:"published"` // 该训练是否已发布过版本(已发布不再显示发布按钮)
|
||||
CurrentEpoch int `json:"currentEpoch"`
|
||||
TotalEpochs int `json:"totalEpochs"`
|
||||
EtaMinutes int `json:"etaMinutes"` // 预计剩余分钟(running 且已完成 ≥1 轮才 >0,按已用均值×剩余轮数估算)
|
||||
}
|
||||
|
||||
// AdminDatasetItem 数据集条目(卡片展示用;AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh);
|
||||
// 双档位训练后每数据集最多两条训练状态(Trains,s 前 n 后),无记录档位不在列)
|
||||
type AdminDatasetItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"` // manual | ai
|
||||
ImageCount int64 `json:"imageCount"`
|
||||
LabeledCount int64 `json:"labeledCount"`
|
||||
Status string `json:"status"` // building | labeled | synced
|
||||
Cover string `json:"cover"` // 封面文件名
|
||||
Description string `json:"description"` // 描述
|
||||
NamePrefix string `json:"namePrefix"` // AI 生成图文件名前缀
|
||||
GenSpecies string `json:"genSpecies"` // 生成参数池:物种(单值)
|
||||
GenTone string `json:"genTone"` // 轮廓色词(单值)
|
||||
GenHeights float64 `json:"genHeights"` // 站高cm(数值)
|
||||
GenScenes string `json:"genScenes"` // 场景池(JSON 数组)
|
||||
GenActions string `json:"genActions"` // 动作池(JSON 数组)
|
||||
GenOcclusions string `json:"genOcclusions"` // 遮挡池(JSON 数组)
|
||||
GenClasses string `json:"genClasses"` // 第二标注类别名(单值)
|
||||
SortOrder int64 `json:"sortOrder"` // 序号(列表排序主键,升序)
|
||||
TrainingId int64 `json:"trainingId"` // 最新训练记录 id(发布/详情用)
|
||||
TrainingStatus string `json:"trainingStatus"` // 最新训练记录状态 running|success|failed|空
|
||||
TrainingError string `json:"trainingError"` // 最新训练记录失败原因(failed 时展示用)
|
||||
TrainingPublished bool `json:"trainingPublished"` // 该训练是否已发布过版本(已发布不再显示发布按钮)
|
||||
TrainingCurrentEpoch int `json:"trainingCurrentEpoch"`
|
||||
TrainingTotalEpochs int `json:"trainingTotalEpochs"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt"`
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"` // manual | ai
|
||||
ImageCount int64 `json:"imageCount"`
|
||||
LabeledCount int64 `json:"labeledCount"`
|
||||
Status string `json:"status"` // building | labeled | synced
|
||||
Cover string `json:"cover"` // 封面文件名
|
||||
Description string `json:"description"`
|
||||
NamePrefix string `json:"namePrefix"` // AI 生成图文件名前缀
|
||||
GenSpecies string `json:"genSpecies"` // 生成参数池:物种(单值)
|
||||
GenTone string `json:"genTone"` // 轮廓色词(单值)
|
||||
GenHeights float64 `json:"genHeights"` // 站高cm(数值)
|
||||
GenScenes string `json:"genScenes"` // 场景池(JSON 数组)
|
||||
GenActions string `json:"genActions"` // 动作池(JSON 数组)
|
||||
GenOcclusions string `json:"genOcclusions"`
|
||||
GenClasses string `json:"genClasses"` // 第二标注类别名(单值)
|
||||
SortOrder int64 `json:"sortOrder"` // 序号(列表排序主键,升序)
|
||||
Trains []*AdminDatasetTrainBrief `json:"trains"` // 各档位最新训练(无记录为空数组)
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AdminDatasetListRes struct {
|
||||
@@ -118,6 +126,30 @@ 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"`
|
||||
}
|
||||
|
||||
// AdminNegativeGenerateReq 负样本库批量生成(异步任务):config.yml 内置场景池按序循环组装提示词
|
||||
// (negativeScenes/negativeAnimals 池),逐张生成后自动过 RF-DETR 全图检测,有检出即剔除不入库
|
||||
type AdminNegativeGenerateReq struct {
|
||||
g.Meta `path:"/datasets/negative/generate" method:"post" summary:"负样本批量生成(异步任务)" tags:"管理端"`
|
||||
Count int `json:"count" v:"required|integer|min:1|max:1000" dc:"生成张数 1-1000"`
|
||||
}
|
||||
|
||||
type AdminNegativeGenerateRes struct {
|
||||
TaskId int64 `json:"taskId"` // 生成任务 id(复用 GET /datasets/gen-task 按 datasetId 轮询进度)
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// AdminDatasetUploadReq 上传图片(multipart 多文件;重名跳过并计数)
|
||||
type AdminDatasetUploadReq struct {
|
||||
g.Meta `path:"/datasets/upload" method:"post" summary:"上传图片" tags:"管理端" mime:"multipart/form-data"`
|
||||
@@ -133,7 +165,8 @@ type AdminDatasetUploadRes struct {
|
||||
// AdminDatasetGenerateReq AI 生成图片(异步任务;prompt 可手填覆盖模板,禁止含目标位置描述;
|
||||
// 未手填走 imageGen.promptTemplate 组装(单物种规则:物种固定取数据集 gen_species[0]/数据集名,
|
||||
// 表单无物种输入),scene/action/occlusion 从数据集表池随机。
|
||||
// animalCount 仅作为标注框数上限存储(自动标注按置信度裁剪 ≤N),不参与生成校验;
|
||||
// animalCount 为生成提示词中的声明目标数量,仅信息性存储(自动标注上限为固定 3 个,
|
||||
// 与声明数无关——生成图可能实际含多个目标,检出超 3 个按置信度取前 3,2026-09-02);
|
||||
// 距离校验已取消(2026-08-28)。文件名前缀为数据集属性(datasets 表 name_prefix)。
|
||||
// 生成耗时约 40s/张(localai),批量请用返回的 TaskId 轮询 /datasets/gen-task 进度。
|
||||
type AdminDatasetGenerateReq struct {
|
||||
@@ -162,6 +195,7 @@ type AdminGenTaskQueryRes struct {
|
||||
Status string `json:"status"` // running | done | failed
|
||||
Total int `json:"total"`
|
||||
Done int `json:"done"`
|
||||
Rejected int `json:"rejected"` // 空检剔除张数(负样本生成专用,其余任务恒 0)
|
||||
Error string `json:"error"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
FinishedAt *gtime.Time `json:"finishedAt"`
|
||||
@@ -173,13 +207,17 @@ type AdminDatasetImagesReq struct {
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
// AdminImageItem 图片条目(url 为管理端图片访问地址,前端拼 apiBaseUrl 使用)
|
||||
// AdminImageItem 图片条目(url 为管理端图片访问地址,前端拼 apiBaseUrl 使用;
|
||||
// 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"`
|
||||
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 {
|
||||
@@ -250,7 +288,7 @@ type AdminGenCoverRes struct {
|
||||
// AdminTrainingListReq 训练任务列表(创建时间倒序)
|
||||
type AdminTrainingListReq struct {
|
||||
g.Meta `path:"/trainings" method:"get" summary:"训练任务列表" tags:"管理端"`
|
||||
Status string `json:"status" v:"in:running,success,failed" dc:"状态筛选"`
|
||||
Status string `json:"status" v:"in:queued,running,success,failed" dc:"状态筛选"`
|
||||
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
|
||||
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
|
||||
}
|
||||
@@ -261,13 +299,17 @@ type AdminTrainingItem struct {
|
||||
Name string `json:"name"`
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Status string `json:"status"` // running | success | failed
|
||||
Kind string `json:"kind"` // species 单物种 | combined 综合
|
||||
Variant string `json:"variant"` // s(高识别) | n(高性能)
|
||||
Status string `json:"status"` // queued | running | success | failed
|
||||
Published bool `json:"published"` // 该训练是否已发布过版本(发布入口状态展示)
|
||||
Imgsz int `json:"imgsz"`
|
||||
Epochs int `json:"epochs"`
|
||||
Batch int `json:"batch"`
|
||||
Device string `json:"device"`
|
||||
CurrentEpoch int `json:"currentEpoch"`
|
||||
TotalEpochs int `json:"totalEpochs"`
|
||||
EtaMinutes int `json:"etaMinutes"` // 预计剩余分钟(running 且已完成 ≥1 轮才 >0)
|
||||
Metrics string `json:"metrics"`
|
||||
Error string `json:"error"`
|
||||
StartedAt *gtime.Time `json:"startedAt"`
|
||||
@@ -280,17 +322,33 @@ type AdminTrainingListRes struct {
|
||||
List []*AdminTrainingItem `json:"list"`
|
||||
}
|
||||
|
||||
// AdminTrainingStartReq 发起训练(并发度 1:已有 running 任务时拒绝;训练参数走 config.yml training 节点,name 空则自动生成)
|
||||
// AdminTrainingStartReq 发起训练(GPU 独占排队:忙时新任务落 queued,running 结束自动晋级;
|
||||
// 同 (数据集,档位) 已有 running/queued 任务时拒绝;训练参数走 config.yml training 节点,name 空则自动生成)
|
||||
type AdminTrainingStartReq struct {
|
||||
g.Meta `path:"/trainings" method:"post" summary:"发起训练" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Name string `json:"name" v:"length:0,50" dc:"任务名称,空自动生成"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Name string `json:"name" v:"length:0,50" dc:"任务名称,空自动生成"`
|
||||
Variants []string `json:"variants" dc:"限定档位 s/n(省略=双档各建一条任务;只补跑高性能档传 [\"n\"];含未知档位/未配置档位报错)"`
|
||||
}
|
||||
|
||||
type AdminTrainingStartRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminTrainingCombinedStartReq 综合训练发起(2026-09-09 多物种合并模型):
|
||||
// 勾选 ≥2 个数据集,多物种合并训练一个全类 tflite(类别表 = 各物种名 + 共享 suspect);
|
||||
// 每档位一条 kind=combined 任务(dataset_id=0),与单物种任务同队列排队
|
||||
type AdminTrainingCombinedStartReq struct {
|
||||
g.Meta `path:"/trainings/combined" method:"post" summary:"发起综合训练" tags:"管理端"`
|
||||
DatasetIds []int64 `json:"datasetIds" v:"required" dc:"参与合并的数据集 id 列表(≥2,互不相同)"`
|
||||
Name string `json:"name" v:"length:0,50" dc:"任务名称,空自动生成"`
|
||||
Variants []string `json:"variants" dc:"限定档位 s/n(省略=双档各建一条任务;含未知档位/未配置档位报错)"`
|
||||
}
|
||||
|
||||
type AdminTrainingCombinedStartRes struct {
|
||||
FirstId int64 `json:"firstId"` // 首条任务 id(variants 多条时取最小)
|
||||
}
|
||||
|
||||
// AdminTrainingDetailReq 训练任务详情(含日志尾部)
|
||||
type AdminTrainingDetailReq struct {
|
||||
g.Meta `path:"/trainings/detail" method:"get" summary:"训练任务详情" tags:"管理端"`
|
||||
@@ -303,7 +361,7 @@ type AdminTrainingDetailRes struct {
|
||||
LogTail string `json:"logTail"`
|
||||
}
|
||||
|
||||
// AdminTrainingCancelReq 取消训练(杀进程,任务置 failed)
|
||||
// AdminTrainingCancelReq 取消训练(running=杀进程,queued=无进程直接置 failed)
|
||||
type AdminTrainingCancelReq struct {
|
||||
g.Meta `path:"/trainings/cancel" method:"post" summary:"取消训练" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"训练任务 id"`
|
||||
@@ -311,7 +369,8 @@ type AdminTrainingCancelReq struct {
|
||||
|
||||
type AdminTrainingCancelRes struct{}
|
||||
|
||||
// AdminTrainingPublishReq 发布模型版本(仅 success 任务;按数据集版本号 m<major>.<minor>.<patch> 自增)
|
||||
// AdminTrainingPublishReq 发布模型版本(仅 success 任务;按任务档位发布——s/n 各自的
|
||||
// is_latest 互不影响,版本号数据集内共用 m<major>.<minor>.<patch> 自增)
|
||||
type AdminTrainingPublishReq struct {
|
||||
g.Meta `path:"/trainings/publish" method:"post" summary:"发布模型版本" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"训练任务 id"`
|
||||
@@ -353,15 +412,16 @@ 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 {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminImageVlmReviewReq 单图 VLM 补检:qwen3.6-35b-a3b 按「环境/光线/习性」推理 RF-DETR 之外
|
||||
// 可能藏匿目标的位置(排除已确认坐标),最多补 3 个疑似框(class 1)追加到 labels_json。
|
||||
// AdminImageVlmReviewReq 单图 VLM 补检:qwen3.6-35b-a3b 按「环境/季节/时间/天气/光线/地形/习性」
|
||||
// 综合判读画面,推理 RF-DETR 之外可能藏身的位置(排除已确认坐标),最多补 3 个疑似框(class 1)
|
||||
// 追加到 labels_json。
|
||||
// 依赖 localAi.vlmModel(默认 qwen3.6-35b-a3b);VL 与 z-image 生成不能同时驻留显存,批量补检
|
||||
// 应在生成队列空闲时执行
|
||||
type AdminImageVlmReviewReq struct {
|
||||
@@ -392,14 +452,15 @@ type AdminLabelBox struct {
|
||||
Class int `json:"class"` // 0 确认 | 1 疑似
|
||||
}
|
||||
|
||||
// AdminLabelImageItem 工作台单张图:全部标注(labels_json,AI 自动标注与人工框同层,人工可修改/清理)
|
||||
// AdminLabelImageItem 工作台单张图:全部标注(labels_json,AI 预标与人工框同层,人工可修改/清理)
|
||||
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 {
|
||||
@@ -438,6 +499,60 @@ type AdminLabelSaveRes struct {
|
||||
LabeledCount int64 `json:"labeledCount"` // 该数据集当前已标注图数
|
||||
}
|
||||
|
||||
// ---------- 数据清洗 ----------
|
||||
|
||||
// AdminDatasetCleanPreviewReq 数据清洗预览(训练集去冗余候选清单,语义见技术设计.md「数据清洗」):
|
||||
// 返回桶分布 + 超配桶候选清单(dHash 贪心挑保留)+ 已排除清单(可恢复)。
|
||||
// Quotas 与档位一一对应覆盖「保留张数」,缺省用 consts.CleanBuckets 默认;<2% 豁免档无视。
|
||||
type AdminDatasetCleanPreviewReq struct {
|
||||
g.Meta `path:"/datasets/clean/preview" method:"post" summary:"数据清洗预览" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Quotas []int `json:"quotas" v:"length:0,8" dc:"各档配额覆盖(与档位一一对应,可选)"`
|
||||
}
|
||||
|
||||
// AdminCleanBucketRes 单档分布(档位定义与顺序见 consts.CleanBuckets)
|
||||
type AdminCleanBucketRes struct {
|
||||
Label string `json:"label"`
|
||||
Exempt bool `json:"exempt"` // 豁免档(<2% 极远,不进候选)
|
||||
Total int `json:"total"` // 该档有确认目标(class 0)标注图总数
|
||||
Excluded int `json:"excluded"` // 其中已清洗排除数
|
||||
Quota int `json:"quota"` // 生效保留张数(请求覆盖或默认)
|
||||
Over int `json:"over"` // 超配需排除数 = max(0, 未排除-配额);豁免档恒 0
|
||||
}
|
||||
|
||||
// AdminCleanImage 候选/已排除单图(两个清单共用)
|
||||
type AdminCleanImage struct {
|
||||
ImageId int64 `json:"imageId"`
|
||||
Filename string `json:"filename"`
|
||||
Source string `json:"source"` // manual | ai
|
||||
BucketLabel string `json:"bucketLabel"` // 所在档(无 class 0 标注的已排除图为空)
|
||||
BoxHeightPct float64 `json:"boxHeightPct"` // 主目标框高占图高百分比(取最大 class 0 框,保留 1 位小数)
|
||||
MinHeightPct float64 `json:"minHeightPct"` // 图内最小确认目标占比(档内优先保留依据,保留 1 位小数;单目标图 = 主尺寸)
|
||||
TargetCount int `json:"targetCount"` // class 0 目标总数(多目标图里的小目标样本稀缺,看主尺寸+最小占比判断取舍)
|
||||
}
|
||||
|
||||
type AdminDatasetCleanPreviewRes struct {
|
||||
Total int `json:"total"` // 参与统计图数(有 class 0 标注)
|
||||
Excluded int `json:"excluded"` // 已排除总数(恢复入口角标)
|
||||
Buckets []*AdminCleanBucketRes `json:"buckets"`
|
||||
Candidates []*AdminCleanImage `json:"candidates"` // 建议排除候选(默认全选即执行)
|
||||
ExcludedImages []*AdminCleanImage `json:"excludedImages"` // 已排除清单(勾选恢复)
|
||||
}
|
||||
|
||||
// AdminDatasetCleanApplyReq 数据清洗执行:批量置/清 clean_excluded(排除不删文件,
|
||||
// 只影响训练集打包;恢复 = 同接口 exclude=false 重提该清单)。imageIds 须全属该数据集。
|
||||
type AdminDatasetCleanApplyReq struct {
|
||||
g.Meta `path:"/datasets/clean/apply" method:"post" summary:"数据清洗执行(排除/恢复)" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
ImageIds []int64 `json:"imageIds" v:"required|min-length:1" dc:"图片记录 id 列表"`
|
||||
Exclude bool `json:"exclude" dc:"true=置排除标记(出训练集);false=清标记(恢复)"`
|
||||
}
|
||||
|
||||
type AdminDatasetCleanApplyRes struct {
|
||||
Applied int `json:"applied"` // 处理图片数(幂等)
|
||||
Excluded int64 `json:"excluded"` // 该数据集当前排除总数(角标/计数刷新)
|
||||
}
|
||||
|
||||
// ---------- 客户端模型目录 ----------
|
||||
|
||||
// ModelCatalogReq 模型目录(客户端登录态):全部数据集的当前生效模型
|
||||
@@ -445,18 +560,22 @@ type ModelCatalogReq struct {
|
||||
g.Meta `path:"/models" method:"get" summary:"模型目录" tags:"客户端"`
|
||||
}
|
||||
|
||||
// ModelCatalogItem 客户端模型条目(App 按需下载,多模型并行推理合并)
|
||||
// ModelCatalogItem 客户端模型条目(App 按需下载,多模型并行推理合并;variant 区分档位,
|
||||
// s/n 两档同数据集各一条 is_latest,App 按识别档位筛选加载)
|
||||
type ModelCatalogItem struct {
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
DatasetName string `json:"datasetName"`
|
||||
Variant string `json:"variant"` // s(高识别) | n(高性能)
|
||||
Kind string `json:"kind"` // species 单物种(缺省视为 species,老 App 忽略) | combined 综合
|
||||
DatasetIds []int64 `json:"datasetIds,omitempty"` // combined:覆盖的数据集 id 列表
|
||||
Version string `json:"version"`
|
||||
Labels []string `json:"labels"` // 类别名,App 推理结果展示用
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Sha256 string `json:"sha256"`
|
||||
Notes string `json:"notes"`
|
||||
PublishedAt *gtime.Time `json:"publishedAt"`
|
||||
DownloadUrl string `json:"downloadUrl"` // /download/trainings/<文件名前缀>.tflite(前缀空回退数据集名)
|
||||
CoverUrl string `json:"coverUrl"` // /api/v1/app/cover?namePrefix=<前缀>(App 模型清单缩略图)
|
||||
DownloadUrl string `json:"downloadUrl"` // s 档 /download/trainings/<文件名前缀>.tflite;n 档 <文件名前缀>_n.tflite;combined 固定 combined(_n)
|
||||
CoverUrl string `json:"coverUrl"` // /api/v1/app/cover?namePrefix=<前缀>(App 模型清单缩略图);combined 为空
|
||||
}
|
||||
|
||||
type ModelCatalogRes 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:"审核时间"`
|
||||
}
|
||||
@@ -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:"下发时间"`
|
||||
}
|
||||
@@ -5,11 +5,14 @@ import "github.com/gogf/gf/v2/os/gtime"
|
||||
// DatasetImage 数据集图片:文件在 datasets/<数据集名>/<filename>;
|
||||
// 标注为 JSON 数组存本表(元素同 dto.AdminLabelBox:class/cx/cy/w/h/confidence,YOLO 归一化)。
|
||||
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=未限定),自动标注按置信度裁剪到此数"`
|
||||
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 自动标注与人工标注同存,null/''/'[]'=未标注)"`
|
||||
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:"创建时间"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// FalseTargetReport 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流,
|
||||
// 客户端仅上传误报框裁剪图(剥离元数据);管理端审核通过后迁移入负样本库当背景图训练。
|
||||
type FalseTargetReport struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"上报用户手机号"`
|
||||
File string `json:"file" orm:"file" description:"裁剪图文件名(uuid.jpg)"`
|
||||
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"检测框快照(label/class/score/归一化xywh)"`
|
||||
Species string `json:"species" orm:"species" description:"上报时来源模型名(=数据集名)"`
|
||||
Status string `json:"status" orm:"status" description:"pending|approved"`
|
||||
ImageHash int64 `json:"imageHash" orm:"image_hash" description:"整帧 dHash 64 位(上报查重;0=未算/存量回填前)"`
|
||||
SuspectReal int `json:"suspectReal" orm:"suspect_real" description:"RF-DETR 高分检出疑似真目标预判标记(0/1,2026-09-09)"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"上报时间"`
|
||||
ReviewedAt *gtime.Time `json:"reviewedAt" orm:"reviewed_at" description:"审核时间"`
|
||||
}
|
||||
@@ -8,7 +8,8 @@ type GenTask struct {
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
|
||||
Status string `json:"status" orm:"status" description:"running|done|failed"`
|
||||
Total int `json:"total" orm:"total" description:"计划生成张数"`
|
||||
Done int `json:"done" orm:"done" description:"已生成张数"`
|
||||
Done int `json:"done" orm:"done" description:"已处理张数(含剔除)"`
|
||||
Rejected int `json:"rejected" orm:"rejected" description:"空检剔除张数(负样本生成专用,RF-DETR 检出即剔除)"`
|
||||
Error string `json:"error" orm:"error" description:"失败原因(部分失败/全部失败)"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
FinishedAt *gtime.Time `json:"finishedAt" orm:"finished_at" description:"完成时间"`
|
||||
|
||||
@@ -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:"更新时间"`
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// ModelTraining 训练任务:runner 启动训练进程,轮询解析 epoch 日志更新进度/指标,
|
||||
// 日志尾部截断存 log_tail;pid 用于取消与存活探测。
|
||||
// 状态机 queued → running → success/failed(queued=GPU 忙排队,轮询晋级启动)。
|
||||
type ModelTraining struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
Name string `json:"name" orm:"name" description:"任务名"`
|
||||
Status string `json:"status" orm:"status" description:"running|success|failed"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"来源数据集"`
|
||||
Status string `json:"status" orm:"status" description:"queued|running|success|failed"`
|
||||
Variant string `json:"variant" orm:"variant" description:"档位 s(高识别)|n(高性能)"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"来源数据集(综合任务=0)"`
|
||||
Kind string `json:"kind" orm:"kind" description:"species 单物种|combined 多物种综合"`
|
||||
DatasetIds string `json:"datasetIds" orm:"dataset_ids" description:"综合任务覆盖的数据集 id JSON 数组"`
|
||||
Imgsz int `json:"imgsz" orm:"imgsz" description:"训练分辨率"`
|
||||
Epochs int `json:"epochs" orm:"epochs" description:"目标轮数"`
|
||||
Batch int `json:"batch" orm:"batch" description:"batch size"`
|
||||
|
||||
@@ -2,19 +2,23 @@ package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// ModelVersion 模型版本:每数据集独立版本序列(m1.0.0 递增,UNIQUE(dataset_id, version))。
|
||||
// 模型文件不落表:训练成功即直写 trainings/<文件名前缀>.tflite(前缀空回退数据集名,无存档回退机制);
|
||||
// labels 为类别名数组 JSON(App 多模型合并推理依赖)。
|
||||
// ModelVersion 模型版本:每数据集全局共用版本序列(s/n 交替发布同一序列,UNIQUE(dataset_id, version)),
|
||||
// is_latest 按 (数据集, 档位) 各记一条。
|
||||
// 模型文件不落表:训练成功即直写 trainings/<文件名前缀>.tflite(s 档)/<文件名前缀>_n.tflite(n 档,
|
||||
// 前缀空回退数据集名,无存档回退机制);labels 为类别名数组 JSON(App 多模型合并推理依赖)。
|
||||
type ModelVersion struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集(综合模型=0)"`
|
||||
Kind string `json:"kind" orm:"kind" description:"species 单物种|combined 多物种综合"`
|
||||
DatasetIds string `json:"datasetIds" orm:"dataset_ids" description:"综合覆盖的数据集 id JSON 数组"`
|
||||
Variant string `json:"variant" orm:"variant" description:"档位 s|n"`
|
||||
Version string `json:"version" orm:"version" description:"m1.0.0 递增"`
|
||||
TrainingId int64 `json:"trainingId" orm:"training_id" description:"来源训练任务"`
|
||||
Metrics string `json:"metrics" orm:"metrics" description:"JSON 指标"`
|
||||
Labels string `json:"labels" orm:"labels" description:"JSON 类别名数组"`
|
||||
Sha256 string `json:"sha256" orm:"sha256" description:"tflite 文件校验"`
|
||||
SizeBytes int64 `json:"sizeBytes" orm:"size_bytes" description:"文件大小"`
|
||||
IsLatest int `json:"isLatest" orm:"is_latest" description:"1=该数据集当前生效"`
|
||||
IsLatest int `json:"isLatest" orm:"is_latest" description:"1=该(数据集,档位)当前生效"`
|
||||
Notes string `json:"notes" orm:"notes" description:"备注"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"发布时间"`
|
||||
}
|
||||
|
||||
@@ -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:"发放时间"`
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package service
|
||||
|
||||
// 管理端白盒测试:手动授权/撤销、订单分页、套餐更新、账号注册/登录。
|
||||
// 运行方式同 payment_test.go:cd server && GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func startOfToday() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// TestAuthRegisterLogin 注册/登录:注册后可登录、密码错误拒绝、重复注册报错
|
||||
func TestAuthRegisterLogin(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
// 登录成功,token 可解析回手机号
|
||||
res, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"})
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
got, err := common.ParseToken(ctx(), res.Token)
|
||||
if err != nil || got != phone {
|
||||
t.Fatalf("parse token = %q, %v; want %q", got, err, phone)
|
||||
}
|
||||
// 密码错误
|
||||
if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "wrong"}); err == nil {
|
||||
t.Fatal("wrong password should fail")
|
||||
}
|
||||
// 重复注册
|
||||
if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "other123"}); err == nil {
|
||||
t.Fatal("duplicate register should fail")
|
||||
}
|
||||
// 未注册手机号登录自动创建账号并登录成功(自动注册式登录)
|
||||
phone2 := uniquePhone()
|
||||
res2, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone2, Password: "pass123456"})
|
||||
if err != nil {
|
||||
t.Fatalf("login auto-register: %v", err)
|
||||
}
|
||||
got2, err := common.ParseToken(ctx(), res2.Token)
|
||||
if err != nil || got2 != phone2 {
|
||||
t.Fatalf("auto-register token = %q, %v; want %q", got2, err, phone2)
|
||||
}
|
||||
// 自动注册后已存在,重复注册报错
|
||||
if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone2, Password: "other123"}); err == nil {
|
||||
t.Fatal("register after auto-register should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthLoginFillPlaceholder 运营发卡占位行(无密码)→ 登录用输入密码补写并成功,授权保留;错误密码被拒
|
||||
func TestAuthLoginFillPlaceholder(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "day"}); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("login fill password: %v", err)
|
||||
}
|
||||
// 补密码不能清掉已授权
|
||||
lic, err := dao.License.GetByPhone(ctx(), phone)
|
||||
if err != nil || lic == nil || lic.ExpiresAt == nil {
|
||||
t.Fatalf("grant lost after login fill password: %+v, %v", lic, err)
|
||||
}
|
||||
// 密码已写入:错误密码被拒
|
||||
if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "wrong"}); err == nil {
|
||||
t.Fatal("wrong password should fail after fill")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRegisterAfterGrant 运营先手动授权(占位行无密码)→ 用户注册补密码 → 登录成功
|
||||
func TestAuthRegisterAfterGrant(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "day"}); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("register after grant: %v", err)
|
||||
}
|
||||
if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("login after register: %v", err)
|
||||
}
|
||||
// 注册补密码不能清掉已授权
|
||||
lic, err := dao.License.GetByPhone(ctx(), phone)
|
||||
if err != nil || lic == nil || lic.ExpiresAt == nil {
|
||||
t.Fatalf("grant lost after register: %+v, %v", lic, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminGrant 手动授权:新账号从今天 0 点起按套餐天数累计
|
||||
func TestAdminGrant(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
res, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "week"})
|
||||
if err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
want := startOfToday().AddDate(0, 0, 7)
|
||||
if !res.ExpiresAt.Time.Equal(want) {
|
||||
t.Fatalf("grant expiresAt = %s, want %s", res.ExpiresAt, want)
|
||||
}
|
||||
lic, err := License.Get(common.WithPhone(ctx(), phone), &dto.LicenseReq{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !lic.Active {
|
||||
t.Fatalf("license res = %+v", lic)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminGrantRenewal 手动授权续期叠加:未过期顺延;无效套餐报错
|
||||
func TestAdminGrantRenewal(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "week"}); err != nil {
|
||||
t.Fatalf("grant 1: %v", err)
|
||||
}
|
||||
lic, _ := dao.License.GetByPhone(ctx(), phone)
|
||||
|
||||
res, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "month"})
|
||||
if err != nil {
|
||||
t.Fatalf("grant 2: %v", err)
|
||||
}
|
||||
want := lic.ExpiresAt.Time.AddDate(0, 0, 30)
|
||||
if !res.ExpiresAt.Time.Equal(want) {
|
||||
t.Fatalf("renewal expiresAt = %s, want %s (base %s)", res.ExpiresAt, want, lic.ExpiresAt)
|
||||
}
|
||||
|
||||
if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "nope"}); err == nil {
|
||||
t.Fatal("invalid plan should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminRevoke 撤销授权:清授权字段保留账号(可继续登录),授权查询立即 inactive
|
||||
func TestAdminRevoke(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "day"}); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
if _, err := License.AdminRevoke(ctx(), &dto.AdminRevokeReq{PhoneNum: phone}); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
lic, err := License.Get(common.WithPhone(ctx(), phone), &dto.LicenseReq{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lic.Active {
|
||||
t.Fatal("license should be inactive after revoke")
|
||||
}
|
||||
row, err := dao.License.GetByPhone(ctx(), phone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row == nil || row.ExpiresAt != nil {
|
||||
t.Fatalf("account row should be kept with auth cleared: %+v", row)
|
||||
}
|
||||
// 账号仍可登录(密码未删)
|
||||
if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("login after revoke: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminListOrders 订单分页与筛选:总数、渠道筛选、分页截断
|
||||
func TestAdminListOrders(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
orderId := uniqueOrderId("O-list-2-")
|
||||
for i, channel := range []string{consts.ChannelWechat, consts.ChannelWechat, consts.ChannelAlipay} {
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: uniqueOrderId("O-list-" + strconv.Itoa(i) + "-"), PhoneNum: phone, PlanId: "day", Channel: channel,
|
||||
AmountCents: 1000, Status: consts.OrderStatusCreated, CreatedAt: gtime.Now(),
|
||||
})
|
||||
}
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: orderId, PhoneNum: phone, PlanId: "day", Channel: consts.ChannelWechat,
|
||||
AmountCents: 1000, Status: consts.OrderStatusCreated, CreatedAt: gtime.Now(),
|
||||
})
|
||||
|
||||
res, err := Order.AdminListOrders(ctx(), &dto.AdminOrderListReq{PhoneNum: phone, Size: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("list orders: %v", err)
|
||||
}
|
||||
if res.Total != 4 || len(res.List) != 2 {
|
||||
t.Fatalf("total=%d len=%d, want 4/2", res.Total, len(res.List))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestListPlans 套餐列表:来自 testdata/config.yml plans 节点(配置驱动,无数据库表)
|
||||
func TestListPlans(t *testing.T) {
|
||||
res, err := Order.ListPlans(ctx(), &dto.ListPlansReq{})
|
||||
if err != nil {
|
||||
t.Fatalf("list plans: %v", err)
|
||||
}
|
||||
if len(res.List) != 3 {
|
||||
t.Fatalf("plans = %d, want 3", len(res.List))
|
||||
}
|
||||
var week *dto.PlanItem
|
||||
for _, p := range res.List {
|
||||
if p.PlanId == "week" {
|
||||
week = p
|
||||
}
|
||||
}
|
||||
if week == nil || week.Days != 7 || week.PriceCents != 5600 {
|
||||
t.Fatalf("week plan = %+v, want days=7 priceCents=5600", week)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
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 {
|
||||
// 通过 = 已标注(全部框原样保留——通过即人工背书,疑似框是合法的 class 1 训练标注)
|
||||
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
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package service
|
||||
|
||||
// App 版本管理白盒测试:下发(APK 上传 + 记录)、最新版本查询、重复版本拒绝、
|
||||
// APK 固定文件覆盖(目录永远只有一个文件)。
|
||||
// 运行方式同 admin_test.go:cd server && GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// uniqueVersion 每次运行生成唯一版本号,避免测试库残留数据冲突
|
||||
func uniqueVersion() string {
|
||||
return "9." + strconv.FormatInt(time.Now().UnixNano()%100000000, 10) + ".0"
|
||||
}
|
||||
|
||||
// newApkUpload 构造真实 multipart 上传文件(Save 需要可读的文件内容)
|
||||
func newApkUpload(t *testing.T, filename, content string) *ghttp.UploadFile {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
form, err := multipart.NewReader(&buf, w.Boundary()).ReadForm(1 << 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &ghttp.UploadFile{FileHeader: form.File["file"][0]}
|
||||
}
|
||||
|
||||
// countApk 目录下 apk 文件数(不含临时文件)
|
||||
func countApk(t *testing.T) int {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(common.ApkDir(ctx()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) == ".apk" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestAppVersionUpdate 下发:落库 + APK 保存固定文件,更新检查返回最新记录
|
||||
func TestAppVersionUpdate(t *testing.T) {
|
||||
ver := uniqueVersion()
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
Notes: "修复识别准确率", File: newApkUpload(t, "observer-"+ver+".apk", "apk-v1"),
|
||||
}); err != nil {
|
||||
t.Fatalf("add version: %v", err)
|
||||
}
|
||||
res, err := AppVersion.GetUpdate(ctx(), &dto.AppUpdateReq{})
|
||||
if err != nil {
|
||||
t.Fatalf("get update: %v", err)
|
||||
}
|
||||
if res.Version != ver || res.Notes != "修复识别准确率" {
|
||||
t.Fatalf("update = %+v", res)
|
||||
}
|
||||
// APK 已保存为固定文件名
|
||||
content, err := os.ReadFile(common.ApkFilePath(ctx()))
|
||||
if err != nil || string(content) != "apk-v1" {
|
||||
t.Fatalf("apk file = %q, %v", content, err)
|
||||
}
|
||||
if n := countApk(t); n != 1 {
|
||||
t.Fatalf("apk count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersionOverwrite 再次下发:固定文件被新内容覆盖,目录仍只有一个文件
|
||||
func TestAppVersionOverwrite(t *testing.T) {
|
||||
ver1 := uniqueVersion()
|
||||
ver2 := uniqueVersion() + "1"
|
||||
req1 := &dto.AdminAppVersionAddReq{File: newApkUpload(t, "observer-"+ver1+".apk", "apk-old")}
|
||||
req2 := &dto.AdminAppVersionAddReq{File: newApkUpload(t, "observer-"+ver2+".apk", "apk-new")}
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), req1); err != nil {
|
||||
t.Fatalf("add v1: %v", err)
|
||||
}
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), req2); err != nil {
|
||||
t.Fatalf("add v2: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(common.ApkFilePath(ctx()))
|
||||
if err != nil || string(content) != "apk-new" {
|
||||
t.Fatalf("apk file = %q, %v; want new content", content, err)
|
||||
}
|
||||
if n := countApk(t); n != 1 {
|
||||
t.Fatalf("apk count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersionInvalidFile 无文件 / 文件名不含版本号(格式不符)拒绝
|
||||
func TestAppVersionInvalidFile(t *testing.T) {
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{}); err == nil {
|
||||
t.Fatal("missing file should fail")
|
||||
}
|
||||
for _, name := range []string{"app.txt", "myapp-1.0.apk", "observer-1.0.apk", "observer-abc.apk"} {
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
File: newApkUpload(t, name, "not an apk"),
|
||||
}); err == nil {
|
||||
t.Fatalf("file %s should fail (version not parseable)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersionDuplicate 同版本号重复下发报错
|
||||
func TestAppVersionDuplicate(t *testing.T) {
|
||||
ver := uniqueVersion()
|
||||
req := &dto.AdminAppVersionAddReq{File: newApkUpload(t, "observer-"+ver+".apk", "apk")}
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), req); err != nil {
|
||||
t.Fatalf("add version: %v", err)
|
||||
}
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), req); err == nil {
|
||||
t.Fatal("duplicate version should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersionList 分页列表:新增多条后按下发时间倒序
|
||||
func TestAppVersionList(t *testing.T) {
|
||||
base := uniqueVersion()
|
||||
for _, v := range []string{base, base + "1"} {
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
File: newApkUpload(t, "observer-"+v+".apk", "apk"),
|
||||
}); err != nil {
|
||||
t.Fatalf("add version %s: %v", v, err)
|
||||
}
|
||||
}
|
||||
res, err := AppVersion.AdminListVersions(ctx(), &dto.AdminAppVersionListReq{Size: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list versions: %v", err)
|
||||
}
|
||||
if res.Total < 2 {
|
||||
t.Fatalf("total = %d, want >= 2", res.Total)
|
||||
}
|
||||
// 倒序:最新一条为 base+1
|
||||
if res.List[0].Version != base+"1" {
|
||||
t.Fatalf("latest = %s, want %s", res.List[0].Version, base+"1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersionDeleteLatest 删除最新版本:记录删除 + APK 文件联动删除
|
||||
func TestAppVersionDeleteLatest(t *testing.T) {
|
||||
ver := uniqueVersion()
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
File: newApkUpload(t, "observer-"+ver+".apk", "apk-v1"),
|
||||
}); err != nil {
|
||||
t.Fatalf("add version: %v", err)
|
||||
}
|
||||
latest, err := dao.AppVersion.Latest(ctx())
|
||||
if err != nil || latest == nil {
|
||||
t.Fatalf("latest = %+v, %v", latest, err)
|
||||
}
|
||||
if _, err := AppVersion.AdminDeleteVersion(ctx(), &dto.AdminAppVersionDeleteReq{Id: latest.Id}); err != nil {
|
||||
t.Fatalf("delete version: %v", err)
|
||||
}
|
||||
// 记录已删(测试库有历史残留记录,update 不再返回被删版本即可)
|
||||
gone, err := dao.AppVersion.GetById(ctx(), latest.Id)
|
||||
if err != nil || gone != nil {
|
||||
t.Fatalf("record should be deleted: %+v, %v", gone, err)
|
||||
}
|
||||
res, err := AppVersion.GetUpdate(ctx(), &dto.AppUpdateReq{})
|
||||
if err != nil {
|
||||
t.Fatalf("get update: %v", err)
|
||||
}
|
||||
if res.Version == ver {
|
||||
t.Fatalf("update still returns deleted version %s", ver)
|
||||
}
|
||||
// APK 文件联动删除(下载 404)
|
||||
if _, err := os.Stat(common.ApkFilePath(ctx())); !os.IsNotExist(err) {
|
||||
t.Fatalf("apk file should be removed, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersionDeleteHistoric 删除历史版本:仅删记录,APK 文件保留(仍对应最新版本)
|
||||
func TestAppVersionDeleteHistoric(t *testing.T) {
|
||||
ver1 := uniqueVersion()
|
||||
ver2 := uniqueVersion() + "1"
|
||||
for _, v := range []string{ver1, ver2} {
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
File: newApkUpload(t, "observer-"+v+".apk", "apk"),
|
||||
}); err != nil {
|
||||
t.Fatalf("add version %s: %v", v, err)
|
||||
}
|
||||
}
|
||||
old, err := dao.AppVersion.GetByVersion(ctx(), ver1)
|
||||
if err != nil || old == nil {
|
||||
t.Fatalf("get by version: %+v, %v", old, err)
|
||||
}
|
||||
if _, err := AppVersion.AdminDeleteVersion(ctx(), &dto.AdminAppVersionDeleteReq{Id: old.Id}); err != nil {
|
||||
t.Fatalf("delete version: %v", err)
|
||||
}
|
||||
// 最新记录仍是 ver2
|
||||
res, err := AppVersion.GetUpdate(ctx(), &dto.AppUpdateReq{})
|
||||
if err != nil {
|
||||
t.Fatalf("get update: %v", err)
|
||||
}
|
||||
if res.Version != ver2 {
|
||||
t.Fatalf("update = %+v, want %s", res, ver2)
|
||||
}
|
||||
// APK 文件保留
|
||||
if _, err := os.Stat(common.ApkFilePath(ctx())); err != nil {
|
||||
t.Fatalf("apk file should remain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersionDeleteNotFound 删除不存在的版本报错
|
||||
func TestAppVersionDeleteNotFound(t *testing.T) {
|
||||
if _, err := AppVersion.AdminDeleteVersion(ctx(), &dto.AdminAppVersionDeleteReq{Id: 999999999}); err == nil {
|
||||
t.Fatal("delete missing version should fail")
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"math"
|
||||
"math/bits"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -75,8 +77,10 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
|
||||
}
|
||||
// 已发布过的训练不再返回发布按钮(model_version.training_id 反查)
|
||||
trainingIds := make([]int64, 0, len(latest))
|
||||
for _, t := range latest {
|
||||
trainingIds = append(trainingIds, t.Id)
|
||||
for _, ts := range latest {
|
||||
for _, t := range ts {
|
||||
trainingIds = append(trainingIds, t.Id)
|
||||
}
|
||||
}
|
||||
published, err := dao.ModelVersion.PublishedByTrainingIds(ctx, trainingIds)
|
||||
if err != nil {
|
||||
@@ -103,16 +107,21 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
|
||||
GenOcclusions: v.GenOcclusions,
|
||||
GenClasses: v.GenClasses,
|
||||
SortOrder: v.SortOrder,
|
||||
Trains: []*dto.AdminDatasetTrainBrief{},
|
||||
CreatedAt: v.CreatedAt,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
}
|
||||
if t, ok := latest[v.Id]; ok {
|
||||
item.TrainingId = t.Id
|
||||
item.TrainingStatus = t.Status
|
||||
item.TrainingError = t.Error
|
||||
item.TrainingPublished = published[t.Id]
|
||||
item.TrainingCurrentEpoch = t.CurrentEpoch
|
||||
item.TrainingTotalEpochs = t.TotalEpochs
|
||||
for _, t := range latest[v.Id] {
|
||||
item.Trains = append(item.Trains, &dto.AdminDatasetTrainBrief{
|
||||
TrainingId: t.Id,
|
||||
Variant: t.Variant,
|
||||
Status: t.Status,
|
||||
Error: t.Error,
|
||||
Published: published[t.Id],
|
||||
CurrentEpoch: t.CurrentEpoch,
|
||||
TotalEpochs: t.TotalEpochs,
|
||||
EtaMinutes: trainingEtaMinutes(t),
|
||||
})
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
@@ -232,11 +241,18 @@ func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, n
|
||||
}
|
||||
|
||||
// migrateModelFile 模型文件随命名变更迁移(基名 = 文件名前缀,空回退数据集名):
|
||||
// 双档位(2026-09-03)s/n 两文件一并迁移(n 档文件名带 _n 后缀);
|
||||
// 旧文件不存在或新旧路径相同(前缀未变且非空)直接跳过;失败仅记日志不阻断业务——
|
||||
// 模型文件为付费训练产物,保留旧名总比删除好(下次训练直写新名覆盖)。
|
||||
func migrateModelFile(ctx context.Context, oldName, oldPrefix, newName, newPrefix string) {
|
||||
oldPath := common.TrainingModelPath(ctx, modelFileName(oldName, oldPrefix))
|
||||
newPath := common.TrainingModelPath(ctx, modelFileName(newName, newPrefix))
|
||||
migrateModelFileVariant(ctx, oldName, oldPrefix, newName, newPrefix, "")
|
||||
migrateModelFileVariant(ctx, oldName, oldPrefix, newName, newPrefix, consts.TrainingVariantNFileSuffix)
|
||||
}
|
||||
|
||||
// migrateModelFileVariant 迁移单档位文件(variantSuffix 空=s 档,_n=n 档)
|
||||
func migrateModelFileVariant(ctx context.Context, oldName, oldPrefix, newName, newPrefix, variantSuffix string) {
|
||||
oldPath := common.TrainingModelPath(ctx, modelFileName(oldName, oldPrefix)+variantSuffix)
|
||||
newPath := common.TrainingModelPath(ctx, modelFileName(newName, newPrefix)+variantSuffix)
|
||||
if oldPath == newPath {
|
||||
return
|
||||
}
|
||||
@@ -484,6 +500,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 {
|
||||
@@ -556,6 +575,197 @@ 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) {
|
||||
lib, err := s.ensureNegativeLibrary(ctx)
|
||||
if err != nil {
|
||||
return nil, 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
|
||||
}
|
||||
|
||||
// ensureNegativeLibrary 懒创建并返回负样本库数据集(Serial 内防并发重建;幂等)
|
||||
func (s *datasetService) ensureNegativeLibrary(ctx context.Context) (*entity.Dataset, 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, "创建负样本库目录失败")
|
||||
}
|
||||
return lib, nil
|
||||
}
|
||||
|
||||
// AdminNegativeGenerate 负样本批量生成(异步任务):提示词从 config.yml 内置场景池按序循环组装
|
||||
// (negativeScenes 空场景 + negativeHumans 人物/衣物——动物不进统一负样本,它们将来可能是正式识别目标)。
|
||||
// 空场景图逐张过 RF-DETR 全图检测,**有检出即剔除不入库**(图里有像目标的东西就不能当背景喂训练);
|
||||
// 人物图不做空检(检出人是预期,检测模型不区分目标物种)。
|
||||
// 检测服务不可达时 fail-open 保留该图并记日志(付费资产原则:不因质检通道故障丢弃已生成图)。
|
||||
// 进度复用 GET /datasets/gen-task 按 datasetId 轮询(rejected 列为剔除计数)。
|
||||
func (s *datasetService) AdminNegativeGenerate(ctx context.Context, req *dto.AdminNegativeGenerateReq) (*dto.AdminNegativeGenerateRes, error) {
|
||||
provider := common.ImageGen(ctx)
|
||||
if provider == nil {
|
||||
return nil, gerror.NewCode(common.CodeImageGenNotConfigured)
|
||||
}
|
||||
tpl := g.Cfg().MustGet(ctx, "imageGen.negativePromptTemplate").String()
|
||||
scenes := g.Cfg().MustGet(ctx, "imageGen.negativeScenes").Strings()
|
||||
humanTpl := g.Cfg().MustGet(ctx, "imageGen.negativeHumanPromptTemplate").String()
|
||||
humans := g.Cfg().MustGet(ctx, "imageGen.negativeHumans").Strings()
|
||||
if tpl == "" || len(scenes) == 0 {
|
||||
return nil, gerror.New("未配置负样本生成模板/场景池(imageGen.negativePromptTemplate/negativeScenes)")
|
||||
}
|
||||
lib, err := s.ensureNegativeLibrary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := gtime.Now()
|
||||
var taskId int64
|
||||
if err := common.Serial().Submit(ctx, func() error {
|
||||
running, err := dao.GenTask.GetRunningByDataset(ctx, lib.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if running != nil {
|
||||
return gerror.NewCode(common.CodeGenTaskRunning)
|
||||
}
|
||||
taskId, err = dao.GenTask.Insert(ctx, &entity.GenTask{
|
||||
DatasetId: lib.Id,
|
||||
Status: consts.GenTaskRunning,
|
||||
Total: req.Count,
|
||||
CreatedAt: now,
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.runNegativeGenTask(taskId, lib, req.Count, tpl, scenes, humanTpl, humans, provider, now)
|
||||
return &dto.AdminNegativeGenerateRes{TaskId: taskId, Total: req.Count}, nil
|
||||
}
|
||||
|
||||
// runNegativeGenTask 负样本生成执行协程(生命周期任务,同 runGenTask 模式):池内逐张生成 →
|
||||
// 转 jpg → RF-DETR 空检 → 合格才落盘入库。done 计已处理张数(含剔除),rejected 计剔除张数。
|
||||
func (s *datasetService) runNegativeGenTask(taskId int64, lib *entity.Dataset, count int, tpl string, scenes []string, humanTpl string, humans []string, provider common.ImageGenProvider, now *gtime.Time) {
|
||||
bgCtx := context.Background()
|
||||
go func() {
|
||||
dir := common.DatasetImagesDir(bgCtx, lib.Name)
|
||||
qa := common.LocalAiClient(bgCtx) // 空检通道(可能为 nil:fail-open 全保留并记日志)
|
||||
combined := make([]string, 0, len(scenes)+len(humans))
|
||||
combined = append(combined, scenes...)
|
||||
humanStart := len(combined)
|
||||
combined = append(combined, humans...)
|
||||
generated := 0
|
||||
rejected := 0
|
||||
processed := 0
|
||||
for i := 0; i < count; i++ {
|
||||
// 按序循环取池:每个短语均匀覆盖,不漏条目
|
||||
idx := i % len(combined)
|
||||
isHuman := idx >= humanStart // 人物/衣物:主体是画面意图本身,且永远不会是识别目标
|
||||
entry := combined[idx]
|
||||
prompt := tpl
|
||||
if isHuman && humanTpl != "" {
|
||||
prompt = humanTpl // 否定句只排除目标物种
|
||||
}
|
||||
prompt = strings.Replace(prompt, "{scene}", entry, 1)
|
||||
var data []byte
|
||||
err := common.GenTaskPoolInstance().Submit(bgCtx, func(ctx context.Context) error {
|
||||
d, e := provider.Generate(ctx, prompt, "704x1248")
|
||||
data = d
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
_ = dao.GenTask.Finish(bgCtx, taskId, fmt.Sprintf("第 %d 张生成失败(已入库 %d 张): %v", i+1, generated, err))
|
||||
return
|
||||
}
|
||||
data, jErr := ensureJpeg(data)
|
||||
if jErr != nil {
|
||||
_ = dao.GenTask.Finish(bgCtx, taskId, fmt.Sprintf("第 %d 张转 jpg 失败(已入库 %d 张): %v", i+1, generated, jErr))
|
||||
return
|
||||
}
|
||||
// 空检质检:RF-DETR 全图检测有候选即剔除(负样本必须全空);检测失败 fail-open 保留。
|
||||
// 仅对空场景生效——人物图检出人是预期,不做空检
|
||||
if qa != nil && !isHuman {
|
||||
w, h := imageSize(data)
|
||||
if w > 0 && h > 0 {
|
||||
dets, dErr := qa.Detect(bgCtx, data, "image/jpeg", w, h)
|
||||
if dErr != nil {
|
||||
g.Log().Warningf(bgCtx, "负样本空检失败(fail-open 保留): %v", dErr)
|
||||
} else if len(dets) > 0 {
|
||||
rejected++
|
||||
processed++
|
||||
_ = common.Serial().Submit(bgCtx, func() error {
|
||||
if rErr := dao.GenTask.UpdateRejected(bgCtx, taskId, rejected); rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
return dao.GenTask.UpdateProgress(bgCtx, taskId, processed)
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
filename := fmt.Sprintf("neg_%s_%04d.jpg", now.Time.Format("20060102150405"), i)
|
||||
if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil {
|
||||
_ = dao.GenTask.Finish(bgCtx, taskId, fmt.Sprintf("第 %d 张保存失败(已入库 %d 张): %v", i+1, generated, err))
|
||||
return
|
||||
}
|
||||
if insErr := common.Serial().Submit(bgCtx, func() error {
|
||||
_, err := dao.DatasetImage.Insert(bgCtx, &entity.DatasetImage{
|
||||
DatasetId: lib.Id,
|
||||
Filename: filename,
|
||||
Source: "ai",
|
||||
CreatedAt: now,
|
||||
})
|
||||
return err
|
||||
}); insErr != nil {
|
||||
_ = dao.GenTask.Finish(bgCtx, taskId, fmt.Sprintf("第 %d 张入库失败(已入库 %d 张): %v", i+1, generated, insErr))
|
||||
return
|
||||
}
|
||||
generated++
|
||||
processed++
|
||||
_ = common.Serial().Submit(bgCtx, func() error {
|
||||
return dao.GenTask.UpdateProgress(bgCtx, taskId, processed)
|
||||
})
|
||||
}
|
||||
if err := common.Serial().Submit(bgCtx, func() error {
|
||||
if err := dao.GenTask.UpdateRejected(bgCtx, taskId, rejected); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.Dataset.UpdateCounters(bgCtx, lib.Id, int64(generated), -1, "")
|
||||
}); err != nil {
|
||||
g.Log().Errorf(bgCtx, "负样本生成任务 %d 收尾更新失败: %+v", taskId, err)
|
||||
}
|
||||
_ = dao.GenTask.Finish(bgCtx, taskId, "")
|
||||
}()
|
||||
}
|
||||
|
||||
// ensureJpeg 模型生成的图片统一转 jpg:解码校验 + 非 jpeg(png/webp 等)转 jpeg(Quality 92)。
|
||||
// 生成服务返回格式不可控,扩展名统一 .jpg,内容须与扩展名一致(RF-DETR 按扩展名推断 mime 提交)。
|
||||
func ensureJpeg(data []byte) ([]byte, error) {
|
||||
@@ -629,17 +839,12 @@ func (s *datasetService) genCoverWithImageGen(ctx context.Context, datasetId int
|
||||
if species == "" {
|
||||
species = datasetName
|
||||
}
|
||||
// 显存互斥:z-image 生成任务占显存时跳过(同步等待不现实)
|
||||
running, err := dao.GenTask.ListRunning(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(running) > 0 {
|
||||
return "", gerror.New("生成任务进行中(z-image 占用显存),未生成封面")
|
||||
}
|
||||
// 生成任务进行中直接调用:封面与任务图片同为 z-image 单张请求,由 local-ai 服务端排队串行,
|
||||
// 无需整任务互斥(曾按 ListRunning 整任务拒绝,2026-09-02 放开)
|
||||
prompt := fmt.Sprintf("野外实拍照片:一只雄性%s和一只雌性%s并排站立在开阔的自然栖息地中,"+
|
||||
"雄雌各一只清晰可见,16:9 横幅构图,真实照片质感,光线自然,画面清晰美观", species, species)
|
||||
genCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
||||
// 180s:放开互斥后须覆盖 local-ai 排队等待(排队 ≤ 当前一张 ~40s + 自身生成),曾 120s 仅直发余量
|
||||
genCtx, cancel := context.WithTimeout(ctx, 180*time.Second)
|
||||
defer cancel()
|
||||
// 直接指定封面统一尺寸 1248x704(z-image 接受 16 整除尺寸);resizeCover 兜底统一规格
|
||||
data, err := provider.Generate(genCtx, prompt, "1248x704")
|
||||
@@ -701,21 +906,15 @@ func (s *datasetService) AdminGenPools(ctx context.Context, req *dto.AdminGenPoo
|
||||
|
||||
// genPoolsWithVLM 调 qwen3.6-35b-a3b 生成数据集生成参数池(单物种规则:物种=数据集名,
|
||||
// VLM 只生成轮廓色/站高/场景/动作/遮挡池)。
|
||||
// 无 localAi 配置/显存被生成任务占用/JSON 非法/校验不过 → 返回 error(调用方决定是否阻断);
|
||||
// 无 localAi 配置/JSON 非法/校验不过 → 返回 error(调用方决定是否阻断);
|
||||
// 生成任务进行中直接调用——VLM 与 z-image 同机共存,请求由 local-ai 服务端排队
|
||||
// (曾按 ListRunning 整任务拒绝,2026-09-02 放开);
|
||||
// 校验通过返回 entity.Dataset(仅 7 个池字段,其余空)。
|
||||
// 纯文本场景下 llama.cpp mmproj 需图片输入,传 64x64 纯灰占位图(提示词声明忽略图片)。
|
||||
func (s *datasetService) genPoolsWithVLM(ctx context.Context, datasetName string) (*entity.Dataset, error) {
|
||||
if common.LocalAiClient(ctx) == nil {
|
||||
return nil, nil // 未配置标注服务:不生成也不报错(池为空,生成图片时提示补参数)
|
||||
}
|
||||
// 显存互斥:z-image 生成任务占显存时跳过(同步等待不现实——生成任务可达小时级)
|
||||
running, err := dao.GenTask.ListRunning(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(running) > 0 {
|
||||
return nil, gerror.New("生成任务进行中(z-image 占用显存),暂无法生成参数,稍后重试")
|
||||
}
|
||||
target := datasetName
|
||||
prompt := fmt.Sprintf(
|
||||
"你是野外野生动物监测数据集专家。请为数据集「%s」设计 AI 生成训练图的生成参数池。目标动物:%s。\n"+
|
||||
@@ -736,7 +935,8 @@ func (s *datasetService) genPoolsWithVLM(ctx context.Context, datasetName string
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 60}); err != nil {
|
||||
return nil, gerror.Wrap(err, "构造 VLM 输入失败")
|
||||
}
|
||||
genCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
// 120s:放开互斥后排队等待(生成任务图片 ~40s/张)+ 自身推理余量(曾 90s 仅直发余量)
|
||||
genCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
||||
defer cancel()
|
||||
content, err := common.QwenVL(genCtx, buf.Bytes(), "image/jpeg", prompt)
|
||||
if err != nil {
|
||||
@@ -802,7 +1002,7 @@ func (s *datasetService) parseGenPoolsJSON(content string, species string) (*ent
|
||||
}
|
||||
|
||||
// AdminDeleteDataset 删除数据集:有 running 标注任务 / 该数据集训练进行中 / 已发布模型版本时拒绝
|
||||
// (训练产物与模型为付费资产,需先删除模型版本再删数据集)。
|
||||
// (训练产物与模型为付费资产,需先删除模型版本再删数据集);排队训练任务直接置 failed。
|
||||
// 删除 = 删图片/模型目录 + 删记录(标注随图片行删除,Serial 单写者串行)。
|
||||
func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteReq) (*dto.AdminDatasetDeleteRes, error) {
|
||||
var name, modelName string
|
||||
@@ -814,6 +1014,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 正在扫该数据集图片)
|
||||
@@ -828,6 +1031,10 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD
|
||||
} else if t != nil {
|
||||
return gerror.New("该数据集有训练任务进行中,无法删除")
|
||||
}
|
||||
// 排队任务引用将删的目录,晋级必失败:直接置 failed 带出原因(双档位串行队列 2026-09-03 起支持排队)
|
||||
if err := dao.Training.FailQueuedByDataset(ctx, d.Id, "数据集已删除,排队训练取消"); err != nil {
|
||||
return err
|
||||
}
|
||||
// 模型版本记录随数据集级联删除(管理端无模型管理界面,2026-08-26 决策;
|
||||
// 若需保留已下发模型,删除数据集前先确认客户端不再需要)
|
||||
if err := dao.ModelVersion.DeleteByDataset(ctx, d.Id); err != nil {
|
||||
@@ -841,8 +1048,12 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 文件清理(图片目录 + 当前生效模型文件;删除失败仅记日志,记录已删)
|
||||
paths := []string{common.DatasetImagesDir(ctx, name), common.TrainingModelPath(ctx, modelName)}
|
||||
// 文件清理(图片目录 + 当前生效模型文件,s/n 双档位文件一并清理;删除失败仅记日志,记录已删)
|
||||
paths := []string{
|
||||
common.DatasetImagesDir(ctx, name),
|
||||
common.TrainingModelPath(ctx, modelName),
|
||||
common.TrainingModelPath(ctx, modelName+consts.TrainingVariantNFileSuffix),
|
||||
}
|
||||
for _, p := range paths {
|
||||
if err := os.RemoveAll(p); err != nil {
|
||||
g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", p, err)
|
||||
@@ -891,13 +1102,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 {
|
||||
@@ -930,30 +1135,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
|
||||
}
|
||||
|
||||
@@ -994,7 +1176,7 @@ const singleAnimalClause = "画面中有且只有这一只动物,没有任何
|
||||
// 空则数据集名本身——生成表单无物种输入,不随机)。
|
||||
// 物种只写名字不写羽毛细节(模型对正确名称自带外观先验);场景/动作/遮挡从数据集表池随机
|
||||
// (创建时 VLM 生成,config.yml 不再兜底;池空报错——生成图按池组装,池质量决定训练数据质量);
|
||||
// 光线走 config 通用池 lights。
|
||||
// 场景池条目兼容捆绑组(树栖物种场景与动作/遮挡联动,见 parseScenePool);光线走 config 通用池 lights。
|
||||
func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *entity.Dataset, dist int, size string) (string, error) {
|
||||
tpl := g.Cfg().MustGet(ctx, "imageGen.promptTemplate").String()
|
||||
if tpl == "" {
|
||||
@@ -1004,7 +1186,8 @@ func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *e
|
||||
if species == "" {
|
||||
species = dataset.Name
|
||||
}
|
||||
// 场景/动作/遮挡按数据集习性取池(表存储,VLM 生成;池空报错——生成图按池组装,池质量决定训练数据质量)
|
||||
// 场景/动作/遮挡按数据集习性取池(表存储,VLM 生成;池空报错——生成图按池组装,池质量决定训练数据质量)。
|
||||
// 场景池条目兼容捆绑组对象:选中捆绑组时动作/遮挡仅从组内池随机(生境联动),否则走全局池
|
||||
poolOf := func(raw string) []string {
|
||||
var pool []string
|
||||
if json.Unmarshal([]byte(raw), &pool) != nil {
|
||||
@@ -1012,9 +1195,23 @@ func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *e
|
||||
}
|
||||
return pool
|
||||
}
|
||||
scene := pickCfgList(poolOf(dataset.GenScenes))
|
||||
action := pickCfgList(poolOf(dataset.GenActions))
|
||||
occlusion := pickCfgList(poolOf(dataset.GenOcclusions))
|
||||
scene, action, occlusion := "", "", ""
|
||||
bundle := pickSceneEntry(parseScenePool(dataset.GenScenes))
|
||||
if bundle != nil {
|
||||
scene = bundle.Scene
|
||||
if len(bundle.Actions) > 0 {
|
||||
action = pickCfgList(bundle.Actions)
|
||||
}
|
||||
if len(bundle.Occlusions) > 0 {
|
||||
occlusion = pickCfgList(bundle.Occlusions)
|
||||
}
|
||||
}
|
||||
if action == "" {
|
||||
action = pickCfgList(poolOf(dataset.GenActions))
|
||||
}
|
||||
if occlusion == "" {
|
||||
occlusion = pickCfgList(poolOf(dataset.GenOcclusions))
|
||||
}
|
||||
if scene == "" || action == "" || occlusion == "" {
|
||||
return "", gerror.New("数据集「" + dataset.Name + "」未配置生成参数池(场景/动作/遮挡),请在编辑数据集生成或手填提示词")
|
||||
}
|
||||
@@ -1026,6 +1223,12 @@ func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *e
|
||||
}
|
||||
// 距离描述从表单固定值生成(25 → "25米外")
|
||||
distWord := fmt.Sprintf("%d米外", dist)
|
||||
// 位置锚 {position}:默认地平线锚(地面/水面场景,目标在地平线附近语义成立);
|
||||
// 捆绑组 position 覆盖(树栖场景目标须锚到枝头/树冠,否则「地平线附近有且仅有斑鸠」仍落在地面)
|
||||
position := "在" + distWord + "的地平线附近"
|
||||
if bundle != nil && strings.TrimSpace(bundle.Position) != "" {
|
||||
position = strings.ReplaceAll(bundle.Position, "{distanceWord}", distWord)
|
||||
}
|
||||
// 尺寸提示:按物理公式把表单距离换算成目标在图中的像素高与画面占比
|
||||
sizeHint, pctVal, sErr := s.buildSizeHint(ctx, dataset, dist, size)
|
||||
if sErr != nil {
|
||||
@@ -1038,7 +1241,8 @@ func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *e
|
||||
tpl = tiny
|
||||
}
|
||||
}
|
||||
// 每张固定 1 个目标(数量词固定,animal_count 列同为 1 供自动标注裁剪)
|
||||
// 每张固定 1 个目标(数量词固定,animal_count 列同为 1,仅信息性存储;
|
||||
// 生成图可能实际含多个目标,预标检出超 3 个按置信度取前 3,2026-09-02)
|
||||
countWord := "1只"
|
||||
// 性别随机(50/50):两性体型与外观差异大(雉鸡雄艳雌褐、野鸭雄艳雌素等),
|
||||
// 每张随机让训练数据覆盖两性形态
|
||||
@@ -1051,6 +1255,7 @@ func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *e
|
||||
"{scene}": scene, "{species}": sexWord + species, "{count}": countWord,
|
||||
"{action}": action, "{occlusion}": occlusion, "{light}": light,
|
||||
"{distanceWord}": distWord, "{sizeHint}": sizeHint, "{tone}": tone,
|
||||
"{position}": position,
|
||||
} {
|
||||
out = strings.ReplaceAll(out, k, v)
|
||||
}
|
||||
@@ -1133,6 +1338,66 @@ func pickCfgList(pool []string) string {
|
||||
return pool[rand.Intn(len(pool))]
|
||||
}
|
||||
|
||||
// genSceneEntry 场景池条目:字符串条目解析为仅含 Scene 的本结构(weight=1,动作/遮挡走全局池);
|
||||
// 捆绑组对象 {scene, weight, actions, occlusions, position} 的动作/遮挡与场景联动(2026-09-04,
|
||||
// 斑鸠树栖 80%——三池独立随机会拼出「树上场景+地面动作/草丛遮挡」矛盾提示词,见技术设计.md);
|
||||
// position 覆盖默认位置锚「在N米外的地平线附近」(树栖目标锚地平线=语义落地,须锚到枝头,
|
||||
// 内可用 {distanceWord} 占位)
|
||||
type genSceneEntry struct {
|
||||
Scene string `json:"scene"`
|
||||
Weight int `json:"weight"`
|
||||
Actions []string `json:"actions"`
|
||||
Occlusions []string `json:"occlusions"`
|
||||
Position string `json:"position"`
|
||||
}
|
||||
|
||||
// parseScenePool 解析场景池 JSON 数组:条目兼容字符串与捆绑组对象,非法条目跳过
|
||||
func parseScenePool(raw string) []genSceneEntry {
|
||||
var items []json.RawMessage
|
||||
if json.Unmarshal([]byte(raw), &items) != nil {
|
||||
return nil
|
||||
}
|
||||
entries := make([]genSceneEntry, 0, len(items))
|
||||
for _, it := range items {
|
||||
var s string
|
||||
if json.Unmarshal(it, &s) == nil {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue // 空串/null 条目(Unmarshal null 入 string 成功但为空)不进池
|
||||
}
|
||||
entries = append(entries, genSceneEntry{Scene: s, Weight: 1})
|
||||
continue
|
||||
}
|
||||
var b genSceneEntry
|
||||
if json.Unmarshal(it, &b) != nil || strings.TrimSpace(b.Scene) == "" {
|
||||
continue
|
||||
}
|
||||
if b.Weight < 1 {
|
||||
b.Weight = 1
|
||||
}
|
||||
entries = append(entries, b)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// pickSceneEntry 场景池加权随机一项(weight 缺省 1;空池返回 nil)
|
||||
func pickSceneEntry(pool []genSceneEntry) *genSceneEntry {
|
||||
total := 0
|
||||
for i := range pool {
|
||||
total += pool[i].Weight
|
||||
}
|
||||
if total <= 0 {
|
||||
return nil
|
||||
}
|
||||
n := rand.Intn(total)
|
||||
for i := range pool {
|
||||
n -= pool[i].Weight
|
||||
if n < 0 {
|
||||
return &pool[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminGenerateImages AI 生成图片(异步任务):校验通过后插 gen_task 立即返回 TaskId,
|
||||
// 后台协程逐张生成(不设调用超时,失败由 provider 返回错误决定),进度落库供前端轮询;
|
||||
// 完成(或部分失败)后对本次新增图触发自动标注(付费资产,失败保留已生成图不删除)。
|
||||
@@ -1148,10 +1413,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, "创建图片目录失败")
|
||||
@@ -1290,7 +1555,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, "")
|
||||
@@ -1300,33 +1564,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)
|
||||
@@ -1349,6 +1596,7 @@ func (s *datasetService) AdminGenTaskQuery(ctx context.Context, req *dto.AdminGe
|
||||
Status: t.Status,
|
||||
Total: t.Total,
|
||||
Done: t.Done,
|
||||
Rejected: t.Rejected,
|
||||
Error: t.Error,
|
||||
CreatedAt: t.CreatedAt,
|
||||
FinishedAt: t.FinishedAt,
|
||||
@@ -1371,11 +1619,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),
|
||||
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
|
||||
@@ -1466,3 +1716,233 @@ func (s *datasetService) ImageFile(ctx context.Context, datasetId int64, filenam
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ---------- 数据清洗(训练集去冗余;语义与档位见技术设计.md「数据清洗」/consts.CleanBuckets) ----------
|
||||
|
||||
// cleanImageStat 参与统计的单图(labels_json 含有效框即入统计,2026-09-07 起不区分确认/疑似类别:
|
||||
// 面积最大者定档,其框高为展示主尺寸;档内保留取舍看 minHPct——小目标样本稀缺,先保最小目标更小的图)
|
||||
type cleanImageStat struct {
|
||||
id int64
|
||||
filename string
|
||||
source string
|
||||
area float64 // 最大框面积 w*h(找最大框用)
|
||||
hPct float64 // 该框高占图高百分比(定档与主尺寸展示)
|
||||
minHPct float64 // 图内最小框占比(优先保留依据;单目标图 = hPct)
|
||||
targetCnt int // 框总数
|
||||
bucket int
|
||||
}
|
||||
|
||||
// AdminCleanPreview 数据清洗预览:桶分布 + 超配桶候选清单 + 已排除清单。
|
||||
// 候选生成不随机——超配桶内先按 minHPct 升序扫描(小目标样本稀缺先占保留名额,同尺寸按 id 稳定),
|
||||
// 再整图 dHash 贪心:与已保留图最小汉明距离 > 阈值才保留(连拍帧哈希近距只留首帧),
|
||||
// 留够配额即停,其余进候选;文件缺失/解码失败无法比较,宁可保留。
|
||||
func (s *datasetService) AdminCleanPreview(ctx context.Context, req *dto.AdminDatasetCleanPreviewReq) (*dto.AdminDatasetCleanPreviewRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
quotas := make([]int, len(consts.CleanBuckets))
|
||||
for i, b := range consts.CleanBuckets {
|
||||
quotas[i] = b.Quota
|
||||
}
|
||||
if len(req.Quotas) > 0 {
|
||||
if len(req.Quotas) != len(consts.CleanBuckets) {
|
||||
return nil, gerror.Newf("配额数量须与档数一致(共 %d 档)", len(consts.CleanBuckets))
|
||||
}
|
||||
for i, q := range req.Quotas {
|
||||
if q < 0 {
|
||||
return nil, gerror.New("配额不能为负数")
|
||||
}
|
||||
if i == 0 {
|
||||
continue // <2% 极远档豁免,配额固定不适用
|
||||
}
|
||||
quotas[i] = q
|
||||
}
|
||||
}
|
||||
images, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 贪心扫描按 id 升序(ListByDataset 为文件名数值倒序,须重排保证确定性)
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Id < images[j].Id })
|
||||
res := &dto.AdminDatasetCleanPreviewRes{
|
||||
Buckets: make([]*dto.AdminCleanBucketRes, len(consts.CleanBuckets)),
|
||||
Candidates: []*dto.AdminCleanImage{},
|
||||
ExcludedImages: []*dto.AdminCleanImage{},
|
||||
}
|
||||
for i, b := range consts.CleanBuckets {
|
||||
res.Buckets[i] = &dto.AdminCleanBucketRes{Label: b.Label, Exempt: i == 0, Quota: quotas[i]}
|
||||
}
|
||||
// 第一遍:定档统计(含任意有效框的图,不区分确认/疑似),已排除清单全量收集(当前无框的也列出,恢复不丢)
|
||||
actives := make([][]*cleanImageStat, len(consts.CleanBuckets)) // 桶内未排除图,保持 id 升序
|
||||
for _, img := range images {
|
||||
if img.CleanExcluded == 1 {
|
||||
res.Excluded++
|
||||
}
|
||||
info := &dto.AdminCleanImage{ImageId: img.Id, Filename: img.Filename, Source: img.Source}
|
||||
var stat = cleanImageStat{id: img.Id, filename: img.Filename, source: img.Source}
|
||||
if img.LabelsJson != "" && img.LabelsJson != "[]" {
|
||||
var boxes []*dto.AdminLabelBox
|
||||
if json.Unmarshal([]byte(img.LabelsJson), &boxes) == nil {
|
||||
for _, b := range boxes {
|
||||
if b.W <= 0 || b.H <= 0 {
|
||||
continue // 无效框不入尺寸统计(确认/疑似均参与,2026-09-07 定案)
|
||||
}
|
||||
pct := b.H * 100
|
||||
stat.targetCnt++
|
||||
if stat.minHPct == 0 || pct < stat.minHPct {
|
||||
stat.minHPct = pct
|
||||
}
|
||||
if b.W*b.H > stat.area {
|
||||
stat.area = b.W * b.H
|
||||
stat.hPct = pct
|
||||
stat.bucket = cleanBucketIdx(pct)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if stat.area <= 0 { // 无任何有效框(未标注/空检出):不入档
|
||||
if img.CleanExcluded == 1 {
|
||||
res.ExcludedImages = append(res.ExcludedImages, info)
|
||||
}
|
||||
continue
|
||||
}
|
||||
info.BucketLabel = res.Buckets[stat.bucket].Label
|
||||
info.BoxHeightPct = math.Round(stat.hPct*10) / 10
|
||||
bk := res.Buckets[stat.bucket]
|
||||
bk.Total++
|
||||
res.Total++
|
||||
if img.CleanExcluded == 1 {
|
||||
bk.Excluded++
|
||||
res.ExcludedImages = append(res.ExcludedImages, info)
|
||||
} else {
|
||||
actives[stat.bucket] = append(actives[stat.bucket], &stat)
|
||||
}
|
||||
}
|
||||
// 第二遍:超配桶(未排除 > 配额)内 dHash 贪心挑保留,其余进候选
|
||||
imgDir := common.DatasetImagesDir(ctx, dataset.Name)
|
||||
for i, bk := range res.Buckets {
|
||||
if bk.Exempt {
|
||||
continue // 极远档豁免:固定保留,不进候选(over 恒 0)
|
||||
}
|
||||
over := len(actives[i]) - bk.Quota
|
||||
if over <= 0 {
|
||||
continue
|
||||
}
|
||||
bk.Over = over
|
||||
// 小目标样本稀缺:同档内先保最小确认目标更小的图(同尺寸按 id 保持稳定输出)
|
||||
sort.Slice(actives[i], func(a, b int) bool {
|
||||
if actives[i][a].minHPct != actives[i][b].minHPct {
|
||||
return actives[i][a].minHPct < actives[i][b].minHPct
|
||||
}
|
||||
return actives[i][a].id < actives[i][b].id
|
||||
})
|
||||
kept, keptHashes := 0, make([]uint64, 0, bk.Quota)
|
||||
for _, a := range actives[i] {
|
||||
cand := &dto.AdminCleanImage{ImageId: a.id, Filename: a.filename, Source: a.source,
|
||||
BucketLabel: bk.Label, BoxHeightPct: math.Round(a.hPct*10) / 10,
|
||||
MinHeightPct: math.Round(a.minHPct*10) / 10, TargetCount: a.targetCnt}
|
||||
if kept >= bk.Quota { // 留够配额,其余全进候选
|
||||
res.Candidates = append(res.Candidates, cand)
|
||||
continue
|
||||
}
|
||||
h, ok := imageDHashFile(ctx, imgDir, a.filename)
|
||||
if !ok { // 文件缺失/解码失败无法比较,宁可保留
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
if minHamming(h, keptHashes) > consts.CleanHashHamming { // 与已保留图均拉开距离才留
|
||||
keptHashes = append(keptHashes, h)
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
res.Candidates = append(res.Candidates, cand)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// AdminCleanApply 数据清洗执行:批量置/清 clean_excluded(排除不删文件,只影响训练集打包;
|
||||
// 恢复 = Exclude=false 重提该批 id)。imageIds 须全属该数据集。
|
||||
func (s *datasetService) AdminCleanApply(ctx context.Context, req *dto.AdminDatasetCleanApplyReq) (*dto.AdminDatasetCleanApplyRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
ids := make([]int64, 0, len(req.ImageIds))
|
||||
seen := make(map[int64]bool, len(req.ImageIds))
|
||||
for _, id := range req.ImageIds {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
images, err := dao.DatasetImage.GetByIds(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(images) != len(ids) {
|
||||
return nil, gerror.New("部分图片不存在,请刷新后重试")
|
||||
}
|
||||
for _, img := range images {
|
||||
if img.DatasetId != dataset.Id {
|
||||
return nil, gerror.Newf("图片 %s 不属于该数据集", img.Filename)
|
||||
}
|
||||
}
|
||||
flag := 0
|
||||
if req.Exclude {
|
||||
flag = 1
|
||||
}
|
||||
if err := common.Serial().Submit(ctx, func() error {
|
||||
return dao.DatasetImage.UpdateCleanExcluded(ctx, ids, flag)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
excluded, err := dao.DatasetImage.CountExcludedByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminDatasetCleanApplyRes{Applied: len(ids), Excluded: excluded}, nil
|
||||
}
|
||||
|
||||
// imageDHashFile 读图并算整图 64 位 dHash(读失败/解码失败返回 ok=false,调用方宁可保留)
|
||||
func imageDHashFile(ctx context.Context, dir, filename string) (uint64, bool) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, filename))
|
||||
if err != nil {
|
||||
g.Log().Debugf(ctx, "数据清洗读图失败 %s: %+v", filename, err)
|
||||
return 0, false
|
||||
}
|
||||
h, err := common.ImageDHash64(data)
|
||||
if err != nil {
|
||||
g.Log().Debugf(ctx, "数据清洗解码失败 %s: %+v", filename, err)
|
||||
return 0, false
|
||||
}
|
||||
return h, true
|
||||
}
|
||||
|
||||
// minHamming 与已保留哈希集的最小汉明距离(空集 = 无可比对象,返回 65 恒过阈值)
|
||||
func minHamming(h uint64, hashes []uint64) int {
|
||||
min := 65
|
||||
for _, x := range hashes {
|
||||
if d := bits.OnesCount64(h ^ x); d < min {
|
||||
min = d
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
// cleanBucketIdx 框高占比(%)落档(下含上不含;float 边界未命中回退最大档)
|
||||
func cleanBucketIdx(pct float64) int {
|
||||
for i, b := range consts.CleanBuckets {
|
||||
if pct >= b.MinPct && pct < b.MaxPct {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(consts.CleanBuckets) - 1
|
||||
}
|
||||
|
||||