1
This commit is contained in:
+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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>视野管理端</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-BWn_FP8l.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-nixix_KO.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-S93JCAwW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-66aoa8zi.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
@@ -22,7 +23,6 @@ func init() {
|
||||
dataset_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
prompt TEXT,
|
||||
animal_count INTEGER NOT NULL DEFAULT 0,
|
||||
labels_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -35,6 +35,20 @@ func init() {
|
||||
// 注意 EnsureColumn 的 ddl 参数须自带列名(拼接为 ALTER TABLE ADD COLUMN <ddl>)
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "labels_json", "labels_json TEXT")
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "animal_count", "animal_count INTEGER NOT NULL DEFAULT 0")
|
||||
// 存量库迁移:删除 prompt 列(生成提示词不再存储,2026-09-02 决策)
|
||||
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(dataset_image)")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, col := range cols {
|
||||
if gconv.String(col["name"]) == "prompt" {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE dataset_image DROP COLUMN prompt"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
g.Log().Warningf(ctx, "存量表 dataset_image 已迁移:删除 prompt 列")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert 插入图片记录
|
||||
@@ -43,7 +57,6 @@ func (d *datasetImageDao) Insert(ctx context.Context, m *entity.DatasetImage) (i
|
||||
"dataset_id": m.DatasetId,
|
||||
"filename": m.Filename,
|
||||
"source": m.Source,
|
||||
"prompt": m.Prompt,
|
||||
"animal_count": m.AnimalCount,
|
||||
"created_at": m.CreatedAt,
|
||||
}).Insert()
|
||||
|
||||
@@ -178,7 +178,6 @@ type AdminImageItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Source string `json:"source"` // manual | ai
|
||||
Prompt string `json:"prompt"`
|
||||
Url string `json:"url"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@ package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// DatasetImage 数据集图片:文件在 datasets/<数据集名>/<filename>,
|
||||
// AI 生成图记录 prompt(付费资产,追溯用);标注为 JSON 数组存本表
|
||||
// (元素同 dto.AdminLabelBox:class/cx/cy/w/h/confidence,YOLO 归一化)。
|
||||
// DatasetImage 数据集图片:文件在 datasets/<数据集名>/<filename>;
|
||||
// 标注为 JSON 数组存本表(元素同 dto.AdminLabelBox:class/cx/cy/w/h/confidence,YOLO 归一化)。
|
||||
type DatasetImage struct {
|
||||
Id int64 `json:"id" orm:"id" description:"自增主键"`
|
||||
DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"`
|
||||
Filename string `json:"filename" orm:"filename" description:"唯一文件名(防重名加时间戳后缀)"`
|
||||
Source string `json:"source" orm:"source" description:"manual|ai"`
|
||||
Prompt string `json:"prompt" orm:"prompt" description:"AI 生成图提示词"`
|
||||
AnimalCount int `json:"animalCount" orm:"animal_count" description:"AI 生成时的目标数量上限(0=未限定),自动标注按置信度裁剪到此数"`
|
||||
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 自动标注与人工标注同存,null/''/'[]'=未标注)"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
|
||||
@@ -1274,7 +1274,6 @@ func (s *datasetService) runGenTask(taskId int64, dataset *entity.Dataset, req *
|
||||
DatasetId: dataset.Id,
|
||||
Filename: filename,
|
||||
Source: "ai",
|
||||
Prompt: prompt,
|
||||
AnimalCount: 1,
|
||||
CreatedAt: now,
|
||||
})
|
||||
@@ -1375,7 +1374,6 @@ func (s *datasetService) AdminListImages(ctx context.Context, req *dto.AdminData
|
||||
Id: v.Id,
|
||||
Filename: v.Filename,
|
||||
Source: v.Source,
|
||||
Prompt: v.Prompt,
|
||||
Url: datasetImageUrl(ctx, dataset.Id, v.Filename),
|
||||
CreatedAt: v.CreatedAt,
|
||||
})
|
||||
|
||||
@@ -335,20 +335,18 @@ func (s *labelTaskService) AdminImageVlmReview(ctx context.Context, req *dto.Adm
|
||||
}
|
||||
exclude = strings.Join(parts, " ")
|
||||
}
|
||||
// 物种:从生成提示词与数据集物种(gen_species 单值)匹配(匹配不到交给 VLM 从已确认框自行判断)
|
||||
species := ""
|
||||
if dataset.GenSpecies != "" && strings.Contains(img.Prompt, dataset.GenSpecies) {
|
||||
species = dataset.GenSpecies
|
||||
}
|
||||
// 物种:数据集单物种(gen_species)直接注入;提示词不再存储(2026-09-02 清理),
|
||||
// 无法从提示词匹配,交给 VLM 从已确认框自行判断
|
||||
speciesLine := "从已确认目标框自行判断物种"
|
||||
if species != "" {
|
||||
speciesLine = species
|
||||
if dataset.GenSpecies != "" {
|
||||
speciesLine = dataset.GenSpecies
|
||||
}
|
||||
prompt := fmt.Sprintf(
|
||||
"你是野生动物监测照片分析助手。\n物种:%s\n拍摄场景与时间光线线索:%s\n已确认目标位置(归一化 cx,cy,w,h,已排除,不要再输出这些位置):%s\n"+
|
||||
"你是野生动物监测照片分析助手。\n物种:%s\n已确认目标位置(归一化 cx,cy,w,h):%s\n"+
|
||||
"不要出现与已有位置绘制矩形重叠的框。\n"+
|
||||
"请基于环境、天气、光线与该物种习性(觅食路线、隐蔽处、阴影边缘、植被深处),分析除已确认位置外还可能藏匿个体的位置。\n"+
|
||||
"输出归一化 bbox 的 JSON 数组:[{\"cx\":0.5,\"cy\":0.5,\"w\":0.2,\"h\":0.15}],最多 3 个,按可能性从高到低;确无可能则输出 []。只输出 JSON 数组本身,不要其他文字。",
|
||||
speciesLine, img.Prompt, exclude)
|
||||
speciesLine, exclude)
|
||||
content, err := common.QwenVL(ctx, data, imageMime(img.Filename), prompt)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "VLM 推理失败")
|
||||
|
||||
Binary file not shown.
+7
-6
@@ -83,13 +83,13 @@ CREATE TABLE IF NOT EXISTS dataset_image (
|
||||
dataset_id INTEGER NOT NULL, -- → dataset.id
|
||||
filename TEXT NOT NULL, -- 唯一文件名(防重名加时间戳后缀)
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- manual | ai
|
||||
prompt TEXT, -- AI 生成图记录提示词(追溯用)
|
||||
labels_json TEXT, -- 标注 JSON 数组(YOLO 归一化 xywh+类别+置信度),AI 自动标注直写、人工仅审核确认(2026-08-28:疑似确认/误检删除,不手动画框),null/''/'[]'=未标注
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (dataset_id, filename)
|
||||
);
|
||||
-- 存量库迁移:EnsureColumn 逐列检测补列(v9);candidates_json 列已随 v10 删除(DROP COLUMN);
|
||||
-- model_version.model_file 列已随 v11 删除(模型无存档回退机制,只留 latest.tflite)
|
||||
-- model_version.model_file 列已随 v11 删除(模型无存档回退机制,只留 latest.tflite);
|
||||
-- dataset_image.prompt 列已随 2026-09-02 删除(生成提示词不再存储/展示,生成仍临时使用但不落库)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_training (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -319,7 +319,7 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不
|
||||
|
||||
- 图片目录:`app.datasetDir`(默认 `./workspace/`)下 `datasets/<数据集名>/`(图片平铺,文件名唯一防重名);标注存 `dataset_image.labels_json`(JSON 数组,每元素 `{class,cx,cy,w,h,confidence}` 归一化,AI 自动标注直写、人工仅审核确认——2026-08-28:疑似确认/误检删除,不手动画框);**历史 labels/ txt 目录与 yolo 暂存目录已删除(2026-08-26,标注全在 DB,训练集流式化不落本地盘)**
|
||||
- **封面命名规范(2026-08-26)**:封面文件不得固定命名 `cover`,上传时解码为 **jpg 格式 + UUIDv4 命名**(`common.UuidV4()` 生成,crypto/rand 无第三方依赖,落盘 `datasets/<name>/<uuid>.jpg`,旧封面删除);DB `cover` 列只存文件名,前端用 DB 值拼 URL 零改动;删除/校验按 `isCoverName` 正则(UUID v4 + `.jpg` 后缀)识别,不认固定名——历史 `cover.jpg` 等存量封面启动时 `MigrateLegacyCovers` 幂等重命名迁移
|
||||
- DB 只存文件名/来源/prompt 等元数据(`dataset` / `dataset_image`),**禁止图片进库**
|
||||
- DB 只存文件名/来源等元数据(`dataset` / `dataset_image`;生成提示词不落库,2026-09-02 起),**禁止图片进库**
|
||||
- 生成/上传图片是**付费资产**:删除接口必须带前端确认文案(提示 AI 生成图有成本);删除 = 删文件 + 删记录,目录清理
|
||||
- 训练机与 Go 服务器可能异机:训练前按 `training` 通道同步(subprocess 同机 cp、ssh 异机 scp);**数据集在训练机上的权威路径** `training.workdir`/`training.datasetDir`/`<name>/`,DB `model_training.dataset` 只存数据集名
|
||||
|
||||
@@ -330,10 +330,10 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不
|
||||
- `localai`(本机/训练机 local-ai,`POST {baseUrl}/v1/images/generations`,OpenAI 兼容):`baseUrl` + `model`(local-ai 上加载的模型名,如 qwen-image);返回 url 为容器内 localhost 地址,下载时替换为 `baseUrl` 的 host;step/cfg 等采样参数用 local-ai 模型配置默认值,不随接口传;生成耗时取决于模型与硬件,不设调用超时(见下)
|
||||
- `baseUrl`/`apiKey` 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
|
||||
- 尺寸参数由前端传(固定竖版 704x1248,比例与既有训练图 1152x2048 一致、32 倍数对齐);**实测竖版图在 local-ai 上极慢(约 26 分钟/张,Qwen-Image 行列注意力对非方形输入效率差,与分辨率关系不大;方形 1024x1024 仅 5 分钟)**,前端等待按此预估,一次建议 1 张
|
||||
- **生成异步化(2026-08-28,count 1..1000)**:`/datasets/generate` 仅做校验(provider/数据集/标注服务/Serial 内并发检查已有 running 生成任务)+ 插 `gen_task` 落 running 记录即返回 `{taskId, total}`(毫秒级,前端 10s 超时无忧);后台协程逐张生成(`context.Background()` 生命周期模式,与预标注 `runDetection` 同构;`imageGen.poolSize` 并发池,z-image 显存独占默认 1),**每张不设调用超时(2026-08-29)**——异步任务超时只会造成假失败:超时仅放弃等待、取消不了 local-ai backend,任务置 failed 而模型仍被占用、poolSize=1 的池被占死、后续请求连环超时;失败判定完全由 provider 真实返回决定(dashscope 内部自限 120s 轮询不受影响);极端情况 local-ai 挂死时任务保持 running(状态诚实),由启动恢复兜底;逐张落盘 + 入库(记录 prompt 便于追溯)+ Serial 更新进度,完成(或部分失败)后对本次新增图触发自动标注,置 done/failed 由前端轮询 `/datasets/gen-task` 呈现;中途失败保留已生成图(不删除——付费资产原则);启动恢复 `recoverGenTasks` 孤儿 running 置 failed;VLM 补检与生成显存互斥(该数据集有 running 生成任务时拒绝)
|
||||
- **生成异步化(2026-08-28,count 1..1000)**:`/datasets/generate` 仅做校验(provider/数据集/标注服务/Serial 内并发检查已有 running 生成任务)+ 插 `gen_task` 落 running 记录即返回 `{taskId, total}`(毫秒级,前端 10s 超时无忧);后台协程逐张生成(`context.Background()` 生命周期模式,与预标注 `runDetection` 同构;`imageGen.poolSize` 并发池,z-image 显存独占默认 1),**每张不设调用超时(2026-08-29)**——异步任务超时只会造成假失败:超时仅放弃等待、取消不了 local-ai backend,任务置 failed 而模型仍被占用、poolSize=1 的池被占死、后续请求连环超时;失败判定完全由 provider 真实返回决定(dashscope 内部自限 120s 轮询不受影响);极端情况 local-ai 挂死时任务保持 running(状态诚实),由启动恢复兜底;逐张落盘 + 入库(2026-09-02 起不记录 prompt——生成提示词临时使用不落库)+ Serial 更新进度,完成(或部分失败)后对本次新增图触发自动标注,置 done/failed 由前端轮询 `/datasets/gen-task` 呈现;中途失败保留已生成图(不删除——付费资产原则);启动恢复 `recoverGenTasks` 孤儿 running 置 failed;VLM 补检与生成显存互斥(该数据集有 running 生成任务时拒绝)
|
||||
- prompt 手填覆盖;留空走 `imageGen.promptTemplate` 通用模板逐张组装(**物种/场景/动作/遮挡/轮廓色/站高池均从数据集表读取(见下节),每张独立组装保持多样性**;**性别每张随机雄/雌(2026-08-28)**——两性体型大小与外观差异大(雉鸡雄艳雌褐、野鸭雄艳雌素等),随机让训练数据覆盖两性形态,`{species}` 替换为「雄/雌性+物种」;物种只写名字不写羽毛细节,细节会拉近镜头);**位置/镜头描述(2026-08-31 v4 最终定案:手机原相机主摄不变焦)**:2026-08-28 曾定「不得包含目标位置描述」(上部1/3 弱约束遵循力差、角度与位置耦合沉底);2026-08-31 用户确认恢复距离感锚定,镜头词四版演进:广角(构图先验=前景大主体,用户实测野鸡在画面下半部)→ 长焦(**长焦物理放大与 13px 像素数学矛盾**,用户实测野鸡占半图宽)→ 全删 → **「手机原相机主摄不变焦」**(用户提议;与 13px 像素数学完全自洽——公式即按主摄 vfov 52° 计算;手机拍远处目标天然显小,先验同向;顺带对齐推理端域——App 就是手机拍摄);保留「**在{distanceWord}的地平线附近**」位置锚定(地平线是远景场景锚点,不同于上部1/3 弱约束);**远景小目标模板 `promptTemplateTiny`(2026-08-31)**:尺寸占比 <2% 时切换——目标小到动作/遮挡/性别细节无法呈现,保留细节描写(低头啄食/只露出头背)会迫使模型把目标画大(语义矛盾:1% 目标不可见细节),只留「远到看不清任何细节,只是隐约可辨的小点」
|
||||
- **生成图距离/数量校验(2026-08-28)**:表单填 `distance`(米,固定值) 与 `animalCount` 后逐张启用;**距离校验已取消(2026-08-28)**:生成即入库,无逐张拒检;参数与公式见 git 历史(物理公式 d=体高×focalPx÷像素高)
|
||||
- **VLM 藏匿位补检(两阶段第二阶段)**:`/datasets/images/vlm-review` 单图接口;qwen3.6-35b-a3b(+mmproj) 以「图片+物种(生成 prompt 与物种池匹配)+场景光线线索+已确认框坐标(排除)」推理藏匿位,输出归一化 bbox JSON(范围校验+与已有框重叠去重),追加 ≤3 个 class=1 疑似框进同一 labels_json;VL 与 z-image 显存互斥(12G 装不下两者),批量补检在生成队列空闲后执行;qwen3.6-35b-a3b yaml 自带 mmproj 实为多模态模型(生成校验);自动标注框数按置信度降序裁剪 ≤ animal_count(存 dataset_image.animal_count 列),估算距离 = 物种站高 × focalPx ÷ 最大框高像素(focalPx=`图高/2/tan(vfov/2)`,站高按物种查 `imageGen.speciesHeights`,单位 cm 换算为米,未配置物种代码兜底 35cm;**2026-08-28 语义修正**:speciesHeights 为站高(垂直尺度,鸟=脚到头顶、兽=蹲坐高),勿用体长——与提示词「高度」描述及检测框高自洽;避免跨物种共用一个标定系数)(K=1.8:6%≈30m、4.5%≈40m,K 隐含镜头视场角假设);**镜头固定主摄(2026-08-28 定案)**:vfov 固定 `imageGen.assumedVfovDeg=52°`(手机默认主摄;曾随机 [15,90] 覆盖面广但目标像素波动 7~57px 不可控,曾试超广角 90 亦否决);**场景/动作按数据集(2026-08-28)**:`imageGen.sceneByDataset`/`actionByDataset` 按数据集名配池(鸭→水面、雉鸡→灌丛草丛、兔→草坡荒地、鹌鹑→草丛),未配置回退通用 scenes/actions;**遮挡描述(2026-08-28)**:模板加 `{occlusion}` 占位(插在「在{action}」之后)——身体完全暴露的个体对识别/标注训练无意义,野外目标必然存在植被等遮挡;`imageGen.occlusionByDataset` 按数据集配遮挡池(雉鸡→草丛灌丛半掩、兔→荒草只露头耳、鸭→芦苇水面半没、鹌鹑→密草藏身),未配置回退通用 `imageGen.occlusions`;池内条目覆盖轻(身体半掩)~重(只露头背)遮挡,每张随机取一条,杜绝完全暴露;遮挡物多样(杂草/树叶/枝条,2026-08-28 补树叶)且须与 sceneByDataset 场景自洽;遮挡后可见部分小于站高,检测框高与距离估算按可见部分仍自洽(真实场景同此);**拍摄角度(2026-08-28,已废弃角度描述)**:不带任何拍摄角度描述——演进:固定「上部三分之一」区域描述对文生图模型遵循力弱(生成物落底)→ 加固定俯拍(目标上移成功但构图单一,用户否决)→ 池内随机俯/平/仰(仰拍使贴地目标必然沉底,用户否决)→ 只留俯拍/平视(用户仍嫌啰嗦)→ **完全去掉角度描述**(2026-08-28 定案):交由模型平视先验自由构图,目标自然落在画面中部/上部;模板只靠「{species}离镜头很远,只是很小的{tone}轮廓」(物种名替代泛称「目标」,不限定鸟类——物种池含兔子等非鸟物种;`{tone}` 轮廓色词按 `imageGen.speciesTone.<物种>` 取,默认深色剪影,**白雉鸡配浅色**——白羽个体用「深色轮廓」描述会与模型渲染矛盾,2026-08-28)维持远景感,手填提示词仅追加数量约束;**2026-08-31 改版(最终定案)**:镜头词全删(广角/长焦实测均失败);「{distanceWord}有且仅有」→「在{distanceWord}的地平线附近有且仅有」;{action} 从句末主体降级至遮挡之后(动作=叙事焦点=视觉焦点,弱化动作降低主体视觉权重);{sizeHint} 由「像素数+占比」改「画面占比分级尺寸词(微小几乎难辨/很小/较小/中等/较大)+占比」(量化像素数与固定物体类比词针尖/米粒/拳头均不跨物种/距离通用,用户否决);**远景小目标模板 `promptTemplateTiny`**:占比 <2% 时切换,动作/遮挡/性别细节全删(保留细节描写迫使模型画大);不合格丢弃重生成,单张上限 3 次(`consts.GenValidateMaxAttempts`),连续不合格整体报错(已合格张数保留)
|
||||
- **VLM 藏匿位补检(两阶段第二阶段)**:`/datasets/images/vlm-review` 单图接口;qwen3.6-35b-a3b(+mmproj) 以「图片+物种(数据集单物种 gen_species 注入;2026-09-02 prompt 不再存储,原「prompt 含物种名才注入」匹配逻辑删除)+已确认框坐标(排除)」推理藏匿位,输出归一化 bbox JSON(范围校验+与已有框重叠去重),追加 ≤3 个 class=1 疑似框进同一 labels_json;VL 与 z-image 显存互斥(12G 装不下两者),批量补检在生成队列空闲后执行;qwen3.6-35b-a3b yaml 自带 mmproj 实为多模态模型(生成校验);自动标注框数按置信度降序裁剪 ≤ animal_count(存 dataset_image.animal_count 列),估算距离 = 物种站高 × focalPx ÷ 最大框高像素(focalPx=`图高/2/tan(vfov/2)`,站高按物种查 `imageGen.speciesHeights`,单位 cm 换算为米,未配置物种代码兜底 35cm;**2026-08-28 语义修正**:speciesHeights 为站高(垂直尺度,鸟=脚到头顶、兽=蹲坐高),勿用体长——与提示词「高度」描述及检测框高自洽;避免跨物种共用一个标定系数)(K=1.8:6%≈30m、4.5%≈40m,K 隐含镜头视场角假设);**镜头固定主摄(2026-08-28 定案)**:vfov 固定 `imageGen.assumedVfovDeg=52°`(手机默认主摄;曾随机 [15,90] 覆盖面广但目标像素波动 7~57px 不可控,曾试超广角 90 亦否决);**场景/动作按数据集(2026-08-28)**:`imageGen.sceneByDataset`/`actionByDataset` 按数据集名配池(鸭→水面、雉鸡→灌丛草丛、兔→草坡荒地、鹌鹑→草丛),未配置回退通用 scenes/actions;**遮挡描述(2026-08-28)**:模板加 `{occlusion}` 占位(插在「在{action}」之后)——身体完全暴露的个体对识别/标注训练无意义,野外目标必然存在植被等遮挡;`imageGen.occlusionByDataset` 按数据集配遮挡池(雉鸡→草丛灌丛半掩、兔→荒草只露头耳、鸭→芦苇水面半没、鹌鹑→密草藏身),未配置回退通用 `imageGen.occlusions`;池内条目覆盖轻(身体半掩)~重(只露头背)遮挡,每张随机取一条,杜绝完全暴露;遮挡物多样(杂草/树叶/枝条,2026-08-28 补树叶)且须与 sceneByDataset 场景自洽;遮挡后可见部分小于站高,检测框高与距离估算按可见部分仍自洽(真实场景同此);**拍摄角度(2026-08-28,已废弃角度描述)**:不带任何拍摄角度描述——演进:固定「上部三分之一」区域描述对文生图模型遵循力弱(生成物落底)→ 加固定俯拍(目标上移成功但构图单一,用户否决)→ 池内随机俯/平/仰(仰拍使贴地目标必然沉底,用户否决)→ 只留俯拍/平视(用户仍嫌啰嗦)→ **完全去掉角度描述**(2026-08-28 定案):交由模型平视先验自由构图,目标自然落在画面中部/上部;模板只靠「{species}离镜头很远,只是很小的{tone}轮廓」(物种名替代泛称「目标」,不限定鸟类——物种池含兔子等非鸟物种;`{tone}` 轮廓色词按 `imageGen.speciesTone.<物种>` 取,默认深色剪影,**白雉鸡配浅色**——白羽个体用「深色轮廓」描述会与模型渲染矛盾,2026-08-28)维持远景感,手填提示词仅追加数量约束;**2026-08-31 改版(最终定案)**:镜头词全删(广角/长焦实测均失败);「{distanceWord}有且仅有」→「在{distanceWord}的地平线附近有且仅有」;{action} 从句末主体降级至遮挡之后(动作=叙事焦点=视觉焦点,弱化动作降低主体视觉权重);{sizeHint} 由「像素数+占比」改「画面占比分级尺寸词(微小几乎难辨/很小/较小/中等/较大)+占比」(量化像素数与固定物体类比词针尖/米粒/拳头均不跨物种/距离通用,用户否决);**远景小目标模板 `promptTemplateTiny`**:占比 <2% 时切换,动作/遮挡/性别细节全删(保留细节描写迫使模型画大);不合格丢弃重生成,单张上限 3 次(`consts.GenValidateMaxAttempts`),连续不合格整体报错(已合格张数保留)
|
||||
|
||||
### 生成参数存储于数据集表(2026-08-28)
|
||||
|
||||
@@ -395,7 +395,8 @@ training:
|
||||
### 标注工作台(依赖 local-ai 可达;2026-08-26 布局重构)
|
||||
|
||||
- `localAi` 配置节点:`{ baseUrl: "http://127.0.0.1:18080", model: "rfdetr-xlarge", threshold: 0.08, confConfirmed: 0.2, overlapThreshold: 0.3 }`——**部署前提:RF-DETR 服务须从 Go 服务器可达**(现跑在 Mac 上,部署时搬服务器/训练机;**未配置时图片上传/生成接口直接报错**——标注是强语义,不允许产生无标注图片;配置调不通则任务 failed,页顶错误条展示,不自动重试);**端点为配置唯一来源,无运行时覆盖**
|
||||
- **预标注自动触发(2026-08-26 决策,无手动按钮)**:手动上传/AI 生成图片入库成功后,自动对**本次新增图**发起标注;**`localAi` 未配置 → 上传/生成接口直接报错**(同步检查,生成场景在第一张生成前检查避免付费资产生成后标不了);**已有 running 标注任务(忙)→ 不报错**:上传/生成照常成功,新图由「任务成功完成后自动补标未标注图」机制兜底(每轮任务成功结束时检查该数据集未标注图,有则自动续一轮只标未标注图——失败任务不续,防配置坏时无限重试);service 逐张调 local-ai 推理(common 池并行,`label_task` 记录 total/done 进度)→ 扫描结果(YOLO 归一化 xywh + 置信度 + 建议类别,conf≥confConfirmed 为 class 0)**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**;**重叠去重(2026-08-26 修订)**:NMS 风格、按置信度降序依次保留,与已保留框**重叠比(minIoU = 交叠面积/两框较小面积)> `localAi.overlapThreshold`(默认 0.3)**的框剔除(同目标被重复检出只留置信度最高者,跨 class 去重;人工画框不参与去重)——**用 minIoU 而非 IoU**:RF-DETR 对同一目标常输出一大一小两个框(标准 IoU 仅 0.3~0.5 会漏杀,实测红黄重叠即此形态),大框套小框时小框被覆盖比例高,minIoU 命中;相邻目标两框互有外露,minIoU 通常 < 0.3;**全图扫描**(项目既定规则:不套用生成规格的位置裁剪,候选宁多勿漏);不设候选确认两阶段(2026-08-26 撤销 candidates_json);`POST /admin/label-tasks` 接口保留(详情页「全量标注」按钮,2026-08-26 恢复),可手动全量/指定图重标
|
||||
- **预标注自动触发(2026-08-26 决策,无手动按钮)**:手动上传/AI 生成图片入库成功后,自动对**本次新增图**发起标注;**`localAi` 未配置 → 上传/生成接口直接报错**(同步检查,生成场景在第一张生成前检查避免付费资产生成后标不了);**已有 running 标注任务(忙)→ 不报错**:上传/生成照常成功,新图由「任务成功完成后自动补标未标注图」机制兜底(每轮任务成功结束时检查该数据集未标注图,有则自动续一轮只标未标注图——失败任务不续,防配置坏时无限重试);service 逐张调 local-ai 推理(common 池并行,`label_task` 记录 total/done 进度)→ 扫描结果(YOLO 归一化 xywh + 置信度 + 建议类别,conf≥confConfirmed 为 class 0)**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**;**重叠去重(2026-08-26 修订)**:NMS 风格、按置信度降序依次保留,与已保留框**重叠比(minIoU = 交叠面积/两框较小面积)> `localAi.overlapThreshold`(默认 0.3)**的框剔除(同目标被重复检出只留置信度最高者,跨 class 去重;人工画框不参与去重)——**用 minIoU 而非 IoU**:RF-DETR 对同一目标常输出一大一小两个框(标准 IoU 仅 0.3~0.5 会漏杀,实测红黄重叠即此形态),大框套小框时小框被覆盖比例高,minIoU 命中;相邻目标两框互有外露,minIoU 通常 < 0.3;**全图扫描**(项目既定规则:不套用生成规格的位置裁剪,候选宁多勿漏);不设候选确认两阶段(2026-08-26 撤销 candidates_json);`POST /admin/label-tasks` 接口保留(服务端 `filenames` 参数支持指定图批量重标,2026-09-02 前端入口收敛后仍可全量——不传 filenames 即全量扫描)
|
||||
- **手动标注入口收敛(2026-09-02 决策)**:详情页删除「全量标注」按钮与图片卡片「审核」按钮,**标注入口仅两处(勾选图片驱动,顶栏)**:①「预标」= POST `/admin/label-tasks` 传选中图 `filenames`(只扫选中图,覆盖各图已有标注);②「补标」= 对选中图逐张 POST `/datasets/images/vlm-review`(VLM 藏匿位补检,追加疑似框,批量=前端循环单图接口,依赖已确认框作为排除基准)——VLM 补检按钮从标注编辑器迁出(2026-09-02);点卡片缩略图仍进人工编辑器(确认/清理/画框,编辑器内不再有 VLM 按钮),编辑器文案「图片审核」→「图片标注」;服务端全量重标能力保留但不再有 UI 入口
|
||||
- **详情页布局(无选项卡)**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照;页顶标注任务进度条(自动触发任务进度,含错误展示);标注图 = 原图 + 标注框叠加(只读 canvas,与工作台同一绘制逻辑)
|
||||
- **弹窗标注**:点击原图/标注图打开放大弹窗(大 canvas,1152x2048 原尺寸),支持两点画框/点框删除/清空/类别切换(确认 0/疑似 1)/上下一张/保存——**AI 自动标注与人工框同层可编辑**(含清理 AI 框);保存即整体覆写 `dataset_image.labels_json`(JSON 数组,YOLO 归一化 xywh+类别+置信度,空=清空标注);未保存修改切换图片有确认
|
||||
- 无历史标注任务时详情页工作台数据源为 `GET /admin/label-workbench?dataset_id=`(图片 + 全量标注框),避免依赖人工先跑一次任务
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Minus, MagicStick } from '@element-plus/icons-vue'
|
||||
import { Plus, Minus, MagicStick, Search } from '@element-plus/icons-vue'
|
||||
import request from '../api/request'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -12,7 +12,7 @@ const dataset = ref(null)
|
||||
|
||||
// ---------- 数据 ----------
|
||||
const detail = ref(null) // 工作台数据:images[{filename,url,width,height,boxes,labeled}]
|
||||
const imageMeta = ref({}) // filename -> {id, source, prompt}(来自 /datasets/images,行内展示用)
|
||||
const imageMeta = ref({}) // filename -> {id, source}(来自 /datasets/images,行内展示用)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 18
|
||||
@@ -45,7 +45,7 @@ async function loadAll() {
|
||||
}
|
||||
const imgs = await request.get('/datasets/images', { params: { datasetId: datasetId.value } }).catch(() => null)
|
||||
imageMeta.value = {}
|
||||
for (const i of imgs?.list || []) imageMeta.value[i.filename] = { id: i.id, source: i.source, prompt: i.prompt }
|
||||
for (const i of imgs?.list || []) imageMeta.value[i.filename] = { id: i.id, source: i.source }
|
||||
detail.value = wb || { images: [] }
|
||||
if (editIndex.value >= (detail.value.images || []).length) editIndex.value = 0
|
||||
// 刷新时恢复进行中的自动标注任务
|
||||
@@ -165,55 +165,98 @@ function stopLabelPoll() {
|
||||
}
|
||||
}
|
||||
|
||||
// 全量标注:重扫数据集全部图片,覆盖各图已有标注(含人工修改/清理的框)
|
||||
function startFullLabelTask() {
|
||||
ElMessageBox.confirm('将重新扫描数据集全部图片并生成标注,覆盖各图已有标注(含人工确认/清理的结果)。确定继续?', '全量标注', {
|
||||
confirmButtonText: '开始标注',
|
||||
// 勾选标注:重扫勾选图片,覆盖各图已有标注(含人工修改/清理的框)。
|
||||
// 标注入口仅此一处(2026-09-02 决策):无单图/全量按钮,勾选后点顶栏「标注 N 张」。
|
||||
const selected = ref([])
|
||||
// 单选框:受控模式(el-checkbox 单独用 v-model 绑数组会在勾选时把数组替换成布尔值,故手动维护)
|
||||
function toggleOne(filename, checked) {
|
||||
const s = selected.value
|
||||
if (checked) {
|
||||
if (!s.includes(filename)) s.push(filename)
|
||||
} else {
|
||||
const i = s.indexOf(filename)
|
||||
if (i >= 0) s.splice(i, 1)
|
||||
}
|
||||
}
|
||||
// 全选 = 当前页图片;已全选时再点取消当前页勾选(其他页勾选保留)
|
||||
const allSelected = computed(
|
||||
() => pagedImages.value.length > 0 &&
|
||||
pagedImages.value.every(({ item }) => selected.value.includes(item.filename)),
|
||||
)
|
||||
function toggleSelectAll() {
|
||||
const pageFiles = pagedImages.value.map(({ item }) => item.filename)
|
||||
if (!pageFiles.length) return
|
||||
const pageSet = new Set(pageFiles)
|
||||
if (allSelected.value) {
|
||||
selected.value = selected.value.filter((f) => !pageSet.has(f))
|
||||
} else {
|
||||
const keep = new Set(selected.value)
|
||||
selected.value = [...selected.value, ...pageFiles.filter((f) => !keep.has(f))]
|
||||
}
|
||||
}
|
||||
function startLabelTask() {
|
||||
if (!selected.value.length) return
|
||||
ElMessageBox.confirm(`将重新扫描选中的 ${selected.value.length} 张图片并生成标注,覆盖各图已有标注(含人工确认/清理的结果)。确定继续?`, '预标', {
|
||||
confirmButtonText: '开始预标',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => request.post('/label-tasks', { datasetId: datasetId.value }))
|
||||
.then(() => request.post('/label-tasks', { datasetId: datasetId.value, filenames: selected.value }))
|
||||
.then((data) => {
|
||||
ElMessage.success('已发起全量标注,进度见页顶进度条')
|
||||
ElMessage.success('已发起预标,进度见页顶进度条')
|
||||
pollLabelTask(data.id)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// VLM 藏匿位补检(两阶段标注第二阶段):排除已确认框,按环境/光线/习性找可能藏匿处,追加疑似框
|
||||
// VLM 藏匿位补检(「补标」按钮,勾选驱动批量执行):排除已确认框,按环境/光线/习性
|
||||
// 找可能藏匿处,逐张追加疑似框(黄框,人工确认);依赖已确认框作为排除基准
|
||||
const vlmReviewing = ref(false)
|
||||
async function vlmReview() {
|
||||
if (!currentImage.value) return
|
||||
const m = meta(currentImage.value)
|
||||
if (!m.id) return
|
||||
async function vlmBatchReview() {
|
||||
if (!selected.value.length) return
|
||||
const images = (detail.value.images || []).filter((it) => selected.value.includes(it.filename))
|
||||
if (!images.length) return
|
||||
vlmReviewing.value = true
|
||||
try {
|
||||
const d = await request.post('/datasets/images/vlm-review', { datasetId: datasetId.value, imageId: m.id }, { timeout: 300000 })
|
||||
if (d.added > 0) {
|
||||
ElMessage.success(`VLM 补检新增 ${d.added} 个疑似框(黄框,请人工确认)`)
|
||||
let added = 0
|
||||
let note = ''
|
||||
for (const item of images) {
|
||||
const m = meta(item)
|
||||
if (!m.id) continue
|
||||
const d = await request.post('/datasets/images/vlm-review', { datasetId: datasetId.value, imageId: m.id }, { timeout: 300000 })
|
||||
added += d.added || 0
|
||||
note = d.note || note
|
||||
}
|
||||
if (added > 0) {
|
||||
ElMessage.success(`补标新增 ${added} 个疑似框(黄框,请人工确认)`)
|
||||
} else {
|
||||
ElMessage.info(d.note || 'VLM 未发现可能的藏匿位')
|
||||
ElMessage.info(note || 'VLM 未发现可能的藏匿位')
|
||||
}
|
||||
dirty.value = false
|
||||
await loadAll()
|
||||
nextTick(resetEditor)
|
||||
// 标注弹窗开着时刷新当前图的标注
|
||||
if (editorVisible.value) nextTick(resetEditor)
|
||||
} finally {
|
||||
vlmReviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除单张图片(标注随图片删除,后端级联)
|
||||
function removeCardImage(item) {
|
||||
const m = meta(item)
|
||||
const tip = m.source === 'ai' ? '该图为 AI 生成的付费资产,删除后无法恢复。' : ''
|
||||
// 批量删除勾选图片(标注随图片删除,后端级联)
|
||||
function deleteSelected() {
|
||||
if (!selected.value.length) return
|
||||
const images = (detail.value.images || []).filter((it) => selected.value.includes(it.filename))
|
||||
const ids = images.map((it) => meta(it).id).filter(Boolean)
|
||||
if (!ids.length) return
|
||||
const aiCount = images.filter((it) => meta(it).source === 'ai').length
|
||||
const tip = aiCount ? `(含 ${aiCount} 张 AI 生成图,为付费资产,删除后无法恢复)` : ''
|
||||
ElMessageBox.confirm(
|
||||
`确定删除图片「${item.filename}」?其标注将一并删除。${tip}`,
|
||||
'删除图片',
|
||||
`确定删除选中的 ${ids.length} 张图片?其标注将一并删除。${tip}`,
|
||||
'删除选中',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
.then(() => request.post('/datasets/images/delete', { datasetId: datasetId.value, ids: [m.id] }))
|
||||
.then(() => request.post('/datasets/images/delete', { datasetId: datasetId.value, ids }))
|
||||
.then(() => {
|
||||
ElMessage.success('已删除')
|
||||
selected.value = []
|
||||
loadAll()
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -695,8 +738,26 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 图片工具 -->
|
||||
<div class="img-toolbar">
|
||||
<el-button
|
||||
:disabled="!pagedImages.length"
|
||||
@click="toggleSelectAll"
|
||||
>{{ allSelected ? '取消全选' : '全选' }}</el-button>
|
||||
<el-button type="primary" :icon="Plus" :disabled="genRunning" @click="openAdd">添加</el-button>
|
||||
<el-button :icon="MagicStick" :disabled="labelRunning" @click="startFullLabelTask">全量标注</el-button>
|
||||
<el-button
|
||||
:icon="MagicStick"
|
||||
:disabled="labelRunning || !selected.length"
|
||||
@click="startLabelTask"
|
||||
>预标{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
:icon="Search"
|
||||
:disabled="vlmReviewing || !selected.length"
|
||||
@click="vlmBatchReview"
|
||||
>补标{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:disabled="!selected.length"
|
||||
@click="deleteSelected"
|
||||
>删除选中{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="labelRunning" class="label-progress">
|
||||
@@ -726,19 +787,17 @@ onBeforeUnmount(() => {
|
||||
<div class="card-grid">
|
||||
<div v-for="{ item, gi } in pagedImages" :key="item.filename" class="img-card">
|
||||
<div class="card-img" @click="openEditor(item, gi)">
|
||||
<el-checkbox
|
||||
class="card-select"
|
||||
:model-value="selected.includes(item.filename)"
|
||||
@click.stop
|
||||
@change="(v) => toggleOne(item.filename, v)"
|
||||
/>
|
||||
<img :src="imgUrl(item.url)" :alt="item.filename" loading="lazy" />
|
||||
<span v-if="meta(item).source === 'ai'" class="badge source-badge">AI</span>
|
||||
<span class="badge" :class="item.labeled ? 'ok' : ''">{{ item.labeled ? '已标注' : '未标注' }}</span>
|
||||
</div>
|
||||
<div class="card-name" :title="item.filename">{{ item.filename }}</div>
|
||||
<div class="card-sub">
|
||||
{{ (item.boxes || []).length }} 个标注框
|
||||
</div>
|
||||
<div v-if="meta(item).prompt" class="card-prompt" :title="meta(item).prompt">{{ meta(item).prompt }}</div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" type="primary" @click="openEditor(item, gi)">审核</el-button>
|
||||
<el-button size="small" type="danger" @click="removeCardImage(item)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pager">
|
||||
@@ -777,22 +836,21 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 图片审核(全屏查看 + 标注审核):已标注图直接在其上显示框,可确认疑似/删除误检,不做手动画框 -->
|
||||
<!-- 图片标注(全屏查看 + 标注编辑):已标注图直接在其上显示框,可确认疑似/删除误检,可手动画框补标 -->
|
||||
<el-dialog
|
||||
v-model="editorVisible"
|
||||
fullscreen
|
||||
class="editor-fullscreen"
|
||||
:title="currentImage?.filename || '图片审核'"
|
||||
:title="currentImage?.filename || '图片标注'"
|
||||
append-to-body
|
||||
:before-close="onEditorBeforeClose"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="editor-toolbar">
|
||||
<span class="tip">空白处拖动画红框补标 · 拖拽已有框移位 · 拖角点改大小 · 点框选中 · 列表确认疑似/删除误检 · VLM 自动标注审核</span>
|
||||
<span class="tip">空白处拖动画红框补标 · 拖拽已有框移位 · 拖角点改大小 · 点框选中 · 列表确认疑似/删除误检</span>
|
||||
<div class="toolbar-right">
|
||||
<span class="box-count"><i class="dot red" />确认 {{ confirmCount }} · <i class="dot yellow" />疑似 {{ suspectCount }}</span>
|
||||
<el-button size="small" :disabled="!confirmed.length" @click="clearAllBoxes">清空本张</el-button>
|
||||
<el-button size="small" type="warning" :loading="vlmReviewing" @click="vlmReview">VLM 补检</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="zoom-ctrl">
|
||||
<el-button size="small" circle :icon="Plus" @click="zoomIn" />
|
||||
@@ -1004,6 +1062,15 @@ onBeforeUnmount(() => {
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
.card-select {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 2;
|
||||
padding: 2px 3px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -1032,27 +1099,6 @@ onBeforeUnmount(() => {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-prompt {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user