chore(server): 训练/封面/数据集配置改动快照(含管理端构建产物与数据迁移)

- dataset 封面定位、训练产物命名(RNPHE/pigeon 前缀)、localai/config 训练机配置
- server_admin DatasetDetail/Datasets 调整 + admin_dist 重建产物
- observer.db 数据变更
This commit is contained in:
2026-08-31 15:58:07 +08:00
parent 6d7ba3b6ab
commit a0e75d5def
234 changed files with 314 additions and 102 deletions
+3
View File
@@ -6,3 +6,6 @@
.gstack/
.idea/
flutter_app/android/build/
# 运行时数据集(图片量大,不进 git;trainings 模型产物保留跟踪,见 server/workspace/trainings
#server/workspace/datasets/
+1 -1
View File
@@ -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"]
+3 -3
View File
@@ -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` 直读——新建对话框预生成封面回显) |
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-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>
+15 -3
View File
@@ -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
+34
View File
@@ -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()
+5 -2
View File
@@ -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:"管理端"`
+1
View File
@@ -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 -11
View File
@@ -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
}
+1 -1
View File
@@ -291,7 +291,7 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
}
// AdminImageVlmReview 单图 VLM 藏匿位补检(两阶段标注第二阶段,须与 z-image 生成显存互斥):
// 以当前 labels_jsonRF-DETR/人工框)为排除集,调 qwen3.8-9b(+mmproj) 按环境/光线/习性
// 以当前 labels_jsonRF-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)
+2 -2
View File
@@ -59,14 +59,14 @@ func LocalAiClient(ctx context.Context) *LocalAi {
}
}
// QwenVL 调 local-ai 的多模态 VLMqwen3.8-9b + mmprojllama.cpp 后端)做图片推理。
// QwenVL 调 local-ai 的多模态 VLMqwen3.6-35b-a3b + mmprojllama.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,
+13 -7
View File
@@ -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:18080local-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 更能命中)
Binary file not shown.
+2 -2
View File
@@ -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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

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