Compare commits

..
49 Commits
Author SHA1 Message Date
admin 5a97655646 1 2026-09-13 00:25:08 +08:00
admin cc42045734 1 2026-09-12 16:54:16 +08:00
admin 9e59cc4462 1 2026-09-11 20:02:07 +08:00
admin ba58f9e534 1 2026-09-11 16:06:49 +08:00
admin 9b34426c9f 1 2026-09-11 10:45:04 +08:00
admin 5c8c0f6ad7 1 2026-09-11 09:50:06 +08:00
admin 99263f89e5 1 2026-09-10 21:40:31 +08:00
admin eda9909ec8 1 2026-09-10 10:01:46 +08:00
admin 4b56d0b87b 1 2026-09-10 09:41:13 +08:00
admin c590f74b1e 1 2026-09-09 21:05:25 +08:00
admin 87ab06221c 综合训练(多物种合并模型)全链路:合并打包/独立版本序列/App 覆盖互斥 + 管理端发布入口
- 后端:/admin/trainings/combined 发起(≥2 数据集、类别重映射、防重名、负样本单份);
  model_training/model_version 加 kind+dataset_ids(迁移 v14),综合任务 dataset_id=0、
  文件基名 combined(_n)、版本序列独立;训练列表补 published 标记
- 管理端:数据训练页工具栏发起综合训练;横幅常驻进行中任务 + 每档最近一条已结束任务,
  成功未发布给「发布模型」入口(可关闭收起)
