chore(server): 训练/封面/数据集配置改动快照(含管理端构建产物与数据迁移)
- dataset 封面定位、训练产物命名(RNPHE/pigeon 前缀)、localai/config 训练机配置 - server_admin DatasetDetail/Datasets 调整 + admin_dist 重建产物 - observer.db 数据变更
@@ -6,3 +6,6 @@
|
||||
.gstack/
|
||||
.idea/
|
||||
flutter_app/android/build/
|
||||
|
||||
# 运行时数据集(图片量大,不进 git;trainings 模型产物保留跟踪,见 server/workspace/trainings)
|
||||
#server/workspace/datasets/
|
||||
|
||||
@@ -27,5 +27,5 @@ COPY --from=builder /build/admin_dist /app/admin_dist
|
||||
COPY --from=builder /build/h5 /app/h5
|
||||
# 运行时数据目录(SQLite 库),由 docker-compose 挂载持久化
|
||||
RUN mkdir -p /app/data
|
||||
EXPOSE 8080
|
||||
EXPOSE 18080
|
||||
ENTRYPOINT ["/app/observer-server"]
|
||||
|
||||
@@ -179,11 +179,11 @@ 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","cover":"<文件名>"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名;物种=数据集名(单物种规则),创建时同步调 VLM(qwen3.8-9b) 自动生成物种/场景/动作/遮挡/站高/类别名等生成参数池,响应含 `poolsGenerated`/`poolError`——VLM 失败不阻断创建,参数可事后用 gen-pools 补生成;封面优先用 cover 参数(新建对话框预生成封面回传,跳过自动生成),否则参数池成功后自动生成 16:9(1024x576)封面(1 雄 1 雌并排,响应含 `coverGenerated`/`coverError`,失败可在编辑模式重新生成);模型生成图统一转 jpg 落盘) |
|
||||
| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","namePrefix":"pheasant","source":"manual"\|"ai","cover":"<文件名>"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名;物种=数据集名(单物种规则),创建时同步调 VLM(qwen3.6-35b-a3b) 自动生成物种/场景/动作/遮挡/站高/类别名等生成参数池,响应含 `poolsGenerated`/`poolError`——VLM 失败不阻断创建,参数可事后用 gen-pools 补生成;封面优先用 cover 参数(新建对话框预生成封面回传,跳过自动生成),否则参数池成功后自动生成 16:9(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"}`:名称(改名)/文件名前缀/描述/封面,空值字段不覆盖原值;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/gen-pools` | 重新生成数据集生成参数池 `{"datasetId":1}`:按数据集名(物种)调 VLM(qwen3.6-35b-a3b) 生成轮廓色/站高/场景/动作/遮挡并写表(覆盖旧值);失败报错保留旧值 |
|
||||
| POST | `/admin/datasets/images/vlm-review` | VLM 藏匿位补检 `{"datasetId":1,"imageId":5}`(两阶段标注第二阶段,**须与图像生成显存互斥**):qwen3.6-35b-a3b(+mmproj) 排除已确认框,按环境/光线/习性推理藏匿位,追加 ≤3 个疑似框(class 1)进同一 labels_json |
|
||||
| POST | `/admin/datasets/cover` | 上传数据集封面(multipart:`datasetId`+`file`,jpg/jpeg/png ≤2MB):**自动转 jpg + UUID 命名**落盘并覆盖旧封面 |
|
||||
| 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` 直读——新建对话框预生成封面回显) |
|
||||
|
||||
@@ -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-C8_wFaUY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-VLYrhJmc.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-BWn_FP8l.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-nixix_KO.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -31,6 +31,7 @@ func init() {
|
||||
cover TEXT,
|
||||
description TEXT,
|
||||
name_prefix TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
gen_species TEXT,
|
||||
gen_tone TEXT,
|
||||
gen_heights TEXT,
|
||||
@@ -46,6 +47,8 @@ func init() {
|
||||
}
|
||||
// 存量库迁移:生成图文件名前缀列(EnsureColumn 的 ddl 须自带列名)
|
||||
common.EnsureColumn(ctx, consts.TableDataset, "name_prefix", "name_prefix TEXT NOT NULL DEFAULT ''")
|
||||
// 存量库迁移:序号列(列表排序主键,升序;同号按创建时间倒序)
|
||||
common.EnsureColumn(ctx, consts.TableDataset, "sort_order", "sort_order INTEGER NOT NULL DEFAULT 0")
|
||||
// 存量库迁移:生成参数池列(VLM 自动生成,2026-08-28)
|
||||
for _, c := range []struct{ name, ddl string }{
|
||||
{"gen_species", "gen_species TEXT"},
|
||||
@@ -66,6 +69,7 @@ func (d *datasetDao) Insert(ctx context.Context, m *entity.Dataset) (int64, erro
|
||||
"name": m.Name,
|
||||
"source": m.Source,
|
||||
"name_prefix": m.NamePrefix,
|
||||
"sort_order": m.SortOrder,
|
||||
"image_count": m.ImageCount,
|
||||
"labeled_count": m.LabeledCount,
|
||||
"status": m.Status,
|
||||
@@ -173,6 +177,13 @@ func (d *datasetDao) UpdateDescription(ctx context.Context, id int64, descriptio
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateSortOrder 更新序号(列表排序主键,升序;同号按创建时间倒序)
|
||||
func (d *datasetDao) UpdateSortOrder(ctx context.Context, id int64, sortOrder int64) error {
|
||||
_, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("id", id).
|
||||
Data(g.Map{"sort_order": sortOrder, "updated_at": gtime.Now()}).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearCover 清空封面字段(删除封面用;UpdateConfigs 空值不覆盖,无法复用)
|
||||
func (d *datasetDao) ClearCover(ctx context.Context, id int64) error {
|
||||
_, err := g.DB().Model(consts.TableDataset).Ctx(ctx).Where("id", id).Data(g.Map{"cover": "", "updated_at": gtime.Now()}).Update()
|
||||
@@ -193,6 +204,7 @@ func (d *datasetDao) DeleteById(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
// PageByKeyword 数据集分页:名称模糊匹配(Like 命中目录名,数据库内仅元数据)
|
||||
// 排序同 Page:序号升序,同号按创建时间倒序(id 降序)
|
||||
func (d *datasetDao) PageByKeyword(ctx context.Context, keyword string, page, size int) ([]*entity.Dataset, int64, error) {
|
||||
base := g.DB().Model(consts.TableDataset).Ctx(ctx).WhereLike("name", "%"+keyword+"%")
|
||||
total, err := base.Count()
|
||||
@@ -200,7 +212,7 @@ func (d *datasetDao) PageByKeyword(ctx context.Context, keyword string, page, si
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.Dataset
|
||||
err = base.OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
|
||||
err = base.Order("sort_order ASC, id DESC").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.Dataset{}, int64(total), nil
|
||||
@@ -223,7 +235,7 @@ func (d *datasetDao) ListAll(ctx context.Context) ([]*entity.Dataset, error) {
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// Page 数据集分页:创建时间倒序
|
||||
// Page 数据集分页:序号升序,同号按创建时间倒序(id 降序)
|
||||
func (d *datasetDao) Page(ctx context.Context, page, size int) ([]*entity.Dataset, int64, error) {
|
||||
base := g.DB().Model(consts.TableDataset).Ctx(ctx)
|
||||
total, err := base.Count()
|
||||
@@ -231,7 +243,7 @@ func (d *datasetDao) Page(ctx context.Context, page, size int) ([]*entity.Datase
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.Dataset
|
||||
err = base.OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
|
||||
err = base.Order("sort_order ASC, id DESC").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.Dataset{}, int64(total), nil
|
||||
|
||||
@@ -145,6 +145,40 @@ func (d *datasetImageDao) CountLabeledByDataset(ctx context.Context, datasetId i
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
// CountStat 某数据集图片数/已标注数(列表实时统计:冗余计数列会漂移,展示一律以此为准)
|
||||
type CountStat struct {
|
||||
Count int64
|
||||
Labeled int64
|
||||
}
|
||||
|
||||
// CountStatByDatasets 多数据集图片数/已标注数(一次 GROUP BY 查询;IN 按 ≤100 分批)
|
||||
func (d *datasetImageDao) CountStatByDatasets(ctx context.Context, datasetIds []int64) (map[int64]CountStat, error) {
|
||||
stat := make(map[int64]CountStat)
|
||||
for start := 0; start < len(datasetIds); start += 100 {
|
||||
end := start + 100
|
||||
if end > len(datasetIds) {
|
||||
end = len(datasetIds)
|
||||
}
|
||||
var rows []struct {
|
||||
DatasetId int64 `orm:"dataset_id"`
|
||||
Cnt int64 `orm:"cnt"`
|
||||
Labeled int64 `orm:"labeled_cnt"`
|
||||
}
|
||||
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
WhereIn("dataset_id", datasetIds[start:end]).
|
||||
Fields("dataset_id, COUNT(*) AS cnt, SUM(CASE WHEN labels_json IS NOT NULL AND labels_json != '' AND labels_json != '[]' THEN 1 ELSE 0 END) AS labeled_cnt").
|
||||
Group("dataset_id").
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
stat[r.DatasetId] = CountStat{Count: r.Cnt, Labeled: r.Labeled}
|
||||
}
|
||||
}
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
// DeleteByIds 按 id 批量删除(标注随行删除)
|
||||
func (d *datasetImageDao) DeleteByIds(ctx context.Context, ids []int64) error {
|
||||
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).WhereIn("id", ids).Delete()
|
||||
|
||||
@@ -39,6 +39,7 @@ type AdminDatasetItem struct {
|
||||
GenActions string `json:"genActions"` // 动作池(JSON 数组)
|
||||
GenOcclusions string `json:"genOcclusions"` // 遮挡池(JSON 数组)
|
||||
GenClasses string `json:"genClasses"` // 第二标注类别名(单值)
|
||||
SortOrder int64 `json:"sortOrder"` // 序号(列表排序主键,升序)
|
||||
TrainingId int64 `json:"trainingId"` // 最新训练记录 id(发布/详情用)
|
||||
TrainingStatus string `json:"trainingStatus"` // 最新训练记录状态 running|success|failed|空
|
||||
TrainingError string `json:"trainingError"` // 最新训练记录失败原因(failed 时展示用)
|
||||
@@ -66,6 +67,7 @@ type AdminDatasetUpdateReq struct {
|
||||
NamePrefix string `json:"namePrefix" v:"length:0,50" dc:"AI 生成图文件名前缀(非空才更新)"`
|
||||
Description string `json:"description" v:"length:0,500" dc:"描述(非空才更新)"`
|
||||
Cover string `json:"cover" v:"length:0,200" dc:"封面文件名(非空才更新)"`
|
||||
SortOrder *int64 `json:"sortOrder" dc:"序号(升序排列,缺省不更新)"`
|
||||
}
|
||||
|
||||
type AdminDatasetUpdateRes struct {
|
||||
@@ -80,6 +82,7 @@ type AdminDatasetCreateReq struct {
|
||||
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:"封面文件名(新建对话框预生成封面后回传;非空则跳过自动生成)"`
|
||||
SortOrder *int64 `json:"sortOrder" dc:"序号(列表排序主键,升序;缺省 0)"`
|
||||
}
|
||||
|
||||
type AdminDatasetCreateRes struct {
|
||||
@@ -358,9 +361,9 @@ type AdminLabelTaskStartRes struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminImageVlmReviewReq 单图 VLM 补检:qwen3.8-9b 按「环境/光线/习性」推理 RF-DETR 之外
|
||||
// AdminImageVlmReviewReq 单图 VLM 补检:qwen3.6-35b-a3b 按「环境/光线/习性」推理 RF-DETR 之外
|
||||
// 可能藏匿目标的位置(排除已确认坐标),最多补 3 个疑似框(class 1)追加到 labels_json。
|
||||
// 依赖 localAi.vlmModel(默认 qwen3.8-9b);VL 与 z-image 生成不能同时驻留显存,批量补检
|
||||
// 依赖 localAi.vlmModel(默认 qwen3.6-35b-a3b);VL 与 z-image 生成不能同时驻留显存,批量补检
|
||||
// 应在生成队列空闲时执行
|
||||
type AdminImageVlmReviewReq struct {
|
||||
g.Meta `path:"/datasets/images/vlm-review" method:"post" summary:"VLM 藏匿位补检" tags:"管理端"`
|
||||
|
||||
@@ -17,6 +17,7 @@ type Dataset struct {
|
||||
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)"`
|
||||
SortOrder int64 `json:"sortOrder" orm:"sort_order" description:"序号(列表排序主键,升序;同号按创建时间倒序)"`
|
||||
Description string `json:"description" orm:"description" description:"描述(卡片展示)"`
|
||||
GenSpecies string `json:"genSpecies" orm:"gen_species" description:"物种(单值,同数据集名)"`
|
||||
GenTone string `json:"genTone" orm:"gen_tone" description:"轮廓色词(单值,深色/浅色)"`
|
||||
|
||||
@@ -61,6 +61,12 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
|
||||
for _, v := range list {
|
||||
ids = append(ids, v.Id)
|
||||
}
|
||||
// 图片数/已标注数实时统计(冗余计数列 image_count/labeled_count 曾因生成任务部分失败
|
||||
// 未回写等路径漂移为负数,展示一律以 dataset_image 实际统计为准)
|
||||
stats, err := dao.DatasetImage.CountStatByDatasets(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latest, err := dao.Training.LatestByDatasets(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -76,12 +82,13 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
|
||||
}
|
||||
items := make([]*dto.AdminDatasetItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
st := stats[v.Id] // 无图片行时为零值(计数 0),不落冗余列
|
||||
item := &dto.AdminDatasetItem{
|
||||
Id: v.Id,
|
||||
Name: v.Name,
|
||||
Source: v.Source,
|
||||
ImageCount: v.ImageCount,
|
||||
LabeledCount: v.LabeledCount,
|
||||
ImageCount: st.Count,
|
||||
LabeledCount: st.Labeled,
|
||||
Status: v.Status,
|
||||
Cover: v.Cover,
|
||||
Description: v.Description,
|
||||
@@ -93,6 +100,7 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
|
||||
GenActions: v.GenActions,
|
||||
GenOcclusions: v.GenOcclusions,
|
||||
GenClasses: v.GenClasses,
|
||||
SortOrder: v.SortOrder,
|
||||
CreatedAt: v.CreatedAt,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
}
|
||||
@@ -155,6 +163,12 @@ 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
|
||||
}
|
||||
// 序号独立更新(指针区分未传与显式 0;0 时按创建时间倒序排)
|
||||
if req.SortOrder != nil {
|
||||
if err := dao.Dataset.UpdateSortOrder(ctx, req.Id, *req.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// gen_* 7 列不在此维护:创建数据集时 VLM 生成,重新生成走 gen-pools 接口
|
||||
return &dto.AdminDatasetUpdateRes{Id: req.Id}, nil
|
||||
}
|
||||
@@ -425,10 +439,15 @@ func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminD
|
||||
if prefix != "" && !datasetNameRe.MatchString(prefix) {
|
||||
return gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50")
|
||||
}
|
||||
sortOrder := int64(0)
|
||||
if req.SortOrder != nil {
|
||||
sortOrder = *req.SortOrder
|
||||
}
|
||||
id, err = dao.Dataset.Insert(ctx, &entity.Dataset{
|
||||
Name: req.Name,
|
||||
Source: req.Source,
|
||||
NamePrefix: prefix,
|
||||
SortOrder: sortOrder,
|
||||
Status: consts.DatasetStatusBuilding,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -577,7 +596,7 @@ func (s *datasetService) AdminGenPools(ctx context.Context, req *dto.AdminGenPoo
|
||||
}, nil
|
||||
}
|
||||
|
||||
// genPoolsWithVLM 调 qwen3.8-9b 生成数据集生成参数池(单物种规则:物种=数据集名,
|
||||
// genPoolsWithVLM 调 qwen3.6-35b-a3b 生成数据集生成参数池(单物种规则:物种=数据集名,
|
||||
// VLM 只生成轮廓色/站高/场景/动作/遮挡池)。
|
||||
// 无 localAi 配置/显存被生成任务占用/JSON 非法/校验不过 → 返回 error(调用方决定是否阻断);
|
||||
// 校验通过返回 entity.Dataset(仅 7 个池字段,其余空)。
|
||||
@@ -604,8 +623,8 @@ func (s *datasetService) genPoolsWithVLM(ctx context.Context, datasetName string
|
||||
"要求:tone 为该物种轮廓色词(绝大多数「深色」,白化/浅色个体配「浅色」);"+
|
||||
"height_cm 为该物种站高厘米(鸟=脚到头顶、兽=蹲坐/站立肩高,数值 10~200);"+
|
||||
"scenes 4~6 条野外开阔场景,每条含「延伸向远方/地平线/开阔」等拉开距离的元素,符合该动物栖息习性"+
|
||||
"(雉鸡→农田灌丛、野鸭→水面芦苇、兔子→草坡荒地);actions 4~6 条动作短语(如「觅食」「走动啄食」),不能以「在/于」开头;"+
|
||||
"occlusions 4~6 条遮挡描述,覆盖轻(身体半掩)到重(只露头背),遮挡物符合该动物栖息环境(草丛/芦苇/树叶/枝条等)。\n"+
|
||||
"(雉鸡→农田灌丛、野鸭→水面芦苇、兔子→草坡荒地);按该动物冬季习性配场景:冬季在本地活跃(留鸟/不南迁,如雉鸡、兔子)须含至少 1 条冬季积雪场景(如雪后田野),冬季南迁物种(如野鸭、鹌鹑)不配雪地;若该物种会落树栖息(如雉鸡、锦鸡、马鸡、鸽子等树上夜栖鸟),场景须含林缘与树上落栖场景;不上树的地栖种(如石鸡、鹌鹑)不配树上场景;actions 4~6 条动作短语(如「觅食」「走动啄食」),不能以「在/于」开头;"+
|
||||
"occlusions 4~6 条遮挡描述,覆盖轻(身体半掩)到重(只露头背),遮挡物符合该动物栖息环境(草丛/芦苇/树叶/枝条等);具备树上/林缘场景的物种,遮挡池须含树叶/树枝类遮挡(不能只有草丛)。\n"+
|
||||
"忽略图片内容,只按上述要求输出 JSON。",
|
||||
datasetName, target)
|
||||
// 64x64 纯灰占位图(VLM 输入占位,避免空白无法推理)
|
||||
@@ -905,10 +924,17 @@ func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *e
|
||||
// 距离描述从表单固定值生成(25 → "25米外")
|
||||
distWord := fmt.Sprintf("%d米外", dist)
|
||||
// 尺寸提示:按物理公式把表单距离换算成目标在图中的像素高与画面占比
|
||||
sizeHint, sErr := s.buildSizeHint(ctx, dataset, dist, size)
|
||||
sizeHint, pctVal, sErr := s.buildSizeHint(ctx, dataset, dist, size)
|
||||
if sErr != nil {
|
||||
return "", sErr
|
||||
}
|
||||
// 占比 <2% 时切换远景小目标模板:目标小到动作/遮挡/性别细节无法呈现,
|
||||
// 保留细节描写会迫使模型把目标画大(2026-08-31 实测两次失败后定案)
|
||||
if pctVal < 2.0 {
|
||||
if tiny := g.Cfg().MustGet(ctx, "imageGen.promptTemplateTiny").String(); tiny != "" {
|
||||
tpl = tiny
|
||||
}
|
||||
}
|
||||
// 每张固定 1 个目标(数量词固定,animal_count 列同为 1 供自动标注裁剪)
|
||||
countWord := "1只"
|
||||
// 性别随机(50/50):两性体型与外观差异大(雉鸡雄艳雌褐、野鸭雄艳雌素等),
|
||||
@@ -930,12 +956,13 @@ func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *e
|
||||
}
|
||||
|
||||
// buildSizeHint 尺寸提示:目标像素高 = 体高 × focalPx ÷ 距离(focalPx = 图高/2 ÷ tan(vfov/2)),
|
||||
// 输出如「(在画面中高度仅约15像素,占画面高度约1.2%)」。
|
||||
// 占比向下取整且至少 1%,避免出现 0%
|
||||
func (s *datasetService) buildSizeHint(ctx context.Context, dataset *entity.Dataset, dist int, size string) (string, error) {
|
||||
// 尺寸词按「像素高÷图高」画面占比分级(量化像素数对文生图模型无意义;固定物体类比词
|
||||
// 不跨物种/距离通用,2026-08-31 用户否决),输出如「(在画面中很小的身影,约占画面高度1.0%)」;
|
||||
// 返回占比供调用方按 <2% 切换远景小目标模板。占比保留一位小数,避免出现 0%
|
||||
func (s *datasetService) buildSizeHint(ctx context.Context, dataset *entity.Dataset, dist int, size string) (string, float64, error) {
|
||||
_, imgH, err := parseGenSize(size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", 0, err
|
||||
}
|
||||
// 站高 cm 从数据集表 gen_heights 读(数据库维护,编辑界面手改真实值;<=0 物理兜底 35cm),换算成米参与投影公式
|
||||
heightCM := dataset.GenHeights
|
||||
@@ -955,8 +982,25 @@ func (s *datasetService) buildSizeHint(ctx context.Context, dataset *entity.Data
|
||||
pct := func(px int) string {
|
||||
return fmt.Sprintf("%.1f", float64(px)*100/float64(imgH))
|
||||
}
|
||||
// 尺寸词按画面占比分级(占比=像素高÷图高,与物种/距离无关:同占比同观感;
|
||||
// 固定物体类比词(针尖/米粒/拳头)不跨物种通用,用户否决 2026-08-31;
|
||||
// 站高 0.6m、focalPx≈1280 时:60m≈13px≈1.0%、25m≈31px≈2.5%、100m≈8px≈0.6%)
|
||||
px := pxAt(dist)
|
||||
return fmt.Sprintf("(在画面中高度仅约%d像素,占画面高度约%s%%)", px, pct(px)), nil
|
||||
pctVal := float64(px) * 100 / float64(imgH)
|
||||
word := "中等大小的身影"
|
||||
switch {
|
||||
case pctVal < 1.0:
|
||||
word = "微小到几乎难以辨认的身影"
|
||||
case pctVal < 2.0:
|
||||
word = "很小的身影"
|
||||
case pctVal < 5.0:
|
||||
word = "较小的身影"
|
||||
case pctVal < 10.0:
|
||||
word = "中等大小的身影"
|
||||
default:
|
||||
word = "较大的身影"
|
||||
}
|
||||
return fmt.Sprintf("(在画面中%s,约占画面高度%s%%)", word, pct(px)), pctVal, nil
|
||||
}
|
||||
|
||||
// parseGenSize 解析 "704x1248" 形式尺寸
|
||||
@@ -1145,6 +1189,12 @@ func (s *datasetService) runGenTask(taskId int64, dataset *entity.Dataset, req *
|
||||
}
|
||||
if failed != "" {
|
||||
s.genTaskAutoLabel(bgCtx, dataset, addedIds)
|
||||
// 部分失败:已入库图按付费资产保留,计数须计入(曾漏更新致 image_count 漂移为负)
|
||||
if err := common.Serial().Submit(bgCtx, func() error {
|
||||
return dao.Dataset.UpdateCounters(bgCtx, dataset.Id, int64(generated), -1, "")
|
||||
}); err != nil {
|
||||
g.Log().Errorf(bgCtx, "生成任务 %d 部分失败后更新数据集计数失败: %+v", taskId, err)
|
||||
}
|
||||
_ = dao.GenTask.Finish(bgCtx, taskId, failed)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
|
||||
}
|
||||
|
||||
// AdminImageVlmReview 单图 VLM 藏匿位补检(两阶段标注第二阶段,须与 z-image 生成显存互斥):
|
||||
// 以当前 labels_json(RF-DETR/人工框)为排除集,调 qwen3.8-9b(+mmproj) 按环境/光线/习性
|
||||
// 以当前 labels_json(RF-DETR/人工框)为排除集,调 qwen3.6-35b-a3b(+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)
|
||||
|
||||
@@ -59,14 +59,14 @@ func LocalAiClient(ctx context.Context) *LocalAi {
|
||||
}
|
||||
}
|
||||
|
||||
// QwenVL 调 local-ai 的多模态 VLM(qwen3.8-9b + mmproj,llama.cpp 后端)做图片推理。
|
||||
// QwenVL 调 local-ai 的多模态 VLM(qwen3.6-35b-a3b + 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()
|
||||
model := g.Cfg().MustGet(ctx, "localAi.vlmModel", "qwen3.6-35b-a3b").String()
|
||||
b64 := base64.StdEncoding.EncodeToString(imgData)
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": model,
|
||||
|
||||
@@ -34,18 +34,24 @@ app:
|
||||
# 图像生成(管理端「AI 生成图片」):provider 必填项缺失时生成接口返回「图像生成服务未配置」
|
||||
imageGen:
|
||||
provider: localai # dashscope | localai
|
||||
baseUrl: "http://localhost:8080" # localai 必填:本机 local-ai 地址(/v1/images/generations)
|
||||
model: flux1-schnell # localai 上加载的模型名;dashscope 用 qwen-image-3.0
|
||||
baseUrl: "http://192.168.3.210:18080" # localai 必填:本机 local-ai 地址(/v1/images/generations)
|
||||
model: z-image-turbo # localai 上加载的模型名;dashscope 用 qwen-image-3.0
|
||||
apiKey: "" # dashscope 必填:DashScope API Key
|
||||
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},写实野外摄影,真实照片质感"
|
||||
# 表单距离算出像素高,按画面占比分级尺寸词(像素数与固定物体类比词均不通用,2026-08-31)+ 占比。物种只写名字
|
||||
# 不写羽毛细节(细节会把镜头拉近,详见技术设计)。引导语「手机原相机主摄不变焦」(2026-08-31 v4 定案):
|
||||
# 与 13px 像素数学完全自洽(公式即按主摄 vfov 52° 计算);手机拍远处目标天然显小(先验同向);
|
||||
# 顺带对齐推理端域(App 就是手机拍摄)。演进:广角(前景大主体先验)→ 长焦(物理放大)→ 全删 → 手机主摄不变焦。
|
||||
# 拍摄角度描述(仰/俯/平)仍不带;位置锚定「在{distanceWord}的地平线附近」(曾因"上部1/3"遵循力弱去掉位置描述,改地平线锚点重试);
|
||||
# 动作/遮挡在普通模板中位于目标之后(动作=叙事焦点=视觉焦点,降级处理)
|
||||
promptTemplate: "野外大远景实拍:手机原相机主摄不变焦,{scene},在{distanceWord}的地平线附近有且仅有{count}{species}{sizeHint},{occlusion},{species}在{action},只是很小的{tone}轮廓,隐约可辨,画面绝大部分是{scene},没有任何靠近镜头的动物,{light},写实野外摄影,真实照片质感"
|
||||
# 远景小目标模板(代码按尺寸占比 <2% 切换):目标小到动作/遮挡/性别细节无法呈现,保留细节描写会
|
||||
# 迫使模型把目标画大(2026-08-31 实测两次失败后定案)——只留「远到看不清任何细节的小点」
|
||||
promptTemplateTiny: "野外大远景实拍:手机原相机主摄不变焦,{scene},在{distanceWord}的地平线附近有且仅有{count}{species}{sizeHint},远到看不清任何细节,只是隐约可辨的{tone}小点,画面绝大部分是{scene},没有任何靠近镜头的动物,{light},写实野外摄影,真实照片质感"
|
||||
lights: [清晨柔和阳光, 正午明亮天光, 傍晚金色斜阳, 阴天柔和散射光]
|
||||
# 目标尺寸提示(sizeHint):按「体高 × focalPx ÷ 表单距离」算出目标在图中的像素高与占比,
|
||||
# 注入模板 {sizeHint}——把「厘米」换算成「米」再参与投影公式;focalPx = 图高/2 ÷ tan(vfov/2);
|
||||
@@ -77,7 +83,7 @@ training:
|
||||
localAi:
|
||||
baseUrl: "http://192.168.3.210:18080" # 如 http://127.0.0.1:18080(local-ai 兼容 /v1/detection)
|
||||
model: rfdetr-xlarge
|
||||
vlmModel: qwen3.8-9b # VLM 藏匿位补检模型(qwen3.8-9b+mmproj 多模态);与 z-image 显存互斥,两阶段调度
|
||||
vlmModel: qwen3.6-35b-a3b # VLM 藏匿位补检模型(MoE 35B-A3B 多模态,GGUF+mmproj);与 z-image 显存互斥,两阶段调度
|
||||
threshold: 0.08 # 候选置信度阈值(宁多勿漏)
|
||||
confConfirmed: 0.2 # 高于此视为确认(class 0),否则疑似(class 1)
|
||||
overlapThreshold: 0.3 # 重叠去重阈值(交叠/较小框面积,NMS 风格;RF-DETR 同目标常输出一大一小两框,minIoU 比 IoU 更能命中)
|
||||
|
||||
@@ -16,8 +16,8 @@ services:
|
||||
ports:
|
||||
- "18080:18080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./workspace:/app/workspace
|
||||
- /data:/app/data
|
||||
- /workspace:/app/workspace
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
networks:
|
||||
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 470 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 470 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 456 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 443 KiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 427 KiB |