This commit is contained in:
2026-08-29 01:11:01 +08:00
parent 474d561173
commit 5c43370fc9
37 changed files with 1309 additions and 409 deletions
+13 -9
View File
@@ -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` providerdashscope / localaiconfig 切换)逐张生成落盘 + 入库(记录 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` providerdashscope / localaiconfig 切换)逐张生成落盘 + 入库(记录 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 生成图是付费资产,前端确认文案提示) |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+9
View File
@@ -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"
)
+20 -8
View File
@@ -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)
}
+43
View File
@@ -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 {
+131
View File
@@ -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
}
+78 -23
View File
@@ -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 {
+22 -11
View File
@@ -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:"更新时间"`
}
+15
View File
@@ -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:"完成时间"`
}
+1 -1
View File
@@ -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:"自增主键"`
+565 -181
View File
@@ -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:解码校验 + 非 jpegpng/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:91024x576)、画面 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 默认 120slocalai 训练机约 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 默认 120slocalai 训练机约 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 数据集图片列表(创建时间正序,标注工作台/网格预览)
+11 -8
View File
@@ -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 != "" {
+3 -2
View File
@@ -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
+39 -12
View File
@@ -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
}
+1
View File
@@ -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)
)
+41
View File
@@ -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.poolSizez-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()
}
}
+6 -4
View File
@@ -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 十六进制(模型版本校验用)
+17 -100
View File
@@ -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 # 每张生成超时(默认 120z-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,缺失或非法时回退默认值)
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

+19 -6
View File
@@ -329,11 +329,24 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不
- `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` 的 hoststep/cfg 等采样参数用 local-ai 模型配置默认值,不随接口传
- `baseUrl`/`apiKey` 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
- 尺寸参数由前端传(固定竖版 704x1248,比例与既有训练图 1152x2048 一致、32 倍数对齐);**实测竖版图在 local-ai 上极慢(约 26 分钟/张,Qwen-Image 行列注意力对非方形输入效率差,与分辨率关系不大;方形 1024x1024 仅 5 分钟)**,超时与前端等待按此配置,一次建议 1 张
- 生成同步执行(count 1..8,每张超时 `imageGen.timeoutSeconds`默认 120slocalai 建议 600逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则
- prompt 手填覆盖;留空走 `imageGen.promptTemplate` 通用模板逐张组装(物种池 speciesByDataset 按数据集名、数量词=animalCount、场景/动作/光线/遮挡池随机,每张独立组装保持多样性;物种只写名字不写羽毛细节,细节会拉近镜头);**遵守项目提示词规范:不得包含目标位置描述**(位置由模型自行推理,前端模板与校验文案落实此约束)
- **生成异步化(2026-08-28count 1..1000**`/datasets/generate` 仅做校验(provider/数据集/标注服务/Serial 内并发检查已有 running 生成任务)+ 插 `gen_task` 落 running 记录即返回 `{taskId, total}`(毫秒级,前端 10s 超时无忧);后台协程逐张生成(`context.Background()` 生命周期模式,与预标注 `runDetection` 同构;`imageGen.poolSize` 并发池,z-image 显存独占默认 1),每张超时 `imageGen.timeoutSeconds`默认 120slocalai 建议 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_jsonVL 与 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 规范防路径穿越)
- **生成图统一转 jpg2026-08-28**:凡模型产出的图片(z-image 训练图、封面)入库前经 `ensureJpeg` 解码校验,非 jpeg 一律重编码 jpgQuality 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 布局重构)
+61 -33
View File
@@ -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>
+210 -7
View File
@@ -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/png2MB悬停预览右上角 × 可删除当前封面</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:91 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 {