- App:目录解析 kind/datasetIds、激活覆盖互斥、自动更新退场改目标档待办横幅手动一键下载
2026-09-09 15:45:51 +08:00
admin d8853ee2ab 1 2026-09-09 13:40:26 +08:00
admin 8c2c9ad77d 1 2026-09-09 11:36:36 +08:00
admin e519e1d71d 1 2026-09-08 22:29:00 +08:00
admin daf47ca385 1 2026-09-08 15:00:32 +08:00
admin 2e31f33d0c 1 2026-09-07 15:55:03 +08:00
admin 3731583eb1 1 2026-09-07 15:52:15 +08:00
admin 06c7554e08 1 2026-09-07 15:48:31 +08:00
admin f39045a9da 1 2026-09-07 15:26:46 +08:00
admin a23afb25a6 1 2026-09-07 15:04:29 +08:00
admin c58f69cbe1 1 2026-09-07 15:04:11 +08:00
admin f4fe079703 1 2026-09-07 11:02:36 +08:00
admin b8dd969cb1 1 2026-09-07 10:11:36 +08:00
admin 47a06af0d4 1 2026-09-04 17:38:39 +08:00
admin 94aeb828ab Merge remote-tracking branch 'origin/main' 2026-09-04 17:37:32 +08:00
admin 8299432b20 1 2026-09-04 17:37:28 +08:00
admin dc8d783410 删除目录「server/admin_dist」 2026-09-04 09:36:42 +00:00
admin c3e800a630 Merge remote-tracking branch 'origin/main' 2026-09-04 17:35:55 +08:00
admin ab527bcf1e 1 2026-09-04 17:35:44 +08:00
admin 99b1dd699d 删除目录「server/workspace」 2026-09-04 09:34:55 +00:00
admin 95863de113 1 2026-09-04 17:31:51 +08:00
admin 58211aeb08 1 2026-09-04 17:28:53 +08:00
admin 5883045583 1 2026-09-04 17:23:14 +08:00
admin 30ff5b830e 1 2026-09-04 16:59:21 +08:00
admin acd2a8f412 1 2026-09-04 14:44:01 +08:00
admin 79b025e3e3 1 2026-09-04 14:28:41 +08:00
admin 434cf85182 1 2026-09-04 14:26:36 +08:00
admin b3c43ef0fe 1 2026-09-04 14:25:23 +08:00
admin 8f5810f0d4 1 2026-09-04 14:12:30 +08:00
admin 25846b133a 1 2026-09-04 11:22:06 +08:00
admin 19f16aaed3 1 2026-09-04 10:05:06 +08:00
admin 17e5b01773 1 2026-09-04 09:47:55 +08:00
admin 77d3aad6fc 1 2026-09-03 17:50:23 +08:00
admin 7bfafc7be3 1 2026-09-03 10:01:42 +08:00
adminandClaude Opus 4.7 71d042906c 管理端:训练档位选择/排队状态/按档发布(双档体系 UI)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-03 09:20:48 +08:00
adminandClaude Opus 4.7 6f8c54c31d 双档位训练体系(s高识别/n高性能 串行排队、按档发布)与 TFLite 硬件加速补丁
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-03 09:20:46 +08:00
admin bb75c69d91 1 2026-09-02 17:28:32 +08:00
admin 3597eb264c 1 2026-09-02 17:00:47 +08:00
admin 9dd90779c1 1 2026-09-02 16:28:02 +08:00
132 changed files with 10326 additions and 2927 deletions
+16 -2
View File
@@ -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/
+179
View File
@@ -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 端场景的能力输出 |
| 长期 | 识别能力平台化 | 以"数据生产线 + 热更新通道"为底座,向更多垂直识别品类复制方法论 |
+111 -18
View File
@@ -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 次)看「最高分」——
**高分无框 = 卡在过滤链路;最高分也低 = 卡在采集/推理链路**
+4 -2
View File
@@ -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 内更新安装 APKPackageInstaller 会话安装,见 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() {
// configureFlutterEngineonCreate 阶段)只注册通道与 viewFactory
// 实际 bindToLifecycle 由 Flutter 相机页 start 时触发(此时已 RESUMED
cameraChannel = CameraChannel(this, flutterEngine).also { it.register() }
// App 内更新安装 APKPackageInstaller 会话安装 + 进度回传)
installerChannel = InstallerChannel(this, flutterEngine).also { it.register() }
}
companion object {
private const val TAG = "MainActivity"
}
override fun onDestroy() {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+2
View File
@@ -57,6 +57,8 @@
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>需要使用相机进行动物实时识别</string>
+260
View File
@@ -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>());
}
/// 图片完整 URLImage.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,h0~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;
}
+4 -9
View File
@@ -70,24 +70,19 @@ class _StartupGateState extends State<StartupGate> {
Navigator.of(context).pushReplacementNamed('/terms');
return;
}
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于
// 「已装版本与已确认接受版本」中的较大者即强制更新,弹全屏阻塞页。
// APK 版本号不递增时,点过「立即更新」的 accepted 版本参与比较,
// 更新完成后再次启动不反复提示。
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于已装
// 版本即强制更新,弹全屏阻塞页 → 浏览器下载 APK 手动安装
// 2026-09-03App 内安装在小米等 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;
-16
View File
@@ -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;
+402 -137
View File
@@ -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 双平台即时生效;重启相机会重新
// initializeiOS ~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('授权相机')),
],
+77 -19
View File
@@ -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;
}
+11 -10
View File
@@ -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;
+63 -6
View File
@@ -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,
));
}
/// 替换推理 workernull = 停识别仅预览)。旧 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();
}
+101
View File
@@ -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;
}
+379 -141
View File
@@ -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 后。
// 综合(combineddatasetId=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 -3
View File
@@ -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';
+3
View File
@@ -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(综合模型其他物种
/// 索引也 >02026-09-09
bool get isSuspect => label == 'suspect';
DetectionResult copyWith({
double? score,
double? left,
+28 -3
View File
@@ -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;
}
+19
View File
@@ -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;
}
+42 -4
View File
@@ -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 delegateANE/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 {
}
}
/// 平台加速 delegateAndroid=TFLite GPUlibtensorflowlite_gpu_jni.so 已 vendor 到
/// android/app/src/main/jniLibsAAR 因 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) {
+9 -7
View File
@@ -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 {
/// 读取单像素 RGB0~255)。
/// 8888 单平面按实际字节序取通道:BGRA=[b,g,r,a]iOS 插件)、
/// RGBA=[r,g,b,a]Android 自写原生通道)——字节序写死会让 Android
/// 低分框采样到 R/B 互换的颜色(橙色环颈雉鸡身被误判成"蓝色")整批误杀;
/// 低分框采样到 R/B 互换的颜色(橙色目标躯体被误判成"蓝色")整批误杀;
/// YUVy 平面 + 4:2:0 半分辨率 U/VNV12 交错或 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));
}
/// 单独同意弹窗结果存储 keyFlutterSecureStorage,与 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;
}
}
+23 -2
View File
@@ -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) ...[
+4 -1
View File
@@ -46,7 +46,10 @@ class _TermsScreenState extends State<TermsScreen> {
五、识别结果说明
本应用的动物识别结果基于人工智能模型,可能存在误差或漏检,识别结果仅供参考,不构成科学鉴定或法律依据。
六、其他
六、反馈数据条款
当您主动使用「假目标上报」功能时,本应用仅上传您确认上报瞬间的当前画面一帧(与识别使用的画面一致,上传前已在设备本地重编码、移除照片元数据等所有附加信息)及当时的识别框信息,用于改进识别模型。该功能完全自愿,您可以拒绝使用且不影响识别等其他功能;您可联系客服删除已上传的反馈图片。
七、其他
本协议内容可能适时更新,更新后您继续使用即视为接受。''';
Future<void> _accept() async {
+338 -154
View File
@@ -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 字段 → species2026-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.jsonmeta 记录 {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 无 charsethttp 包默认按 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 无 charsethttp 包默认按 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();
}
}
+7 -4
View File
@@ -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();
}
}
-49
View File
@@ -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?,
);
});
}
}
+5 -6
View File
@@ -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),
+27 -201
View File
@@ -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)),
],
),
),
+24
View File
@@ -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:
+4 -2
View File
@@ -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 校验 sha256path_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);
});
}
+193 -43
View File
@@ -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 交错不可控,不在本层断言重启
});
}
+278 -37
View File
@@ -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);
// 重启 + 服务器目录出 v2refresh 内部 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: '清理只在网络拉取成功后执行,离线首启不得误删已下载模型');
});
}
+56 -11
View File
@@ -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);
});
}
+1 -1
View File
@@ -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,
+5
View File
@@ -0,0 +1,5 @@
# 运行时数据目录(compose 挂载卷,不进构建上下文,workspace 含 1.3GB APK 上传)
workspace/
data/
cache/
training/
+11 -4
View File
@@ -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"]
+65 -23
View File
@@ -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` 节点),并发度 1GPU 独占);训练脚本 `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 逻辑在服务端 |
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程**双 tab2026-09-07**:「数据集」tab = 数据集卡片列表(封面图/描述/图片数/已标注数/**训练状态徽标**),「负样本」tab = 负样本库图片网格(上传/删除,见技术设计.md「负样本库」——训练打包时统一混入全部物种数据集);**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片不自动标注(2026-09-04 自动标注退场)**:标注唯一入口 = 勾选图片顶栏「预标」(RF-DETR 四级漏斗检测,见技术设计.md「预标注四级漏斗」),进度条展示在页顶;**预标完成进「待审核」,人工审核通过才「已标注」**(`dataset_image.review_status` 0 未标注/1 待审核/2 已审核 三态,训练集只收已审核图);点击原图/标注图弹窗放大进入标注编辑器(画框/确认/清理,保存即视为已审核);封面(上传/生成统一 1248x704 转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`DB 存元数据 + 标注 JSON**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 |
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置)**双档位(2026-09-03)**:一次发起按档位各建一条任务——高识别档 s(基座 `training.model`、imgsz `training.imgsz`=1280/ 高性能档 n(基座 `training.modelN`=yolov8n.pt、imgsz `training.imgszN`=704),请求传 `variants:["s","n"]` 限定(省略=双档;n 档配置缺失时请求报错),epochs/batch/device 双档共用,任务带 `variant` 快照;**综合训练(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/12026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`annotate_task_id`(2026-09-07 众包下发的任务占用标记,0=未下发;下发即从「未标注」tab 消失,停用任务释放未领取图回 0)、`created_at` |
| `model_training` | 训练任务 | `id`(PK)、`name``status`(queued/running/success/failedqueued=GPU 忙排队中,2026-09-03)、`dataset_id`(综合任务=0)、`variant`(s/n 档位,default s2026-09-03)、`kind`(species/combined2026-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/ndefault s;存量行迁移为 s)、`version`(m1.0.0 递增, 同数据集 UNIQUEs/n 交替发布共用序列)、`training_id``metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256``size_bytes``is_latest`(按 (数据集,档位) 各记一条)、`kind`(species/combined2026-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-042026-09-07 图片粒度下发) | `id`(PK)、`dataset_id``name``status`(published/stopped)、`created_at`(任务图集 = `dataset_image.annotate_task_id` 占用本任务 id 的图,不落任务表) |
| `annotate_record` | 用户标注记录(2026-09-04 | `id`(PK)、`phone_num``task_id``dataset_id`(领取时冗余,详情页过滤用)、`image_id``labels_json`(提交快照)、`status`(pending 领取锁/submitted/approved/rejected)、`created_at``submitted_at``reviewed_at`UNIQUE(phone_num,image_id)partial unique(image_id) WHERE status='pending' |
| `reward_log` | 标注时长发放流水(2026-09-04 | `id`(PK)、`phone_num``minutes``task_id``granted_at`(日上限 = 自然日求和) |
| `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:91248x704)、1 雄 1 雌,物种取 gen_species 空回退数据集名):覆盖旧封面,返回 `{cover}` 新文件名;`datasetId=0` 时传 `{"name":"家鸽"}` 新建预生成(数据集未创建,封面仅落盘不写库,创建请求带 cover 回传写库);生成任务显存互斥,失败不覆盖旧封面 |
| POST | `/admin/datasets/cover/generate` | 生成数据集封面 `{"datasetId":1}`z-image 文生图:16:91248x704)、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=杀训练进程置 failedqueued=无进程直接置 failed |
| POST | `/admin/trainings/publish` | 发布为最新模型 `{"id":1,"notes":"..."}`(仅 success):读训练成功已直写的 tflite(s 档 `<文件名前缀>.tflite` / n 档 `<文件名前缀>_n.tflite`)算 sha256/大小 → 插入 `model_version`(版本号数据集内递增 m1.0.0 → m1.0.1+ 同档位旧版 `is_latest=0`s/n 互不影响) |
| GET | `/admin/label-workbench` | 标注工作台数据 `{"datasetId":1}`:数据集全部图片 + 每张标注框(`boxes`labels_json 全量)——无历史标注任务时详情页工作台的数据源 |
| POST | `/admin/annotate-tasks` | 下发标注任务 `{"datasetId":1,"name":"...","imageIds":[...]}`2026-09-07 图片粒度下发):勾选的未标注图批量占用(`annotate_task_id` 写任务 id;校验属本数据集/未标注/未占用,任一非法整单拒绝),**已下发图即从「未标注」tab 消失**;同数据集已有 published 任务时拒绝 |
| GET | `/admin/annotate-tasks` | 标注任务列表:`datasetId` 可选过滤(**无独立菜单——「标注任务」tab 内嵌数据训练详情页,任务图集网格展示勾选下发的具体图片,头部任务概要条/停用**),含数据集名/池余量/各状态记录数/状态 |
| 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":"..."}`:清冻结标记与统计基线 |
### 标注众包(AppBearer token
| 接口 | 说明 |
|---|---|
| GET | `/api/v1/annotate/tasks` 可领任务列表(published + 池余量),附我的统计:今日已得时长/上限、进度(已提交 x/10)、通过比例、冻结到期时间(冻结中不可领取) |
| POST | `/api/v1/annotate/claim` 领取 `{"taskId":1}`:分配 ≤10 张未处理过的**任务下发图**(池 = 任务占用的未标注图,2026-09-07 起任务为勾选图片集合;pending 锁定,超时惰性释放;返回图片 URL + 数据集物种名) |
| POST | `/api/v1/annotate/submit` 提交 `{"imageId":1,"boxes":[...]}`:写 labels_json + review_status=1 待审核 + 记录置 submitted;每累计 10 张即时发 30 分钟(到期时间顺延),当日累计超 2 小时不再发 |
| GET | `/api/v1/annotate/me` 我的标注统计:累计提交/通过/拒绝、通过比例、累计获得时长、今日已得、冻结状态 |
管理页面由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
### 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-aibaseUrl + 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-aibaseUrl + 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/` 并提交产物**(后端托管的是构建产物,不重新构建则线上/部署版本不生效;禁止只改源码不构建)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -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

-13
View File
@@ -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>
+111 -1
View File
@@ -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;同机位连续帧近距通常 <82026-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/failedqueued=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.tflites 档无后缀 = 旧版唯一位,向后兼容)
TrainingVariantNFileSuffix = "_n"
// 训练/模型类型(2026-09-09 综合训练):species=单物种(存量默认)| combined=多物种综合模型
// dataset_id=0、dataset_ids=覆盖数据集列表,文件基名 combined)
TrainingKindSpecies = "species"
TrainingKindCombined = "combined"
// 综合模型文件基名与训练机目录名
TrainingCombinedBase = "combined"
// 数据集状态 building → labeled → syncedsynced = 已同步训练机)
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,异场景帧通常 >122026-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},
}
+78
View File
@@ -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)
}
+47
View File
@@ -0,0 +1,47 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/net/ghttp"
"observer-server/biz/model/dto"
"observer-server/biz/service"
)
// cAnnotate 标注众包接口(登录态,手机号取自 token)
type cAnnotate struct{}
var Annotate = &cAnnotate{}
// Tasks 可领任务列表 + 我的统计
func (c *cAnnotate) Tasks(ctx context.Context, req *dto.AnnotateTaskListReq) (*dto.AnnotateTaskListRes, error) {
return service.Annotate.AppTaskList(ctx, req)
}
// Claim 领取标注图片
func (c *cAnnotate) Claim(ctx context.Context, req *dto.AnnotateClaimReq) (*dto.AnnotateClaimRes, error) {
return service.Annotate.AppClaim(ctx, req)
}
// Submit 提交标注
func (c *cAnnotate) Submit(ctx context.Context, req *dto.AnnotateSubmitReq) (*dto.AnnotateSubmitRes, error) {
return service.Annotate.AppSubmit(ctx, req)
}
// Me 我的标注统计
func (c *cAnnotate) Me(ctx context.Context, req *dto.AnnotateMeReq) (*dto.AnnotateMeRes, error) {
return service.Annotate.AppMe(ctx, req)
}
// Image 标注图片访问(直写响应体:service 校验归属返回路径,controller 输出二进制;
// 登录态鉴权,与管理端 image 端点隔离)
func (c *cAnnotate) Image(ctx context.Context, req *dto.AnnotateImageReq) (*dto.AnnotateImageRes, error) {
path, err := service.Dataset.ImageFile(ctx, req.DatasetId, req.Filename)
if err != nil {
return nil, err
}
r := ghttp.RequestFromCtx(ctx)
r.Response.ServeFile(path)
return &dto.AnnotateImageRes{}, nil
}
+18
View File
@@ -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)
}
+264
View File
@@ -0,0 +1,264 @@
package dao
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"observer-server/biz/consts"
"observer-server/biz/model/entity"
"observer-server/common"
)
// AnnotateRecord 用户标注记录表 DAOpending 即领取锁(partial unique index 同图仅一条),
// UNIQUE(phone_num, image_id) 一人一图仅一次;拒绝不删记录(低质统计与审计依据)。
type annotateRecordDao struct{}
var AnnotateRecord = &annotateRecordDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS annotate_record (
id 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")
// 领取锁:同一张图同时只允许一条 pendingpartial unique indexSQLite 原生支持)
if _, err := g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_annotate_record_image_pending
ON annotate_record (image_id) WHERE status = 'pending'`); err != nil {
panic(err)
}
}
// InsertInTx 事务内插入领取记录(锁竞争由 partial unique index 兜底,冲突即失败回滚)
func (d *annotateRecordDao) InsertInTx(ctx context.Context, tx gdb.TX, m *entity.AnnotateRecord) error {
_, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).TX(tx).Data(g.Map{
"phone_num": m.PhoneNum,
"task_id": m.TaskId,
"dataset_id": m.DatasetId,
"image_id": m.ImageId,
"labels_json": 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/rejectedpending 是未处理的锁不算)
n, err := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).
Where("phone_num", phone).
WhereIn("status", []string{consts.AnnotateRecordSubmitted, consts.AnnotateRecordApproved, consts.AnnotateRecordRejected}).
Count()
if err != nil {
return nil, err
}
stats.SubmittedTotal = int64(n)
base := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx).Where("phone_num", phone)
if reviewedSince != nil {
base = base.WhereGTE("reviewed_at", reviewedSince)
}
approved, err := base.Clone().Where("status", consts.AnnotateRecordApproved).Count()
if err != nil {
return nil, err
}
rejected, err := base.Where("status", consts.AnnotateRecordRejected).Count()
if err != nil {
return nil, err
}
stats.Approved = int64(approved)
stats.Rejected = int64(rejected)
return stats, nil
}
// PageByFilter 管理端记录分页(datasetId>0/phone 精确/taskId/status 过滤,id 倒序)
func (d *annotateRecordDao) PageByFilter(ctx context.Context, phone string, taskId, datasetId int64, status string, page, size int) ([]*entity.AnnotateRecord, int64, error) {
base := func() *gdb.Model {
m := g.DB().Model(consts.TableAnnotateRecord).Ctx(ctx)
if phone != "" {
m = m.Where("phone_num", phone)
}
if taskId > 0 {
m = m.Where("task_id", taskId)
}
if datasetId > 0 {
m = m.Where("dataset_id", datasetId)
}
if status != "" {
m = m.Where("status", status)
}
return m
}
total, err := base().Count()
if err != nil {
return nil, 0, err
}
var list []*entity.AnnotateRecord
err = base().OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
if err != nil {
if common.IsNoRows(err) {
return []*entity.AnnotateRecord{}, int64(total), nil
}
return nil, 0, err
}
return list, int64(total), nil
}
func gconvInt64Slice(vals gdb.Array) []int64 {
out := make([]int64, 0, len(vals))
for _, v := range vals {
out = append(out, v.Int64())
}
return out
}
+114
View File
@@ -0,0 +1,114 @@
package dao
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"observer-server/biz/consts"
"observer-server/biz/model/entity"
"observer-server/common"
)
// AnnotateTask 标注众包任务表 DAO:管理端下发/停用,App 端只读 published。
type annotateTaskDao struct{}
var AnnotateTask = &annotateTaskDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS annotate_task (
id 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
}
+8 -16
View File
@@ -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 列")
}
}
+45 -27
View File
@@ -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
+134 -30
View File
@@ -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_idSerial 内调用,防与并发下发竞态;IN ≤100 分批)
func (d *datasetImageDao) OccupyByTask(ctx context.Context, ids []int64, taskId int64) error {
for start := 0; start < len(ids); start += 100 {
end := start + 100
if end > len(ids) {
end = len(ids)
}
if _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
WhereIn("id", ids[start:end]).
Where("annotate_task_id", 0).
Data(g.Map{"annotate_task_id": taskId}).Update(); err != nil {
return err
}
}
return nil
}
// ReleaseByTask 停用释放:清指定图的任务占用标记回未标注(仅当仍占用本任务,防误清新任务标记;IN ≤100 分批)
func (d *datasetImageDao) ReleaseByTask(ctx context.Context, ids []int64, taskId int64) error {
for start := 0; start < len(ids); start += 100 {
end := start + 100
if end > len(ids) {
end = len(ids)
}
if _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
WhereIn("id", ids[start:end]).
Where("annotate_task_id", taskId).
Data(g.Map{"annotate_task_id": 0}).Update(); err != nil {
return err
}
}
return nil
}
// GetByIds 按 id 批量取(删除图片定位文件用)
func (d *datasetImageDao) GetByIds(ctx context.Context, ids []int64) ([]*entity.DatasetImage, error) {
var list []*entity.DatasetImage
@@ -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=22026-09-04 起已标注口径
// = 人工审核通过,labels_json 非空只是必要条件)
func (d *datasetImageDao) CountLabeledByDataset(ctx context.Context, datasetId int64) (int64, error) {
n, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
Where("dataset_id", datasetId).
Where("labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]'").
Where("review_status", consts.ReviewImageApproved).
Count()
return int64(n), err
}
@@ -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
}
+144
View File
@@ -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 而非 > 0dHash 最高位为 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
}
+15 -5
View File
@@ -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
+7 -7
View File
@@ -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,
+30 -5
View File
@@ -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).
+85 -16
View File
@@ -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 晋级排队任务为 runningCAS:仅 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
}
+20 -14
View File
@@ -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
}
+9 -9
View File
@@ -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)
+64
View File
@@ -0,0 +1,64 @@
package dao
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"observer-server/biz/consts"
)
// RewardLog 标注时长发放流水表 DAO:只增不改(审计);日上限按自然日求和。
type rewardLogDao struct{}
var RewardLog = &rewardLogDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS reward_log (
id 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
}
+204
View File
@@ -0,0 +1,204 @@
package dto
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// 标注众包与时长激励(技术设计.md「App 标注众包与时长激励」):
// 管理端 = 下发/停用任务、批量审核、用户标注记录、手动解冻;App = 任务列表/领取/提交/我的统计。
// ---------- 管理端 ----------
// AdminAnnotateTaskCreateReq 下发标注任务(2026-09-07 图片粒度:勾选的具体未标注图集合即任务图集,
// 下发即占用(annotate_task_id 写任务 id)从未标注 tab 消失,停用任务释放未领取图)
type AdminAnnotateTaskCreateReq struct {
g.Meta `path:"/annotate-tasks" method:"post" summary:"下发标注任务" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集"`
Name string `json:"name" v:"required|length:1,50" dc:"任务名"`
ImageIds []int64 `json:"imageIds" v:"required|min-length:1" dc:"勾选的未标注图片 id 列表"`
}
type AdminAnnotateTaskCreateRes struct {
Id int64 `json:"id"`
}
// AdminAnnotateTaskListReq 标注任务列表(datasetId 缺省=全部;详情页 tab 内嵌时按数据集过滤)
type AdminAnnotateTaskListReq struct {
g.Meta `path:"/annotate-tasks" method:"get" summary:"标注任务列表" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"integer|min:0" dc:"数据集过滤,0=全部"`
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
// AdminAnnotateTaskItem 任务条目:池余量 + 各状态记录数
type AdminAnnotateTaskItem struct {
Id int64 `json:"id"`
DatasetId int64 `json:"datasetId"`
DatasetName string `json:"datasetName"`
Name string `json:"name"`
Status string `json:"status"`
PoolRemain int64 `json:"poolRemain"`
Pending int64 `json:"pending"`
Submitted int64 `json:"submitted"`
Approved int64 `json:"approved"`
Rejected int64 `json:"rejected"`
CreatedAt *gtime.Time `json:"createdAt"`
}
type AdminAnnotateTaskListRes struct {
Total int64 `json:"total"`
List []*AdminAnnotateTaskItem `json:"list"`
}
// AdminAnnotateTaskStopReq 停用任务
type AdminAnnotateTaskStopReq struct {
g.Meta `path:"/annotate-tasks/stop" method:"post" summary:"停用标注任务" tags:"管理端"`
Id int64 `json:"id" v:"required|min:1" dc:"任务 id"`
}
type AdminAnnotateTaskStopRes struct{}
// AdminAnnotateReviewReq 批量审核:通过 → review_status=2;拒绝 → 清标注回未标注池 +
// 对应 submitted 记录置 rejected(触发低质统计/冻结判定)
type AdminAnnotateReviewReq struct {
g.Meta `path:"/annotate-review" method:"post" summary:"批量审核标注" tags:"管理端"`
ImageIds []int64 `json:"imageIds" v:"required" dc:"图片 id 列表"`
Approve bool `json:"approve" dc:"true=通过 false=拒绝"`
}
type AdminAnnotateReviewRes struct {
Affected int64 `json:"affected"` // 实际变更的图片数(过滤掉不存在/非待审核)
FrozenPhones []string `json:"frozenPhones"` // 本次触发冻结的用户(空=无)
}
// AdminAnnotateRecordListReq 用户标注记录分页(每用户每张图的处理明细;datasetId 缺省=全部)
type AdminAnnotateRecordListReq struct {
g.Meta `path:"/annotate-records" method:"get" summary:"用户标注记录列表" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"integer|min:0" dc:"数据集过滤,0=全部"`
Phone string `json:"phone" v:"length:0,20" dc:"手机号精确过滤"`
TaskId int64 `json:"taskId" v:"integer|min:0" dc:"任务过滤,0=全部"`
Status string `json:"status" v:"in:,pending,submitted,approved,rejected" dc:"状态过滤,空=全部"`
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
// AdminAnnotateRecordItem 记录条目(附图片文件名与数据集名,内存组装)
type AdminAnnotateRecordItem struct {
Id int64 `json:"id"`
PhoneNum string `json:"phoneNum"`
TaskId int64 `json:"taskId"`
DatasetName string `json:"datasetName"`
ImageId int64 `json:"imageId"`
Filename string `json:"filename"`
ImageUrl string `json:"imageUrl"`
Status string `json:"status"`
Boxes []*AdminLabelBox `json:"boxes"`
CreatedAt *gtime.Time `json:"createdAt"`
SubmittedAt *gtime.Time `json:"submittedAt"`
ReviewedAt *gtime.Time `json:"reviewedAt"`
}
type AdminAnnotateRecordListRes struct {
Total int64 `json:"total"`
List []*AdminAnnotateRecordItem `json:"list"`
}
// AdminAnnotateUnfreezeReq 手动解冻
type AdminAnnotateUnfreezeReq struct {
g.Meta `path:"/annotate-unfreeze" method:"post" summary:"手动解冻标注资格" tags:"管理端"`
Phone string `json:"phone" v:"required|length:6,20" dc:"手机号"`
}
type AdminAnnotateUnfreezeRes struct{}
// ---------- AppBearer token,手机号取登录态) ----------
// AnnotateStats 我的标注统计(任务列表/我的页共用)
type AnnotateStats struct {
SubmittedTotal int64 `json:"submittedTotal"` // 累计有效提交
Approved int64 `json:"approved"` // 审核通过
Rejected int64 `json:"rejected"` // 审核拒绝
ApproveRatio float64 `json:"approveRatio"` // 当前统计基线内通过比例(0~1,无样本=1)
ProgressDone int64 `json:"progressDone"` // 距下次奖励进度(累计提交 mod perImages)
RewardPerImages int `json:"rewardPerImages"` // 每次奖励所需提交数
RewardMinutes int `json:"rewardMinutes"` // 每次奖励分钟数
TotalEarnedMinutes int `json:"totalEarnedMinutes"` // 累计获得分钟数
TodayEarnedMinutes int `json:"todayEarnedMinutes"` // 今日已得(自然日)
DailyCapMinutes int `json:"dailyCapMinutes"` // 每日上限
FrozenUntil *gtime.Time `json:"frozenUntil"` // 冻结到期(nil=未冻结)
ExpiresAt *gtime.Time `json:"expiresAt"` // 当前授权到期
}
// AnnotateTaskListReq App 可领任务列表
type AnnotateTaskListReq struct {
g.Meta `path:"/annotate/tasks" method:"get" summary:"可领标注任务列表" tags:"标注众包"`
}
// AnnotateTaskItem 任务条目(含池余量;冻结中仍展示但不可领取)
type AnnotateTaskItem struct {
Id int64 `json:"id"`
Name string `json:"name"`
DatasetId int64 `json:"datasetId"`
DatasetName string `json:"datasetName"`
Species string `json:"species"`
PoolRemain int64 `json:"poolRemain"`
}
type AnnotateTaskListRes struct {
List []*AnnotateTaskItem `json:"list"`
Stats *AnnotateStats `json:"stats"`
}
// AnnotateClaimReq 领取:分配 ≤ClaimSize 张未处理过的池内图(pending 锁定)
type AnnotateClaimReq struct {
g.Meta `path:"/annotate/claim" method:"post" summary:"领取标注图片" tags:"标注众包"`
TaskId int64 `json:"taskId" v:"required|min:1" dc:"任务 id"`
}
// AnnotateClaimImage 分配的单张图(宽高供 App 端 canvas 画框)
type AnnotateClaimImage struct {
ImageId int64 `json:"imageId"`
Url string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
type AnnotateClaimRes struct {
Images []*AnnotateClaimImage `json:"images"`
Species string `json:"species"`
}
// AnnotateSubmitReq 提交标注:写 labels_json + review_status=1 待审核,每累计 RewardPerImages
// 张发 RewardMinutes 分钟(当日超 DailyCapMinutes 不再发);boxes 可为空数组(画面无目标主张)
type AnnotateSubmitReq struct {
g.Meta `path:"/annotate/submit" method:"post" summary:"提交标注" tags:"标注众包"`
ImageId int64 `json:"imageId" v:"required|min:1" dc:"图片 id"`
Boxes []*AdminLabelBox `json:"boxes" dc:"标注框(空数组=画面无目标)"`
}
type AnnotateSubmitRes struct {
Granted bool `json:"granted"` // 本次是否触发发放
Minutes int `json:"minutes"` // 本次发放分钟数(granted 时有效)
TodayEarnedMinutes int `json:"todayEarnedMinutes"` // 今日已得
ExpiresAt *gtime.Time `json:"expiresAt"` // 发放后的授权到期
ProgressDone int64 `json:"progressDone"` // 距下次奖励进度
}
// AnnotateMeReq 我的标注统计
type AnnotateMeReq struct {
g.Meta `path:"/annotate/me" method:"get" summary:"我的标注统计" tags:"标注众包"`
}
type AnnotateMeRes struct {
Stats *AnnotateStats `json:"stats"`
}
// AnnotateImageReq 标注图片访问(登录态;controller 直写响应体,服务端校验图片归属)
type AnnotateImageReq struct {
g.Meta `path:"/annotate/image" method:"get" summary:"标注图片访问" tags:"标注众包"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集"`
Filename string `json:"filename" v:"required" dc:"文件名"`
}
type AnnotateImageRes struct{}
+96
View File
@@ -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)
}
+172 -53
View File
@@ -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_jsonAI 自动标注与人工框同层,人工可修改/清理)
// AdminLabelImageItem 工作台单张图:全部标注(labels_jsonAI 预标与人工框同层,人工可修改/清理)
type AdminLabelImageItem struct {
Filename string `json:"filename"`
Url string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
Boxes []*AdminLabelBox `json:"boxes"`
Labeled bool `json:"labeled"` // 有标注(AI 自动标注或人工保存)
Filename string `json:"filename"`
Url string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
Boxes []*AdminLabelBox `json:"boxes"`
Labeled bool `json:"labeled"` // 有标注AI 预标或人工保存)
ReviewStatus int `json:"reviewStatus"` // 审核三态 0=未标注,1=待审核,2=已审核
}
type AdminLabelTaskDetailRes struct {
@@ -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/<文件名前缀>.tfliten 档 <文件名前缀>_n.tflitecombined 固定 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:"审核时间"`
}
+13
View File
@@ -0,0 +1,13 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// AnnotateTask 标注众包任务(管理端下发):池子动态 = 数据集内 review_status=0
// 且 clean_excluded=0 的图(不落表,实时查询),新增图自动入池,停用即整体不可领。
type AnnotateTask struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"数据集(任务池来源)"`
Name string `json:"name" orm:"name" description:"任务名"`
Status string `json:"status" orm:"status" description:"published|stopped"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"下发时间"`
}
+10 -7
View File
@@ -5,11 +5,14 @@ import "github.com/gogf/gf/v2/os/gtime"
// DatasetImage 数据集图片:文件在 datasets/<数据集名>/<filename>
// 标注为 JSON 数组存本表(元素同 dto.AdminLabelBoxclass/cx/cy/w/h/confidenceYOLO 归一化)。
type DatasetImage struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
Filename string `json:"filename" orm:"filename" description:"唯一文件名(防重名加时间戳后缀)"`
Source string `json:"source" orm:"source" description:"manual|ai"`
AnimalCount int `json:"animalCount" orm:"animal_count" description:"AI 生成时的目标数量上限(0=未限定),自动标注按置信度裁剪到此数"`
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:"审核时间"`
}
+2 -1
View File
@@ -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:"完成时间"`
+8 -6
View File
@@ -5,10 +5,12 @@ import "github.com/gogf/gf/v2/os/gtime"
// License 手机号账号与授权:一账号一记录;password 不对外(json 排除);
// expires_at 未充值(含被撤销)时为 NULL;购买的套餐记录在 payment_order,本表不存。
type License struct {
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"`
Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"`
ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL"`
Remark string `json:"remark" orm:"remark" description:"管理端备注(客户端不可见)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"`
Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"`
ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL;标注奖励分钟级顺延"`
AnnotateFrozenUntil *gtime.Time `json:"-" orm:"annotate_frozen_until" description:"标注低质冻结到期标记(NULL/过期=正常,时间戳对比天然自动解冻)"`
AnnotateStatsSince *gtime.Time `json:"-" orm:"annotate_stats_since" description:"通过比例统计基线(冻结时重置,解冻后重新累计)"`
Remark string `json:"remark" orm:"remark" description:"管理端备注(客户端不可见)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
+6 -2
View File
@@ -4,11 +4,15 @@ import "github.com/gogf/gf/v2/os/gtime"
// ModelTraining 训练任务:runner 启动训练进程,轮询解析 epoch 日志更新进度/指标,
// 日志尾部截断存 log_tail;pid 用于取消与存活探测。
// 状态机 queued → running → success/failedqueued=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"`
+9 -5
View File
@@ -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/<文件名前缀>.tflites 档)/<文件名前缀>_n.tfliten 档,
// 前缀空回退数据集名,无存档回退机制);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:"发布时间"`
}
+12
View File
@@ -0,0 +1,12 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// RewardLog 标注时长发放流水:日上限 = 自然日求和;只增不改(审计)。
type RewardLog struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"用户手机号"`
Minutes int `json:"minutes" orm:"minutes" description:"本次发放分钟数"`
TaskId int64 `json:"taskId" orm:"task_id" description:"触发发放的任务(0=未知)"`
GrantedAt *gtime.Time `json:"grantedAt" orm:"granted_at" description:"发放时间"`
}
-220
View File
@@ -1,220 +0,0 @@
package service
// 管理端白盒测试:手动授权/撤销、订单分页、套餐更新、账号注册/登录。
// 运行方式同 payment_test.gocd 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)
}
}
+725
View File
@@ -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
}
-229
View File
@@ -1,229 +0,0 @@
package service
// App 版本管理白盒测试:下发(APK 上传 + 记录)、最新版本查询、重复版本拒绝、
// APK 固定文件覆盖(目录永远只有一个文件)。
// 运行方式同 admin_test.gocd 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")
}
}
+577 -97
View File
@@ -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:解码校验 + 非 jpegpng/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
}

Some files were not shown because too many files have changed in this diff Show More