1
This commit is contained in:
+4
-3
@@ -178,14 +178,15 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
|||||||
| GET | `/admin/app-versions` | 版本记录列表,`page/size` 分页,按下发时间倒序 |
|
| 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` | 下发新版本(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/app-versions/delete` | 删除版本记录 `{"id":1}`:删**最新版本**时联动删除 APK 文件(客户端不再提示更新、下载 404);删历史版本只删记录不动文件 |
|
||||||
| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","source":"manual"\|"ai"}`(name ≤50 字唯一,目录自动建) |
|
| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","namePrefix":"pheasant","source":"manual"\|"ai"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名) |
|
||||||
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs) |
|
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs) |
|
||||||
| POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"name":"新名","description":"...","cover":"a.jpg"}`:名称(改名)/描述/封面,空值字段不覆盖原值;改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 |
|
| POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"name":"新名","namePrefix":"pheasant","description":"...","cover":"a.jpg"}`:名称(改名)/文件名前缀/描述/封面,空值字段不覆盖原值;改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 |
|
||||||
|
| 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 命名**落盘并覆盖旧封面 |
|
| POST | `/admin/datasets/cover` | 上传数据集封面(multipart:`datasetId`+`file`,jpg/jpeg/png ≤2MB):**自动转 jpg + UUID 命名**落盘并覆盖旧封面 |
|
||||||
| GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位) |
|
| GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位) |
|
||||||
| POST | `/admin/datasets/cover/delete` | 删除数据集封面 `{"datasetId":1}`:删文件 + 清 cover 字段 |
|
| 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/upload` | 上传图片(multipart/form-data:`datasetId` + `files` 多张,仅接受 `.jpg/.jpeg/.png`),存 `app.datasetDir`/`datasets/<name>/`,逐张入库 |
|
||||||
| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"prompt":"...","count":1}`(count 1..8,同步执行,每张超时 `imageGen.timeoutSeconds`):调 `imageGen` provider(dashscope / localai,config 切换)逐张生成落盘 + 入库(记录 prompt),任意失败返回错误 |
|
| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"species":"家鸽","count":1}`(species 可留空=物种池随机;scene/action/light 后端随机;prompt 可手填覆盖模板;文件名前缀取数据集属性)(prompt 留空时按 `imageGen.promptTemplate` 组装:物种按数据集名取 speciesByDataset 池、数量词=表单 animalCount、场景/动作/光线随机;手填覆盖)(count 1..8,同步执行,每张超时 `imageGen.timeoutSeconds`):调 `imageGen` provider(dashscope / localai,config 切换)逐张生成落盘 + 入库(记录 prompt),任意失败返回错误;`namePrefix` 可选,按 `<前缀>_<两位序号>.jpg` 顺序命名并续接已有最大序号(如 pigeon_01.jpg),留空用时间戳命名;`distanceMin/distanceMax`(米)与 `animalCount` 填写后**逐张校验**——RF-DETR 检测目标框,数量须**精确等于** `animalCount`、估算距离(`imageGen.distanceCalib`/最大框高占比)须在范围内,否则丢弃重生成(连续 3 次不合格报错);自动标注框数按置信度裁剪 ≤ `animalCount` |
|
||||||
| GET | `/admin/datasets/images` | 数据集图片列表 `{"datasetId":1}`,返回图片元数据(文件名/来源/prompt/创建时间) |
|
| GET | `/admin/datasets/images` | 数据集图片列表 `{"datasetId":1}`,返回图片元数据(文件名/来源/prompt/创建时间) |
|
||||||
| GET | `/admin/datasets/image` | 图片文件(静态字节流,`datasetId` + `filename` 定位,供缩略图/查看) |
|
| GET | `/admin/datasets/image` | 图片文件(静态字节流,`datasetId` + `filename` 定位,供缩略图/查看) |
|
||||||
| POST | `/admin/datasets/images/delete` | 删除图片 `{"datasetId":1,"ids":[1,2]}`:删文件 + 删记录(AI 生成图是付费资产,前端确认文案提示) |
|
| POST | `/admin/datasets/images/delete` | 删除图片 `{"datasetId":1,"ids":[1,2]}`:删文件 + 删记录(AI 生成图是付费资产,前端确认文案提示) |
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>视野管理端</title>
|
<title>视野管理端</title>
|
||||||
<script type="module" crossorigin src="/admin/assets/index-C_zeeHvw.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-CLCH2RGt.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-f1eLEuzq.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-CQqGq03-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -45,4 +45,7 @@ const (
|
|||||||
|
|
||||||
// 模型版本号前缀(m1.0.0),同数据集内递增
|
// 模型版本号前缀(m1.0.0),同数据集内递增
|
||||||
ModelVersionPrefix = "m"
|
ModelVersionPrefix = "m"
|
||||||
|
|
||||||
|
// AI 生成图距离/数量校验:单张不合格重生成次数上限(连续不合格即报错返回,防死循环烧钱)
|
||||||
|
GenValidateMaxAttempts = 3
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -187,3 +187,13 @@ func (c *cAdmin) LabelWorkbench(ctx context.Context, req *dto.AdminLabelWorkbenc
|
|||||||
func (c *cAdmin) SaveLabel(ctx context.Context, req *dto.AdminLabelSaveReq) (*dto.AdminLabelSaveRes, error) {
|
func (c *cAdmin) SaveLabel(ctx context.Context, req *dto.AdminLabelSaveReq) (*dto.AdminLabelSaveRes, error) {
|
||||||
return service.LabelTask.AdminLabelSave(ctx, req)
|
return service.LabelTask.AdminLabelSave(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VlmReview VLM 藏匿位补检(两阶段标注第二阶段;VL 与图像生成显存互斥,批量补检须在生成空闲时执行)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ func init() {
|
|||||||
status TEXT NOT NULL DEFAULT 'building',
|
status TEXT NOT NULL DEFAULT 'building',
|
||||||
cover TEXT,
|
cover TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
name_prefix TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
// 存量库迁移:生成图文件名前缀列(EnsureColumn 的 ddl 须自带列名)
|
||||||
|
common.EnsureColumn(ctx, consts.TableDataset, "name_prefix", "name_prefix TEXT NOT NULL DEFAULT ''")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert 新建数据集(name UNIQUE 由库兜底)
|
// Insert 新建数据集(name UNIQUE 由库兜底)
|
||||||
@@ -43,6 +46,7 @@ func (d *datasetDao) Insert(ctx context.Context, m *entity.Dataset) (int64, erro
|
|||||||
res, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Data(g.Map{
|
res, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Data(g.Map{
|
||||||
"name": m.Name,
|
"name": m.Name,
|
||||||
"source": m.Source,
|
"source": m.Source,
|
||||||
|
"name_prefix": m.NamePrefix,
|
||||||
"image_count": m.ImageCount,
|
"image_count": m.ImageCount,
|
||||||
"labeled_count": m.LabeledCount,
|
"labeled_count": m.LabeledCount,
|
||||||
"status": m.Status,
|
"status": m.Status,
|
||||||
@@ -105,6 +109,9 @@ func (d *datasetDao) UpdateConfigs(ctx context.Context, id int64, m *entity.Data
|
|||||||
if m.Name != "" {
|
if m.Name != "" {
|
||||||
data["name"] = m.Name
|
data["name"] = m.Name
|
||||||
}
|
}
|
||||||
|
if m.NamePrefix != "" {
|
||||||
|
data["name_prefix"] = m.NamePrefix
|
||||||
|
}
|
||||||
if m.Cover != "" {
|
if m.Cover != "" {
|
||||||
data["cover"] = m.Cover
|
data["cover"] = m.Cover
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func init() {
|
|||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
source TEXT NOT NULL DEFAULT 'manual',
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
prompt TEXT,
|
prompt TEXT,
|
||||||
|
animal_count INTEGER NOT NULL DEFAULT 0,
|
||||||
labels_json TEXT,
|
labels_json TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
UNIQUE (dataset_id, filename)
|
UNIQUE (dataset_id, filename)
|
||||||
@@ -31,17 +32,20 @@ func init() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
// 存量库迁移:标注列缺失时追加(新库建表已含列,幂等跳过);candidates_json 已随 v10 删除
|
// 存量库迁移:标注列缺失时追加(新库建表已含列,幂等跳过);candidates_json 已随 v10 删除
|
||||||
|
// 注意 EnsureColumn 的 ddl 参数须自带列名(拼接为 ALTER TABLE ADD COLUMN <ddl>)
|
||||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json TEXT")
|
common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json TEXT")
|
||||||
|
common.EnsureColumn(ctx, consts.TableDatasetImage, "animal_count", "animal_count INTEGER NOT NULL DEFAULT 0")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert 插入图片记录
|
// Insert 插入图片记录
|
||||||
func (d *datasetImageDao) Insert(ctx context.Context, m *entity.DatasetImage) (int64, error) {
|
func (d *datasetImageDao) Insert(ctx context.Context, m *entity.DatasetImage) (int64, error) {
|
||||||
res, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Data(g.Map{
|
res, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Data(g.Map{
|
||||||
"dataset_id": m.DatasetId,
|
"dataset_id": m.DatasetId,
|
||||||
"filename": m.Filename,
|
"filename": m.Filename,
|
||||||
"source": m.Source,
|
"source": m.Source,
|
||||||
"prompt": m.Prompt,
|
"prompt": m.Prompt,
|
||||||
"created_at": m.CreatedAt,
|
"animal_count": m.AnimalCount,
|
||||||
|
"created_at": m.CreatedAt,
|
||||||
}).Insert()
|
}).Insert()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -49,6 +53,16 @@ func (d *datasetImageDao) Insert(ctx context.Context, m *entity.DatasetImage) (i
|
|||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetById 按主键取图片记录
|
||||||
|
func (d *datasetImageDao) GetById(ctx context.Context, id int64) (*entity.DatasetImage, error) {
|
||||||
|
var one *entity.DatasetImage
|
||||||
|
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("id", id).Scan(&one)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return one, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListByDataset 某数据集全部图片(文件名倒序,详情页逐行展示用)
|
// ListByDataset 某数据集全部图片(文件名倒序,详情页逐行展示用)
|
||||||
func (d *datasetImageDao) ListByDataset(ctx context.Context, datasetId int64) ([]*entity.DatasetImage, error) {
|
func (d *datasetImageDao) ListByDataset(ctx context.Context, datasetId int64) ([]*entity.DatasetImage, error) {
|
||||||
var list []*entity.DatasetImage
|
var list []*entity.DatasetImage
|
||||||
|
|||||||
@@ -83,6 +83,27 @@ func (d *modelVersionDao) ListAllLatest(ctx context.Context) ([]*entity.ModelVer
|
|||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishedByTrainingIds 批量判断训练记录是否已发布过版本(IN 按 ≤100 分批,SQLite 变量数上限内)
|
||||||
|
func (d *modelVersionDao) PublishedByTrainingIds(ctx context.Context, trainingIds []int64) (map[int64]bool, error) {
|
||||||
|
published := make(map[int64]bool)
|
||||||
|
for start := 0; start < len(trainingIds); start += 100 {
|
||||||
|
end := start + 100
|
||||||
|
if end > len(trainingIds) {
|
||||||
|
end = len(trainingIds)
|
||||||
|
}
|
||||||
|
var rows []*entity.ModelVersion
|
||||||
|
err := g.DB().Model(consts.TableModelVersion).Ctx(ctx).
|
||||||
|
WhereIn("training_id", trainingIds[start:end]).Fields("training_id").Scan(&rows)
|
||||||
|
if err != nil && !common.IsNoRows(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
published[r.TrainingId] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return published, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PageByDataset 某数据集版本分页:发布时间倒序
|
// PageByDataset 某数据集版本分页:发布时间倒序
|
||||||
func (d *modelVersionDao) PageByDataset(ctx context.Context, datasetId int64, page, size int) ([]*entity.ModelVersion, int64, error) {
|
func (d *modelVersionDao) PageByDataset(ctx context.Context, datasetId int64, page, size int) ([]*entity.ModelVersion, int64, error) {
|
||||||
base := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Where("dataset_id", datasetId)
|
base := g.DB().Model(consts.TableModelVersion).Ctx(ctx).Where("dataset_id", datasetId)
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ type AdminDatasetItem struct {
|
|||||||
Source string `json:"source"` // manual | ai
|
Source string `json:"source"` // manual | ai
|
||||||
ImageCount int64 `json:"imageCount"`
|
ImageCount int64 `json:"imageCount"`
|
||||||
LabeledCount int64 `json:"labeledCount"`
|
LabeledCount int64 `json:"labeledCount"`
|
||||||
Status string `json:"status"` // building | labeled | synced
|
Status string `json:"status"` // building | labeled | synced
|
||||||
Cover string `json:"cover"` // 封面文件名
|
Cover string `json:"cover"` // 封面文件名
|
||||||
Description string `json:"description"` // 描述
|
Description string `json:"description"` // 描述
|
||||||
TrainingId int64 `json:"trainingId"` // 最新训练记录 id(发布/详情用)
|
NamePrefix string `json:"namePrefix"` // AI 生成图文件名前缀
|
||||||
TrainingStatus string `json:"trainingStatus"` // 最新训练记录状态 running|success|failed|空
|
TrainingId int64 `json:"trainingId"` // 最新训练记录 id(发布/详情用)
|
||||||
TrainingError string `json:"trainingError"` // 最新训练记录失败原因(failed 时展示用)
|
TrainingStatus string `json:"trainingStatus"` // 最新训练记录状态 running|success|failed|空
|
||||||
|
TrainingError string `json:"trainingError"` // 最新训练记录失败原因(failed 时展示用)
|
||||||
|
TrainingPublished bool `json:"trainingPublished"` // 该训练是否已发布过版本(已发布不再显示发布按钮)
|
||||||
TrainingCurrentEpoch int `json:"trainingCurrentEpoch"`
|
TrainingCurrentEpoch int `json:"trainingCurrentEpoch"`
|
||||||
TrainingTotalEpochs int `json:"trainingTotalEpochs"`
|
TrainingTotalEpochs int `json:"trainingTotalEpochs"`
|
||||||
CreatedAt *gtime.Time `json:"createdAt"`
|
CreatedAt *gtime.Time `json:"createdAt"`
|
||||||
@@ -52,6 +54,7 @@ type AdminDatasetUpdateReq struct {
|
|||||||
g.Meta `path:"/datasets/update" method:"post" summary:"更新数据集配置" tags:"管理端"`
|
g.Meta `path:"/datasets/update" method:"post" summary:"更新数据集配置" tags:"管理端"`
|
||||||
Id int64 `json:"id" v:"required|min:1" dc:"数据集 id"`
|
Id int64 `json:"id" v:"required|min:1" dc:"数据集 id"`
|
||||||
Name string `json:"name" v:"length:0,50" dc:"数据集名称(非空且变化才更新;同是目录名)"`
|
Name string `json:"name" v:"length:0,50" dc:"数据集名称(非空且变化才更新;同是目录名)"`
|
||||||
|
NamePrefix string `json:"namePrefix" v:"length:0,50" dc:"AI 生成图文件名前缀(非空才更新)"`
|
||||||
Description string `json:"description" v:"length:0,500" dc:"描述(非空才更新)"`
|
Description string `json:"description" v:"length:0,500" dc:"描述(非空才更新)"`
|
||||||
Cover string `json:"cover" v:"length:0,200" dc:"封面文件名(非空才更新)"`
|
Cover string `json:"cover" v:"length:0,200" dc:"封面文件名(非空才更新)"`
|
||||||
}
|
}
|
||||||
@@ -62,9 +65,10 @@ type AdminDatasetUpdateRes struct {
|
|||||||
|
|
||||||
// AdminDatasetCreateReq 新建数据集(名称同时是磁盘目录名:中文/字母/数字/下划线/短横线,唯一)
|
// AdminDatasetCreateReq 新建数据集(名称同时是磁盘目录名:中文/字母/数字/下划线/短横线,唯一)
|
||||||
type AdminDatasetCreateReq struct {
|
type AdminDatasetCreateReq struct {
|
||||||
g.Meta `path:"/datasets" method:"post" summary:"新建数据集" tags:"管理端"`
|
g.Meta `path:"/datasets" method:"post" summary:"新建数据集" tags:"管理端"`
|
||||||
Name string `json:"name" v:"required|regex:^[a-zA-Z0-9_一-龥-]+$|length:1,50" dc:"数据集名称(同是目录名,唯一)"`
|
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"`
|
Source string `json:"source" v:"required|in:manual,ai" dc:"图片来源 manual|ai"`
|
||||||
|
NamePrefix string `json:"namePrefix" v:"length:0,50" dc:"AI 生成图文件名前缀(可选,字母/数字/下划线/短横线)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminDatasetCreateRes struct {
|
type AdminDatasetCreateRes struct {
|
||||||
@@ -91,13 +95,32 @@ type AdminDatasetUploadRes struct {
|
|||||||
Skipped int `json:"skipped"` // 重名跳过数
|
Skipped int `json:"skipped"` // 重名跳过数
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminDatasetGenerateReq AI 生成图片(同步执行;prompt 禁止含目标位置描述,前端模板+文案约束)
|
// AdminDatasetGenerateReq AI 生成图片(同步执行;prompt 可手填覆盖模板,禁止含目标位置描述;
|
||||||
|
// 未手填走 imageGen.promptTemplate 组装:species/scene 用表单选择,数量词=animalCount。
|
||||||
|
// distanceMin/distanceMax(米)与 animalCount 填写后逐张校验:RF-DETR 检测目标框(去重口径),
|
||||||
|
// 数量精确匹配、估算距离(物理公式)越界自动重新生成(上限 consts.GenValidateMaxAttempts 次)。
|
||||||
|
// 文件名前缀为数据集属性(datasets 表 name_prefix),不再随生成请求传入
|
||||||
type AdminDatasetGenerateReq struct {
|
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"`
|
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||||
Prompt string `json:"prompt" v:"required|length:5,500" dc:"生成提示词(禁止含位置描述)"`
|
Species string `json:"species" v:"length:0,50" dc:"动物名称(如家鸽/山斑鸠;留空按数据集名取物种池随机)"`
|
||||||
Count int `json:"count" v:"required|integer|min:1|max:8" dc:"生成张数 1-8"`
|
Prompt string `json:"prompt" v:"length:0,500" dc:"完整提示词(可选,填写则覆盖模板;禁止含位置描述)"`
|
||||||
Size string `json:"size" v:"required|in:704x1248" dc:"图片尺寸"`
|
Count int `json:"count" v:"required|integer|min:1|max:8" dc:"生成张数 1-8"`
|
||||||
|
Size string `json:"size" v:"required|in:704x1248" dc:"图片尺寸"`
|
||||||
|
DistanceMin int `json:"distanceMin" v:"integer|min:0" dc:"距离范围下限(米,0=不校验距离)"`
|
||||||
|
DistanceMax int `json:"distanceMax" v:"integer|min:0" dc:"距离范围上限(米,0=不校验距离)"`
|
||||||
|
AnimalCount int `json:"animalCount" v:"integer|min:0|max:20" dc:"画面目标数量(精确匹配,0=不校验;标注框数不超过该值)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDatasetGenOptionsReq 生成表单选项(物种池/场景池,来自 config imageGen 配置)
|
||||||
|
type AdminDatasetGenOptionsReq struct {
|
||||||
|
g.Meta `path:"/datasets/gen-options" 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 {
|
type AdminDatasetGenerateRes struct {
|
||||||
@@ -282,6 +305,22 @@ type AdminLabelTaskStartRes struct {
|
|||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminImageVlmReviewReq 单图 VLM 补检:qwen3.8-9b 按「环境/光线/习性」推理 RF-DETR 之外
|
||||||
|
// 可能藏匿目标的位置(排除已确认坐标),最多补 3 个疑似框(class 1)追加到 labels_json。
|
||||||
|
// 依赖 localAi.vlmModel(默认 qwen3.8-9b);VL 与 z-image 生成不能同时驻留显存,批量补检
|
||||||
|
// 应在生成队列空闲时执行
|
||||||
|
type AdminImageVlmReviewReq struct {
|
||||||
|
g.Meta `path:"/datasets/images/vlm-review" method:"post" summary:"VLM 藏匿位补检" tags:"管理端"`
|
||||||
|
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||||
|
ImageId int64 `json:"imageId" v:"required|min:1" dc:"图片 id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminImageVlmReviewRes struct {
|
||||||
|
Added int `json:"added"` // 新增疑似框数(≤3)
|
||||||
|
Boxes []*AdminLabelBox `json:"boxes"` // VLM 新增框(归一化坐标,class=1)
|
||||||
|
Note string `json:"note"` // 未补检原因(如已确认框为空/VL 无输出)
|
||||||
|
}
|
||||||
|
|
||||||
// AdminLabelTaskDetailReq 预标注任务详情(每张图候选框 + 已确认标注,供工作台 canvas 叠框)
|
// AdminLabelTaskDetailReq 预标注任务详情(每张图候选框 + 已确认标注,供工作台 canvas 叠框)
|
||||||
type AdminLabelTaskDetailReq struct {
|
type AdminLabelTaskDetailReq struct {
|
||||||
g.Meta `path:"/label-tasks/detail" method:"get" summary:"预标注任务详情" tags:"管理端"`
|
g.Meta `path:"/label-tasks/detail" method:"get" summary:"预标注任务详情" tags:"管理端"`
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Dataset struct {
|
|||||||
LabeledCount int64 `json:"labeledCount" orm:"labeled_count" description:"已标注数"`
|
LabeledCount int64 `json:"labeledCount" orm:"labeled_count" description:"已标注数"`
|
||||||
Status string `json:"status" orm:"status" description:"building|labeled|synced"`
|
Status string `json:"status" orm:"status" description:"building|labeled|synced"`
|
||||||
Cover string `json:"cover" orm:"cover" description:"封面文件名(卡片展示)"`
|
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:"描述(卡片展示)"`
|
Description string `json:"description" orm:"description" description:"描述(卡片展示)"`
|
||||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||||
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
|
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import "github.com/gogf/gf/v2/os/gtime"
|
|||||||
// AI 生成图记录 prompt(付费资产,追溯用);标注为 JSON 数组存本表
|
// AI 生成图记录 prompt(付费资产,追溯用);标注为 JSON 数组存本表
|
||||||
// (元素同 dto.AdminLabelBox:class/cx/cy/w/h/confidence,YOLO 归一化)。
|
// (元素同 dto.AdminLabelBox:class/cx/cy/w/h/confidence,YOLO 归一化)。
|
||||||
type DatasetImage struct {
|
type DatasetImage struct {
|
||||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
|
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
|
||||||
Filename string `json:"filename" orm:"filename" description:"唯一文件名(防重名加时间戳后缀)"`
|
Filename string `json:"filename" orm:"filename" description:"唯一文件名(防重名加时间戳后缀)"`
|
||||||
Source string `json:"source" orm:"source" description:"manual|ai"`
|
Source string `json:"source" orm:"source" description:"manual|ai"`
|
||||||
Prompt string `json:"prompt" orm:"prompt" description:"AI 生成图提示词"`
|
Prompt string `json:"prompt" orm:"prompt" description:"AI 生成图提示词"`
|
||||||
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 自动标注与人工标注同存,null/''/'[]'=未标注)"`
|
AnimalCount int `json:"animalCount" orm:"animal_count" description:"AI 生成时的目标数量上限(0=未限定),自动标注按置信度裁剪到此数"`
|
||||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 自动标注与人工标注同存,null/''/'[]'=未标注)"`
|
||||||
|
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||||
}
|
}
|
||||||
|
|||||||
+266
-20
@@ -6,10 +6,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -61,6 +64,15 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 已发布过的训练不再返回发布按钮(model_version.training_id 反查)
|
||||||
|
trainingIds := make([]int64, 0, len(latest))
|
||||||
|
for _, t := range latest {
|
||||||
|
trainingIds = append(trainingIds, t.Id)
|
||||||
|
}
|
||||||
|
published, err := dao.ModelVersion.PublishedByTrainingIds(ctx, trainingIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
items := make([]*dto.AdminDatasetItem, 0, len(list))
|
items := make([]*dto.AdminDatasetItem, 0, len(list))
|
||||||
for _, v := range list {
|
for _, v := range list {
|
||||||
item := &dto.AdminDatasetItem{
|
item := &dto.AdminDatasetItem{
|
||||||
@@ -72,6 +84,7 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
|
|||||||
Status: v.Status,
|
Status: v.Status,
|
||||||
Cover: v.Cover,
|
Cover: v.Cover,
|
||||||
Description: v.Description,
|
Description: v.Description,
|
||||||
|
NamePrefix: v.NamePrefix,
|
||||||
CreatedAt: v.CreatedAt,
|
CreatedAt: v.CreatedAt,
|
||||||
UpdatedAt: v.UpdatedAt,
|
UpdatedAt: v.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -79,6 +92,7 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
|
|||||||
item.TrainingId = t.Id
|
item.TrainingId = t.Id
|
||||||
item.TrainingStatus = t.Status
|
item.TrainingStatus = t.Status
|
||||||
item.TrainingError = t.Error
|
item.TrainingError = t.Error
|
||||||
|
item.TrainingPublished = published[t.Id]
|
||||||
item.TrainingCurrentEpoch = t.CurrentEpoch
|
item.TrainingCurrentEpoch = t.CurrentEpoch
|
||||||
item.TrainingTotalEpochs = t.TotalEpochs
|
item.TrainingTotalEpochs = t.TotalEpochs
|
||||||
}
|
}
|
||||||
@@ -114,8 +128,13 @@ func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminD
|
|||||||
return nil, gerror.NewCode(common.CodeImageNotFound)
|
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{
|
if err := dao.Dataset.UpdateConfigs(ctx, req.Id, &entity.Dataset{
|
||||||
Name: newName,
|
Name: newName,
|
||||||
|
NamePrefix: prefix,
|
||||||
Cover: req.Cover,
|
Cover: req.Cover,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -343,12 +362,17 @@ func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminD
|
|||||||
if exists != nil {
|
if exists != nil {
|
||||||
return gerror.NewCode(common.CodeDatasetNameDuplicate)
|
return gerror.NewCode(common.CodeDatasetNameDuplicate)
|
||||||
}
|
}
|
||||||
|
prefix := strings.TrimSpace(req.NamePrefix)
|
||||||
|
if prefix != "" && !datasetNameRe.MatchString(prefix) {
|
||||||
|
return gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50")
|
||||||
|
}
|
||||||
id, err = dao.Dataset.Insert(ctx, &entity.Dataset{
|
id, err = dao.Dataset.Insert(ctx, &entity.Dataset{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Source: req.Source,
|
Source: req.Source,
|
||||||
Status: consts.DatasetStatusBuilding,
|
NamePrefix: prefix,
|
||||||
CreatedAt: now,
|
Status: consts.DatasetStatusBuilding,
|
||||||
UpdatedAt: now,
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -528,6 +552,161 @@ func saveImageFile(f *ghttp.UploadFile, dir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nextImageIndex 生成图顺序编号起点:数据集内 <前缀>_<数字>.jpg 的已有最大序号 + 1
|
||||||
|
func (s *datasetService) nextImageIndex(ctx context.Context, datasetId int64, prefix string) (int, error) {
|
||||||
|
list, err := dao.DatasetImage.ListByDataset(ctx, datasetId)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`^` + regexp.QuoteMeta(prefix) + `_(\d+)\.jpg$`)
|
||||||
|
max := 0
|
||||||
|
for _, img := range list {
|
||||||
|
if m := re.FindStringSubmatch(img.Filename); m != nil {
|
||||||
|
if v, err := strconv.Atoi(m[1]); err == nil && v > max {
|
||||||
|
max = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max + 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateGeneratedImage 生成图距离/数量校验:RF-DETR 全图检测目标框(与自动标注同口径去重),
|
||||||
|
// 数量精确匹配 animalCount;距离用物理公式 d = 物种体高 × focalPx ÷ 目标像素高
|
||||||
|
// (focalPx 由假设视场角 imageGen.assumedVfovDeg 推出;体高按物种查 imageGen.speciesHeights),
|
||||||
|
// 须落在表单 [distanceMin, distanceMax] 内(>0 才启用对应项)
|
||||||
|
func (s *datasetService) validateGeneratedImage(ctx context.Context, data []byte, req *dto.AdminDatasetGenerateReq, species string) (bool, string, error) {
|
||||||
|
client := common.LocalAiClient(ctx)
|
||||||
|
if client == nil {
|
||||||
|
return false, "", gerror.NewCode(common.CodeLocalAiNotConfigured)
|
||||||
|
}
|
||||||
|
// 校验与预标注用途不同:预标注阈值 0.08 宁多勿漏,会把玉米叶/土块算成目标(实测 30-40m 场景 6-18 框噪声);
|
||||||
|
// 校验用高置信度阈值(实测 0.3 时真鸟全留、噪声清零,0.4 起连远处真鸟都丢)
|
||||||
|
client.Threshold = g.Cfg().MustGet(ctx, "imageGen.validateThreshold", 0.3).Float64()
|
||||||
|
imgW, imgH, err := parseGenSize(req.Size)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
dets, err := client.Detect(ctx, data, "image/jpeg", imgW, imgH)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", gerror.Wrap(err, "生成图检测失败")
|
||||||
|
}
|
||||||
|
// 与自动标注同口径去重(同目标多框只计一只),保证「校验数量 == 标注框数」
|
||||||
|
cands := make([]*dto.AdminLabelBox, 0, len(dets))
|
||||||
|
for _, d := range dets {
|
||||||
|
cands = append(cands, &dto.AdminLabelBox{
|
||||||
|
Cx: (d.X + d.Width/2) / float64(imgW),
|
||||||
|
Cy: (d.Y + d.Height/2) / float64(imgH),
|
||||||
|
W: d.Width / float64(imgW),
|
||||||
|
H: d.Height / float64(imgH),
|
||||||
|
Confidence: d.Confidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
deduped := suppressOverlap(cands, client.OverlapThreshold)
|
||||||
|
if len(deduped) == 0 {
|
||||||
|
return false, "未检测到目标动物", nil
|
||||||
|
}
|
||||||
|
// 数量为精确校验:表单指定几只,画面里就必须恰好几只(提示词中的数量应与表单一致)
|
||||||
|
if req.AnimalCount > 0 && len(deduped) != req.AnimalCount {
|
||||||
|
return false, fmt.Sprintf("目标数量 %d 与指定数量 %d 不符", len(deduped), req.AnimalCount), nil
|
||||||
|
}
|
||||||
|
maxRatio := 0.0
|
||||||
|
for _, b := range deduped {
|
||||||
|
if b.H > maxRatio {
|
||||||
|
maxRatio = b.H
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 物理公式:d = 体高 × focalPx ÷ 像素高;focalPx = 图高/2 ÷ tan(vfov/2)
|
||||||
|
heightM := g.Cfg().MustGet(ctx, "imageGen.speciesHeights.default", 0.35).Float64()
|
||||||
|
if h := g.Cfg().MustGet(ctx, "imageGen.speciesHeights."+species).Float64(); h > 0 {
|
||||||
|
heightM = h
|
||||||
|
}
|
||||||
|
vfov := g.Cfg().MustGet(ctx, "imageGen.assumedVfovDeg", 52.0).Float64() * math.Pi / 180
|
||||||
|
focalPx := float64(imgH) / 2 / math.Tan(vfov/2)
|
||||||
|
pxHeight := maxRatio * float64(imgH)
|
||||||
|
estDist := heightM * focalPx / pxHeight
|
||||||
|
if req.DistanceMin > 0 && estDist < float64(req.DistanceMin) {
|
||||||
|
return false, fmt.Sprintf("估算距离约 %.0f 米低于下限 %d 米(目标过近)", estDist, req.DistanceMin), nil
|
||||||
|
}
|
||||||
|
if req.DistanceMax > 0 && estDist > float64(req.DistanceMax) {
|
||||||
|
return false, fmt.Sprintf("估算距离约 %.0f 米超出上限 %d 米(目标过远)", estDist, req.DistanceMax), nil
|
||||||
|
}
|
||||||
|
return true, fmt.Sprintf("数量 %d,最大目标估算距离约 %.0f 米", len(deduped), estDist), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGenSize 解析 "704x1248" 形式尺寸
|
||||||
|
func parseGenSize(size string) (int, int, error) {
|
||||||
|
parts := strings.SplitN(size, "x", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, 0, gerror.Newf("非法图片尺寸 %q", size)
|
||||||
|
}
|
||||||
|
w, errW := strconv.Atoi(parts[0])
|
||||||
|
h, errH := strconv.Atoi(parts[1])
|
||||||
|
if errW != nil || errH != nil || w <= 0 || h <= 0 {
|
||||||
|
return 0, 0, gerror.Newf("非法图片尺寸 %q", size)
|
||||||
|
}
|
||||||
|
return w, h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countWordCN 数量词(与表单 animalCount 精确对应,防提示词文案与校验数量不符)
|
||||||
|
var countWordCN = [...]string{"一只", "两只", "三只", "四只", "五只", "六只", "七只", "八只", "九只", "十只"}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPromptFromTemplate 按 config 通用模板组装提示词:物种/场景优先用表单选择
|
||||||
|
// (物种只写名字不写羽毛细节——模型对正确名称自带外观先验;场景未选则池内随机),
|
||||||
|
// 物种未填按数据集名取物种池随机(池也未配置则用数据集名本身);动作/光线池内随机。
|
||||||
|
func (s *datasetService) buildPromptFromTemplate(ctx context.Context, datasetName, species string, count int) (string, string, error) {
|
||||||
|
tpl := g.Cfg().MustGet(ctx, "imageGen.promptTemplate").String()
|
||||||
|
if tpl == "" {
|
||||||
|
return "", "", gerror.New("未配置通用提示词模板(imageGen.promptTemplate),请手填提示词")
|
||||||
|
}
|
||||||
|
if species == "" {
|
||||||
|
species = datasetName
|
||||||
|
if pool := g.Cfg().MustGet(ctx, "imageGen.speciesByDataset."+datasetName).Strings(); len(pool) > 0 {
|
||||||
|
species = pool[rand.Intn(len(pool))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene := pickCfg(ctx, "imageGen.scenes")
|
||||||
|
action := pickCfg(ctx, "imageGen.actions")
|
||||||
|
light := pickCfg(ctx, "imageGen.lights")
|
||||||
|
dist := g.Cfg().MustGet(ctx, "imageGen.distanceWord", "远处").String()
|
||||||
|
countWord := fmt.Sprintf("%d只", count)
|
||||||
|
if count >= 1 && count <= len(countWordCN) {
|
||||||
|
countWord = countWordCN[count-1]
|
||||||
|
}
|
||||||
|
out := tpl
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
"{scene}": scene, "{species}": species, "{count}": countWord,
|
||||||
|
"{action}": action, "{light}": light, "{distanceWord}": dist,
|
||||||
|
} {
|
||||||
|
out = strings.ReplaceAll(out, k, v)
|
||||||
|
}
|
||||||
|
g.Log().Infof(ctx, "模板组装提示词: %s", out)
|
||||||
|
return out, species, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickCfg 取字符串数组配置项随机一项(空池返回空串)
|
||||||
|
func pickCfg(ctx context.Context, key string) string {
|
||||||
|
pool := g.Cfg().MustGet(ctx, key).Strings()
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return pool[rand.Intn(len(pool))]
|
||||||
|
}
|
||||||
|
|
||||||
// AdminGenerateImages AI 生成图片:同步逐张生成(每张超时 imageGen.timeoutSeconds),逐张落盘 + 入库;
|
// AdminGenerateImages AI 生成图片:同步逐张生成(每张超时 imageGen.timeoutSeconds),逐张落盘 + 入库;
|
||||||
// 中途失败返回错误并附已成功张数(已生成的图为付费资产,保留不删除)。
|
// 中途失败返回错误并附已成功张数(已生成的图为付费资产,保留不删除)。
|
||||||
func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) {
|
func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) {
|
||||||
@@ -555,35 +734,102 @@ func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.Admin
|
|||||||
addedIds := make([]int64, 0, req.Count)
|
addedIds := make([]int64, 0, req.Count)
|
||||||
// 每张生成超时走配置(dashscope 默认 120s;localai 训练机约 5min/张,建议 600)
|
// 每张生成超时走配置(dashscope 默认 120s;localai 训练机约 5min/张,建议 600)
|
||||||
genTimeout := time.Duration(g.Cfg().MustGet(ctx, "imageGen.timeoutSeconds", 120).Int()) * time.Second
|
genTimeout := time.Duration(g.Cfg().MustGet(ctx, "imageGen.timeoutSeconds", 120).Int()) * time.Second
|
||||||
for i := 0; i < req.Count; i++ {
|
handWritten := strings.TrimSpace(req.Prompt) != ""
|
||||||
|
genOnce := func(prompt string) ([]byte, error) {
|
||||||
genCtx, cancel := context.WithTimeout(ctx, genTimeout)
|
genCtx, cancel := context.WithTimeout(ctx, genTimeout)
|
||||||
data, genErr := provider.Generate(genCtx, req.Prompt, req.Size)
|
defer cancel()
|
||||||
cancel()
|
return provider.Generate(genCtx, prompt, req.Size)
|
||||||
|
}
|
||||||
|
// 命名:数据集属性 name_prefix(创建/编辑表单维护)非空时按 <前缀>_<两位序号>.jpg 顺序编号
|
||||||
|
// (延续数据集内已有最大序号,参考野鸡目录 pheasant_NN.jpg 惯例);无前缀按时间戳命名。
|
||||||
|
// 注意:gtime.Time.Format 是 PHP 风格格式符,Go 数字布局须用 .Time.Format,否则原样透传
|
||||||
|
prefix := dataset.NamePrefix
|
||||||
|
nextIdx := 1
|
||||||
|
if prefix != "" {
|
||||||
|
if !datasetNameRe.MatchString(prefix) {
|
||||||
|
return nil, gerror.New("文件名前缀仅支持字母/数字/下划线/短横线")
|
||||||
|
}
|
||||||
|
nextIdx, err = s.nextImageIndex(ctx, dataset.Id, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}, err
|
||||||
|
}
|
||||||
|
// 提示词:手填优先;未手填走 config 通用模板逐张组装——物种/场景用表单选择,
|
||||||
|
// 未填按数据集名取物种池随机、场景池随机;数量词与表单 animalCount 一致;动作/光线随机
|
||||||
|
prompt := strings.TrimSpace(req.Prompt)
|
||||||
|
species := strings.TrimSpace(req.Species)
|
||||||
|
if !handWritten {
|
||||||
|
var tErr error
|
||||||
|
if prompt, species, tErr = s.buildPromptFromTemplate(ctx, dataset.Name, species, req.AnimalCount); tErr != nil {
|
||||||
|
return nil, tErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, genErr := genOnce(prompt)
|
||||||
if genErr != nil {
|
if genErr != nil {
|
||||||
// 付费资产保留原则:已生成的不删除
|
// 付费资产保留原则:已生成的不删除
|
||||||
return &dto.AdminDatasetGenerateRes{Generated: generated},
|
return failWith(gerror.NewCode(common.CodeImageGenFailed, fmt.Sprintf("第 %d 张生成失败(已生成 %d 张): %v", i+1, generated, genErr)))
|
||||||
gerror.NewCode(common.CodeImageGenFailed, fmt.Sprintf("第 %d 张生成失败(已生成 %d 张): %v", i+1, generated, genErr))
|
}
|
||||||
|
// 逐张校验(表单填写距离范围/目标数量后启用):不合格丢弃并重新生成,连续不合格报错返回
|
||||||
|
needValidate := req.DistanceMax > 0 || req.DistanceMin > 0 || req.AnimalCount > 0
|
||||||
|
if needValidate {
|
||||||
|
passed := false
|
||||||
|
reason := ""
|
||||||
|
for attempt := 1; attempt <= consts.GenValidateMaxAttempts; attempt++ {
|
||||||
|
ok, r, vErr := s.validateGeneratedImage(ctx, data, req, species)
|
||||||
|
if vErr != nil {
|
||||||
|
return failWith(gerror.Wrapf(vErr, "第 %d 张校验失败(已生成 %d 张)", i+1, generated))
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
passed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
reason = r
|
||||||
|
g.Log().Infof(ctx, "生成图校验不合格(%s),第 %d 张第 %d 次重新生成", r, i+1, attempt)
|
||||||
|
if data, genErr = genOnce(prompt); genErr != nil {
|
||||||
|
return failWith(gerror.NewCode(common.CodeImageGenFailed, fmt.Sprintf("第 %d 张重新生成失败(已生成 %d 张): %v", i+1, generated, genErr)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !passed {
|
||||||
|
return failWith(gerror.NewCode(common.CodeImageGenFailed, fmt.Sprintf("第 %d 张连续 %d 次校验不合格(%s),请调整提示词或范围后重试(已生成 %d 张)", i+1, consts.GenValidateMaxAttempts, reason, generated)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
filename := fmt.Sprintf("gen_%s_%d.jpg", now.Format("20060102150405"), i)
|
|
||||||
if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil {
|
if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil {
|
||||||
return &dto.AdminDatasetGenerateRes{Generated: generated},
|
return failWith(gerror.Wrapf(err, "第 %d 张保存失败(已生成 %d 张)", i+1, generated))
|
||||||
gerror.Wrapf(err, "第 %d 张保存失败(已生成 %d 张)", i+1, generated)
|
|
||||||
}
|
}
|
||||||
var imgId int64
|
var imgId int64
|
||||||
insErr := common.Serial().Submit(ctx, func() error {
|
insErr := common.Serial().Submit(ctx, func() error {
|
||||||
id, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{
|
id, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{
|
||||||
DatasetId: dataset.Id,
|
DatasetId: dataset.Id,
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
Source: "ai",
|
Source: "ai",
|
||||||
Prompt: req.Prompt,
|
Prompt: prompt,
|
||||||
CreatedAt: now,
|
AnimalCount: req.AnimalCount,
|
||||||
|
CreatedAt: now,
|
||||||
})
|
})
|
||||||
imgId = id
|
imgId = id
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if insErr != nil {
|
if insErr != nil {
|
||||||
return &dto.AdminDatasetGenerateRes{Generated: generated},
|
return failWith(gerror.Wrap(insErr, "生成图片入库失败"))
|
||||||
gerror.Wrap(insErr, "生成图片入库失败")
|
|
||||||
}
|
}
|
||||||
addedIds = append(addedIds, imgId)
|
addedIds = append(addedIds, imgId)
|
||||||
generated++
|
generated++
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -242,7 +243,14 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
|
|||||||
}
|
}
|
||||||
cands = append(cands, box)
|
cands = append(cands, box)
|
||||||
}
|
}
|
||||||
results[i] = suppressOverlap(cands, client.OverlapThreshold)
|
boxes := suppressOverlap(cands, client.OverlapThreshold)
|
||||||
|
// 生成时已指定目标数量上限:自动标注按置信度裁剪到该数
|
||||||
|
// (生成校验已保证图内目标 ≤N,宁多勿漏阈值带出的低置信噪声框在此去除)
|
||||||
|
if img.AnimalCount > 0 && len(boxes) > img.AnimalCount {
|
||||||
|
sort.Slice(boxes, func(a, b int) bool { return boxes[a].Confidence > boxes[b].Confidence })
|
||||||
|
boxes = boxes[:img.AnimalCount]
|
||||||
|
}
|
||||||
|
results[i] = boxes
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
cancel()
|
cancel()
|
||||||
@@ -282,6 +290,124 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminImageVlmReview 单图 VLM 藏匿位补检(两阶段标注第二阶段,须与 z-image 生成显存互斥):
|
||||||
|
// 以当前 labels_json(RF-DETR/人工框)为排除集,调 qwen3.8-9b(+mmproj) 按环境/光线/习性
|
||||||
|
// 推理可能藏匿目标的位置,取前 3 个追加为疑似框(class 1)。
|
||||||
|
func (s *labelTaskService) AdminImageVlmReview(ctx context.Context, req *dto.AdminImageVlmReviewReq) (*dto.AdminImageVlmReviewRes, error) {
|
||||||
|
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if dataset == nil {
|
||||||
|
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||||
|
}
|
||||||
|
img, err := dao.DatasetImage.GetById(ctx, req.ImageId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if img == nil || img.DatasetId != dataset.Id {
|
||||||
|
return nil, gerror.NewCode(common.CodeImageNotFound)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), img.Filename))
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "读取图片失败")
|
||||||
|
}
|
||||||
|
var existing []*dto.AdminLabelBox
|
||||||
|
if strings.TrimSpace(img.LabelsJson) != "" {
|
||||||
|
if jErr := json.Unmarshal([]byte(img.LabelsJson), &existing); jErr != nil {
|
||||||
|
return nil, gerror.Wrap(jErr, "解析已有标注失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 排除集:已确认框坐标(VLM 不得重复输出这些位置)
|
||||||
|
exclude := "无"
|
||||||
|
if len(existing) > 0 {
|
||||||
|
parts := make([]string, 0, len(existing))
|
||||||
|
for _, b := range existing {
|
||||||
|
parts = append(parts, fmt.Sprintf("(%.3f,%.3f,%.3f,%.3f)", b.Cx, b.Cy, b.W, b.H))
|
||||||
|
}
|
||||||
|
exclude = strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
// 物种:从生成提示词与数据集物种池匹配(匹配不到交给 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
speciesLine := "从已确认目标框自行判断物种"
|
||||||
|
if species != "" {
|
||||||
|
speciesLine = species
|
||||||
|
}
|
||||||
|
prompt := fmt.Sprintf(
|
||||||
|
"你是野生动物监测照片分析助手。\n物种:%s\n拍摄场景与时间光线线索:%s\n已确认目标位置(归一化 cx,cy,w,h,已排除,不要再输出这些位置):%s\n"+
|
||||||
|
"请基于环境、天气、光线与该物种习性(觅食路线、隐蔽处、阴影边缘、植被深处),分析除已确认位置外还可能藏匿个体的位置。\n"+
|
||||||
|
"输出归一化 bbox 的 JSON 数组:[{\"cx\":0.5,\"cy\":0.5,\"w\":0.2,\"h\":0.15}],最多 3 个,按可能性从高到低;确无可能则输出 []。只输出 JSON 数组本身,不要其他文字。",
|
||||||
|
speciesLine, img.Prompt, exclude)
|
||||||
|
content, err := common.QwenVL(ctx, data, imageMime(img.Filename), prompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gerror.Wrap(err, "VLM 推理失败")
|
||||||
|
}
|
||||||
|
// 提取首个 JSON 数组并校验
|
||||||
|
m := regexp.MustCompile(`\[[\s\S]*?\]`).FindString(content)
|
||||||
|
res := &dto.AdminImageVlmReviewRes{}
|
||||||
|
if m == "" {
|
||||||
|
res.Note = "VLM 未输出坐标数组"
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
var raw []struct {
|
||||||
|
Cx, Cy, W, H float64
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(m), &raw); err != nil {
|
||||||
|
res.Note = "VLM 坐标解析失败"
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
client := common.LocalAiClient(ctx)
|
||||||
|
overlap := 0.3
|
||||||
|
if client != nil {
|
||||||
|
overlap = client.OverlapThreshold
|
||||||
|
}
|
||||||
|
for _, b := range raw {
|
||||||
|
if len(res.Boxes) >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if b.Cx <= 0 || b.Cx >= 1 || b.Cy <= 0 || b.Cy >= 1 || b.W <= 0 || b.W >= 1 || b.H <= 0 || b.H >= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nb := &dto.AdminLabelBox{Cx: b.Cx, Cy: b.Cy, W: b.W, H: b.H, Confidence: 0.1, Class: 1}
|
||||||
|
dup := false
|
||||||
|
for _, e := range existing {
|
||||||
|
if boxOverlap(nb, e) > overlap {
|
||||||
|
dup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Boxes = append(res.Boxes, nb)
|
||||||
|
}
|
||||||
|
if len(res.Boxes) == 0 {
|
||||||
|
res.Note = "VLM 未给出有效的藏匿位坐标"
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
// 追加为疑似框并落库(与 RF-DETR/人工框同层,工作台可编辑)
|
||||||
|
appended := append(existing, res.Boxes...)
|
||||||
|
if err := common.Serial().Submit(ctx, func() error {
|
||||||
|
rawJson, jErr := json.Marshal(appended)
|
||||||
|
if jErr != nil {
|
||||||
|
return jErr
|
||||||
|
}
|
||||||
|
return dao.DatasetImage.UpdateLabels(ctx, img.Id, string(rawJson))
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.Added = len(res.Boxes)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AdminLabelTaskDetail 预标注任务详情:标注(labels_json)输出,供工作台 canvas 叠框;
|
// AdminLabelTaskDetail 预标注任务详情:标注(labels_json)输出,供工作台 canvas 叠框;
|
||||||
// 图片尺寸读取文件头。
|
// 图片尺寸读取文件头。
|
||||||
func (s *labelTaskService) AdminLabelTaskDetail(ctx context.Context, req *dto.AdminLabelTaskDetailReq) (*dto.AdminLabelTaskDetailRes, error) {
|
func (s *labelTaskService) AdminLabelTaskDetail(ctx context.Context, req *dto.AdminLabelTaskDetailReq) (*dto.AdminLabelTaskDetailRes, error) {
|
||||||
|
|||||||
@@ -59,6 +59,63 @@ func LocalAiClient(ctx context.Context) *LocalAi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QwenVL 调 local-ai 的多模态 VLM(qwen3.8-9b + mmproj,llama.cpp 后端)做图片推理。
|
||||||
|
// 注意:VL 与 z-image 生成不能同时驻留 12G 显存(OOM),调用方须保证阶段互斥。
|
||||||
|
func QwenVL(ctx context.Context, imgData []byte, mime, prompt string) (string, error) {
|
||||||
|
base := strings.TrimRight(g.Cfg().MustGet(ctx, "localAi.baseUrl").String(), "/")
|
||||||
|
if base == "" {
|
||||||
|
return "", fmt.Errorf("localAi.baseUrl 未配置")
|
||||||
|
}
|
||||||
|
model := g.Cfg().MustGet(ctx, "localAi.vlmModel", "qwen3.8-9b").String()
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(imgData)
|
||||||
|
body, err := json.Marshal(map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": []map[string]any{{
|
||||||
|
"role": "user",
|
||||||
|
"content": []map[string]any{
|
||||||
|
{"type": "image_url", "image_url": map[string]string{"url": fmt.Sprintf("data:%s;base64,%s", mime, b64)}},
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
"max_tokens": 600,
|
||||||
|
"temperature": 0.3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("VLM HTTP %d: %s", resp.StatusCode, truncateStr(string(raw), 200))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(out.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("VLM 无响应内容")
|
||||||
|
}
|
||||||
|
return out.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Detect 对单张图片做全图检测(不做任何裁剪,位置由模型自行推理):
|
// Detect 对单张图片做全图检测(不做任何裁剪,位置由模型自行推理):
|
||||||
// 整图等比缩放至最长边 InputSize 提交,坐标映射回原图像素后返回。
|
// 整图等比缩放至最长边 InputSize 提交,坐标映射回原图像素后返回。
|
||||||
// imgW/imgH 为原图尺寸;返回坐标均为原图像素尺度。
|
// imgW/imgH 为原图尺寸;返回坐标均为原图像素尺度。
|
||||||
|
|||||||
+45
-1
@@ -37,7 +37,50 @@ imageGen:
|
|||||||
baseUrl: "http://192.168.3.210:18080" # localai 必填:训练机 local-ai 地址(/v1/images/generations)
|
baseUrl: "http://192.168.3.210:18080" # localai 必填:训练机 local-ai 地址(/v1/images/generations)
|
||||||
model: z-image-turbo # localai 上加载的模型名;dashscope 用 qwen-image-3.0
|
model: z-image-turbo # localai 上加载的模型名;dashscope 用 qwen-image-3.0
|
||||||
apiKey: "" # dashscope 必填:DashScope API Key
|
apiKey: "" # dashscope 必填:DashScope API Key
|
||||||
timeoutSeconds: 1800 # 每张生成超时(默认 120;localai 训练机 704x1248 竖版实测约 26min/张)
|
timeoutSeconds: 600 # 每张生成超时(默认 120;z-image-turbo 首次加载含模型入显存,稳态秒级~分钟级)
|
||||||
|
# 通用提示词模板(管理端填「动物名称」时启用,每张随机取池组装):{species} 优先用表单填写的名称,
|
||||||
|
# 未填则按数据集名取 speciesByDataset 池(再未配置直接用数据集名);{count} 为数量词(与表单
|
||||||
|
# animalCount 一致,防文数不符);{scene}/{action}/{light} 池内随机;物种只写名字不写羽毛细节
|
||||||
|
# (细节会把镜头拉近,详见技术设计)
|
||||||
|
promptTemplate: "野外大远景实拍:广角镜头下{scene},{distanceWord}有{count}{species}的小小身影在{action},鸟离镜头很远,只是很小的深色鸟形轮廓,隐约可辨,画面绝大部分是{scene},没有任何靠近镜头的动物,{light},写实野外摄影,真实照片质感"
|
||||||
|
distanceWord: "二十多米外" # 与校验距离带呼应的观感词
|
||||||
|
speciesByDataset: # 数据集名 → 物种池(表单未填动物名称时随机取一个)
|
||||||
|
鸽子: [家鸽, 山斑鸠, 火斑鸠, 虎纹斑鸠, 珠颈斑鸠]
|
||||||
|
野鸡: [雉鸡]
|
||||||
|
兔子: [野兔]
|
||||||
|
鸭子: [野鸭]
|
||||||
|
鹌鹑: [鹌鹑]
|
||||||
|
scenes: # 环境场景池(同一短语在模板中出现两次,须两处都读得通)
|
||||||
|
- "广袤的玉米地,玉米秆两米多高,田间土路和田埂向远处地平线延伸"
|
||||||
|
- "收割后的金黄麦茬田铺向地平线"
|
||||||
|
- "乡村土路穿过农田伸向远方,路边杂草丛生"
|
||||||
|
- "农田和道路延伸向远方,一根细电线横贯画面远处"
|
||||||
|
- "农田边一棵大树和远处成片的田野"
|
||||||
|
- "农家院落外的打谷场和远处农舍"
|
||||||
|
- "收割后的稻茬田铺向远处村庄"
|
||||||
|
- "果树成行的果园和树下剪短的草地"
|
||||||
|
- "乡间水渠和沙石滩地,渠水流向远方"
|
||||||
|
- "长满枯黄荒草的田埂和弃耕地"
|
||||||
|
actions: [觅食, 走动啄食, 低头啄食, 踱步, 停落休憩]
|
||||||
|
lights: [清晨柔和阳光, 正午明亮天光, 傍晚金色斜阳, 阴天柔和散射光]
|
||||||
|
# 生成图距离校验(物理公式):估算距离 d = speciesHeight × focalPx ÷ 目标像素高
|
||||||
|
# focalPx 由假设视场角推出:f = 图高/2 ÷ tan(vfov/2)(vfov=52° 时 704x1248 图 f≈1281px,
|
||||||
|
# 换算 0.35m 鸟 15px ≈ 30 米,与实测观感标定一致);体高按表单物种查 speciesHeights
|
||||||
|
# (不同物种体型不同,不能共用一个系数),未配置的物种用 default
|
||||||
|
assumedVfovDeg: 52
|
||||||
|
speciesHeights:
|
||||||
|
default: 0.35
|
||||||
|
家鸽: 0.35
|
||||||
|
珠颈斑鸠: 0.30
|
||||||
|
山斑鸠: 0.30
|
||||||
|
火斑鸠: 0.25
|
||||||
|
虎纹斑鸠: 0.30
|
||||||
|
野鸡: 0.45
|
||||||
|
兔子: 0.30
|
||||||
|
野鸭: 0.30
|
||||||
|
鹌鹑: 0.15
|
||||||
|
# 校验用检测置信度阈值(预标注的 localAi.threshold=0.08 宁多勿漏会误把叶片土块算鸟,校验须高阈值)
|
||||||
|
validateThreshold: 0.3
|
||||||
|
|
||||||
# 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务):
|
# 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务):
|
||||||
# mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步)
|
# mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步)
|
||||||
@@ -62,6 +105,7 @@ training:
|
|||||||
localAi:
|
localAi:
|
||||||
baseUrl: "http://192.168.3.210:18080" # 如 http://127.0.0.1:18080(local-ai 兼容 /v1/detection)
|
baseUrl: "http://192.168.3.210:18080" # 如 http://127.0.0.1:18080(local-ai 兼容 /v1/detection)
|
||||||
model: rfdetr-xlarge
|
model: rfdetr-xlarge
|
||||||
|
vlmModel: qwen3.8-9b # VLM 藏匿位补检模型(qwen3.8-9b+mmproj 多模态);与 z-image 显存互斥,两阶段调度
|
||||||
threshold: 0.08 # 候选置信度阈值(宁多勿漏)
|
threshold: 0.08 # 候选置信度阈值(宁多勿漏)
|
||||||
confConfirmed: 0.2 # 高于此视为确认(class 0),否则疑似(class 1)
|
confConfirmed: 0.2 # 高于此视为确认(class 0),否则疑似(class 1)
|
||||||
overlapThreshold: 0.3 # 重叠去重阈值(交叠/较小框面积,NMS 风格;RF-DETR 同目标常输出一大一小两框,minIoU 比 IoU 更能命中)
|
overlapThreshold: 0.3 # 重叠去重阈值(交叠/较小框面积,NMS 风格;RF-DETR 同目标常输出一大一小两框,minIoU 比 IoU 更能命中)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB |
+3
-1
@@ -330,7 +330,9 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不
|
|||||||
- `baseUrl`/`apiKey` 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
|
- `baseUrl`/`apiKey` 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
|
||||||
- 尺寸参数由前端传(固定竖版 704x1248,比例与既有训练图 1152x2048 一致、32 倍数对齐);**实测竖版图在 local-ai 上极慢(约 26 分钟/张,Qwen-Image 行列注意力对非方形输入效率差,与分辨率关系不大;方形 1024x1024 仅 5 分钟)**,超时与前端等待按此配置,一次建议 1 张
|
- 尺寸参数由前端传(固定竖版 704x1248,比例与既有训练图 1152x2048 一致、32 倍数对齐);**实测竖版图在 local-ai 上极慢(约 26 分钟/张,Qwen-Image 行列注意力对非方形输入效率差,与分辨率关系不大;方形 1024x1024 仅 5 分钟)**,超时与前端等待按此配置,一次建议 1 张
|
||||||
- 生成同步执行(count 1..8,每张超时 `imageGen.timeoutSeconds`,默认 120s;localai 建议 600),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则)
|
- 生成同步执行(count 1..8,每张超时 `imageGen.timeoutSeconds`,默认 120s;localai 建议 600),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则)
|
||||||
- prompt 由前端预设模板 + 用户微调;**遵守项目提示词规范:不得包含目标位置描述**(位置由模型自行推理,前端模板与校验文案落实此约束)
|
- prompt 手填覆盖;留空走 `imageGen.promptTemplate` 通用模板逐张组装(物种池 speciesByDataset 按数据集名、数量词=animalCount、场景/动作/光线池随机,每张独立组装保持多样性;物种只写名字不写羽毛细节,细节会拉近镜头);**遵守项目提示词规范:不得包含目标位置描述**(位置由模型自行推理,前端模板与校验文案落实此约束)
|
||||||
|
- **生成图距离/数量校验(2026-08-28)**:表单填 `distanceMin/Max`(米) 与 `animalCount` 后逐张启用;RF-DETR 全图检测,目标数=检测框数,**精确等于 animalCount**(与标注同口径:suppressOverlap 去重后计数)
|
||||||
|
- **VLM 藏匿位补检(两阶段第二阶段)**:`/datasets/images/vlm-review` 单图接口;qwen3.8-9b(+mmproj) 以「图片+物种(生成 prompt 与物种池匹配)+场景光线线索+已确认框坐标(排除)」推理藏匿位,输出归一化 bbox JSON(范围校验+与已有框重叠去重),追加 ≤3 个 class=1 疑似框进同一 labels_json;VL 与 z-image 显存互斥(12G 装不下两者),批量补检在生成队列空闲后执行;qwen3.8-9b yaml 自带 mmproj 实为多模态模型(生成校验);自动标注框数按置信度降序裁剪 ≤ animal_count(存 dataset_image.animal_count 列),估算距离 = 物种体高 × focalPx ÷ 最大框高像素(focalPx=`图高/2/tan(assumedVfovDeg/2)`,体高按物种查 `imageGen.speciesHeights`,default 兜底;避免跨物种共用一个标定系数)(K=1.8:6%≈30m、4.5%≈40m,K 隐含镜头视场角假设,偏差大改配置);不合格丢弃重生成,单张上限 3 次(`consts.GenValidateMaxAttempts`),连续不合格整体报错(已合格张数保留)
|
||||||
|
|
||||||
### 训练通道(runner 抽象,决策:同机/异机不确定 → 可配置)
|
### 训练通道(runner 抽象,决策:同机/异机不确定 → 可配置)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user