1
@@ -16,8 +16,8 @@
|
||||
| 版本管理 | 后台管理端上传 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 自动标注结果直接作为标注,人工仅审核确认;封面(上传自动转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image,`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON |
|
||||
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置):进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1(GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
|
||||
| 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/trainings/<数据集名>.tflite` + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
|
||||
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/trainings/<数据集名>.tflite`;**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 |
|
||||
| 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——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 逻辑在服务端) |
|
||||
|
||||
## 架构与数据流
|
||||
@@ -48,11 +48,12 @@ 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` |
|
||||
| `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` 节点) |
|
||||
| `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`,客户端固定下载,无存档回退) |
|
||||
| `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`(前缀空回退数据集名),客户端固定下载,无存档回退) |
|
||||
| `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` |
|
||||
|
||||
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
|
||||
|
||||
@@ -160,7 +161,7 @@ App 版本更新检查(公开接口,无需 token,未登录/旧版本均可
|
||||
- 检测到新版本(服务器版本高于本地版本)即**强制更新**,客户端弹不可关闭的全屏提示,必须跳转更新后才能继续使用;本地已是新版本则不提示
|
||||
- 客户端以「语义化版本号」比较:`1.10.0 > 1.9.9`(按数字段比较,禁止字符串比较)
|
||||
- 下载地址为固定静态路径:`/download/observer-latest.apk`(`app.apkDir` 目录下永远只有最新一个文件,由后端静态托管),客户端拼 `apiBaseUrl` 访问
|
||||
- **模型热更新(与 APK 更新独立通道)**:服务器有已发布模型时响应额外返回 `models` 数组(与 `GET /api/v1/models` 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl);客户端启动与 APK 更新**独立检查**——某数据集服务器版本高于本地已下载版本即下载 `/download/trainings/<数据集名>.tflite` 到应用私有目录,sha256 校验后原子替换,下次识别生效;**非强制**,失败回退旧模型下次启动重试。App 模型管理页列出服务器全部可用模型,用户自由下载/删除/启用;识别时加载全部已启用模型**并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底。无发布模型时不返回 models 字段(旧 App 忽略新字段、新 App 兼容旧服务器)
|
||||
- **模型热更新(与 APK 更新独立通道)**:服务器有已发布模型时响应额外返回 `models` 数组(与 `GET /api/v1/models` 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl);客户端启动与 APK 更新**独立检查**——某数据集服务器版本高于本地已下载版本即下载 `/download/trainings/<文件名前缀>.tflite`(前缀空回退数据集名) 到应用私有目录,sha256 校验后原子替换,下次识别生效;**非强制**,失败回退旧模型下次启动重试。App 模型管理页列出服务器全部可用模型,用户自由下载/删除/启用;识别时加载全部已启用模型**并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底。无发布模型时不返回 models 字段(旧 App 忽略新字段、新 App 兼容旧服务器)
|
||||
|
||||
### GET /download-page
|
||||
|
||||
@@ -178,15 +179,18 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
||||
| GET | `/admin/app-versions` | 版本记录列表,`page/size` 分页,按下发时间倒序 |
|
||||
| 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"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名) |
|
||||
| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","namePrefix":"pheasant","source":"manual"\|"ai","cover":"<文件名>"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名;物种=数据集名(单物种规则),创建时同步调 VLM(qwen3.8-9b) 自动生成物种/场景/动作/遮挡/站高/类别名等生成参数池,响应含 `poolsGenerated`/`poolError`——VLM 失败不阻断创建,参数可事后用 gen-pools 补生成;封面优先用 cover 参数(新建对话框预生成封面回传,跳过自动生成),否则参数池成功后自动生成 16:9(1024x576)封面(1 雄 1 雌并排,响应含 `coverGenerated`/`coverError`,失败可在编辑模式重新生成);模型生成图统一转 jpg 落盘) |
|
||||
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs) |
|
||||
| POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"name":"新名","namePrefix":"pheasant","description":"...","cover":"a.jpg"}`:名称(改名)/文件名前缀/描述/封面,空值字段不覆盖原值;改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 |
|
||||
| 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.8-9b) 生成轮廓色/站高/场景/动作/遮挡并写表(覆盖旧值);失败报错保留旧值 |
|
||||
| POST | `/admin/datasets/images/vlm-review` | VLM 藏匿位补检 `{"datasetId":1,"imageId":5}`(两阶段标注第二阶段,**须与图像生成显存互斥**):qwen3.8-9b(+mmproj) 排除已确认框,按环境/光线/习性推理藏匿位,追加 ≤3 个疑似框(class 1)进同一 labels_json |
|
||||
| POST | `/admin/datasets/cover` | 上传数据集封面(multipart:`datasetId`+`file`,jpg/jpeg/png ≤2MB):**自动转 jpg + UUID 命名**落盘并覆盖旧封面 |
|
||||
| GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位) |
|
||||
| POST | `/admin/datasets/cover/generate` | 生成数据集封面 `{"datasetId":1}`(z-image 文生图:16:9、1 雄 1 雌,物种取 gen_species 空回退数据集名):覆盖旧封面,返回 `{cover}` 新文件名;`datasetId=0` 时传 `{"name":"家鸽"}` 新建预生成(数据集未创建,封面仅落盘不写库,创建请求带 cover 回传写库);与生成任务显存互斥,失败不覆盖旧封面 |
|
||||
| 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>/`,逐张入库 |
|
||||
| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"species":"家鸽","count":1,"distance":25}`(species 可留空=物种池随机;scene/action/light 后端随机;prompt 可手填覆盖模板;**每张固定 1 个目标**(数量词 "1只",animal_count=1 供标注裁剪);distance 必填注入提示词距离描述;距离校验已取消;文件名前缀取数据集属性)(prompt 留空时按 `imageGen.promptTemplate` 组装:物种按数据集名取 speciesByDataset 池、数量词=表单 animalCount、场景/动作/光线随机;手填覆盖)(count 1..8,同步执行,每张超时 `imageGen.timeoutSeconds`):调 `imageGen` provider(dashscope / localai,config 切换)逐张生成落盘 + 入库(记录 prompt),任意失败返回错误;`namePrefix` 可选,按 `<前缀>_<两位序号>.jpg` 顺序命名并续接已有最大序号(如 pigeon_01.jpg),留空用时间戳命名;`distance`(米)与 `animalCount` 填写后**逐张校验**——RF-DETR 检测目标框,距离校验已取消(2026-08-28,无逐张拒检,生成即入库);`animalCount` 仅作为自动标注框数上限(按置信度裁剪 ≤N),不参与生成校验 |
|
||||
| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"count":1,"distance":25}`(**物种固定取数据集名,表单无需填写**;scene/action/occlusion 后端从数据集表池随机,light 走 config 通用池;**性别每张随机雄/雌**(两性体型外观差异大,随机让训练数据覆盖两性形态);prompt 可手填覆盖模板;**每张固定 1 个目标**(数量词 "1只",animal_count=1 供标注裁剪);distance 必填注入提示词距离描述;距离校验已取消;文件名前缀取数据集属性)(prompt 留空时按 `imageGen.promptTemplate` 组装:**物种=数据集名,场景/动作/遮挡/站高从 dataset 表生成参数池读取(创建数据集时 VLM 生成,无 config 兜底——池为空报错提示补参数或手填提示词)**;手填覆盖)(**count 1..1000,异步任务**:校验通过即返回 `{taskId, total}`,后台协程逐张生成,每张超时 `imageGen.timeoutSeconds`,进度/结果轮询 GET `/admin/datasets/gen-task`):调 `imageGen` provider(dashscope / localai,config 切换)逐张生成落盘 + 入库(记录 prompt);完成或部分失败后自动触发标注;任意失败任务置 failed(已生成图保留,付费资产原则);`namePrefix` 可选,按 `<前缀>_<两位序号>.jpg` 顺序命名并续接已有最大序号(如 pigeon_01.jpg),留空用时间戳命名;`animalCount` 仅作为自动标注框数上限(按置信度裁剪 ≤N),不参与生成校验 |
|
||||
| GET | `/admin/datasets/gen-task` | 生成任务进度 `{"datasetId":1}`:返回最近一次任务 `{id, status(running/done/failed), total, done, error, createdAt, finishedAt}`;无任务返回 null |
|
||||
| GET | `/admin/datasets/images` | 数据集图片列表 `{"datasetId":1}`,返回图片元数据(文件名/来源/prompt/创建时间) |
|
||||
| GET | `/admin/datasets/image` | 图片文件(静态字节流,`datasetId` + `filename` 定位,供缩略图/查看) |
|
||||
| POST | `/admin/datasets/images/delete` | 删除图片 `{"datasetId":1,"ids":[1,2]}`:删文件 + 删记录(AI 生成图是付费资产,前端确认文案提示) |
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<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-D2b99hWE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-CT7bZYxr.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-B2nod8DU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-CgjCJs5M.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
TableTraining = "model_training"
|
||||
TableModelVersion = "model_version"
|
||||
TableLabelTask = "label_task"
|
||||
TableGenTask = "gen_task"
|
||||
|
||||
// 订单状态机 created → paid(closed 仅超时/失败关闭)
|
||||
OrderStatusCreated = "created"
|
||||
@@ -43,6 +44,14 @@ const (
|
||||
LabelTaskRunning = "running"
|
||||
LabelTaskDone = "done"
|
||||
|
||||
// 文生图任务状态 running → done/failed
|
||||
GenTaskRunning = "running"
|
||||
GenTaskDone = "done"
|
||||
GenTaskFailed = "failed"
|
||||
|
||||
// 文生图池默认并发度(被 config.yml imageGen.poolSize 覆盖;z-image 显存独占,默认 1)
|
||||
GenPoolDefaultSize = 1
|
||||
|
||||
// 模型版本号前缀(m1.0.0),同数据集内递增
|
||||
ModelVersionPrefix = "m"
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
@@ -86,11 +87,21 @@ func (c *cAdmin) UploadImages(ctx context.Context, req *dto.AdminDatasetUploadRe
|
||||
return service.Dataset.AdminUploadImages(ctx, req)
|
||||
}
|
||||
|
||||
// GenerateImages AI 生成图片
|
||||
// GenerateImages AI 生成图片(异步任务,返回 taskId 供轮询进度)
|
||||
func (c *cAdmin) GenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) {
|
||||
return service.Dataset.AdminGenerateImages(ctx, req)
|
||||
}
|
||||
|
||||
// GenTaskQuery 生成任务进度查询
|
||||
func (c *cAdmin) GenTaskQuery(ctx context.Context, req *dto.AdminGenTaskQueryReq) (*dto.AdminGenTaskQueryRes, error) {
|
||||
return service.Dataset.AdminGenTaskQuery(ctx, req)
|
||||
}
|
||||
|
||||
// GenPools VLM 重新生成数据集生成参数池
|
||||
func (c *cAdmin) GenPools(ctx context.Context, req *dto.AdminGenPoolsReq) (*dto.AdminGenPoolsRes, error) {
|
||||
return service.Dataset.AdminGenPools(ctx, req)
|
||||
}
|
||||
|
||||
// ListImages 数据集图片列表
|
||||
func (c *cAdmin) ListImages(ctx context.Context, req *dto.AdminDatasetImagesReq) (*dto.AdminDatasetImagesRes, error) {
|
||||
return service.Dataset.AdminListImages(ctx, req)
|
||||
@@ -117,9 +128,10 @@ func (c *cAdmin) UploadCover(ctx context.Context, req *dto.AdminDatasetCoverUplo
|
||||
return service.Dataset.AdminUploadCover(ctx, req)
|
||||
}
|
||||
|
||||
// Cover 封面访问(直写响应体;URL 不随封面更换变化,必须 no-store 防浏览器缓存旧图)
|
||||
// Cover 封面访问(直写响应体;URL 不随封面更换变化,必须 no-store 防浏览器缓存旧图;
|
||||
// datasetId=0 时按 name+filename 直读——新建对话框预生成封面回显)
|
||||
func (c *cAdmin) Cover(ctx context.Context, req *dto.AdminDatasetCoverReq) (*dto.AdminDatasetCoverRes, error) {
|
||||
path, err := service.Dataset.CoverFile(ctx, req.DatasetId)
|
||||
path, err := service.Dataset.CoverFile(ctx, req.DatasetId, strings.TrimSpace(req.Name), strings.TrimSpace(req.Filename))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -134,6 +146,11 @@ func (c *cAdmin) DeleteCover(ctx context.Context, req *dto.AdminDatasetCoverDele
|
||||
return service.Dataset.AdminDeleteCover(ctx, req)
|
||||
}
|
||||
|
||||
// GenCover 生成数据集封面(z-image 文生图;编辑对话框「AI 生成封面」按钮入口,datasetId=0 新建预生成)
|
||||
func (c *cAdmin) GenCover(ctx context.Context, req *dto.AdminGenCoverReq) (*dto.AdminGenCoverRes, error) {
|
||||
return service.Dataset.AdminGenCover(ctx, req)
|
||||
}
|
||||
|
||||
// ---------- 训练编排 ----------
|
||||
|
||||
// ListTrainings 训练任务列表
|
||||
@@ -192,8 +209,3 @@ 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)
|
||||
}
|
||||
|
||||
// GenOptions 生成表单选项(物种池/场景池,config 维护)
|
||||
func (c *cAdmin) GenOptions(ctx context.Context, req *dto.AdminDatasetGenOptionsReq) (*dto.AdminDatasetGenOptionsRes, error) {
|
||||
return service.Dataset.AdminGenOptions(ctx, req)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ func init() {
|
||||
cover TEXT,
|
||||
description TEXT,
|
||||
name_prefix TEXT NOT NULL DEFAULT '',
|
||||
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
|
||||
)`)
|
||||
@@ -39,6 +46,18 @@ func init() {
|
||||
}
|
||||
// 存量库迁移:生成图文件名前缀列(EnsureColumn 的 ddl 须自带列名)
|
||||
common.EnsureColumn(ctx, consts.TableDataset, "name_prefix", "name_prefix TEXT NOT NULL DEFAULT ''")
|
||||
// 存量库迁移:生成参数池列(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"},
|
||||
} {
|
||||
common.EnsureColumn(ctx, consts.TableDataset, c.name, c.ddl)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert 新建数据集(name UNIQUE 由库兜底)
|
||||
@@ -122,6 +141,30 @@ func (d *datasetDao) UpdateConfigs(ctx context.Context, id int64, m *entity.Data
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePools 更新生成参数池(gen_* 7 列,非空才覆盖;仅创建数据集时 VLM 生成调用。
|
||||
// gen_species/gen_tone/gen_scenes/gen_actions/gen_occlusions/gen_classes 字符串空值不覆盖;
|
||||
// gen_heights 数值 0 也不覆盖,避免生成时留空清库)
|
||||
func (d *datasetDao) UpdatePools(ctx context.Context, id int64, m *entity.Dataset) error {
|
||||
data := g.Map{"updated_at": gtime.Now()}
|
||||
for col, v := range map[string]string{
|
||||
"gen_species": m.GenSpecies, "gen_tone": m.GenTone,
|
||||
"gen_scenes": m.GenScenes, "gen_actions": m.GenActions, "gen_occlusions": m.GenOcclusions,
|
||||
"gen_classes": m.GenClasses,
|
||||
} {
|
||||
if v != "" {
|
||||
data[col] = v
|
||||
}
|
||||
}
|
||||
if m.GenHeights > 0 {
|
||||
data["gen_heights"] = m.GenHeights
|
||||
}
|
||||
if len(data) == 1 {
|
||||
return nil
|
||||
}
|
||||
_, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("id", id).Data(data).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateDescription 更新描述(描述是自由文本,允许清空:空值也写入;
|
||||
// UpdateConfigs 空值不覆盖,清空描述无法复用,2026-08-28)
|
||||
func (d *datasetDao) UpdateDescription(ctx context.Context, id int64, description string) error {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// genTaskDao 文生图任务表 DAO:进度更新(done 计数)高频,单行 UPDATE 原子。
|
||||
type genTaskDao struct{}
|
||||
|
||||
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',
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert 创建生成任务,返回自增 id
|
||||
func (d *genTaskDao) Insert(ctx context.Context, m *entity.GenTask) (int64, error) {
|
||||
res, err := g.DB().Model(consts.TableGenTask).Ctx(ctx).Data(g.Map{
|
||||
"dataset_id": m.DatasetId,
|
||||
"status": m.Status,
|
||||
"total": m.Total,
|
||||
"done": m.Done,
|
||||
"error": m.Error,
|
||||
"created_at": m.CreatedAt,
|
||||
"finished_at": m.FinishedAt,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// GetById 按主键查询,不存在返回 nil
|
||||
func (d *genTaskDao) GetById(ctx context.Context, id int64) (*entity.GenTask, error) {
|
||||
var e entity.GenTask
|
||||
err := g.DB().Model(consts.TableGenTask).Ctx(ctx).Where("id", id).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// ListRunning 全部 running 任务(服务重启恢复用)
|
||||
func (d *genTaskDao) ListRunning(ctx context.Context) ([]*entity.GenTask, error) {
|
||||
var list []*entity.GenTask
|
||||
err := g.DB().Model(consts.TableGenTask).Ctx(ctx).
|
||||
Where("status", consts.GenTaskRunning).OrderAsc("id").Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.GenTask{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GetRunningByDataset 某数据集 running 任务(并发检查用)
|
||||
func (d *genTaskDao) GetRunningByDataset(ctx context.Context, datasetId int64) (*entity.GenTask, error) {
|
||||
var e entity.GenTask
|
||||
err := g.DB().Model(consts.TableGenTask).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Where("status", consts.GenTaskRunning).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// UpdateProgress 更新生成进度
|
||||
func (d *genTaskDao) UpdateProgress(ctx context.Context, id int64, done int) error {
|
||||
_, err := g.DB().Model(consts.TableGenTask).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"done": done}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// Finish 完成任务(done 状态 + 完成时间 + 失败原因;errMsg 非空时为 failed)
|
||||
func (d *genTaskDao) Finish(ctx context.Context, id int64, errMsg string) error {
|
||||
status := consts.GenTaskDone
|
||||
if errMsg != "" {
|
||||
status = consts.GenTaskFailed
|
||||
}
|
||||
_, err := g.DB().Model(consts.TableGenTask).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"status": status, "finished_at": gtime.Now(), "error": errMsg}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// LatestByDataset 某数据集最近一次任务(无则 nil)
|
||||
func (d *genTaskDao) LatestByDataset(ctx context.Context, datasetId int64) (*entity.GenTask, error) {
|
||||
var e entity.GenTask
|
||||
err := g.DB().Model(consts.TableGenTask).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).OrderDesc("id").Limit(1).Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// DeleteByDataset 删除数据集关联的全部生成任务(数据集删除时级联清理孤儿记录)
|
||||
func (d *genTaskDao) DeleteByDataset(ctx context.Context, datasetId int64) error {
|
||||
_, err := g.DB().Model(consts.TableGenTask).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Delete()
|
||||
return err
|
||||
}
|
||||
@@ -32,6 +32,13 @@ type AdminDatasetItem struct {
|
||||
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"` // 第二标注类别名(单值)
|
||||
TrainingId int64 `json:"trainingId"` // 最新训练记录 id(发布/详情用)
|
||||
TrainingStatus string `json:"trainingStatus"` // 最新训练记录状态 running|success|failed|空
|
||||
TrainingError string `json:"trainingError"` // 最新训练记录失败原因(failed 时展示用)
|
||||
@@ -49,7 +56,9 @@ type AdminDatasetListRes struct {
|
||||
|
||||
// AdminDatasetUpdateReq 更新数据集配置(空值字段不覆盖原值,保留现有配置;改名时同步迁移
|
||||
// 图片目录与模型文件,名称唯一且同是磁盘目录名;
|
||||
// AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh))
|
||||
// AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh)。
|
||||
// gen_* 7 列(species/tone/heights/scenes/actions/occlusions/classes)不在此维护——
|
||||
// 创建数据集时 VLM 生成,重新生成走 gen-pools 接口
|
||||
type AdminDatasetUpdateReq struct {
|
||||
g.Meta `path:"/datasets/update" method:"post" summary:"更新数据集配置" tags:"管理端"`
|
||||
Id int64 `json:"id" v:"required|min:1" dc:"数据集 id"`
|
||||
@@ -63,16 +72,39 @@ type AdminDatasetUpdateRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminDatasetCreateReq 新建数据集(名称同时是磁盘目录名:中文/字母/数字/下划线/短横线,唯一)
|
||||
// AdminDatasetCreateReq 新建数据集(名称同时是磁盘目录名:中文/字母/数字/下划线/短横线,唯一;
|
||||
// 单物种规则:数据集名即物种,VLM 按数据集名自动生成生成参数池,表单无动物名称输入)
|
||||
type AdminDatasetCreateReq struct {
|
||||
g.Meta `path:"/datasets" method:"post" summary:"新建数据集" tags:"管理端"`
|
||||
Name string `json:"name" v:"required|regex:^[a-zA-Z0-9_一-龥-]+$|length:1,50" dc:"数据集名称(同是目录名,唯一)"`
|
||||
Source string `json:"source" v:"required|in:manual,ai" dc:"图片来源 manual|ai"`
|
||||
NamePrefix string `json:"namePrefix" v:"length:0,50" dc:"AI 生成图文件名前缀(可选,字母/数字/下划线/短横线)"`
|
||||
Cover string `json:"cover" v:"length:0,200" dc:"封面文件名(新建对话框预生成封面后回传;非空则跳过自动生成)"`
|
||||
}
|
||||
|
||||
type AdminDatasetCreateRes struct {
|
||||
Id int64 `json:"id"`
|
||||
Id int64 `json:"id"`
|
||||
PoolsGenerated bool `json:"poolsGenerated"` // 是否已用 VLM 生成生成参数池
|
||||
PoolError string `json:"poolError"` // 参数池生成失败原因(可用 gen-pools 补生成)
|
||||
CoverGenerated bool `json:"coverGenerated"` // 是否已生成封面(16:9、1雄1雌)
|
||||
CoverError string `json:"coverError"` // 封面生成失败原因(可编辑模式重新生成)
|
||||
}
|
||||
|
||||
// AdminGenPoolsReq 重新生成生成参数池(VLM 覆盖写;编辑对话框「VLM 重新生成参数」入口,
|
||||
// 按数据集名生成——单物种规则,无动物名称输入)
|
||||
type AdminGenPoolsReq struct {
|
||||
g.Meta `path:"/datasets/gen-pools" method:"post" summary:"重新生成生成参数池" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
type AdminGenPoolsRes struct {
|
||||
GenSpecies string `json:"genSpecies"`
|
||||
GenTone string `json:"genTone"`
|
||||
GenHeights float64 `json:"genHeights"`
|
||||
GenScenes string `json:"genScenes"`
|
||||
GenActions string `json:"genActions"`
|
||||
GenOcclusions string `json:"genOcclusions"`
|
||||
GenClasses string `json:"genClasses"`
|
||||
}
|
||||
|
||||
// AdminDatasetDeleteReq 删除数据集(删除图片文件+标注目录+记录;AI 生成图为付费资产,前端须带确认文案)
|
||||
@@ -95,34 +127,41 @@ type AdminDatasetUploadRes struct {
|
||||
Skipped int `json:"skipped"` // 重名跳过数
|
||||
}
|
||||
|
||||
// AdminDatasetGenerateReq AI 生成图片(同步执行;prompt 可手填覆盖模板,禁止含目标位置描述;
|
||||
// 未手填走 imageGen.promptTemplate 组装:species 用表单选择(scene/action/light 随机),数量词=animalCount。
|
||||
// AdminDatasetGenerateReq AI 生成图片(异步任务;prompt 可手填覆盖模板,禁止含目标位置描述;
|
||||
// 未手填走 imageGen.promptTemplate 组装(单物种规则:物种固定取数据集 gen_species[0]/数据集名,
|
||||
// 表单无物种输入),scene/action/occlusion 从数据集表池随机。
|
||||
// animalCount 仅作为标注框数上限存储(自动标注按置信度裁剪 ≤N),不参与生成校验;
|
||||
// 距离校验已取消(2026-08-28)。文件名前缀为数据集属性(datasets 表 name_prefix)
|
||||
// 距离校验已取消(2026-08-28)。文件名前缀为数据集属性(datasets 表 name_prefix)。
|
||||
// 生成耗时约 40s/张(localai),批量请用返回的 TaskId 轮询 /datasets/gen-task 进度。
|
||||
type AdminDatasetGenerateReq struct {
|
||||
g.Meta `path:"/datasets/generate" method:"post" summary:"AI 生成图片" tags:"管理端"`
|
||||
g.Meta `path:"/datasets/generate" method:"post" summary:"AI 生成图片(异步任务)" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Species string `json:"species" v:"length:0,50" dc:"动物名称(如家鸽/山斑鸠;留空按数据集名取物种池随机)"`
|
||||
Prompt string `json:"prompt" v:"length:0,500" dc:"完整提示词(可选,填写则覆盖模板;禁止含位置描述)"`
|
||||
Count int `json:"count" v:"required|integer|min:1|max:8" dc:"生成张数 1-8"`
|
||||
Count int `json:"count" v:"required|integer|min:1|max:1000" dc:"生成张数 1-1000"`
|
||||
Size string `json:"size" v:"required|in:704x1248" dc:"图片尺寸"`
|
||||
Distance int `json:"distance" v:"required|min:1" dc:"目标距离(米,固定值,注入提示词)"`
|
||||
}
|
||||
|
||||
// AdminDatasetGenOptionsReq 生成表单选项(物种池/场景池,来自 config imageGen 配置)
|
||||
type AdminDatasetGenOptionsReq struct {
|
||||
g.Meta `path:"/datasets/gen-options" method:"get" summary:"生成表单选项" tags:"管理端"`
|
||||
type AdminDatasetGenerateRes struct {
|
||||
TaskId int64 `json:"taskId"` // 生成任务 id(前端轮询 /datasets/gen-task 进度)
|
||||
Total int `json:"total"` // 计划生成张数
|
||||
}
|
||||
|
||||
// AdminGenTaskQueryReq 生成任务进度查询(前端轮询;任务完成后错误信息在 Error)
|
||||
type AdminGenTaskQueryReq struct {
|
||||
g.Meta `path:"/datasets/gen-task" method:"get" summary:"生成任务进度" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
type AdminDatasetGenOptionsRes struct {
|
||||
Species []string `json:"species"` // 该数据集的物种池(未配置则为空,表单可自由输入)
|
||||
Scenes []string `json:"scenes"` // 环境场景池
|
||||
}
|
||||
|
||||
type AdminDatasetGenerateRes struct {
|
||||
Generated int `json:"generated"` // 成功入库张数(校验不合格的已跳过)
|
||||
Rejected int `json:"rejected"` // 连续 N 次校验不合格被跳过的张数
|
||||
type AdminGenTaskQueryRes struct {
|
||||
Id int64 `json:"id"`
|
||||
DatasetId int64 `json:"datasetId"`
|
||||
Status string `json:"status"` // running | done | failed
|
||||
Total int `json:"total"`
|
||||
Done int `json:"done"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
FinishedAt *gtime.Time `json:"finishedAt"`
|
||||
}
|
||||
|
||||
// AdminDatasetImagesReq 数据集图片列表(标注工作台/网格预览用)
|
||||
@@ -172,10 +211,13 @@ type AdminDatasetCoverUploadReq struct {
|
||||
|
||||
type AdminDatasetCoverUploadRes struct{}
|
||||
|
||||
// AdminDatasetCoverReq 封面访问(直写响应体,与 Image 同模式)
|
||||
// AdminDatasetCoverReq 封面访问(直写响应体,与 Image 同模式;
|
||||
// datasetId=0 时按 name+filename 直读——新建对话框预生成封面回显用)
|
||||
type AdminDatasetCoverReq struct {
|
||||
g.Meta `path:"/datasets/cover" method:"get" summary:"数据集封面访问" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
DatasetId int64 `json:"datasetId" v:"min:0" dc:"数据集 id"`
|
||||
Name string `json:"name" v:"length:0,50" dc:"数据集名称(datasetId=0 时必填)"`
|
||||
Filename string `json:"filename" v:"length:0,200" dc:"封面文件名(datasetId=0 时必填)"`
|
||||
}
|
||||
|
||||
type AdminDatasetCoverRes struct{}
|
||||
@@ -188,6 +230,19 @@ type AdminDatasetCoverDeleteReq struct {
|
||||
|
||||
type AdminDatasetCoverDeleteRes struct{}
|
||||
|
||||
// AdminGenCoverReq 生成数据集封面(z-image 文生图:16:9、1 雄 1 雌;覆盖旧封面;编辑对话框按钮入口)。
|
||||
// datasetId=0 时按 name 预生成(新建对话框——数据集未创建,封面仅落盘不写库,
|
||||
// 创建请求带 cover 回传写库),物种取数据集名。
|
||||
type AdminGenCoverReq struct {
|
||||
g.Meta `path:"/datasets/cover/generate" method:"post" summary:"生成数据集封面" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"min:0" dc:"数据集 id(0=新建预生成)"`
|
||||
Name string `json:"name" v:"length:0,50" dc:"数据集名称(datasetId=0 时必填)"`
|
||||
}
|
||||
|
||||
type AdminGenCoverRes struct {
|
||||
Cover string `json:"cover" dc:"新封面文件名"`
|
||||
}
|
||||
|
||||
// ---------- 训练编排 ----------
|
||||
|
||||
// AdminTrainingListReq 训练任务列表(创建时间倒序)
|
||||
@@ -398,7 +453,7 @@ type ModelCatalogItem struct {
|
||||
Sha256 string `json:"sha256"`
|
||||
Notes string `json:"notes"`
|
||||
PublishedAt *gtime.Time `json:"publishedAt"`
|
||||
DownloadUrl string `json:"downloadUrl"` // /download/trainings/<name>.tflite
|
||||
DownloadUrl string `json:"downloadUrl"` // /download/trainings/<文件名前缀>.tflite(前缀空回退数据集名)
|
||||
}
|
||||
|
||||
type ModelCatalogRes struct {
|
||||
|
||||
@@ -4,16 +4,27 @@ import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// Dataset 训练数据集:图片文件在 app.datasetDir/datasets/<name>/(DB 只存元数据 + 标注 labels_json)。
|
||||
// 每数据集训练一个模型。
|
||||
// 生成参数池(gen_*,创建数据集时 VLM 自动生成,界面不维护;config.yml 无兜底):
|
||||
// gen_species 物种(单值)、gen_tone 轮廓色词(单值)、gen_heights 站高cm(数值);
|
||||
// gen_scenes/gen_actions/gen_occlusions 场景/动作/遮挡池(JSON 数组);
|
||||
// gen_classes 第二标注类别名(单值,"suspect",第一类别=gen_species)。
|
||||
type Dataset struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
Name string `json:"name" orm:"name" description:"数据集名(≤50字,同是目录名,唯一)"`
|
||||
Source string `json:"source" orm:"source" description:"manual|ai"`
|
||||
ImageCount int64 `json:"imageCount" orm:"image_count" description:"图片数(冗余计数)"`
|
||||
LabeledCount int64 `json:"labeledCount" orm:"labeled_count" description:"已标注数"`
|
||||
Status string `json:"status" orm:"status" description:"building|labeled|synced"`
|
||||
Cover string `json:"cover" orm:"cover" description:"封面文件名(卡片展示)"`
|
||||
NamePrefix string `json:"namePrefix" orm:"name_prefix" description:"AI 生成图文件名前缀(如 pigeon,生成图命名 pigeon_01.jpg)"`
|
||||
Description string `json:"description" orm:"description" description:"描述(卡片展示)"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
Name string `json:"name" orm:"name" description:"数据集名(≤50字,同是目录名,唯一)"`
|
||||
Source string `json:"source" orm:"source" description:"manual|ai"`
|
||||
ImageCount int64 `json:"imageCount" orm:"image_count" description:"图片数(冗余计数)"`
|
||||
LabeledCount int64 `json:"labeledCount" orm:"labeled_count" description:"已标注数"`
|
||||
Status string `json:"status" orm:"status" description:"building|labeled|synced"`
|
||||
Cover string `json:"cover" orm:"cover" description:"封面文件名(卡片展示)"`
|
||||
NamePrefix string `json:"namePrefix" orm:"name_prefix" description:"AI 生成图文件名前缀(如 pigeon,生成图命名 pigeon_01.jpg)"`
|
||||
Description string `json:"description" orm:"description" description:"描述(卡片展示)"`
|
||||
GenSpecies string `json:"genSpecies" orm:"gen_species" description:"物种(单值,同数据集名)"`
|
||||
GenTone string `json:"genTone" orm:"gen_tone" description:"轮廓色词(单值,深色/浅色)"`
|
||||
GenHeights float64 `json:"genHeights" orm:"gen_heights" description:"站高cm(数值,距离感公式用)"`
|
||||
GenScenes string `json:"genScenes" orm:"gen_scenes" description:"场景池(JSON 数组)"`
|
||||
GenActions string `json:"genActions" orm:"gen_actions" description:"动作池(JSON 数组)"`
|
||||
GenOcclusions string `json:"genOcclusions" orm:"gen_occlusions" description:"遮挡池(JSON 数组)"`
|
||||
GenClasses string `json:"genClasses" orm:"gen_classes" description:"第二标注类别名(单值,suspect;第一类别=gen_species)"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// GenTask 文生图批量生成任务:异步逐张调 local-ai,进度落库供前端轮询。
|
||||
type GenTask struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
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:"已生成张数"`
|
||||
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:"完成时间"`
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package entity
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// ModelVersion 模型版本:每数据集独立版本序列(m1.0.0 递增,UNIQUE(dataset_id, version))。
|
||||
// 模型文件不落表:训练成功即直写 trainings/<数据集名>.tflite(无存档回退机制);
|
||||
// 模型文件不落表:训练成功即直写 trainings/<文件名前缀>.tflite(前缀空回退数据集名,无存档回退机制);
|
||||
// labels 为类别名数组 JSON(App 多模型合并推理依赖)。
|
||||
type ModelVersion struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
@@ -76,17 +77,24 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
|
||||
items := make([]*dto.AdminDatasetItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
item := &dto.AdminDatasetItem{
|
||||
Id: v.Id,
|
||||
Name: v.Name,
|
||||
Source: v.Source,
|
||||
ImageCount: v.ImageCount,
|
||||
LabeledCount: v.LabeledCount,
|
||||
Status: v.Status,
|
||||
Cover: v.Cover,
|
||||
Description: v.Description,
|
||||
NamePrefix: v.NamePrefix,
|
||||
CreatedAt: v.CreatedAt,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
Id: v.Id,
|
||||
Name: v.Name,
|
||||
Source: v.Source,
|
||||
ImageCount: v.ImageCount,
|
||||
LabeledCount: v.LabeledCount,
|
||||
Status: v.Status,
|
||||
Cover: v.Cover,
|
||||
Description: v.Description,
|
||||
NamePrefix: v.NamePrefix,
|
||||
GenSpecies: v.GenSpecies,
|
||||
GenTone: v.GenTone,
|
||||
GenHeights: v.GenHeights,
|
||||
GenScenes: v.GenScenes,
|
||||
GenActions: v.GenActions,
|
||||
GenOcclusions: v.GenOcclusions,
|
||||
GenClasses: v.GenClasses,
|
||||
CreatedAt: v.CreatedAt,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
}
|
||||
if t, ok := latest[v.Id]; ok {
|
||||
item.TrainingId = t.Id
|
||||
@@ -112,11 +120,19 @@ func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminD
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
newName := strings.TrimSpace(req.Name)
|
||||
prefix := strings.TrimSpace(req.NamePrefix)
|
||||
if prefix != "" && !datasetNameRe.MatchString(prefix) {
|
||||
return nil, gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50")
|
||||
}
|
||||
if newName != "" && newName != existing.Name {
|
||||
if err := s.renameDataset(ctx, existing.Id, existing.Name, newName); err != nil {
|
||||
// 改名(可同时改前缀):图片目录 + 模型文件随命名迁移(模型文件名 = 前缀,空回退数据集名)
|
||||
if err := s.renameDataset(ctx, existing.Id, existing.Name, newName, existing.NamePrefix, prefix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.Name = newName
|
||||
} else if prefix != existing.NamePrefix {
|
||||
// 仅前缀变更:模型文件名随前缀迁移(改名场景已由 renameDataset 处理)
|
||||
migrateModelFile(ctx, existing.Name, existing.NamePrefix, existing.Name, prefix)
|
||||
}
|
||||
// 封面须属于该数据集图片(防伪造文件名指向任意文件)
|
||||
if req.Cover != "" {
|
||||
@@ -128,10 +144,6 @@ func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminD
|
||||
return nil, gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
}
|
||||
prefix := strings.TrimSpace(req.NamePrefix)
|
||||
if prefix != "" && !datasetNameRe.MatchString(prefix) {
|
||||
return nil, gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50")
|
||||
}
|
||||
if err := dao.Dataset.UpdateConfigs(ctx, req.Id, &entity.Dataset{
|
||||
Name: newName,
|
||||
NamePrefix: prefix,
|
||||
@@ -143,12 +155,14 @@ func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminD
|
||||
if err := dao.Dataset.UpdateDescription(ctx, req.Id, strings.TrimSpace(req.Description)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// gen_* 7 列不在此维护:创建数据集时 VLM 生成,重新生成走 gen-pools 接口
|
||||
return &dto.AdminDatasetUpdateRes{Id: req.Id}, nil
|
||||
}
|
||||
|
||||
// renameDataset 数据集改名:名称同是磁盘目录名,需同步迁移图片目录与当前生效模型文件。
|
||||
// renameDataset 数据集改名:名称同是磁盘目录名,需同步迁移图片目录;
|
||||
// 模型文件按文件名前缀命名(空回退数据集名),随改名/改前缀迁移(失败仅记日志不阻断——旧文件保留)。
|
||||
// 进行中的标注/训练任务持有旧路径,须先等任务结束;改名在 Serial 串行执行,与其他写操作互斥。
|
||||
func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, newName string) error {
|
||||
func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, newName, oldPrefix, newPrefix string) error {
|
||||
err := common.Serial().Submit(ctx, func() error {
|
||||
if !datasetNameRe.MatchString(newName) {
|
||||
return gerror.New("数据集名称仅支持中文/字母/数字/下划线/短横线,长度 1-50")
|
||||
@@ -173,35 +187,21 @@ func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, n
|
||||
if exists != nil {
|
||||
return gerror.NewCode(common.CodeDatasetNameDuplicate)
|
||||
}
|
||||
// 目录迁移:任一步失败回滚已迁移项(图片目录 / 当前生效模型文件可能不存在,缺省跳过)
|
||||
// 图片目录迁移:失败回滚已迁移项(图片目录可能不存在,缺省跳过)
|
||||
oldImgDir := common.DatasetImagesDir(ctx, oldName)
|
||||
newImgDir := common.DatasetImagesDir(ctx, newName)
|
||||
oldModel := common.TrainingModelPath(ctx, oldName)
|
||||
newModel := common.TrainingModelPath(ctx, newName)
|
||||
movedImgDir, movedModel := false, false
|
||||
movedImgDir := false
|
||||
if _, err := os.Stat(oldImgDir); err == nil {
|
||||
if err := os.Rename(oldImgDir, newImgDir); err != nil {
|
||||
return gerror.Wrap(err, "迁移图片目录失败")
|
||||
}
|
||||
movedImgDir = true
|
||||
}
|
||||
if _, err := os.Stat(oldModel); err == nil {
|
||||
if err := os.Rename(oldModel, newModel); err != nil {
|
||||
if movedImgDir {
|
||||
if rbErr := os.Rename(newImgDir, oldImgDir); rbErr != nil {
|
||||
g.Log().Errorf(ctx, "回滚图片目录失败: %+v", rbErr)
|
||||
}
|
||||
}
|
||||
return gerror.Wrap(err, "迁移模型文件失败")
|
||||
}
|
||||
movedModel = true
|
||||
}
|
||||
// 模型文件随命名迁移(prefix 空回退数据集名;改名不改前缀时路径不变,内部跳过)
|
||||
migrateModelFile(ctx, oldName, oldPrefix, newName, newPrefix)
|
||||
if err := dao.Dataset.UpdateConfigs(ctx, id, &entity.Dataset{Name: newName}); err != nil {
|
||||
if movedModel {
|
||||
if rbErr := os.Rename(newModel, oldModel); rbErr != nil {
|
||||
g.Log().Errorf(ctx, "回滚模型文件失败: %+v", rbErr)
|
||||
}
|
||||
}
|
||||
// DB 失败回滚已迁移项(模型文件反向迁移失败仅记日志,旧文件可能留新名)
|
||||
migrateModelFile(ctx, newName, newPrefix, oldName, oldPrefix)
|
||||
if movedImgDir {
|
||||
if rbErr := os.Rename(newImgDir, oldImgDir); rbErr != nil {
|
||||
g.Log().Errorf(ctx, "回滚图片目录失败: %+v", rbErr)
|
||||
@@ -215,6 +215,23 @@ func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, n
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateModelFile 模型文件随命名变更迁移(基名 = 文件名前缀,空回退数据集名):
|
||||
// 旧文件不存在或新旧路径相同(前缀未变且非空)直接跳过;失败仅记日志不阻断业务——
|
||||
// 模型文件为付费训练产物,保留旧名总比删除好(下次训练直写新名覆盖)。
|
||||
func migrateModelFile(ctx context.Context, oldName, oldPrefix, newName, newPrefix string) {
|
||||
oldPath := common.TrainingModelPath(ctx, modelFileName(oldName, oldPrefix))
|
||||
newPath := common.TrainingModelPath(ctx, modelFileName(newName, newPrefix))
|
||||
if oldPath == newPath {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(oldPath); err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
g.Log().Errorf(ctx, "迁移模型文件 %s → %s 失败: %+v", oldPath, newPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminUploadCover 上传数据集封面:解码校验图片 → 转 jpg(Quality 92)→ UUID 命名落盘,
|
||||
// 删除旧封面文件(UUID 互不覆盖,但 DB 指向切换)→ 更新 cover 字段。
|
||||
func (s *datasetService) AdminUploadCover(ctx context.Context, req *dto.AdminDatasetCoverUploadReq) (*dto.AdminDatasetCoverUploadRes, error) {
|
||||
@@ -274,23 +291,33 @@ func isCoverName(name string) bool {
|
||||
return coverNameRe.MatchString(filepath.Base(name))
|
||||
}
|
||||
|
||||
// CoverFile 封面文件定位(校验归属;controller 直写响应体输出)
|
||||
func (s *datasetService) CoverFile(ctx context.Context, datasetId int64) (string, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, datasetId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
// CoverFile 封面文件定位(校验归属;controller 直写响应体输出)。
|
||||
// datasetId>0 从库取封面名;datasetId=0 时按 name+filename 直读(新建对话框预生成封面回显,
|
||||
// 数据集未创建无库记录;filename 须符合 UUID jpg 规范防路径穿越)。
|
||||
func (s *datasetService) CoverFile(ctx context.Context, datasetId int64, name, filename string) (string, error) {
|
||||
if datasetId > 0 {
|
||||
dataset, err := dao.Dataset.GetById(ctx, datasetId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dataset == nil {
|
||||
return "", gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
if dataset.Cover == "" {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
name = dataset.Name
|
||||
filename = dataset.Cover
|
||||
}
|
||||
if dataset == nil {
|
||||
return "", gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
if dataset.Cover == "" {
|
||||
name = filepath.Base(name)
|
||||
if !datasetNameRe.MatchString(name) {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
name := filepath.Base(dataset.Cover)
|
||||
if !isCoverName(name) {
|
||||
filename = filepath.Base(filename)
|
||||
if !isCoverName(filename) {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
path := filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), name)
|
||||
path := filepath.Join(common.DatasetImagesDir(ctx, name), filename)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", gerror.NewCode(common.CodeImageNotFound)
|
||||
}
|
||||
@@ -315,6 +342,34 @@ func (s *datasetService) AdminDeleteCover(ctx context.Context, req *dto.AdminDat
|
||||
return &dto.AdminDatasetCoverDeleteRes{}, nil
|
||||
}
|
||||
|
||||
// AdminGenCover 生成数据集封面(对话框「AI 生成封面」按钮入口):
|
||||
// datasetId>0 走完整链路(16:9、1 雄 1 雌,物种取 gen_species 空回退数据集名,写库+删旧封面);
|
||||
// datasetId=0 新建预生成(物种=表单数据集名,仅落盘,创建请求带 cover 回传写库);
|
||||
// 成功返回新 cover 文件名供前端回显;失败报错不覆盖旧封面。
|
||||
func (s *datasetService) AdminGenCover(ctx context.Context, req *dto.AdminGenCoverReq) (*dto.AdminGenCoverRes, error) {
|
||||
datasetName := strings.TrimSpace(req.Name)
|
||||
species := ""
|
||||
if req.DatasetId > 0 {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
datasetName = dataset.Name
|
||||
species = dataset.GenSpecies
|
||||
}
|
||||
if !datasetNameRe.MatchString(datasetName) {
|
||||
return nil, gerror.New("缺少有效的数据集名称(新建时请先填写名称再生成封面)")
|
||||
}
|
||||
cover, err := s.genCoverWithImageGen(ctx, req.DatasetId, datasetName, species)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminGenCoverRes{Cover: cover}, nil
|
||||
}
|
||||
|
||||
// MigrateLegacyCovers 存量封面迁移:cover 非空且不符合 UUID jpg 规范(历史 cover.jpg/cover.png 等)
|
||||
// → 重命名为 <uuid>.jpg(保留原图字节)并更新 cover 字段。幂等:已符合规范的行跳过。
|
||||
func (s *datasetService) MigrateLegacyCovers(ctx context.Context) error {
|
||||
@@ -353,7 +408,8 @@ func (s *datasetService) MigrateLegacyCovers(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录
|
||||
// AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录 +
|
||||
// VLM 同步生成生成参数池(失败不阻断创建,poolsGenerated/poolError 反馈)。
|
||||
func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminDatasetCreateReq) (*dto.AdminDatasetCreateRes, error) {
|
||||
now := gtime.Now()
|
||||
var id int64
|
||||
@@ -385,14 +441,249 @@ func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminD
|
||||
if err := os.MkdirAll(common.DatasetImagesDir(ctx, req.Name), 0o755); err != nil {
|
||||
return nil, gerror.Wrap(err, "创建数据集目录失败")
|
||||
}
|
||||
return &dto.AdminDatasetCreateRes{Id: id}, nil
|
||||
res := &dto.AdminDatasetCreateRes{Id: id}
|
||||
// VLM 生成参数池:失败不阻断创建(返回原因,可用 gen-pools 补生成/手填提示词)
|
||||
var species string
|
||||
if pools, pErr := s.genPoolsWithVLM(ctx, req.Name); pErr != nil {
|
||||
res.PoolError = pErr.Error()
|
||||
} else if pools != nil {
|
||||
if uErr := common.Serial().Submit(ctx, func() error {
|
||||
return dao.Dataset.UpdatePools(ctx, id, pools)
|
||||
}); uErr == nil {
|
||||
res.PoolsGenerated = true
|
||||
species = pools.GenSpecies
|
||||
} else {
|
||||
res.PoolError = "参数写入失败: " + uErr.Error()
|
||||
}
|
||||
}
|
||||
// 封面:新建对话框预生成(req.Cover 非空且文件已落盘)优先——跳过自动生成;否则自动生成
|
||||
// (16:9、1雄1雌),失败不阻断创建(前端提示可编辑模式重新生成)
|
||||
if cover := strings.TrimSpace(req.Cover); cover != "" && isCoverName(cover) {
|
||||
if _, statErr := os.Stat(filepath.Join(common.DatasetImagesDir(ctx, req.Name), filepath.Base(cover))); statErr == nil {
|
||||
if uErr := common.Serial().Submit(ctx, func() error {
|
||||
return dao.Dataset.UpdateConfigs(ctx, id, &entity.Dataset{Cover: filepath.Base(cover)})
|
||||
}); uErr == nil {
|
||||
res.CoverGenerated = true
|
||||
return res, nil
|
||||
} else {
|
||||
res.CoverError = "封面写入失败: " + uErr.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, coverErr := s.genCoverWithImageGen(ctx, id, req.Name, species); coverErr != nil {
|
||||
res.CoverError = coverErr.Error()
|
||||
} else {
|
||||
res.CoverGenerated = true
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ensureJpeg 模型生成的图片统一转 jpg:解码校验 + 非 jpeg(png/webp 等)转 jpeg(Quality 92)。
|
||||
// 生成服务返回格式不可控,扩展名统一 .jpg,内容须与扩展名一致(RF-DETR 按扩展名推断 mime 提交)。
|
||||
func ensureJpeg(data []byte) ([]byte, error) {
|
||||
img, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "生成图片解码失败")
|
||||
}
|
||||
if format == "jpeg" {
|
||||
return data, nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 92}); err != nil {
|
||||
return nil, gerror.Wrap(err, "生成图片转 jpg 失败")
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// genCoverWithImageGen 用 imageGen provider 生成数据集封面:16:9(1024x576)、画面 1 雄 1 雌,
|
||||
// 同一物种雄性+雌性各一只;UUID 命名落盘,返回新文件名。
|
||||
// datasetId>0:删旧封面文件 + 更新库 cover 字段(与手动上传同路径);
|
||||
// datasetId<=0:仅落盘(新建对话框预生成,数据集未创建,创建请求带 cover 回传写库)。
|
||||
// 封面仅供展示,不入 dataset_image 表(不参与训练);失败保留旧封面。
|
||||
func (s *datasetService) genCoverWithImageGen(ctx context.Context, datasetId int64, datasetName, species string) (string, error) {
|
||||
provider := common.ImageGen(ctx)
|
||||
if provider == nil {
|
||||
return "", gerror.New("图像生成服务未配置,未生成封面")
|
||||
}
|
||||
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 占用显存),未生成封面")
|
||||
}
|
||||
prompt := fmt.Sprintf("野外实拍照片:一只雄性%s和一只雌性%s并排站立在开阔的自然栖息地中,"+
|
||||
"雄雌各一只清晰可见,16:9 横幅构图,真实照片质感,光线自然,画面清晰美观", species, species)
|
||||
genCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
||||
defer cancel()
|
||||
data, err := provider.Generate(genCtx, prompt, "1024x576")
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "封面生成失败")
|
||||
}
|
||||
if data, err = ensureJpeg(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := common.DatasetImagesDir(ctx, datasetName)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", gerror.Wrap(err, "创建图片目录失败")
|
||||
}
|
||||
if datasetId > 0 {
|
||||
if dataset, dErr := dao.Dataset.GetById(ctx, datasetId); dErr == nil && dataset != nil && dataset.Cover != "" {
|
||||
_ = os.Remove(filepath.Join(dir, filepath.Base(dataset.Cover)))
|
||||
}
|
||||
}
|
||||
name := common.UuidV4() + ".jpg"
|
||||
if err := common.WriteFileAtomic(filepath.Join(dir, name), data); err != nil {
|
||||
return "", gerror.Wrap(err, "保存封面失败")
|
||||
}
|
||||
if datasetId > 0 {
|
||||
if err := dao.Dataset.UpdateConfigs(ctx, datasetId, &entity.Dataset{Cover: name}); err != nil {
|
||||
_ = os.Remove(filepath.Join(dir, name))
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// AdminGenPools 重新生成生成参数池(VLM 覆盖写;失败报错保留旧值——编辑对话框按钮入口)
|
||||
func (s *datasetService) AdminGenPools(ctx context.Context, req *dto.AdminGenPoolsReq) (*dto.AdminGenPoolsRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
pools, err := s.genPoolsWithVLM(ctx, dataset.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pools == nil {
|
||||
return nil, gerror.New("生成参数生成失败,请稍后重试")
|
||||
}
|
||||
if err := common.Serial().Submit(ctx, func() error {
|
||||
return dao.Dataset.UpdatePools(ctx, dataset.Id, pools)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminGenPoolsRes{
|
||||
GenSpecies: pools.GenSpecies, GenTone: pools.GenTone, GenHeights: pools.GenHeights,
|
||||
GenScenes: pools.GenScenes, GenActions: pools.GenActions, GenOcclusions: pools.GenOcclusions,
|
||||
GenClasses: pools.GenClasses,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// genPoolsWithVLM 调 qwen3.8-9b 生成数据集生成参数池(单物种规则:物种=数据集名,
|
||||
// VLM 只生成轮廓色/站高/场景/动作/遮挡池)。
|
||||
// 无 localAi 配置/显存被生成任务占用/JSON 非法/校验不过 → 返回 error(调用方决定是否阻断);
|
||||
// 校验通过返回 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"+
|
||||
"生成图规格:手机远距离拍摄的野外大远景实拍图,每张图有且只有一只目标动物,目标在画面中很小。\n"+
|
||||
"请输出严格 JSON(只输出 JSON 对象本身,不要任何其他文字或代码块标记):\n"+
|
||||
"{\n \"tone\": \"深色\",\n \"height_cm\": 30,\n \"scenes\": [\"场景描述\", ...],\n"+
|
||||
" \"actions\": [\"动作词\", ...],\n \"occlusions\": [\"遮挡描述\", ...]\n}\n"+
|
||||
"要求:tone 为该物种轮廓色词(绝大多数「深色」,白化/浅色个体配「浅色」);"+
|
||||
"height_cm 为该物种站高厘米(鸟=脚到头顶、兽=蹲坐/站立肩高,数值 10~200);"+
|
||||
"scenes 4~6 条野外开阔场景,每条含「延伸向远方/地平线/开阔」等拉开距离的元素,符合该动物栖息习性"+
|
||||
"(雉鸡→农田灌丛、野鸭→水面芦苇、兔子→草坡荒地);actions 4~6 条动作短语(如「觅食」「走动啄食」),不能以「在/于」开头;"+
|
||||
"occlusions 4~6 条遮挡描述,覆盖轻(身体半掩)到重(只露头背),遮挡物符合该动物栖息环境(草丛/芦苇/树叶/枝条等)。\n"+
|
||||
"忽略图片内容,只按上述要求输出 JSON。",
|
||||
datasetName, target)
|
||||
// 64x64 纯灰占位图(VLM 输入占位,避免空白无法推理)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
var buf bytes.Buffer
|
||||
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)
|
||||
defer cancel()
|
||||
content, err := common.QwenVL(genCtx, buf.Bytes(), "image/jpeg", prompt)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "VLM 生成参数失败")
|
||||
}
|
||||
return s.parseGenPoolsJSON(content, target)
|
||||
}
|
||||
|
||||
// parseGenPoolsJSON 解析并校验 VLM 输出的参数池 JSON(容忍代码块/前后缀),
|
||||
// 物种固定为 species(单物种规则:每数据集一个物种,第一标注类别名=物种,第二类别名="suspect")。
|
||||
// 校验失败返回 error(宁可报错也不落不合格池——生成图按池组装,池质量决定训练数据质量)。
|
||||
func (s *datasetService) parseGenPoolsJSON(content string, species string) (*entity.Dataset, error) {
|
||||
m := regexp.MustCompile(`\{[\s\S]*\}`).FindString(content)
|
||||
if m == "" {
|
||||
return nil, gerror.New("VLM 未输出有效 JSON")
|
||||
}
|
||||
var raw struct {
|
||||
Tone string `json:"tone"`
|
||||
HeightCm float64 `json:"height_cm"`
|
||||
Scenes []string `json:"scenes"`
|
||||
Actions []string `json:"actions"`
|
||||
Occlusions []string `json:"occlusions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(m), &raw); err != nil {
|
||||
return nil, gerror.Wrap(err, "VLM 输出 JSON 解析失败")
|
||||
}
|
||||
valid := func(list []string, min int, maxLen int) bool {
|
||||
if len(list) < min {
|
||||
return false
|
||||
}
|
||||
for _, v := range list {
|
||||
if strings.TrimSpace(v) == "" || len([]rune(v)) > maxLen {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !valid(raw.Scenes, 3, 100) || !valid(raw.Actions, 3, 30) || !valid(raw.Occlusions, 3, 100) {
|
||||
return nil, gerror.New("VLM 场景/动作/遮挡池非法(各需至少 3 条)")
|
||||
}
|
||||
tone := "深色"
|
||||
if strings.TrimSpace(raw.Tone) != "" {
|
||||
tone = strings.TrimSpace(raw.Tone)
|
||||
}
|
||||
// 站高存数据集表 gen_heights(数据库维护,用户可在编辑界面手改真实值),VLM 输出为初始值
|
||||
height := 35.0
|
||||
if raw.HeightCm >= 10 && raw.HeightCm <= 200 {
|
||||
height = raw.HeightCm
|
||||
}
|
||||
scenesRaw, _ := json.Marshal(raw.Scenes)
|
||||
actionsRaw, _ := json.Marshal(raw.Actions)
|
||||
occlRaw, _ := json.Marshal(raw.Occlusions)
|
||||
// 单值列:物种 / 轮廓色词 / 站高 / 第二类别名(第一类别名=物种,训练 data.yaml names / 模型 labels)
|
||||
return &entity.Dataset{
|
||||
GenSpecies: species,
|
||||
GenTone: tone,
|
||||
GenHeights: height,
|
||||
GenScenes: string(scenesRaw),
|
||||
GenActions: string(actionsRaw),
|
||||
GenOcclusions: string(occlRaw),
|
||||
GenClasses: "suspect",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminDeleteDataset 删除数据集:有 running 标注任务 / 该数据集训练进行中 / 已发布模型版本时拒绝
|
||||
// (训练产物与模型为付费资产,需先删除模型版本再删数据集)。
|
||||
// 删除 = 删图片/模型目录 + 删记录(标注随图片行删除,Serial 单写者串行)。
|
||||
func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteReq) (*dto.AdminDatasetDeleteRes, error) {
|
||||
var name string
|
||||
var name, modelName string
|
||||
err := common.Serial().Submit(ctx, func() error {
|
||||
d, err := dao.Dataset.GetById(ctx, req.Id)
|
||||
if err != nil {
|
||||
@@ -402,6 +693,7 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD
|
||||
return gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
name = d.Name
|
||||
modelName = modelFileName(d.Name, d.NamePrefix)
|
||||
// 预标注任务进行中(RF-DETR 正在扫该数据集图片)
|
||||
if t, err := dao.LabelTask.GetRunningByDataset(ctx, d.Id); err != nil {
|
||||
return err
|
||||
@@ -428,7 +720,7 @@ func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminD
|
||||
return nil, err
|
||||
}
|
||||
// 文件清理(图片目录 + 当前生效模型文件;删除失败仅记日志,记录已删)
|
||||
paths := []string{common.DatasetImagesDir(ctx, name), common.TrainingModelPath(ctx, name)}
|
||||
paths := []string{common.DatasetImagesDir(ctx, name), common.TrainingModelPath(ctx, modelName)}
|
||||
for _, p := range paths {
|
||||
if err := os.RemoveAll(p); err != nil {
|
||||
g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", p, err)
|
||||
@@ -573,58 +865,61 @@ func (s *datasetService) nextImageIndex(ctx context.Context, datasetId int64, pr
|
||||
return max + 1, nil
|
||||
}
|
||||
|
||||
// AdminGenOptions 生成表单选项:该数据集的物种池 + 场景池(config 维护,前端下拉用)
|
||||
func (s *datasetService) AdminGenOptions(ctx context.Context, req *dto.AdminDatasetGenOptionsReq) (*dto.AdminDatasetGenOptionsRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
return &dto.AdminDatasetGenOptionsRes{
|
||||
Species: g.Cfg().MustGet(ctx, "imageGen.speciesByDataset."+dataset.Name).Strings(),
|
||||
Scenes: g.Cfg().MustGet(ctx, "imageGen.scenes").Strings(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// singleAnimalClause 目标数量约束:每张仅一只动物,手填提示词统一追加(模板已由 {count} 固定 "1只")
|
||||
const singleAnimalClause = "画面中有且只有这一只动物,没有任何其他动物"
|
||||
|
||||
// buildPromptFromTemplate 按 config 通用模板组装提示词:物种/场景优先用表单选择
|
||||
// (物种只写名字不写羽毛细节——模型对正确名称自带外观先验;场景未选则池内随机),
|
||||
// 物种未填按数据集名取物种池随机(池也未配置则用数据集名本身);动作/光线池内随机。
|
||||
func (s *datasetService) buildPromptFromTemplate(ctx context.Context, datasetName, species string, dist int, size string) (string, error) {
|
||||
// buildPromptFromTemplate 按 config 通用模板组装提示词(单物种规则:物种固定取数据集 gen_species,
|
||||
// 空则数据集名本身——生成表单无物种输入,不随机)。
|
||||
// 物种只写名字不写羽毛细节(模型对正确名称自带外观先验);场景/动作/遮挡从数据集表池随机
|
||||
// (创建时 VLM 生成,config.yml 不再兜底;池空报错——生成图按池组装,池质量决定训练数据质量);
|
||||
// 光线走 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 == "" {
|
||||
return "", gerror.New("未配置通用提示词模板(imageGen.promptTemplate),请手填提示词")
|
||||
}
|
||||
species := strings.TrimSpace(dataset.GenSpecies)
|
||||
if species == "" {
|
||||
species = datasetName
|
||||
if pool := g.Cfg().MustGet(ctx, "imageGen.speciesByDataset."+datasetName).Strings(); len(pool) > 0 {
|
||||
species = pool[rand.Intn(len(pool))]
|
||||
species = dataset.Name
|
||||
}
|
||||
// 场景/动作/遮挡按数据集习性取池(表存储,VLM 生成;池空报错——生成图按池组装,池质量决定训练数据质量)
|
||||
poolOf := func(raw string) []string {
|
||||
var pool []string
|
||||
if json.Unmarshal([]byte(raw), &pool) != nil {
|
||||
return nil
|
||||
}
|
||||
return pool
|
||||
}
|
||||
scene := pickCfgList(poolOf(dataset.GenScenes))
|
||||
action := pickCfgList(poolOf(dataset.GenActions))
|
||||
occlusion := pickCfgList(poolOf(dataset.GenOcclusions))
|
||||
if scene == "" || action == "" || occlusion == "" {
|
||||
return "", gerror.New("数据集「" + dataset.Name + "」未配置生成参数池(场景/动作/遮挡),请在编辑数据集生成或手填提示词")
|
||||
}
|
||||
// 场景/动作/遮挡按数据集习性取池(鸭水面、雉鸡灌丛……),未配置该数据集回退通用池;
|
||||
// 遮挡:身体完全暴露的个体对识别标注训练无意义,每张必带(程度轻~重随机)
|
||||
scene := pickCfgByDataset(ctx, datasetName, "imageGen.sceneByDataset", "imageGen.scenes")
|
||||
action := pickCfgByDataset(ctx, datasetName, "imageGen.actionByDataset", "imageGen.actions")
|
||||
occlusion := pickCfgByDataset(ctx, datasetName, "imageGen.occlusionByDataset", "imageGen.occlusions")
|
||||
light := pickCfg(ctx, "imageGen.lights")
|
||||
// 轮廓色词按物种(白化个体如白雉鸡配浅色,默认深色剪影)
|
||||
tone := g.Cfg().MustGet(ctx, "imageGen.speciesTone."+species, "深色").String()
|
||||
// 轮廓色词(表 gen_tone,白化个体如白雉鸡配浅色,未配置默认深色剪影)
|
||||
tone := strings.TrimSpace(dataset.GenTone)
|
||||
if tone == "" {
|
||||
tone = "深色"
|
||||
}
|
||||
// 距离描述从表单固定值生成(25 → "25米外")
|
||||
distWord := fmt.Sprintf("%d米外", dist)
|
||||
// 尺寸提示:按物理公式把表单距离换算成目标在图中的像素高与画面占比
|
||||
sizeHint, sErr := s.buildSizeHint(ctx, species, dist, size)
|
||||
sizeHint, sErr := s.buildSizeHint(ctx, dataset, dist, size)
|
||||
if sErr != nil {
|
||||
return "", sErr
|
||||
}
|
||||
// 每张固定 1 个目标(数量词固定,animal_count 列同为 1 供自动标注裁剪)
|
||||
countWord := "1只"
|
||||
// 性别随机(50/50):两性体型与外观差异大(雉鸡雄艳雌褐、野鸭雄艳雌素等),
|
||||
// 每张随机让训练数据覆盖两性形态
|
||||
sexWord := "雄性"
|
||||
if rand.Intn(2) == 1 {
|
||||
sexWord = "雌性"
|
||||
}
|
||||
out := tpl
|
||||
for k, v := range map[string]string{
|
||||
"{scene}": scene, "{species}": species, "{count}": countWord,
|
||||
"{scene}": scene, "{species}": sexWord + species, "{count}": countWord,
|
||||
"{action}": action, "{occlusion}": occlusion, "{light}": light,
|
||||
"{distanceWord}": distWord, "{sizeHint}": sizeHint, "{tone}": tone,
|
||||
} {
|
||||
@@ -637,13 +932,16 @@ func (s *datasetService) buildPromptFromTemplate(ctx context.Context, datasetNam
|
||||
// buildSizeHint 尺寸提示:目标像素高 = 体高 × focalPx ÷ 距离(focalPx = 图高/2 ÷ tan(vfov/2)),
|
||||
// 输出如「(在画面中高度仅约15像素,占画面高度约1.2%)」。
|
||||
// 占比向下取整且至少 1%,避免出现 0%
|
||||
func (s *datasetService) buildSizeHint(ctx context.Context, species string, dist int, size string) (string, error) {
|
||||
func (s *datasetService) buildSizeHint(ctx context.Context, dataset *entity.Dataset, dist int, size string) (string, error) {
|
||||
_, imgH, err := parseGenSize(size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 配置单位为 cm(实际体型),换算成米参与投影公式;未配置物种兜底 35cm
|
||||
heightCM := g.Cfg().MustGet(ctx, "imageGen.speciesHeights."+species, 35.0).Float64()
|
||||
// 站高 cm 从数据集表 gen_heights 读(数据库维护,编辑界面手改真实值;<=0 物理兜底 35cm),换算成米参与投影公式
|
||||
heightCM := dataset.GenHeights
|
||||
if heightCM <= 0 {
|
||||
heightCM = 35.0
|
||||
}
|
||||
heightM := heightCM / 100
|
||||
// 镜头固定为手机默认主摄(垂直视场角配置值,默认 52°;2026-08-28 曾随机 [15,90]、曾试超广角,用户最终定为默认镜头)
|
||||
vfov := g.Cfg().MustGet(ctx, "imageGen.assumedVfovDeg", 52.0).Float64() * math.Pi / 180
|
||||
@@ -677,24 +975,20 @@ func parseGenSize(size string) (int, int, error) {
|
||||
|
||||
// pickCfg 取字符串数组配置项随机一项(空池返回空串)
|
||||
func pickCfg(ctx context.Context, key string) string {
|
||||
pool := g.Cfg().MustGet(ctx, key).Strings()
|
||||
return pickCfgList(g.Cfg().MustGet(ctx, key).Strings())
|
||||
}
|
||||
|
||||
// pickCfgList 从字符串数组随机取一项(空池返回空串)
|
||||
func pickCfgList(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.Intn(len(pool))]
|
||||
}
|
||||
|
||||
// pickCfgByDataset 按数据集名取配置池(key 形如 imageGen.sceneByDataset.<数据集名>)随机,
|
||||
// 该数据集未配置时回退通用池
|
||||
func pickCfgByDataset(ctx context.Context, datasetName, byDatasetKey, genericKey string) string {
|
||||
if pool := g.Cfg().MustGet(ctx, byDatasetKey+"."+datasetName).Strings(); len(pool) > 0 {
|
||||
return pool[rand.Intn(len(pool))]
|
||||
}
|
||||
return pickCfg(ctx, genericKey)
|
||||
}
|
||||
|
||||
// AdminGenerateImages AI 生成图片:同步逐张生成(每张超时 imageGen.timeoutSeconds),逐张落盘 + 入库;
|
||||
// 中途失败返回错误并附已成功张数(已生成的图为付费资产,保留不删除)。
|
||||
// AdminGenerateImages AI 生成图片(异步任务):校验通过后插 gen_task 立即返回 TaskId,
|
||||
// 后台协程逐张生成(每张超时 imageGen.timeoutSeconds),进度落库供前端轮询;
|
||||
// 完成(或部分失败)后对本次新增图触发自动标注(付费资产,失败保留已生成图不删除)。
|
||||
func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) {
|
||||
provider := common.ImageGen(ctx)
|
||||
if provider == nil {
|
||||
@@ -715,17 +1009,6 @@ func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.Admin
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, gerror.Wrap(err, "创建图片目录失败")
|
||||
}
|
||||
now := gtime.Now()
|
||||
generated := 0
|
||||
addedIds := make([]int64, 0, req.Count)
|
||||
// 每张生成超时走配置(dashscope 默认 120s;localai 训练机约 5min/张,建议 600)
|
||||
genTimeout := time.Duration(g.Cfg().MustGet(ctx, "imageGen.timeoutSeconds", 120).Int()) * time.Second
|
||||
handWritten := strings.TrimSpace(req.Prompt) != ""
|
||||
genOnce := func(prompt string) ([]byte, error) {
|
||||
genCtx, cancel := context.WithTimeout(ctx, genTimeout)
|
||||
defer cancel()
|
||||
return provider.Generate(genCtx, prompt, req.Size)
|
||||
}
|
||||
// 命名:数据集属性 name_prefix(创建/编辑表单维护)非空时按 <前缀>_<两位序号>.jpg 顺序编号
|
||||
// (延续数据集内已有最大序号,参考既有图片目录的连续编号惯例);无前缀按时间戳命名。
|
||||
// 注意:gtime.Time.Format 是 PHP 风格格式符,Go 数字布局须用 .Time.Format,否则原样透传
|
||||
@@ -740,85 +1023,186 @@ func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.Admin
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rejected := 0
|
||||
for i := 0; i < req.Count; i++ {
|
||||
// 部分成功也要标注:报错返回前对已入库图片触发自动标注(外部重试驱动依赖此语义攒量)
|
||||
failWith := func(err error) (*dto.AdminDatasetGenerateRes, error) {
|
||||
if generated > 0 && len(addedIds) > 0 {
|
||||
if newImages, lErr := dao.DatasetImage.GetByIds(ctx, addedIds); lErr == nil {
|
||||
if aErr := LabelTask.AutoLabel(ctx, dataset, newImages); aErr != nil {
|
||||
g.Log().Errorf(ctx, "部分成功后自动标注触发失败: %+v", aErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &dto.AdminDatasetGenerateRes{Generated: generated, Rejected: rejected}, err
|
||||
}
|
||||
// 提示词:手填优先;未手填走 config 通用模板逐张组装——物种用表单选择(未填池内随机),
|
||||
// 场景/动作/光线随机。距离校验已取消(2026-08-28)
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if handWritten {
|
||||
// 手填提示词统一追加数量约束:仅一只动物(拍摄角度不带,交由模型平视先验自由构图)
|
||||
prompt = strings.TrimSuffix(prompt, "。") + "。"
|
||||
if !strings.Contains(prompt, "一只") && !strings.Contains(prompt, "1只") && !strings.Contains(prompt, "唯一") {
|
||||
prompt += singleAnimalClause
|
||||
}
|
||||
}
|
||||
if !handWritten {
|
||||
var tErr error
|
||||
if prompt, tErr = s.buildPromptFromTemplate(ctx, dataset.Name, strings.TrimSpace(req.Species), req.Distance, req.Size); tErr != nil {
|
||||
return nil, tErr
|
||||
}
|
||||
}
|
||||
data, genErr := genOnce(prompt)
|
||||
if genErr != nil {
|
||||
// 付费资产保留原则:已生成的不删除
|
||||
return failWith(gerror.NewCode(common.CodeImageGenFailed, fmt.Sprintf("第 %d 张生成失败(已生成 %d 张): %v", i+1, generated, genErr)))
|
||||
}
|
||||
filename := ""
|
||||
if prefix != "" {
|
||||
filename = fmt.Sprintf("%s_%02d.jpg", prefix, nextIdx)
|
||||
nextIdx++
|
||||
} else {
|
||||
filename = fmt.Sprintf("gen_%s_%d.jpg", now.Time.Format("20060102150405"), i)
|
||||
}
|
||||
if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil {
|
||||
return failWith(gerror.Wrapf(err, "第 %d 张保存失败(已生成 %d 张)", i+1, generated))
|
||||
}
|
||||
var imgId int64
|
||||
insErr := common.Serial().Submit(ctx, func() error {
|
||||
id, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{
|
||||
DatasetId: dataset.Id,
|
||||
Filename: filename,
|
||||
Source: "ai",
|
||||
Prompt: prompt,
|
||||
AnimalCount: 1,
|
||||
CreatedAt: now,
|
||||
})
|
||||
imgId = id
|
||||
return err
|
||||
})
|
||||
if insErr != nil {
|
||||
return failWith(gerror.Wrap(insErr, "生成图片入库失败"))
|
||||
}
|
||||
addedIds = append(addedIds, imgId)
|
||||
generated++
|
||||
}
|
||||
// Serial 内并发检查 + 插任务:同数据集已有 running 生成任务则拒绝
|
||||
now := gtime.Now()
|
||||
var taskId int64
|
||||
if err := common.Serial().Submit(ctx, func() error {
|
||||
return dao.Dataset.UpdateCounters(ctx, dataset.Id, int64(generated), -1, "")
|
||||
running, err := dao.GenTask.GetRunningByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if running != nil {
|
||||
return gerror.NewCode(common.CodeGenTaskRunning)
|
||||
}
|
||||
taskId, err = dao.GenTask.Insert(ctx, &entity.GenTask{
|
||||
DatasetId: dataset.Id,
|
||||
Status: consts.GenTaskRunning,
|
||||
Total: req.Count,
|
||||
CreatedAt: now,
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 自动触发标注:忙(已有 running 任务)不报错,由任务完成后的自动补标轮兜底;
|
||||
// 其他失败报错但保留已生成图(付费资产,不可删)
|
||||
s.runGenTask(taskId, dataset, req, provider, now, prefix, nextIdx)
|
||||
return &dto.AdminDatasetGenerateRes{TaskId: taskId, Total: req.Count}, nil
|
||||
}
|
||||
|
||||
// recoverGenTasks 服务重启恢复:孤儿 running 生成任务置 failed + 错误提示
|
||||
// (生成中断后已生成图为付费资产保留;重新发起即可补齐,startDetection 并发检查不受孤儿影响)。
|
||||
func (s *datasetService) recoverGenTasks(ctx context.Context) {
|
||||
list, err := dao.GenTask.ListRunning(ctx)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "恢复生成任务失败: %+v", err)
|
||||
return
|
||||
}
|
||||
for _, t := range list {
|
||||
if err := dao.GenTask.Finish(ctx, t.Id, "服务重启,任务中断,可重新发起"); err != nil {
|
||||
g.Log().Errorf(ctx, "恢复生成任务 %d 失败: %+v", t.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runGenTask 生成执行协程(生命周期任务):脱离请求 ctx(请求结束即取消会让 Submit 秒退 +
|
||||
// Finish 静默失败 → 任务悬挂),池内逐张生成(imageGen.poolSize 并发,z-image 显存独占默认 1),
|
||||
// 每张成功后 Serial 更新进度;全部完成 → 更新数据集计数 → 自动标注 → 置 done;
|
||||
// 中途失败 → 已生成图先触发自动标注 → 置 failed(错误信息带已生成张数)。
|
||||
func (s *datasetService) runGenTask(taskId int64, dataset *entity.Dataset, req *dto.AdminDatasetGenerateReq, provider common.ImageGenProvider, now *gtime.Time, prefix string, nextIdx int) {
|
||||
bgCtx := context.Background()
|
||||
go func() {
|
||||
dir := common.DatasetImagesDir(bgCtx, dataset.Name)
|
||||
// 每张生成超时走配置(dashscope 默认 120s;localai 训练机约 5min/张,建议 600)
|
||||
genTimeout := time.Duration(g.Cfg().MustGet(bgCtx, "imageGen.timeoutSeconds", 120).Int()) * time.Second
|
||||
handWritten := strings.TrimSpace(req.Prompt) != ""
|
||||
generated := 0
|
||||
addedIds := make([]int64, 0, req.Count)
|
||||
failed := ""
|
||||
for i := 0; i < req.Count; i++ {
|
||||
// 提示词:手填优先(统一追加数量约束:仅一只动物,拍摄角度不带,
|
||||
// 交由模型平视先验自由构图);未手填走 config 通用模板逐张组装。距离校验已取消(2026-08-28)
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if handWritten {
|
||||
prompt = strings.TrimSuffix(prompt, "。") + "。"
|
||||
if !strings.Contains(prompt, "一只") && !strings.Contains(prompt, "1只") && !strings.Contains(prompt, "唯一") {
|
||||
prompt += singleAnimalClause
|
||||
}
|
||||
} else {
|
||||
var tErr error
|
||||
if prompt, tErr = s.buildPromptFromTemplate(bgCtx, dataset, req.Distance, req.Size); tErr != nil {
|
||||
failed = tErr.Error()
|
||||
break
|
||||
}
|
||||
}
|
||||
genCtx, cancel := context.WithTimeout(bgCtx, genTimeout)
|
||||
var data []byte
|
||||
err := common.GenTaskPoolInstance().Submit(genCtx, func(ctx context.Context) error {
|
||||
d, e := provider.Generate(ctx, prompt, req.Size)
|
||||
data = d
|
||||
return e
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
// 付费资产保留原则:已生成的不删除
|
||||
failed = fmt.Sprintf("第 %d 张生成失败(已生成 %d 张): %v", i+1, generated, err)
|
||||
break
|
||||
}
|
||||
filename := ""
|
||||
if prefix != "" {
|
||||
filename = fmt.Sprintf("%s_%02d.jpg", prefix, nextIdx)
|
||||
nextIdx++
|
||||
} else {
|
||||
filename = fmt.Sprintf("gen_%s_%d.jpg", now.Time.Format("20060102150405"), i)
|
||||
}
|
||||
// 生成图统一转 jpg(模型可能返回 png/webp,扩展名 .jpg 须与内容一致)
|
||||
data, jErr := ensureJpeg(data)
|
||||
if jErr != nil {
|
||||
failed = fmt.Sprintf("第 %d 张转 jpg 失败(已生成 %d 张): %v", i+1, generated, jErr)
|
||||
break
|
||||
}
|
||||
if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil {
|
||||
failed = fmt.Sprintf("第 %d 张保存失败(已生成 %d 张): %v", i+1, generated, err)
|
||||
break
|
||||
}
|
||||
var imgId int64
|
||||
if insErr := common.Serial().Submit(bgCtx, func() error {
|
||||
id, err := dao.DatasetImage.Insert(bgCtx, &entity.DatasetImage{
|
||||
DatasetId: dataset.Id,
|
||||
Filename: filename,
|
||||
Source: "ai",
|
||||
Prompt: prompt,
|
||||
AnimalCount: 1,
|
||||
CreatedAt: now,
|
||||
})
|
||||
imgId = id
|
||||
return err
|
||||
}); insErr != nil {
|
||||
failed = fmt.Sprintf("第 %d 张入库失败(已生成 %d 张): %v", i+1, generated, insErr)
|
||||
break
|
||||
}
|
||||
addedIds = append(addedIds, imgId)
|
||||
generated++
|
||||
_ = common.Serial().Submit(bgCtx, func() error {
|
||||
return dao.GenTask.UpdateProgress(bgCtx, taskId, generated)
|
||||
})
|
||||
}
|
||||
if failed != "" {
|
||||
s.genTaskAutoLabel(bgCtx, dataset, addedIds)
|
||||
_ = dao.GenTask.Finish(bgCtx, taskId, failed)
|
||||
return
|
||||
}
|
||||
// 全部成功:更新数据集计数 → 自动标注 → 置 done
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil {
|
||||
return &dto.AdminDatasetGenerateRes{Generated: generated, Rejected: rejected},
|
||||
gerror.Wrap(err, "图片已生成入库,但自动标注触发失败")
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
return &dto.AdminDatasetGenerateRes{Generated: generated, Rejected: rejected}, nil
|
||||
t, err := dao.GenTask.LatestByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &dto.AdminGenTaskQueryRes{
|
||||
Id: t.Id,
|
||||
DatasetId: t.DatasetId,
|
||||
Status: t.Status,
|
||||
Total: t.Total,
|
||||
Done: t.Done,
|
||||
Error: t.Error,
|
||||
CreatedAt: t.CreatedAt,
|
||||
FinishedAt: t.FinishedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminListImages 数据集图片列表(创建时间正序,标注工作台/网格预览)
|
||||
|
||||
@@ -301,6 +301,14 @@ func (s *labelTaskService) AdminImageVlmReview(ctx context.Context, req *dto.Adm
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
// 显存互斥:该数据集有生成任务进行中(z-image 占满显存)时拒绝 VLM 补检
|
||||
running, err := dao.GenTask.GetRunningByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if running != nil {
|
||||
return nil, gerror.NewCode(common.CodeGenTaskRunning)
|
||||
}
|
||||
img, err := dao.DatasetImage.GetById(ctx, req.ImageId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -327,15 +335,10 @@ func (s *labelTaskService) AdminImageVlmReview(ctx context.Context, req *dto.Adm
|
||||
}
|
||||
exclude = strings.Join(parts, " ")
|
||||
}
|
||||
// 物种:从生成提示词与数据集物种池匹配(匹配不到交给 VLM 从已确认框自行判断)
|
||||
// 物种:从生成提示词与数据集物种(gen_species 单值)匹配(匹配不到交给 VLM 从已确认框自行判断)
|
||||
species := ""
|
||||
if pool := g.Cfg().MustGet(ctx, "imageGen.speciesByDataset."+dataset.Name).Strings(); len(pool) > 0 {
|
||||
for _, name := range pool {
|
||||
if strings.Contains(img.Prompt, name) {
|
||||
species = name
|
||||
break
|
||||
}
|
||||
}
|
||||
if dataset.GenSpecies != "" && strings.Contains(img.Prompt, dataset.GenSpecies) {
|
||||
species = dataset.GenSpecies
|
||||
}
|
||||
speciesLine := "从已确认目标框自行判断物种"
|
||||
if species != "" {
|
||||
|
||||
@@ -15,13 +15,14 @@ type modelVersionService struct{}
|
||||
var ModelVersion = &modelVersionService{}
|
||||
|
||||
// ClientCatalog 客户端模型目录:全部数据集当前生效模型(App 多模型下载热更新)。
|
||||
// downloadUrl 复用 /download 静态托管(/download/trainings/<数据集名>.tflite)。
|
||||
// downloadUrl 复用 /download 静态托管(/download/trainings/<文件名前缀>.tflite,前缀空回退数据集名)。
|
||||
func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCatalogRes, error) {
|
||||
list, err := dao.ModelVersion.ListAllLatest(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := Training.datasetNameMap(ctx)
|
||||
modelNames := Training.datasetModelNameMap(ctx)
|
||||
items := make([]*dto.ModelCatalogItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
datasetName := names[v.DatasetId]
|
||||
@@ -37,7 +38,7 @@ func (s *modelVersionService) ClientCatalog(ctx context.Context) (*dto.ModelCata
|
||||
Sha256: v.Sha256,
|
||||
Notes: v.Notes,
|
||||
PublishedAt: v.CreatedAt,
|
||||
DownloadUrl: "/download/trainings/" + datasetName + ".tflite",
|
||||
DownloadUrl: "/download/trainings/" + modelNames[v.DatasetId] + ".tflite",
|
||||
})
|
||||
}
|
||||
return &dto.ModelCatalogRes{List: items}, nil
|
||||
|
||||
@@ -27,10 +27,11 @@ type trainingService struct{}
|
||||
|
||||
var Training = &trainingService{}
|
||||
|
||||
// StartBackgroundJobs 启动后台协程:训练进度轮询 + 孤儿预标注任务恢复(main.go 启动时调用)。
|
||||
// StartBackgroundJobs 启动后台协程:训练进度轮询 + 孤儿预标注/生成任务恢复(main.go 启动时调用)。
|
||||
// 单协程生命周期任务(非并行工作负载),不做池封装。
|
||||
func (s *trainingService) StartBackgroundJobs(ctx context.Context) {
|
||||
LabelTask.recoverLabelTasks(ctx)
|
||||
Dataset.recoverGenTasks(ctx)
|
||||
if err := dao.Training.FailUnstarted(ctx); err != nil {
|
||||
g.Log().Errorf(ctx, "恢复未启动训练任务失败: %+v", err)
|
||||
}
|
||||
@@ -139,7 +140,7 @@ func (s *trainingService) handleResult(ctx context.Context, runner common.Traini
|
||||
}
|
||||
|
||||
// finishSuccess 训练成功:解析 result.json(最终指标 + 类别名)→ 拉取 tflite → 更新任务。
|
||||
// tflite 直写 trainings/<数据集名>.tflite(当前生效模型唯一位,无 per-task 存档、无 zip)。
|
||||
// tflite 直写 trainings/<文件名前缀>.tflite(前缀空回退数据集名;当前生效模型唯一位,无 per-task 存档、无 zip)。
|
||||
func (s *trainingService) finishSuccess(ctx context.Context, runner common.TrainingRunner, job *common.TrainingJob, t *entity.ModelTraining, result, tail string) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
|
||||
if err != nil {
|
||||
@@ -180,7 +181,7 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
|
||||
_ = s.finishFailed(ctx, t, "训练完成但 result.json 缺少 best_tflite")
|
||||
return
|
||||
}
|
||||
dest := common.TrainingModelPath(ctx, dataset.Name)
|
||||
dest := common.TrainingModelPath(ctx, modelFileName(dataset.Name, dataset.NamePrefix))
|
||||
if err := runner.FetchArtifact(ctx, job, res.BestTflite, dest); err != nil {
|
||||
g.Log().Errorf(ctx, "训练 %d 拉取 best.tflite 失败: %+v", t.Id, err)
|
||||
_ = s.finishFailed(ctx, t, "拉取训练产物失败: %v", err)
|
||||
@@ -324,6 +325,27 @@ func (s *trainingService) datasetNameMap(ctx context.Context) map[int64]string {
|
||||
return m
|
||||
}
|
||||
|
||||
// datasetModelNameMap 全量数据集 id → 模型文件基名(训练产物命名,避免 N+1)
|
||||
func (s *trainingService) datasetModelNameMap(ctx context.Context) map[int64]string {
|
||||
m := map[int64]string{}
|
||||
list, err := dao.Dataset.ListAll(ctx)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
for _, d := range list {
|
||||
m[d.Id] = modelFileName(d.Name, d.NamePrefix)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// modelFileName 模型文件基名:优先数据集文件名前缀(name_prefix),空则回退数据集名(存量数据集无前缀)
|
||||
func modelFileName(name, prefix string) string {
|
||||
if p := strings.TrimSpace(prefix); p != "" {
|
||||
return p
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// AdminStartTraining 发起训练:并发度 1(已有 running 拒绝);先本地整理 yolo 训练集
|
||||
// (80/20 拆 train/val,有标注才可训练)落 running 记录,请求毫秒级返回。
|
||||
// 训练机侧准备(写任务参数 → 同步数据集 → 启动进程)耗时可达分钟级(ssh 同步整包),
|
||||
@@ -398,7 +420,7 @@ func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.Admin
|
||||
trainPath := filepath.Join(cfg.Workdir, cfg.DatasetDir, "yolo", dataset.Name)
|
||||
pkg.Files = append(pkg.Files, common.YoloFile{
|
||||
Name: "dataset.yaml",
|
||||
Content: []byte(yoloYamlContent(trainPath, localAiClassNames(bgCtx))),
|
||||
Content: []byte(yoloYamlContent(trainPath, localAiClassNames(dataset))),
|
||||
})
|
||||
taskJSON, _ := json.Marshal(map[string]any{
|
||||
"workdir": cfg.Workdir,
|
||||
@@ -442,13 +464,18 @@ func yoloYamlContent(trainPath string, names []string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// localAiClassNames 标注类别名(config.yml localAi.classNames,默认 class0/class1)
|
||||
func localAiClassNames(ctx context.Context) []string {
|
||||
names := g.Cfg().MustGet(ctx, "localAi.classNames").Strings()
|
||||
if len(names) == 0 {
|
||||
// localAiClassNames 标注类别名:第一类别=数据集物种(gen_species,空回退数据集名),
|
||||
// 第二类别=gen_classes 单值("suspect");空则回退 class0/class1(未生成参数池的存量数据集,提示生成后再训练)
|
||||
func localAiClassNames(dataset *entity.Dataset) []string {
|
||||
species := strings.TrimSpace(dataset.GenSpecies)
|
||||
if species == "" {
|
||||
species = dataset.Name
|
||||
}
|
||||
cls := strings.TrimSpace(dataset.GenClasses)
|
||||
if cls == "" {
|
||||
return []string{"class0", "class1"}
|
||||
}
|
||||
return names
|
||||
return []string{species, cls}
|
||||
}
|
||||
|
||||
// AdminTrainingDetail 训练任务详情(含日志尾部)
|
||||
@@ -518,7 +545,7 @@ func (s *trainingService) AdminCancelTraining(ctx context.Context, req *dto.Admi
|
||||
return &dto.AdminTrainingCancelRes{}, nil
|
||||
}
|
||||
|
||||
// AdminPublish 发布模型版本:仅 success 任务 + trainings/<数据集名>.tflite 存在;
|
||||
// AdminPublish 发布模型版本:仅 success 任务 + trainings/<文件名前缀>.tflite 存在(前缀空回退数据集名);
|
||||
// 版本号同数据集内 m<major>.<minor>.<patch> 自增(无记录从 m1.0.0 起)。
|
||||
// 文件在训练成功时已直写最终位置(无额外副本),发布仅落版本记录(sha256/size 取自现有文件)。
|
||||
func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTrainingPublishReq) (*dto.AdminTrainingPublishRes, error) {
|
||||
@@ -539,7 +566,7 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
bestTflite := common.TrainingModelPath(ctx, dataset.Name)
|
||||
bestTflite := common.TrainingModelPath(ctx, modelFileName(dataset.Name, dataset.NamePrefix))
|
||||
data, err := os.ReadFile(bestTflite)
|
||||
if err != nil {
|
||||
return nil, gerror.New("训练产物 tflite 缺失,无法发布")
|
||||
@@ -576,7 +603,7 @@ func (s *trainingService) AdminPublish(ctx context.Context, req *dto.AdminTraini
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 文件已由训练成功直写 trainings/<数据集名>.tflite,发布仅落版本记录,无额外副本
|
||||
// 文件已由训练成功直写 trainings/<文件名前缀>.tflite,发布仅落版本记录,无额外副本
|
||||
return &dto.AdminTrainingPublishRes{Version: version}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,5 @@ var (
|
||||
CodeLabelTaskRunning = gcode.New(1020, "该数据集已有预标注任务进行中", nil)
|
||||
CodeLocalAiNotConfigured = gcode.New(1021, "标注服务未配置(localAi 节点)", nil)
|
||||
CodeImageNotFound = gcode.New(1022, "图片不存在", nil)
|
||||
CodeGenTaskRunning = gcode.New(1023, "该数据集已有生成任务进行中(生成与 VLM 补检显存互斥,等任务完成)", nil)
|
||||
)
|
||||
|
||||
@@ -91,3 +91,44 @@ func (p *LabelTaskPool) Submit(ctx context.Context, fn func(ctx context.Context)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// GenTaskPool 文生图任务并发池:逐张调 local-ai 生成图片(IO 等待为主),
|
||||
// 并发度来自 config.yml imageGen.poolSize(z-image 显存独占,默认 1),
|
||||
// 缺失或非法时回退 consts 默认值。池内任务禁止提交本池(防 worker 饿死死锁)。
|
||||
type GenTaskPool struct {
|
||||
pool *grpool.Pool
|
||||
}
|
||||
|
||||
var (
|
||||
genTaskPoolOnce sync.Once
|
||||
genTaskPool *GenTaskPool
|
||||
)
|
||||
|
||||
// GenTaskPoolInstance 进程级文生图池单例(懒初始化,读取配置)。
|
||||
func GenTaskPoolInstance() *GenTaskPool {
|
||||
genTaskPoolOnce.Do(func() {
|
||||
ctx := context.Background()
|
||||
size := g.Cfg().MustGet(ctx, "imageGen.poolSize", consts.GenPoolDefaultSize).Int()
|
||||
if size <= 0 {
|
||||
size = consts.GenPoolDefaultSize
|
||||
}
|
||||
genTaskPool = &GenTaskPool{pool: grpool.New(size, size)}
|
||||
})
|
||||
return genTaskPool
|
||||
}
|
||||
|
||||
// Submit 提交单张图片的生成任务并等待完成,返回任务的 error。
|
||||
func (p *GenTaskPool) Submit(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
res := make(chan error, 1)
|
||||
if err := p.pool.Add(ctx, func(ctx context.Context) {
|
||||
res <- fn(ctx)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case err := <-res:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
// 训练体系运行时数据布局(config.yml app.datasetDir,默认 ./workspace,挂载持久化、不提交 git):
|
||||
//
|
||||
// datasets/<name>/ 数据集图片(平铺,文件名唯一,标注存 DB dataset_image.labels_json)
|
||||
// trainings/<name>.tflite 某数据集当前生效模型(训练成功即直写,客户端固定下载,无存档回退)
|
||||
// trainings/<model>.tflite 某数据集当前生效模型(训练成功即直写,客户端固定下载,无存档回退;
|
||||
// <model> = 数据集文件名前缀 name_prefix,空则回退数据集名,2026-08-28)
|
||||
//
|
||||
// 训练机与 Go 服务器异机时,数据集经 training 通道同步(见 common/training_runner.go)。
|
||||
|
||||
@@ -26,9 +27,10 @@ func DatasetImagesDir(ctx context.Context, datasetName string) string {
|
||||
return filepath.Join(DatasetDir(ctx), "datasets", datasetName)
|
||||
}
|
||||
|
||||
// TrainingModelPath 某数据集当前生效模型文件(训练成功即从训练机直写,客户端固定下载)
|
||||
func TrainingModelPath(ctx context.Context, datasetName string) string {
|
||||
return filepath.Join(DatasetDir(ctx), "trainings", datasetName+".tflite")
|
||||
// TrainingModelPath 当前生效模型文件路径(modelName 为模型文件基名 = 数据集文件名前缀,
|
||||
// 空则回退数据集名——见 service.modelFileName;训练成功即从训练机直写,客户端固定下载)
|
||||
func TrainingModelPath(ctx context.Context, modelName string) string {
|
||||
return filepath.Join(DatasetDir(ctx), "trainings", modelName+".tflite")
|
||||
}
|
||||
|
||||
// Sha256Hex 计算文件内容 SHA-256 十六进制(模型版本校验用)
|
||||
|
||||
@@ -34,117 +34,33 @@ app:
|
||||
# 图像生成(管理端「AI 生成图片」):provider 必填项缺失时生成接口返回「图像生成服务未配置」
|
||||
imageGen:
|
||||
provider: localai # dashscope | localai
|
||||
baseUrl: "http://192.168.3.210:18080" # localai 必填:训练机 local-ai 地址(/v1/images/generations)
|
||||
model: z-image-turbo # localai 上加载的模型名;dashscope 用 qwen-image-3.0
|
||||
baseUrl: "http://localhost:8080" # localai 必填:训练机 local-ai 地址(/v1/images/generations)
|
||||
model: flux2-klein # localai 上加载的模型名;dashscope 用 qwen-image-3.0
|
||||
apiKey: "" # dashscope 必填:DashScope API Key
|
||||
timeoutSeconds: 600 # 每张生成超时(默认 120;z-image-turbo 首次加载含模型入显存,稳态秒级~分钟级)
|
||||
# 通用提示词模板(管理端填「动物名称」时启用,每张随机取池组装):{species} 优先用表单填写的名称,
|
||||
# 未填则按数据集名取 speciesByDataset 池(再未配置直接用数据集名);{count} 为数量词(固定 "1只",每张仅 1 个目标;原表单
|
||||
# 数量选择已取消);{scene}/{action}/{light} 池内随机;{tone} 为物种轮廓色词(默认深色,白化个体配浅色);
|
||||
# 物种只写名字不写羽毛细节(细节会把镜头拉近,详见技术设计)。不带拍摄角度描述(2026-08-28):
|
||||
# 角度描述会与目标位置耦合,仰拍使贴地目标沉底、固定俯拍构图单一——交由模型平视先验自由构图,
|
||||
# 目标自然落在中部/上部
|
||||
poolSize: 1 # 生成任务并发度(z-image 显存独占,默认 1)
|
||||
# 通用提示词模板:{species} 取数据集 gen_species(单值,空回退数据集名);{count} 为数量词
|
||||
# (固定 "1只",每张仅 1 个目标;原表单数量选择已取消);{scene}/{action}/{light} 池内随机
|
||||
# (scene/action/occlusion 从数据集表 gen_scenes/gen_actions/gen_occlusions 池随机,无 config 兜底);
|
||||
# {tone} 取数据集表 gen_tone(默认深色,白化个体配浅色);{sizeHint} 按数据集表 gen_heights 站高与
|
||||
# 表单距离算出像素占比。物种只写名字不写羽毛细节(细节会把镜头拉近,详见技术设计)。不带拍摄角度
|
||||
# 描述(2026-08-28):角度描述会与目标位置耦合,仰拍使贴地目标沉底、固定俯拍构图单一——
|
||||
# 交由模型平视先验自由构图,目标自然落在中部/上部
|
||||
promptTemplate: "野外大远景实拍:广角镜头下{scene},{distanceWord}有且仅有{count}{species}的小小身影{sizeHint}在{action},{occlusion},{species}离镜头很远,只是很小的{tone}轮廓,隐约可辨,画面绝大部分是{scene},没有任何靠近镜头的动物,{light},写实野外摄影,真实照片质感"
|
||||
speciesByDataset: # 数据集名 → 物种池(表单未填动物名称时随机取一个)
|
||||
鸽子: [家鸽,火斑鸠,虎纹斑鸠,珠颈斑鸠,灰斑鸠]
|
||||
雉鸡: [雉鸡,白雉鸡]
|
||||
兔子: [兔子]
|
||||
鸭子: [野鸭]
|
||||
鹌鹑: [鹌鹑]
|
||||
speciesTone: # 物种轮廓色词(模板 {tone} 占位,未配置默认深色):远景小目标深色剪影易辨,
|
||||
# 白化个体(白雉鸡)用「深色轮廓」描述与白色羽毛矛盾,须配浅色
|
||||
白雉鸡: 浅色
|
||||
scenes: # 环境场景池(统一远景原则:每条含开阔/延伸向远方/地平线元素,
|
||||
# 把地平线推开、消除近拍暗示;同一短语在模板中出现两次,须两处都读得通)
|
||||
- "一望无际的玉米地,玉米秆两米多高,田间土路和田埂延伸向地平线"
|
||||
- "一望无际的金黄麦茬田,收割后一直铺到地平线"
|
||||
- "乡村土路穿过广阔的农田伸向远方,路边杂草丛生"
|
||||
- "广阔的农田和道路延伸向远方,一根细电线横贯画面远处"
|
||||
- "广阔田野间一棵高大的树,四周的农田一直延伸到地平线"
|
||||
- "村边开阔的打谷场延伸向远方,尽头是零星的农舍"
|
||||
- "收割后的稻茬田一望无际,铺向远处的村庄"
|
||||
- "果树成行的果园延伸向远方,树下是剪短的草地"
|
||||
- "开阔的沙石滩地和蜿蜒的乡间水渠延伸向远处的田野"
|
||||
- "长满枯黄荒草的田埂和弃耕地延伸向远方"
|
||||
actions: [觅食, 走动啄食, 低头啄食, 踱步, 停落休憩]
|
||||
lights: [清晨柔和阳光, 正午明亮天光, 傍晚金色斜阳, 阴天柔和散射光]
|
||||
sceneByDataset: # 数据集名 → 场景池(按动物习性配置,未配置的数据集回退通用 scenes;
|
||||
# 每条仍含「延伸向远方」元素,且同一短语在模板中出现两次须两处都读得通)
|
||||
雉鸡:
|
||||
- "田边茂密的草丛和矮灌丛连绵延伸到远方"
|
||||
- "荒草坡和灌木丛连成一片,一直铺向远处地平线"
|
||||
- "收割后的农田边缘杂草丛生,矮灌丛点缀延伸到远方"
|
||||
- "稀疏灌木点缀的草坡,一直延伸到远处的林地边缘"
|
||||
兔子:
|
||||
- "枯黄的草坡和荒草地一直延伸到远方"
|
||||
- "弃耕地和田埂长满野草,延伸向远处的林地"
|
||||
- "草丛和矮灌丛交错的荒地,向远处延展"
|
||||
鸭子:
|
||||
- "宽阔的池塘水面延伸到远方,岸边芦苇丛生"
|
||||
- "水田和浅滩连成一片,水面延伸到远处"
|
||||
- "河湖水面平展到远方,岸边是泥滩和杂草"
|
||||
鹌鹑:
|
||||
- "茂密草丛的田野,草层延伸到远方"
|
||||
- "稻茬田边草丛和荒地相连,延伸向远处"
|
||||
actionByDataset: # 数据集名 → 动作池(按动物习性;动作词不得以「在/于」开头,模板为「在{action}」)
|
||||
雉鸡: [低头啄食, 草丛中觅食, 踱步, 停歇理羽, 警觉张望]
|
||||
兔子: [低头啃食草叶, 警觉地竖起耳朵, 小步跳跃前行, 蹲坐不动, 啃食草叶]
|
||||
鸭子: [缓缓游动于水面, 低头潜入水中觅食, 水面梳理羽毛, 缓慢划水前行, 岸边泥滩觅食]
|
||||
鹌鹑: [低头啄食, 草丛间觅食, 小步疾走, 停歇不动]
|
||||
# 遮挡描述池(模板 {occlusion} 占位,插在「在{action}」之后):身体完全暴露的个体对识别/标注训练
|
||||
# 无意义,每张必取一条(全池覆盖轻~重遮挡,程度随机);遮挡物多样(杂草/树叶/枝条等),须与场景自洽
|
||||
occlusions: # 通用遮挡池(未配置 occlusionByDataset 的数据集回退此池)
|
||||
- "身体部分被前景的杂草遮挡"
|
||||
- "大半个身体隐入草丛,只露出头背"
|
||||
- "身体被身前的杂草枝条半遮半掩"
|
||||
- "只有头背部从茂密草丛中露出"
|
||||
- "身体部分被低垂的树叶和枝条遮挡"
|
||||
- "大半个身体隐在枝叶间,只露出头背"
|
||||
occlusionByDataset: # 数据集名 → 遮挡池(按动物习性与场景配置;未配置回退通用 occlusions)
|
||||
雉鸡:
|
||||
- "身体部分被前方的草丛和矮灌丛遮挡"
|
||||
- "大半个身子隐入灌丛枝叶间,只露出头颈"
|
||||
- "身体被身前的枯草丛半遮半掩"
|
||||
- "只有头背部从密草丛中露出"
|
||||
- "身体藏在树影下,被垂落的树叶半遮"
|
||||
兔子:
|
||||
- "身体部分被荒草遮挡,只露出头部"
|
||||
- "大半身体隐入草丛,一对长耳竖在草尖上方"
|
||||
- "身体被身前的枯黄野草半掩"
|
||||
- "身体部分被林缘的树叶和枝条遮挡"
|
||||
鸭子:
|
||||
- "身体部分隐入岸边芦苇丛中"
|
||||
- "半身没入水面,只露出头背"
|
||||
- "身体被岸边的芦苇和杂草半遮"
|
||||
- "身体部分隐入岸边垂落的枝叶间"
|
||||
鹌鹑:
|
||||
- "身体隐在密草丛中,只露出头部"
|
||||
- "大半身体藏进草丛,隐约只见头和背部"
|
||||
- "身体藏在草叶和落叶之间,只露出头部"
|
||||
# 目标尺寸提示(sizeHint):按「体高 × focalPx ÷ 表单距离」算出目标在图中的像素高与占比,
|
||||
# 注入模板 {sizeHint}——把「厘米」换算成「米」再参与投影公式;focalPx = 图高/2 ÷ tan(vfov/2)
|
||||
# 注入模板 {sizeHint}——把「厘米」换算成「米」再参与投影公式;focalPx = 图高/2 ÷ tan(vfov/2);
|
||||
# 站高取数据集表 gen_heights(创建数据集时 VLM 生成,界面不维护,代码/配置不硬编码)
|
||||
assumedVfovDeg: 52 # 镜头垂直视场角(度),固定为手机默认主摄(2026-08-28 用户定案;
|
||||
# 曾随机 [15,90]、曾试超广角 90,均已否决);体高按物种查 speciesHeights
|
||||
speciesHeights: # 物种站高(厘米,垂直尺度:鸟=脚底到头顶、兽=蹲坐高度);
|
||||
# 与「高度」像素描述及检测框高(≈站高)自洽,勿用体长(体长是水平尺度);
|
||||
# 未配置的物种代码兜底 35cm
|
||||
家鸽: 20
|
||||
珠颈斑鸠: 20
|
||||
灰斑鸠: 18
|
||||
火斑鸠: 15
|
||||
虎纹斑鸠: 18
|
||||
雉鸡: 55
|
||||
白雉鸡: 55
|
||||
兔子: 35
|
||||
野鸭: 35
|
||||
鹌鹑: 12
|
||||
# 曾随机 [15,90]、曾试超广角 90,均已否决)
|
||||
|
||||
# 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务):
|
||||
# mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步)
|
||||
training:
|
||||
mode: ssh # subprocess | ssh
|
||||
ssh:
|
||||
host: "192.168.3.210" # 训练机地址(mode=ssh 必填)
|
||||
# host: "192.168.3.210" # 训练机地址(mode=ssh 必填)
|
||||
host: "192.168.3.210"
|
||||
user: "root"
|
||||
port: 22
|
||||
privateKeyPath: "" # 私钥路径与 password 二选一
|
||||
@@ -167,7 +83,8 @@ localAi:
|
||||
confConfirmed: 0.2 # 高于此视为确认(class 0),否则疑似(class 1)
|
||||
overlapThreshold: 0.3 # 重叠去重阈值(交叠/较小框面积,NMS 风格;RF-DETR 同目标常输出一大一小两框,minIoU 比 IoU 更能命中)
|
||||
inputSize: 700 # 提交前整图等比缩放最长边
|
||||
classNames: [pheasant, suspect] # 标注类别名(写入 data.yaml,随 result.json 存模型 labels)
|
||||
# 标注类别名(写入 data.yaml,随 result.json 存模型 labels)从数据集表取:
|
||||
# 第一类 = gen_species(空回退数据集名),第二类 = gen_classes(空回退 class0/class1)
|
||||
|
||||
labelTask:
|
||||
# 预标注并发度(逐张调 RF-DETR,缺失或非法时回退默认值)
|
||||
|
||||
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 176 KiB |
@@ -329,11 +329,24 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不
|
||||
- `localai`(本机/训练机 local-ai,`POST {baseUrl}/v1/images/generations`,OpenAI 兼容):`baseUrl` + `model`(local-ai 上加载的模型名,如 qwen-image)+ `timeoutSeconds`(训练机 qwen-image 1024x1024/30 步约 5 分钟,默认 600);返回 url 为容器内 localhost 地址,下载时替换为 `baseUrl` 的 host;step/cfg 等采样参数用 local-ai 模型配置默认值,不随接口传
|
||||
- `baseUrl`/`apiKey` 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
|
||||
- 尺寸参数由前端传(固定竖版 704x1248,比例与既有训练图 1152x2048 一致、32 倍数对齐);**实测竖版图在 local-ai 上极慢(约 26 分钟/张,Qwen-Image 行列注意力对非方形输入效率差,与分辨率关系不大;方形 1024x1024 仅 5 分钟)**,超时与前端等待按此配置,一次建议 1 张
|
||||
- 生成同步执行(count 1..8,每张超时 `imageGen.timeoutSeconds`,默认 120s;localai 建议 600),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则)
|
||||
- prompt 手填覆盖;留空走 `imageGen.promptTemplate` 通用模板逐张组装(物种池 speciesByDataset 按数据集名、数量词=animalCount、场景/动作/光线/遮挡池随机,每张独立组装保持多样性;物种只写名字不写羽毛细节,细节会拉近镜头);**遵守项目提示词规范:不得包含目标位置描述**(位置由模型自行推理,前端模板与校验文案落实此约束)
|
||||
- **生成异步化(2026-08-28,count 1..1000)**:`/datasets/generate` 仅做校验(provider/数据集/标注服务/Serial 内并发检查已有 running 生成任务)+ 插 `gen_task` 落 running 记录即返回 `{taskId, total}`(毫秒级,前端 10s 超时无忧);后台协程逐张生成(`context.Background()` 生命周期模式,与预标注 `runDetection` 同构;`imageGen.poolSize` 并发池,z-image 显存独占默认 1),每张超时 `imageGen.timeoutSeconds`(默认 120s;localai 建议 600);逐张落盘 + 入库(记录 prompt 便于追溯)+ Serial 更新进度,完成(或部分失败)后对本次新增图触发自动标注,置 done/failed 由前端轮询 `/datasets/gen-task` 呈现;中途失败保留已生成图(不删除——付费资产原则);启动恢复 `recoverGenTasks` 孤儿 running 置 failed;VLM 补检与生成显存互斥(该数据集有 running 生成任务时拒绝)
|
||||
- prompt 手填覆盖;留空走 `imageGen.promptTemplate` 通用模板逐张组装(**物种/场景/动作/遮挡/轮廓色/站高池均从数据集表读取(见下节),每张独立组装保持多样性**;**性别每张随机雄/雌(2026-08-28)**——两性体型大小与外观差异大(雉鸡雄艳雌褐、野鸭雄艳雌素等),随机让训练数据覆盖两性形态,`{species}` 替换为「雄/雌性+物种」;物种只写名字不写羽毛细节,细节会拉近镜头);**遵守项目提示词规范:不得包含目标位置描述**(位置由模型自行推理,前端模板与校验文案落实此约束)
|
||||
- **生成图距离/数量校验(2026-08-28)**:表单填 `distance`(米,固定值) 与 `animalCount` 后逐张启用;**距离校验已取消(2026-08-28)**:生成即入库,无逐张拒检;参数与公式见 git 历史(物理公式 d=体高×focalPx÷像素高)
|
||||
- **VLM 藏匿位补检(两阶段第二阶段)**:`/datasets/images/vlm-review` 单图接口;qwen3.8-9b(+mmproj) 以「图片+物种(生成 prompt 与物种池匹配)+场景光线线索+已确认框坐标(排除)」推理藏匿位,输出归一化 bbox JSON(范围校验+与已有框重叠去重),追加 ≤3 个 class=1 疑似框进同一 labels_json;VL 与 z-image 显存互斥(12G 装不下两者),批量补检在生成队列空闲后执行;qwen3.8-9b yaml 自带 mmproj 实为多模态模型(生成校验);自动标注框数按置信度降序裁剪 ≤ animal_count(存 dataset_image.animal_count 列),估算距离 = 物种站高 × focalPx ÷ 最大框高像素(focalPx=`图高/2/tan(vfov/2)`,站高按物种查 `imageGen.speciesHeights`,单位 cm 换算为米,未配置物种代码兜底 35cm;**2026-08-28 语义修正**:speciesHeights 为站高(垂直尺度,鸟=脚到头顶、兽=蹲坐高),勿用体长——与提示词「高度」描述及检测框高自洽;避免跨物种共用一个标定系数)(K=1.8:6%≈30m、4.5%≈40m,K 隐含镜头视场角假设);**镜头固定主摄(2026-08-28 定案)**:vfov 固定 `imageGen.assumedVfovDeg=52°`(手机默认主摄;曾随机 [15,90] 覆盖面广但目标像素波动 7~57px 不可控,曾试超广角 90 亦否决);**场景/动作按数据集(2026-08-28)**:`imageGen.sceneByDataset`/`actionByDataset` 按数据集名配池(鸭→水面、雉鸡→灌丛草丛、兔→草坡荒地、鹌鹑→草丛),未配置回退通用 scenes/actions;**遮挡描述(2026-08-28)**:模板加 `{occlusion}` 占位(插在「在{action}」之后)——身体完全暴露的个体对识别/标注训练无意义,野外目标必然存在植被等遮挡;`imageGen.occlusionByDataset` 按数据集配遮挡池(雉鸡→草丛灌丛半掩、兔→荒草只露头耳、鸭→芦苇水面半没、鹌鹑→密草藏身),未配置回退通用 `imageGen.occlusions`;池内条目覆盖轻(身体半掩)~重(只露头背)遮挡,每张随机取一条,杜绝完全暴露;遮挡物多样(杂草/树叶/枝条,2026-08-28 补树叶)且须与 sceneByDataset 场景自洽;遮挡后可见部分小于站高,检测框高与距离估算按可见部分仍自洽(真实场景同此);**拍摄角度(2026-08-28,已废弃角度描述)**:不带任何拍摄角度描述——演进:固定「上部三分之一」区域描述对文生图模型遵循力弱(生成物落底)→ 加固定俯拍(目标上移成功但构图单一,用户否决)→ 池内随机俯/平/仰(仰拍使贴地目标必然沉底,用户否决)→ 只留俯拍/平视(用户仍嫌啰嗦)→ **完全去掉角度描述**(2026-08-28 定案):交由模型平视先验自由构图,目标自然落在画面中部/上部;模板只靠「{species}离镜头很远,只是很小的{tone}轮廓」(物种名替代泛称「目标」,不限定鸟类——物种池含兔子等非鸟物种;`{tone}` 轮廓色词按 `imageGen.speciesTone.<物种>` 取,默认深色剪影,**白雉鸡配浅色**——白羽个体用「深色轮廓」描述会与模型渲染矛盾,2026-08-28)维持远景感,手填提示词仅追加数量约束;不合格丢弃重生成,单张上限 3 次(`consts.GenValidateMaxAttempts`),连续不合格整体报错(已合格张数保留)
|
||||
|
||||
### 生成参数存储于数据集表(2026-08-28)
|
||||
|
||||
- **决策**:per-dataset 生成参数(物种/轮廓色/站高/场景/动作/遮挡/标注类别名)从 `config.yml` 迁入 `dataset` 表——配置随数据集走,新建数据集用 VLM(qwen3.8-9b)自动生成,**config.yml 不再兜底**(表为空即缺失,不静默回退)
|
||||
- **单物种规则(2026-08-28 定案)**:每数据集只对应**一个物种**(每数据集训练一个模型,类别固定 `[物种, suspect]`);**生成表单无任何物种/动物名称输入,物种直接取数据集名**(生成提示词按数据集名组装);不同物种须拆到不同数据集(近缘种不共数据集)
|
||||
- **表结构(4 单值 + 3 JSON 数组,2026-08-28 简化)**:`gen_species`(单值 = 数据集物种,即标注类别 0 名)、`gen_tone`(单值 轮廓色词,白化个体配浅色)、`gen_heights`(REAL 数值 站高 cm)、`gen_scenes`/`gen_actions`/`gen_occlusions`(JSON 数组,各 ≥3 条,保持多样性)、`gen_classes`(单值 第二标注类别名 = "suspect",第一类别 = gen_species,写入训练 data.yaml 的 names);**简化动机**:单物种规则下原 JSON 对象/数组恒为 1 key 1 value(`[物种]`/`{物种:值}`),两性差异由提示词性别随机词表达,站高单值对距离感公式(像素高 = 站高 × focalPx ÷ 距离)影响 <1 像素量级,无需分键
|
||||
- **创建数据集同步生成**:`POST /datasets` 不接收任何物种/动物名称参数,**物种 = 数据集名**(单物种规则,新建对话框无动物名称输入框);调 `QwenVL`(64x64 占位图 + 文本,llama.cpp mmproj 需图片输入)输出严格 JSON `{tone, height_cm, scenes[], actions[], occlusions[]}`,校验(scenes/actions/occlusions ≥3 条、height_cm 数值 10~200、tone 为颜色词)后组装写表:`gen_species=物种`、`gen_tone=tone`、`gen_heights=height_cm`、`gen_classes="suspect"`;**站高属数据库数据(gen_heights),代码不硬编码物种→高度**——VLM 输出即最终值,界面不维护(2026-08-28 用户定案:gen_* 7 列全部仅由模型生成,编辑界面只读展示,值不准走 `gen-pools` 重新生成);**失败不阻断创建**——返回 `poolsGenerated:false` + `poolError`,前端提示(可事后 `POST /datasets/gen-pools` 补生成);显存互斥:存在任意 running 生成任务(z-image 占显存)时跳过生成并提示;前端创建请求超时放宽 180s
|
||||
- **封面自动生成(2026-08-28)**:参数池生成成功后取数据集物种调 z-image 文生图,**16:9 横幅(1024x576)**,提示词要求**「一只雄性 X 和一只雌性 X 并排站立」**(1 雄 1 雌),失败不阻断创建(返回 `coverGenerated:false` + `coverError`,可编辑模式重新生成);结果 `ensureJpeg` 转 jpg 后按封面规范落盘(UUIDv4.jpg,删除旧封面,写 `cover` 列);与生成任务显存互斥(有 running 生成任务时跳过)
|
||||
- **封面手动生成(2026-08-28)**:新增 `POST /datasets/cover/generate`(对话框「AI 生成封面」按钮)——**新建模式** `datasetId=0` + `name` 预生成:数据集未创建,封面仅落盘于数据集目录(物种=表单数据集名),创建请求带 `cover` 回传写库(创建接口 cover 非空且文件存在则跳过自动生成);**编辑模式** `datasetId` 完整链路(物种取 gen_species 空回退数据集名,写库+删旧封面);返回 `{cover}` 供前端回显;封面访问接口支持 `datasetId=0` 按 `name`+`filename` 直读(filename 须 UUID jpg 规范防路径穿越)
|
||||
- **生成图统一转 jpg(2026-08-28)**:凡模型产出的图片(z-image 训练图、封面)入库前经 `ensureJpeg` 解码校验,非 jpeg 一律重编码 jpg(Quality 92)——RF-DETR 按扩展名推断 mime,扩展名与编码不一致会报「Not a JPEG file」类错误;命名统一 `.jpg`
|
||||
- **重新生成接口**:`POST /datasets/gen-pools`(`{datasetId}`,物种取数据集名)——编辑对话框「VLM 重新生成参数」按钮入口,VLM 失败报错(不覆盖旧值,旧值保留)
|
||||
- **读取链(无 config 兜底)**:物种 = 表 `gen_species` 单值 → 空则数据集名本身(**固定,不随机**);场景/动作/遮挡 = 表池随机 → **空则模板组装报错**(提示手填提示词或编辑数据集补参数);`{tone}` = 表 `gen_tone` 单值 → 空默认「深色」;站高 = 表 `gen_heights` 数值(**创建时 VLM 生成,界面不维护,代码/配置不硬编码**)→ ≤0 兜底 35cm(物理默认,非配置回退);标注类别名 = 第一类 `gen_species` + 第二类 `gen_classes`(训练 data.yaml / 模型 labels)
|
||||
- **界面不维护 gen_*(2026-08-28 用户定案)**:`gen_species/gen_tone/gen_heights/gen_scenes/gen_actions/gen_occlusions/gen_classes` 全部仅由模型生成(创建数据集时 VLM 自动、`gen-pools` 重新生成),管理端编辑表单对 7 列只读展示、无输入框——`POST /datasets/update` 不接收任何 gen 字段;config.yml 无任何 per-dataset 生成参数节点(`speciesByDataset/speciesTone/speciesHeights/sceneByDataset/actionByDataset/occlusionByDataset/scenes/actions/occlusions/localAi.classNames` 已删,代码无引用),保留:`promptTemplate`(通用模板)与 `lights`(光线池)、`assumedVfovDeg`;数据源唯一入口为数据集表(本库存量 15 行已直接改单值 + 真实站高,无启动迁移)
|
||||
|
||||
### 训练通道(runner 抽象,决策:同机/异机不确定 → 可配置)
|
||||
|
||||
```yaml
|
||||
@@ -359,7 +372,7 @@ training:
|
||||
- **任务生命周期**:`running → success/failed`;取消 = 杀进程(ssh 模式远程 kill pid);超时无心跳判死;**Go 服务重启后启动扫描** running 任务按 pid 存活探测(subprocess 本机、ssh 远程 `kill -0`),进程已死则置 failed
|
||||
- **发起训练异步化(2026-08-27)**:发起请求仅做校验(数据集存在 / `prepareYoloSet` 有标注 / Serial 内并发检查)+ 落 running 记录即返回(毫秒级);训练机侧准备(写任务参数 → ssh tar 同步数据集 → 启动进程,耗时可达分钟级)在后台协程执行(`context.Background()`,与预标注 `runDetection` 同模式),任何一步失败经 `finishFailed` 置任务 failed 由列表/轮询呈现——此前同步执行超过管理端 axios 10s 超时,出现「任务已落库但前端报 timeout」的不一致
|
||||
- **并发度 1**:发起训练时若已有 running 任务返回错误「训练进行中」;训练任务不排队(简化,管理端人工再点一次)
|
||||
- 产物拉取(2026-08-27 重构):成功后只拉 `best.tflite` 直写服务器 `workspace/trainings/<数据集名>.tflite`(原子覆盖,无 per-task 存档、不再打包 zip)
|
||||
- 产物拉取(2026-08-27 重构;命名 2026-08-28 改):成功后只拉 `best.tflite` 直写服务器 `workspace/trainings/<文件名前缀>.tflite`(前缀空回退数据集名;原子覆盖,无 per-task 存档、不再打包 zip)
|
||||
- 写操作走 `common.Serial()` 单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务
|
||||
|
||||
### 模型版本(每数据集一个模型,多模型体系)
|
||||
@@ -367,11 +380,11 @@ training:
|
||||
**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注(类别名不同则自然互补,同类名跨模型 NMS 去重)。
|
||||
|
||||
- 版本号规则:`m<major>.<minor>.<patch>`,**同一数据集内**每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);`UNIQUE(dataset_id, version)` 防重复
|
||||
- 文件布局(2026-08-27 重构):`workspace/trainings/<数据集名>.tflite` 即当前生效模型唯一位——训练成功时从训练机直写(原子覆盖),客户端固定下载该文件;**无 `<version>.tflite` 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚)**;每数据集一个文件互不影响
|
||||
- 文件布局(2026-08-27 重构;命名 2026-08-28 改):`workspace/trainings/<文件名前缀>.tflite` 即当前生效模型唯一位(前缀空回退数据集名——存量数据集无前缀;改名/改前缀时模型文件随命名迁移)——训练成功时从训练机直写(原子覆盖),客户端固定下载该文件;**无 `<version>.tflite` 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚)**;每数据集一个文件互不影响
|
||||
- 类别名:发布时从训练任务/数据集记录类别(训练脚本 result.json 输出 `names`),存 `model_version.labels`(JSON 数组),**App 合并推理依赖它**
|
||||
- **发布**(`POST /admin/trainings/publish`):校验任务 success + `trainings/<数据集名>.tflite` 存在 → 读文件算 sha256/size → 插 `model_version` + 该数据集旧版 `is_latest=0`;文件已在训练成功时就位,发布仅落版本记录
|
||||
- **发布**(`POST /admin/trainings/publish`):校验任务 success + `trainings/<文件名前缀>.tflite` 存在(前缀空回退数据集名)→ 读文件算 sha256/size → 插 `model_version` + 该数据集旧版 `is_latest=0`;文件已在训练成功时就位,发布仅落版本记录
|
||||
- **管理端无模型管理界面**(2026-08-26 决策):删 `AdminListModels`/`AdminActivateModel`/`AdminDeleteModel` 三个管理接口,`model_version` 表保留——仅支撑客户端下发目录;版本只增不删不回滚(发布即最新)
|
||||
- **模型目录(客户端拉取)**:`GET /api/v1/models`(公开,登录态即可)返回所有数据集当前生效模型:`{datasetId, datasetName, version, labels, sizeBytes, sha256, notes, publishedAt, downloadUrl}`;下载 URL `/download/trainings/<数据集名>.tflite`(复用 `/download` 静态托管,文件名含中文需 URL 编码)
|
||||
- **模型目录(客户端拉取)**:`GET /api/v1/models`(公开,登录态即可)返回所有数据集当前生效模型:`{datasetId, datasetName, version, labels, sizeBytes, sha256, notes, publishedAt, downloadUrl}`;下载 URL `/download/trainings/<文件名前缀>.tflite`(前缀空回退数据集名;复用 `/download` 静态托管,文件名含中文需 URL 编码)
|
||||
|
||||
### 标注工作台(依赖 local-ai 可达;2026-08-26 布局重构)
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ async function loadAll() {
|
||||
const tasks = await request.get('/label-tasks', { params: { page: 1, size: 100 } }).catch(() => null)
|
||||
const running = (tasks?.list || []).find((t) => t.datasetId === datasetId.value && t.status === 'running')
|
||||
if (running) pollLabelTask(running.id)
|
||||
// 刷新时恢复进行中的 AI 生成任务(或展示最近一次失败的提示)
|
||||
const gt = await request.get('/datasets/gen-task', { params: { datasetId: datasetId.value } }).catch(() => null)
|
||||
if (gt && gt.status === 'running') {
|
||||
genTask.value = { id: gt.id, status: gt.status, total: gt.total, done: gt.done, error: gt.error }
|
||||
pollGenTask()
|
||||
} else if (gt && gt.error) {
|
||||
genTask.value = gt
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -59,41 +67,63 @@ async function loadAll() {
|
||||
|
||||
// ---------- 添加图片(AI 生成) ----------
|
||||
const addVisible = ref(false)
|
||||
const genForm = reactive({ species: '', count: 1, size: '704x1248', distance: 25 })
|
||||
const speciesOptions = ref([])
|
||||
// 单物种规则:物种固定取数据集名,表单无物种输入
|
||||
const genForm = reactive({ count: 1, size: '704x1248', distance: 25 })
|
||||
const generating = ref(false)
|
||||
|
||||
// 生成表单选项(物种池/场景池来自 config,随配置热更新)
|
||||
function loadGenOptions() {
|
||||
request.get('/datasets/gen-options', { params: { datasetId: datasetId.value } }).then((d) => {
|
||||
speciesOptions.value = d.species || []
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
genForm.species = ''
|
||||
loadGenOptions()
|
||||
genForm.count = 1
|
||||
genForm.size = '704x1248'
|
||||
genForm.distance = 25
|
||||
addVisible.value = true
|
||||
}
|
||||
|
||||
// AI 生成为异步任务:提交即返回 taskId,轮询 /datasets/gen-task 看进度
|
||||
const genTask = ref(null)
|
||||
const genPoll = ref(null)
|
||||
const genRunning = computed(() => genTask.value && genTask.value.status === 'running')
|
||||
|
||||
function pollGenTask() {
|
||||
stopGenPoll()
|
||||
genPoll.value = setInterval(async () => {
|
||||
const d = await request.get('/datasets/gen-task', { params: { datasetId: datasetId.value } }).catch(() => null)
|
||||
if (!d) return
|
||||
genTask.value = { id: d.id, status: d.status, total: d.total, done: d.done, error: d.error }
|
||||
if (d.status !== 'running') {
|
||||
stopGenPoll()
|
||||
if (d.error) {
|
||||
ElMessage.error(`AI 生成未完成:${d.error}`)
|
||||
} else if (d.total > 0) {
|
||||
ElMessage.success(`AI 生成完成(${d.done}/${d.total} 张),自动标注处理中…`)
|
||||
}
|
||||
loadAll().then(() => {
|
||||
if (editorVisible.value) nextTick(resetEditor)
|
||||
})
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopGenPoll() {
|
||||
if (genPoll.value) {
|
||||
clearInterval(genPoll.value)
|
||||
genPoll.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function submitGen() {
|
||||
generating.value = true
|
||||
request
|
||||
.post('/datasets/generate', {
|
||||
datasetId: datasetId.value,
|
||||
species: genForm.species.trim(),
|
||||
count: genForm.count,
|
||||
size: genForm.size,
|
||||
distance: genForm.distance,
|
||||
}, { timeout: 2400000 })
|
||||
})
|
||||
.then((data) => {
|
||||
const rj = data.rejected ? `,${data.rejected} 张不合格已跳过` : ''
|
||||
ElMessage.success(`已生成 ${data.generated || 0} 张${rj}(生成图片为付费资产,删除前请确认),自动标注处理中…`)
|
||||
addVisible.value = false
|
||||
loadAll()
|
||||
genTask.value = { id: data.taskId, status: 'running', total: data.total, done: 0, error: '' }
|
||||
ElMessage.success(`已提交生成任务(共 ${data.total} 张,约 40 秒/张),进度见页顶进度条`)
|
||||
pollGenTask()
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -491,6 +521,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopLabelPoll()
|
||||
stopGenPoll()
|
||||
stopHighlight()
|
||||
window.removeEventListener('resize', onWinResize)
|
||||
})
|
||||
@@ -512,7 +543,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 图片工具 -->
|
||||
<div class="img-toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="openAdd">添加</el-button>
|
||||
<el-button type="primary" :icon="Plus" :disabled="genRunning" @click="openAdd">添加</el-button>
|
||||
<el-button :icon="MagicStick" :disabled="labelRunning" @click="startFullLabelTask">全量标注</el-button>
|
||||
</div>
|
||||
|
||||
@@ -526,6 +557,16 @@ onBeforeUnmount(() => {
|
||||
<div v-if="labelTask && labelTask.status === 'done' && labelTask.error" class="label-error">
|
||||
自动标注未完成:{{ labelTask.error }}
|
||||
</div>
|
||||
<div v-if="genRunning" class="label-progress">
|
||||
<el-progress
|
||||
:percentage="genTask.total ? Math.min(100, Math.round((genTask.done / genTask.total) * 100)) : 0"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
<span class="label-progress-tip">AI 生成中 {{ genTask.done }}/{{ genTask.total }} 张(约 40 秒/张)…</span>
|
||||
</div>
|
||||
<div v-if="genTask && genTask.status !== 'running' && genTask.error" class="label-error">
|
||||
AI 生成未完成:{{ genTask.error }}
|
||||
</div>
|
||||
|
||||
<!-- 图片卡片:点击缩略图直接进标注弹窗(有标注框直接显示在图上,可继续画框) -->
|
||||
<div v-loading="loading" class="rows-area">
|
||||
@@ -564,21 +605,8 @@ onBeforeUnmount(() => {
|
||||
<!-- 添加图片:AI 生成 -->
|
||||
<el-dialog v-model="addVisible" title="AI 生成图片" width="min(520px, 92vw)" append-to-body>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="动物名称">
|
||||
<el-select
|
||||
v-model="genForm.species"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="留空按数据集物种池随机"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="s in speciesOptions" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="生成张数">
|
||||
<el-input-number v-model="genForm.count" :min="1" :max="8" />
|
||||
<el-input-number v-model="genForm.count" :min="1" :max="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标距离">
|
||||
<div class="gen-inline">
|
||||
@@ -589,11 +617,11 @@ onBeforeUnmount(() => {
|
||||
<el-form-item label="图片尺寸">
|
||||
<span class="gen-size">竖版 704x1248</span>
|
||||
</el-form-item>
|
||||
<div class="gen-tip">生成图片为付费资产,请勿随意删除;手填提示词时无需写目标位置(系统统一约束目标只出现在画面上部三分之一区域)。</div>
|
||||
<div class="gen-tip">按本数据集物种({{ dataset?.name || '' }})生成,每张固定 1 只;生成图片为付费资产,请勿随意删除;批量生成为后台任务(约 40 秒/张),进度见页顶进度条;手填提示词时无需写拍摄角度/位置(系统自动约束画面中有且只有这一只动物)。</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button type="primary" :loading="generating" @click="submitGen">开始生成(可能需数分钟)</el-button>
|
||||
<el-button type="primary" :loading="generating" @click="submitGen">开始生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { EditPen, Picture, Plus, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { EditPen, Loading, Picture, Plus, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '../api/request'
|
||||
|
||||
@@ -15,6 +15,7 @@ const keyword = ref('')
|
||||
|
||||
const formVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const generatingCover = ref(false)
|
||||
// 新建/编辑共用弹窗(改名/前缀/描述/封面;改名同步迁移磁盘目录)
|
||||
const dlgForm = reactive({
|
||||
mode: 'create', // create | edit
|
||||
@@ -23,7 +24,17 @@ const dlgForm = reactive({
|
||||
namePrefix: '',
|
||||
description: '',
|
||||
cover: '',
|
||||
pendingCover: '', // 新建预生成封面文件名(创建请求回传写库)
|
||||
// 生成参数池(创建时 VLM 自动生成;编辑模式可查看/手改/重新生成)
|
||||
genSpecies: '',
|
||||
genTone: '',
|
||||
genHeights: '',
|
||||
genScenes: '',
|
||||
genActions: '',
|
||||
genOcclusions: '',
|
||||
genClasses: '',
|
||||
})
|
||||
const savingPools = ref(false) // VLM 重新生成参数请求中
|
||||
const coverFile = ref(null) // 新选择的封面文件(el-upload 单文件)
|
||||
const coverFileList = ref([]) // 封面回显:已有封面(服务端 url)或新选文件(本地预览)
|
||||
const coverDeleted = ref(false) // 用户删除了已有封面(保存时调删除接口)
|
||||
@@ -61,7 +72,22 @@ function resetCoverState() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(dlgForm, { mode: 'create', id: 0, name: '', namePrefix: '', description: '', cover: '' })
|
||||
Object.assign(dlgForm, {
|
||||
mode: 'create',
|
||||
id: 0,
|
||||
name: '',
|
||||
namePrefix: '',
|
||||
description: '',
|
||||
cover: '',
|
||||
pendingCover: '',
|
||||
genSpecies: '',
|
||||
genTone: '',
|
||||
genHeights: '',
|
||||
genScenes: '',
|
||||
genActions: '',
|
||||
genOcclusions: '',
|
||||
genClasses: '',
|
||||
})
|
||||
resetCoverState()
|
||||
coverFileList.value = []
|
||||
formVisible.value = true
|
||||
@@ -75,6 +101,14 @@ function openEdit(row) {
|
||||
namePrefix: row.namePrefix || '',
|
||||
description: row.description || '',
|
||||
cover: row.cover || '',
|
||||
pendingCover: '',
|
||||
genSpecies: row.genSpecies || '',
|
||||
genTone: row.genTone || '',
|
||||
genHeights: String(row.genHeights ?? ''),
|
||||
genScenes: row.genScenes || '',
|
||||
genActions: row.genActions || '',
|
||||
genOcclusions: row.genOcclusions || '',
|
||||
genClasses: row.genClasses || '',
|
||||
})
|
||||
resetCoverState()
|
||||
// 已有封面回显:el-upload picture-card 直接以 url 项展示(带版本参数防浏览器缓存旧封面)
|
||||
@@ -122,7 +156,22 @@ async function submitDlg() {
|
||||
try {
|
||||
let id = dlgForm.id
|
||||
if (dlgForm.mode === 'create') {
|
||||
id = (await request.post('/datasets', { name, namePrefix: dlgForm.namePrefix.trim(), source: 'manual' })).id
|
||||
// 创建会同步调 VLM 生成参数池 + z-image 生成封面,超时放宽 180s
|
||||
const res = await request.post(
|
||||
'/datasets',
|
||||
{ name, namePrefix: dlgForm.namePrefix.trim(), source: 'manual', cover: dlgForm.pendingCover || undefined },
|
||||
{ timeout: 180000 },
|
||||
)
|
||||
id = res.id
|
||||
if (res.poolError) {
|
||||
ElMessage.warning(`数据集已创建,但自动生成参数池失败:${res.poolError}(可编辑重试或手填提示词)`)
|
||||
} else if (res.coverError) {
|
||||
ElMessage.warning(`数据集已创建,参数池已生成,但封面生成失败:${res.coverError}(可稍后进入编辑上传)`)
|
||||
} else if (res.poolsGenerated || res.coverGenerated) {
|
||||
ElMessage.success('数据集已创建,生成参数池与封面已自动生成')
|
||||
} else {
|
||||
ElMessage.success('数据集已创建,请上传或生成图片')
|
||||
}
|
||||
}
|
||||
if (coverFile.value) {
|
||||
const fd = new FormData()
|
||||
@@ -132,11 +181,18 @@ async function submitDlg() {
|
||||
} else if (dlgForm.mode === 'edit' && coverDeleted.value) {
|
||||
await request.post('/datasets/cover/delete', { datasetId: id })
|
||||
}
|
||||
// create 且未填描述时跳过 update(服务端空值不覆盖,避免空跑一次)
|
||||
// create 且未填描述时跳过 update(服务端空值不覆盖,避免空跑一次);
|
||||
// gen_* 参数池由 VLM 生成,不随本表单提交(重新生成走 gen-pools 接口)
|
||||
if (dlgForm.mode === 'edit' || dlgForm.description.trim()) {
|
||||
await request.post('/datasets/update', { id, name, namePrefix: dlgForm.namePrefix.trim(), description: dlgForm.description.trim(), cover: '' })
|
||||
await request.post('/datasets/update', {
|
||||
id,
|
||||
name,
|
||||
namePrefix: dlgForm.namePrefix.trim(),
|
||||
description: dlgForm.description.trim(),
|
||||
cover: '',
|
||||
})
|
||||
}
|
||||
ElMessage.success(dlgForm.mode === 'create' ? '数据集已创建,请上传或生成图片' : '已保存')
|
||||
if (dlgForm.mode === 'edit') ElMessage.success('已保存')
|
||||
formVisible.value = false
|
||||
page.value = 1
|
||||
load()
|
||||
@@ -145,6 +201,64 @@ async function submitDlg() {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成封面(z-image 文生图:16:9、1 雄 1 雌):新建模式预生成(datasetId=0,仅落盘,
|
||||
// 创建请求带 cover 回传写库),编辑模式写库覆盖旧封面;成功后回显封面
|
||||
async function genCover() {
|
||||
if (generatingCover.value) return
|
||||
if (dlgForm.mode === 'create' && !dlgForm.name.trim()) {
|
||||
ElMessage.warning('请先填写数据集名称')
|
||||
return
|
||||
}
|
||||
generatingCover.value = true
|
||||
try {
|
||||
// z-image 生成约 40s(首次含模型入显存可达分钟级),超时对齐后端 120s
|
||||
const data = await request.post('/datasets/cover/generate', {
|
||||
datasetId: dlgForm.id,
|
||||
name: dlgForm.mode === 'create' ? dlgForm.name.trim() : undefined,
|
||||
}, { timeout: 120000 })
|
||||
if (dlgForm.mode === 'create') {
|
||||
dlgForm.pendingCover = data.cover
|
||||
coverFileList.value = [{
|
||||
name: 'AI 封面',
|
||||
url: imgUrl(`/api/v1/admin/datasets/cover?name=${encodeURIComponent(dlgForm.name.trim())}&filename=${encodeURIComponent(data.cover)}`),
|
||||
}]
|
||||
} else {
|
||||
coverFileList.value = [{ name: 'AI 封面', url: imgUrl(`/api/v1/admin/datasets/cover?datasetId=${dlgForm.id}&v=${Date.now()}`) }]
|
||||
coverDeleted.value = false
|
||||
load()
|
||||
}
|
||||
ElMessage.success('封面已生成')
|
||||
} catch (e) {
|
||||
// 错误已由请求层提示(如 local-ai 未恢复/生成任务占用显存)
|
||||
} finally {
|
||||
generatingCover.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// VLM 重新生成生成参数池(编辑模式;接口直接写库,成功回填展示;失败保留旧值,错误已由请求层提示)
|
||||
function regenPools() {
|
||||
if (savingPools.value) return
|
||||
savingPools.value = true
|
||||
request
|
||||
.post('/datasets/gen-pools', { datasetId: dlgForm.id })
|
||||
.then((data) => {
|
||||
Object.assign(dlgForm, {
|
||||
genSpecies: data.genSpecies || '',
|
||||
genTone: data.genTone || '',
|
||||
genHeights: String(data.genHeights ?? ''),
|
||||
genScenes: data.genScenes || '',
|
||||
genActions: data.genActions || '',
|
||||
genOcclusions: data.genOcclusions || '',
|
||||
genClasses: data.genClasses || '',
|
||||
})
|
||||
ElMessage.success('生成参数池已重新生成')
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
savingPools.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 开始训练(一键发起:任务名自动生成,参数走 config.yml training 节点) ----------
|
||||
|
||||
function startTrain(row) {
|
||||
@@ -338,7 +452,7 @@ onBeforeUnmount(() => {
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="dlgForm.description" type="textarea" :rows="6" maxlength="500" show-word-limit placeholder="数据集说明,展示在卡片上" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面">
|
||||
<el-form-item v-if="dlgForm.mode === 'edit'" label="封面">
|
||||
<div class="cover-row">
|
||||
<el-upload
|
||||
v-model:file-list="coverFileList"
|
||||
@@ -355,6 +469,34 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</el-upload>
|
||||
<span class="add-tip">jpg/jpeg/png,≤2MB;悬停预览右上角 × 可删除当前封面</span>
|
||||
<el-button size="small" :loading="generatingCover" @click="genCover">AI 生成封面</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="封面">
|
||||
<div class="cover-row">
|
||||
<div v-if="coverFileList.length === 0" class="gen-cover-tile" @click="genCover">
|
||||
<el-icon v-if="generatingCover" class="is-loading" :size="22"><Loading /></el-icon>
|
||||
<el-icon v-else :size="22"><Picture /></el-icon>
|
||||
<span>{{ generatingCover ? '生成中...' : '生成封面' }}</span>
|
||||
</div>
|
||||
<el-image v-else :src="coverFileList[0].url" fit="cover" class="gen-cover-preview" />
|
||||
<span class="add-tip">点击生成 AI 封面(16:9、1 雄 1 雌,约 40 秒);创建后也可进入编辑重新生成</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<!-- 生成参数池:创建时 VLM 自动生成(无需手填);编辑模式只读展示,重新生成走 VLM -->
|
||||
<el-form-item v-if="dlgForm.mode === 'edit'" label="生成参数池">
|
||||
<div class="pools-wrap">
|
||||
<div class="pools-head">
|
||||
<span class="pools-tip">VLM 自动生成,AI 生成图片按此池组装提示词(站高cm 用于距离感公式)</span>
|
||||
<el-button size="small" :loading="savingPools" @click="regenPools">VLM 重新生成参数</el-button>
|
||||
</div>
|
||||
<div class="pool-row"><span class="pool-key">物种</span><span class="pool-val">{{ dlgForm.genSpecies || '—' }}</span></div>
|
||||
<div class="pool-row"><span class="pool-key">轮廓色词</span><span class="pool-val">{{ dlgForm.genTone || '—' }}</span></div>
|
||||
<div class="pool-row"><span class="pool-key">站高 cm</span><span class="pool-val">{{ dlgForm.genHeights || '—' }}</span></div>
|
||||
<div class="pool-row"><span class="pool-key">场景池</span><span class="pool-val">{{ dlgForm.genScenes || '—' }}</span></div>
|
||||
<div class="pool-row"><span class="pool-key">动作池</span><span class="pool-val">{{ dlgForm.genActions || '—' }}</span></div>
|
||||
<div class="pool-row"><span class="pool-key">遮挡池</span><span class="pool-val">{{ dlgForm.genOcclusions || '—' }}</span></div>
|
||||
<div class="pool-row"><span class="pool-key">第二类别</span><span class="pool-val">{{ dlgForm.genClasses || '—' }}</span></div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -511,6 +653,67 @@ onBeforeUnmount(() => {
|
||||
align-self: flex-start;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.gen-cover-tile {
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: #8c939d;
|
||||
cursor: pointer;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gen-cover-tile:hover {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
}
|
||||
.gen-cover-preview {
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pools-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.pools-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.pools-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.pool-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.pool-key {
|
||||
flex-shrink: 0;
|
||||
color: #909399;
|
||||
width: 72px;
|
||||
}
|
||||
.pool-val {
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.toolbar {
|
||||
|
||||