1
This commit is contained in:
+4
-2
@@ -14,7 +14,7 @@
|
||||
| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**(1000 / 5600 / 18000) |
|
||||
| 后台管理端 | `server_admin/`(Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token`) |
|
||||
| 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**(iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
|
||||
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗为**审核视图(不做手动画框)**:点击框选中,列表可确认疑似框/删除误检框/清空并保存——AI 自动标注结果直接作为标注,人工仅审核确认;封面(上传/生成统一 1248x704 转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image,`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON |
|
||||
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗为**审核视图(不做手动画框)**:点击框选中,列表可确认疑似框/删除误检框/清空并保存——AI 自动标注结果直接作为标注,人工仅审核确认;封面(上传/生成统一 1248x704 转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image,`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON;**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 |
|
||||
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置):进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1(GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
|
||||
| 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/trainings/<文件名前缀>.tflite`(前缀空回退数据集名) + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
|
||||
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/trainings/<文件名前缀>.tflite`(前缀空回退数据集名);**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 |
|
||||
@@ -49,7 +49,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
|
||||
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`remark`(管理端备注)、`created_at`、`updated_at` |
|
||||
| `app_version` | App 版本管理 | `id`(PK)、`version`(x.y.z, UNIQUE)、`notes`(更新说明)、`created_at`、`updated_at`(下载地址不落表:APK 固定文件 `app.apkDir`/`observer-latest.apk`,默认 `./workspace/`) |
|
||||
| `dataset` | 训练数据集 | `id`(PK)、`name`(UNIQUE)、`source`(manual/ai)、`image_count`、`labeled_count`、`status`(building/synced/labeled)、`cover`(封面文件名,UUID 命名 jpg,如 `9f2a...-xx.jpg`)、`description`、`created_at`、`updated_at`(图片文件在 `app.datasetDir`/`datasets/<name>/`,DB 只存元数据;AI 标注/训练机 SSH 配置走 `config.yml` 的 `localAi` / `training.ssh` 节点);**生成参数池(创建时 VLM 自动生成,界面不维护,可 `POST /datasets/gen-pools` 重新生成)**:`gen_species`(单值=数据集物种)、`gen_tone`(单值 轮廓色词 深色/浅色)、`gen_heights`(数值 站高cm,距离感公式用)、`gen_scenes`/`gen_actions`/`gen_occlusions`(JSON 数组 各≥3条)、`gen_classes`(单值 第二标注类别名="suspect",第一类别=gen_species,训练 data.yaml names);**单物种规则:每数据集只对应一个物种(生成图片固定按数据集名),不同物种拆到不同数据集** |
|
||||
| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id`、`filename`、`source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 自动标注与人工标注同存、人工可修改/清理,null/''/'[]'=未标注)、`created_at` |
|
||||
| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id`、`filename`、`source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 自动标注与人工标注同存、人工可修改/清理,null/''/'[]'=未标注)、`clean_excluded`(0/1,2026-09-02 数据清洗排除出训练集标记,prepare_yolo 打包跳过,可恢复)、`created_at` |
|
||||
| `model_training` | 训练任务 | `id`(PK)、`name`、`status`(running/success/failed)、`dataset`(训练机数据集名)、`imgsz`/`epochs`/`batch`/`device`(参数快照)、`current_epoch`/`total_epochs`、`metrics`(JSON)、`log_tail`、`pid`、`error`、`started_at`/`finished_at`、`created_at` |
|
||||
| `model_version` | 模型版本(每数据集独立序列) | `id`(PK)、`dataset_id`、`version`(m1.0.0 递增, 同数据集 UNIQUE)、`training_id`、`metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256`、`size_bytes`、`is_latest`、`notes`、`created_at`(模型文件不落表:发布即写 `trainings/<文件名前缀>.tflite`(前缀空回退数据集名),客户端固定下载,无存档回退) |
|
||||
| `label_task` | 标注任务 | `id`(PK)、`dataset_id`、`filenames`(JSON 选中图片列表,NULL=全量)、`status`(running/done)、`total`/`done`、`created_at`、`finished_at` |
|
||||
@@ -196,6 +196,8 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
||||
| POST | `/admin/datasets/images/delete` | 删除图片 `{"datasetId":1,"ids":[1,2]}`:删文件 + 删记录(AI 生成图是付费资产,前端确认文案提示) |
|
||||
| GET | `/admin/datasets/export` | 导出数据集 zip:`datasetId`,打包图片目录为 zip 下载(标注衔接用) |
|
||||
| POST | `/admin/datasets/sync` | 同步数据集到训练机 `{"id":1}`:按 `training` 通道推送图片到训练机 `datasetDir/<name>/`(subprocess 同机 cp、ssh 异机 scp/rsync);训练启动前自动执行 |
|
||||
| POST | `/admin/datasets/clean/preview` | 数据清洗预览 `{"datasetId":1,"quotas":{...}}`(quotas 各档配额缺省用代码默认):按标注框高占比细档(<2/2-3/3-4/4-6/6-8/8-12/12-20/>20%)统计分布与超配量,超配桶按图内最小确认目标尺寸升序优先保留(小目标样本稀缺先保)、再整图 dHash 去近重复,其余进 `candidates`(含主/最小目标占比 + 目标数 + 实拍生成标识);返回各档分布 + 候选清单 + 已排除列表;<2% 极远档固定豁免,仅 class1 疑似图/空检 `[]` 不入清单 |
|
||||
| POST | `/admin/datasets/clean/apply` | 清洗执行 `{"imageIds":[1,2],"exclude":true}`:批量置/清 `clean_excluded`(排除=打标记,prepare_yolo 打包跳过;false=恢复),不删文件不删标注 |
|
||||
| POST | `/admin/label-tasks` | 发起预标注 `{"datasetId":1,"filenames":["a.jpg","b.jpg"]}`(详情页「全量标注」按钮入口,图片入库亦自动触发):**filenames 缺省=全量**,指定图则只扫描选中图(全量重标/单张修补);调 `config.yml` `localAi` 配置的 AI 端点 RF-DETR 逐张推理(common 池并行,未配置报错),扫描结果做**重叠去重**(NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除,同目标只留置信度最高者)后**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**,`label_task` 记录进度 |
|
||||
| GET | `/admin/label-tasks` | 标注任务列表:`page/size` 分页(含 status/total/done) |
|
||||
| GET | `/admin/label-tasks/detail` | 标注任务详情:返回数据集全部图片 + 每张标注框(`boxes`,YOLO 归一化 xywh + 置信度 + 类别) |
|
||||
|
||||
File diff suppressed because one or more lines are too long
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-CHT1qyKR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-aSE0N1Mp.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-BMgzRXh7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-Cp_kU-Al.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -55,6 +55,37 @@ const (
|
||||
// 单图 VLM 藏匿位补检疑似框(class=1)上限;每次调用可追加数 = 上限 - 图内已有疑似框数,≤0 跳过调用
|
||||
VlmMaxSuspectPerImage = 3
|
||||
|
||||
// 单图预标(RF-DETR)框数上限:去重后检出仍超过该数按置信度取前 N——
|
||||
// 生成声明 1 个目标不代表图内只有 1 个,曾按声明数量裁剪漏目标,改固定上限
|
||||
// (与 VlmMaxSuspectPerImage 同量级,单图人工复核工作量可控)
|
||||
MaxPrelabelPerImage = 3
|
||||
|
||||
// 模型版本号前缀(m1.0.0),同数据集内递增
|
||||
ModelVersionPrefix = "m"
|
||||
|
||||
// 桶内多样性保留的整图 dHash 相似阈值:与已保留图最小汉明距离 > 该值才保留
|
||||
// (同场地连拍帧整图哈希近距通常 <8,异场景帧通常 >12,2026-09-02 实测校准)
|
||||
CleanHashHamming = 8
|
||||
)
|
||||
|
||||
// CleanBuckets 数据清洗目标尺寸档(训练集去冗余,见技术设计.md「数据清洗」):
|
||||
// 按最大 class 0 框的归一化框高 h 划分,边界为占图高百分比,下含上不含。
|
||||
// <2% 极远档豁免——recall 瓶颈档只缺不多,不进候选清单(配额 0 固定不适用);
|
||||
// 其余档 Quota 为「目标保留张数」缺省值,preview 请求可覆盖(管理端配额表默认展示即此表)。
|
||||
type CleanBucket struct {
|
||||
Label string
|
||||
MinPct float64
|
||||
MaxPct float64
|
||||
Quota int
|
||||
}
|
||||
|
||||
var CleanBuckets = []CleanBucket{
|
||||
{"<2%", 0, 2, 0},
|
||||
{"2-3%", 2, 3, 30},
|
||||
{"3-4%", 3, 4, 40},
|
||||
{"4-6%", 4, 6, 50},
|
||||
{"6-8%", 6, 8, 40},
|
||||
{"8-12%", 8, 12, 40},
|
||||
{"12-20%", 12, 20, 30},
|
||||
{">20%", 20, 100, 20},
|
||||
}
|
||||
|
||||
@@ -112,6 +112,16 @@ func (c *cAdmin) DeleteImages(ctx context.Context, req *dto.AdminDatasetImagesDe
|
||||
return service.Dataset.AdminDeleteImages(ctx, req)
|
||||
}
|
||||
|
||||
// CleanPreview 数据清洗预览(桶分布/候选清单/已排除清单)
|
||||
func (c *cAdmin) CleanPreview(ctx context.Context, req *dto.AdminDatasetCleanPreviewReq) (*dto.AdminDatasetCleanPreviewRes, error) {
|
||||
return service.Dataset.AdminCleanPreview(ctx, req)
|
||||
}
|
||||
|
||||
// CleanApply 数据清洗执行(批量置/清排除标记)
|
||||
func (c *cAdmin) CleanApply(ctx context.Context, req *dto.AdminDatasetCleanApplyReq) (*dto.AdminDatasetCleanApplyRes, error) {
|
||||
return service.Dataset.AdminCleanApply(ctx, req)
|
||||
}
|
||||
|
||||
// Image 图片访问(直写响应体:service 校验归属返回路径,controller 输出二进制)
|
||||
func (c *cAdmin) Image(ctx context.Context, req *dto.AdminDatasetImageReq) (*dto.AdminDatasetImageRes, error) {
|
||||
path, err := service.Dataset.ImageFile(ctx, req.DatasetId, req.Filename)
|
||||
|
||||
@@ -35,6 +35,7 @@ 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")
|
||||
common.EnsureColumn(ctx, consts.TableDatasetImage, "clean_excluded", "clean_excluded INTEGER NOT NULL DEFAULT 0")
|
||||
// 存量库迁移:删除 prompt 列(生成提示词不再存储,2026-09-02 决策)
|
||||
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(dataset_image)")
|
||||
if err != nil {
|
||||
@@ -93,12 +94,16 @@ func (d *datasetImageDao) ListByDataset(ctx context.Context, datasetId int64) ([
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ListUnlabeledByDataset 未标注图片(labels_json 为空/null/'[]';自动补标轮次用)
|
||||
// ListUnlabeledByDataset 从未扫描过的图片(labels_json 为 null 或空串;自动补标轮次用)。
|
||||
// '[]'(已自动扫描但检出为空)不在此列:空检出图重扫结果不变,若仍入池,自动补标的
|
||||
// 「排除本轮已扫图」只换子集不灭池,会乒乓死循环(2026-09-02 兔子 3 张空检出实测:
|
||||
// {9}→{10,17}→{9}… 无限交替续跑,每轮白打一次 RF-DETR);空检出图仍在管理端未标注
|
||||
// tab 等人工画框,重扫走手动触发(AdminStartLabelTask 按文件名批量或全量)。
|
||||
func (d *datasetImageDao) ListUnlabeledByDataset(ctx context.Context, datasetId int64) ([]*entity.DatasetImage, error) {
|
||||
var list []*entity.DatasetImage
|
||||
err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).
|
||||
Where("labels_json IS NULL OR labels_json = '' OR labels_json = '[]'").
|
||||
Where("labels_json IS NULL OR labels_json = ''").
|
||||
OrderAsc("id").Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
@@ -203,3 +208,26 @@ func (d *datasetImageDao) DeleteByDataset(ctx context.Context, datasetId int64)
|
||||
_, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).Where("dataset_id", datasetId).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCleanExcluded 批量置/清数据清洗排除标记(excluded: 1=出训练集, 0=恢复;IN ≤100 分批)
|
||||
func (d *datasetImageDao) UpdateCleanExcluded(ctx context.Context, ids []int64, excluded int) error {
|
||||
for start := 0; start < len(ids); start += 100 {
|
||||
end := start + 100
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
if _, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
WhereIn("id", ids[start:end]).
|
||||
Data(g.Map{"clean_excluded": excluded}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountExcludedByDataset 某数据集已排除(clean_excluded=1)图数(数据清洗后角标/计数刷新)
|
||||
func (d *datasetImageDao) CountExcludedByDataset(ctx context.Context, datasetId int64) (int64, error) {
|
||||
n, err := g.DB().Model(consts.TableDatasetImage).Ctx(ctx).
|
||||
Where("dataset_id", datasetId).Where("clean_excluded", 1).Count()
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
@@ -133,7 +133,8 @@ type AdminDatasetUploadRes struct {
|
||||
// AdminDatasetGenerateReq AI 生成图片(异步任务;prompt 可手填覆盖模板,禁止含目标位置描述;
|
||||
// 未手填走 imageGen.promptTemplate 组装(单物种规则:物种固定取数据集 gen_species[0]/数据集名,
|
||||
// 表单无物种输入),scene/action/occlusion 从数据集表池随机。
|
||||
// animalCount 仅作为标注框数上限存储(自动标注按置信度裁剪 ≤N),不参与生成校验;
|
||||
// animalCount 为生成提示词中的声明目标数量,仅信息性存储(自动标注上限为固定 3 个,
|
||||
// 与声明数无关——生成图可能实际含多个目标,检出超 3 个按置信度取前 3,2026-09-02);
|
||||
// 距离校验已取消(2026-08-28)。文件名前缀为数据集属性(datasets 表 name_prefix)。
|
||||
// 生成耗时约 40s/张(localai),批量请用返回的 TaskId 轮询 /datasets/gen-task 进度。
|
||||
type AdminDatasetGenerateReq struct {
|
||||
@@ -173,13 +174,15 @@ type AdminDatasetImagesReq struct {
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
}
|
||||
|
||||
// AdminImageItem 图片条目(url 为管理端图片访问地址,前端拼 apiBaseUrl 使用)
|
||||
// AdminImageItem 图片条目(url 为管理端图片访问地址,前端拼 apiBaseUrl 使用;
|
||||
// cleanExcluded=1 的图归「已清洗」tab——数据清洗排除,训练集打包跳过,可恢复)
|
||||
type AdminImageItem struct {
|
||||
Id int64 `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Source string `json:"source"` // manual | ai
|
||||
Url string `json:"url"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
Id int64 `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Source string `json:"source"` // manual | ai
|
||||
Url string `json:"url"`
|
||||
CleanExcluded int `json:"cleanExcluded"` // 1=已清洗排除
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AdminDatasetImagesRes struct {
|
||||
@@ -438,6 +441,60 @@ type AdminLabelSaveRes struct {
|
||||
LabeledCount int64 `json:"labeledCount"` // 该数据集当前已标注图数
|
||||
}
|
||||
|
||||
// ---------- 数据清洗 ----------
|
||||
|
||||
// AdminDatasetCleanPreviewReq 数据清洗预览(训练集去冗余候选清单,语义见技术设计.md「数据清洗」):
|
||||
// 返回桶分布 + 超配桶候选清单(dHash 贪心挑保留)+ 已排除清单(可恢复)。
|
||||
// Quotas 与档位一一对应覆盖「保留张数」,缺省用 consts.CleanBuckets 默认;<2% 豁免档无视。
|
||||
type AdminDatasetCleanPreviewReq struct {
|
||||
g.Meta `path:"/datasets/clean/preview" method:"post" summary:"数据清洗预览" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
Quotas []int `json:"quotas" v:"length:0,8" dc:"各档配额覆盖(与档位一一对应,可选)"`
|
||||
}
|
||||
|
||||
// AdminCleanBucketRes 单档分布(档位定义与顺序见 consts.CleanBuckets)
|
||||
type AdminCleanBucketRes struct {
|
||||
Label string `json:"label"`
|
||||
Exempt bool `json:"exempt"` // 豁免档(<2% 极远,不进候选)
|
||||
Total int `json:"total"` // 该档有确认目标(class 0)标注图总数
|
||||
Excluded int `json:"excluded"` // 其中已清洗排除数
|
||||
Quota int `json:"quota"` // 生效保留张数(请求覆盖或默认)
|
||||
Over int `json:"over"` // 超配需排除数 = max(0, 未排除-配额);豁免档恒 0
|
||||
}
|
||||
|
||||
// AdminCleanImage 候选/已排除单图(两个清单共用)
|
||||
type AdminCleanImage struct {
|
||||
ImageId int64 `json:"imageId"`
|
||||
Filename string `json:"filename"`
|
||||
Source string `json:"source"` // manual | ai
|
||||
BucketLabel string `json:"bucketLabel"` // 所在档(无 class 0 标注的已排除图为空)
|
||||
BoxHeightPct float64 `json:"boxHeightPct"` // 主目标框高占图高百分比(取最大 class 0 框,保留 1 位小数)
|
||||
MinHeightPct float64 `json:"minHeightPct"` // 图内最小确认目标占比(档内优先保留依据,保留 1 位小数;单目标图 = 主尺寸)
|
||||
TargetCount int `json:"targetCount"` // class 0 目标总数(多目标图里的小目标样本稀缺,看主尺寸+最小占比判断取舍)
|
||||
}
|
||||
|
||||
type AdminDatasetCleanPreviewRes struct {
|
||||
Total int `json:"total"` // 参与统计图数(有 class 0 标注)
|
||||
Excluded int `json:"excluded"` // 已排除总数(恢复入口角标)
|
||||
Buckets []*AdminCleanBucketRes `json:"buckets"`
|
||||
Candidates []*AdminCleanImage `json:"candidates"` // 建议排除候选(默认全选即执行)
|
||||
ExcludedImages []*AdminCleanImage `json:"excludedImages"` // 已排除清单(勾选恢复)
|
||||
}
|
||||
|
||||
// AdminDatasetCleanApplyReq 数据清洗执行:批量置/清 clean_excluded(排除不删文件,
|
||||
// 只影响训练集打包;恢复 = 同接口 exclude=false 重提该清单)。imageIds 须全属该数据集。
|
||||
type AdminDatasetCleanApplyReq struct {
|
||||
g.Meta `path:"/datasets/clean/apply" method:"post" summary:"数据清洗执行(排除/恢复)" tags:"管理端"`
|
||||
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
|
||||
ImageIds []int64 `json:"imageIds" v:"required|min-length:1" dc:"图片记录 id 列表"`
|
||||
Exclude bool `json:"exclude" dc:"true=置排除标记(出训练集);false=清标记(恢复)"`
|
||||
}
|
||||
|
||||
type AdminDatasetCleanApplyRes struct {
|
||||
Applied int `json:"applied"` // 处理图片数(幂等)
|
||||
Excluded int64 `json:"excluded"` // 该数据集当前排除总数(角标/计数刷新)
|
||||
}
|
||||
|
||||
// ---------- 客户端模型目录 ----------
|
||||
|
||||
// ModelCatalogReq 模型目录(客户端登录态):全部数据集的当前生效模型
|
||||
|
||||
@@ -5,11 +5,12 @@ import "github.com/gogf/gf/v2/os/gtime"
|
||||
// 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"`
|
||||
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:"创建时间"`
|
||||
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"`
|
||||
AnimalCount int `json:"animalCount" orm:"animal_count" description:"AI 生成提示词中的声明目标数量(0=手动上传,仅信息性,自动标注上限固定 3 与声明数无关)"`
|
||||
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"标注 JSON 数组(AI 自动标注与人工标注同存,null/''/'[]'=未标注)"`
|
||||
CleanExcluded int `json:"cleanExcluded" orm:"clean_excluded" description:"数据清洗排除标记(0=正常,1=已从训练集排除;只影响训练集打包,图片/标注不动,可恢复)"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"math"
|
||||
"math/bits"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -1038,7 +1040,8 @@ func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *e
|
||||
tpl = tiny
|
||||
}
|
||||
}
|
||||
// 每张固定 1 个目标(数量词固定,animal_count 列同为 1 供自动标注裁剪)
|
||||
// 每张固定 1 个目标(数量词固定,animal_count 列同为 1,仅信息性存储;
|
||||
// 生成图可能实际含多个目标,预标检出超 3 个按置信度取前 3,2026-09-02)
|
||||
countWord := "1只"
|
||||
// 性别随机(50/50):两性体型与外观差异大(雉鸡雄艳雌褐、野鸭雄艳雌素等),
|
||||
// 每张随机让训练数据覆盖两性形态
|
||||
@@ -1371,11 +1374,12 @@ func (s *datasetService) AdminListImages(ctx context.Context, req *dto.AdminData
|
||||
items := make([]*dto.AdminImageItem, 0, len(list))
|
||||
for _, v := range list {
|
||||
items = append(items, &dto.AdminImageItem{
|
||||
Id: v.Id,
|
||||
Filename: v.Filename,
|
||||
Source: v.Source,
|
||||
Url: datasetImageUrl(ctx, dataset.Id, v.Filename),
|
||||
CreatedAt: v.CreatedAt,
|
||||
Id: v.Id,
|
||||
Filename: v.Filename,
|
||||
Source: v.Source,
|
||||
Url: datasetImageUrl(ctx, dataset.Id, v.Filename),
|
||||
CleanExcluded: v.CleanExcluded,
|
||||
CreatedAt: v.CreatedAt,
|
||||
})
|
||||
}
|
||||
return &dto.AdminDatasetImagesRes{List: items}, nil
|
||||
@@ -1466,3 +1470,233 @@ func (s *datasetService) ImageFile(ctx context.Context, datasetId int64, filenam
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ---------- 数据清洗(训练集去冗余;语义与档位见技术设计.md「数据清洗」/consts.CleanBuckets) ----------
|
||||
|
||||
// cleanImageStat 参与统计的单图(labels_json 含 class 0 框:面积最大者定档,其框高为展示主尺寸;
|
||||
// 档内保留取舍看 minHPct——小目标样本稀缺,先保最小确认目标更小的图)
|
||||
type cleanImageStat struct {
|
||||
id int64
|
||||
filename string
|
||||
source string
|
||||
area float64 // 最大 class0 框面积 w*h(找最大框用)
|
||||
hPct float64 // 该框高占图高百分比(定档与主尺寸展示)
|
||||
minHPct float64 // 图内最小 class0 框占比(优先保留依据;单目标图 = hPct)
|
||||
targetCnt int // class0 目标总数
|
||||
bucket int
|
||||
}
|
||||
|
||||
// AdminCleanPreview 数据清洗预览:桶分布 + 超配桶候选清单 + 已排除清单。
|
||||
// 候选生成不随机——超配桶内先按 minHPct 升序扫描(小目标样本稀缺先占保留名额,同尺寸按 id 稳定),
|
||||
// 再整图 dHash 贪心:与已保留图最小汉明距离 > 阈值才保留(连拍帧哈希近距只留首帧),
|
||||
// 留够配额即停,其余进候选;文件缺失/解码失败无法比较,宁可保留。
|
||||
func (s *datasetService) AdminCleanPreview(ctx context.Context, req *dto.AdminDatasetCleanPreviewReq) (*dto.AdminDatasetCleanPreviewRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
quotas := make([]int, len(consts.CleanBuckets))
|
||||
for i, b := range consts.CleanBuckets {
|
||||
quotas[i] = b.Quota
|
||||
}
|
||||
if len(req.Quotas) > 0 {
|
||||
if len(req.Quotas) != len(consts.CleanBuckets) {
|
||||
return nil, gerror.Newf("配额数量须与档数一致(共 %d 档)", len(consts.CleanBuckets))
|
||||
}
|
||||
for i, q := range req.Quotas {
|
||||
if q < 0 {
|
||||
return nil, gerror.New("配额不能为负数")
|
||||
}
|
||||
if i == 0 {
|
||||
continue // <2% 极远档豁免,配额固定不适用
|
||||
}
|
||||
quotas[i] = q
|
||||
}
|
||||
}
|
||||
images, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 贪心扫描按 id 升序(ListByDataset 为文件名数值倒序,须重排保证确定性)
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Id < images[j].Id })
|
||||
res := &dto.AdminDatasetCleanPreviewRes{
|
||||
Buckets: make([]*dto.AdminCleanBucketRes, len(consts.CleanBuckets)),
|
||||
Candidates: []*dto.AdminCleanImage{},
|
||||
ExcludedImages: []*dto.AdminCleanImage{},
|
||||
}
|
||||
for i, b := range consts.CleanBuckets {
|
||||
res.Buckets[i] = &dto.AdminCleanBucketRes{Label: b.Label, Exempt: i == 0, Quota: quotas[i]}
|
||||
}
|
||||
// 第一遍:定档统计(仅含 class 0 的图),已排除清单全量收集(当前无 class 0 标注的也列出,恢复不丢)
|
||||
actives := make([][]*cleanImageStat, len(consts.CleanBuckets)) // 桶内未排除图,保持 id 升序
|
||||
for _, img := range images {
|
||||
if img.CleanExcluded == 1 {
|
||||
res.Excluded++
|
||||
}
|
||||
info := &dto.AdminCleanImage{ImageId: img.Id, Filename: img.Filename, Source: img.Source}
|
||||
var stat = cleanImageStat{id: img.Id, filename: img.Filename, source: img.Source}
|
||||
if img.LabelsJson != "" && img.LabelsJson != "[]" {
|
||||
var boxes []*dto.AdminLabelBox
|
||||
if json.Unmarshal([]byte(img.LabelsJson), &boxes) == nil {
|
||||
for _, b := range boxes {
|
||||
if b.Class != 0 || b.W <= 0 || b.H <= 0 {
|
||||
continue // 只认确认框(class 0);疑似框(class 1)不入尺寸统计
|
||||
}
|
||||
pct := b.H * 100
|
||||
stat.targetCnt++
|
||||
if stat.minHPct == 0 || pct < stat.minHPct {
|
||||
stat.minHPct = pct
|
||||
}
|
||||
if b.W*b.H > stat.area {
|
||||
stat.area = b.W * b.H
|
||||
stat.hPct = pct
|
||||
stat.bucket = cleanBucketIdx(pct)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if stat.area <= 0 { // 无确认目标(未标注/空检出/仅 class 1):不入档
|
||||
if img.CleanExcluded == 1 {
|
||||
res.ExcludedImages = append(res.ExcludedImages, info)
|
||||
}
|
||||
continue
|
||||
}
|
||||
info.BucketLabel = res.Buckets[stat.bucket].Label
|
||||
info.BoxHeightPct = math.Round(stat.hPct*10) / 10
|
||||
bk := res.Buckets[stat.bucket]
|
||||
bk.Total++
|
||||
res.Total++
|
||||
if img.CleanExcluded == 1 {
|
||||
bk.Excluded++
|
||||
res.ExcludedImages = append(res.ExcludedImages, info)
|
||||
} else {
|
||||
actives[stat.bucket] = append(actives[stat.bucket], &stat)
|
||||
}
|
||||
}
|
||||
// 第二遍:超配桶(未排除 > 配额)内 dHash 贪心挑保留,其余进候选
|
||||
imgDir := common.DatasetImagesDir(ctx, dataset.Name)
|
||||
for i, bk := range res.Buckets {
|
||||
if bk.Exempt {
|
||||
continue // 极远档豁免:固定保留,不进候选(over 恒 0)
|
||||
}
|
||||
over := len(actives[i]) - bk.Quota
|
||||
if over <= 0 {
|
||||
continue
|
||||
}
|
||||
bk.Over = over
|
||||
// 小目标样本稀缺:同档内先保最小确认目标更小的图(同尺寸按 id 保持稳定输出)
|
||||
sort.Slice(actives[i], func(a, b int) bool {
|
||||
if actives[i][a].minHPct != actives[i][b].minHPct {
|
||||
return actives[i][a].minHPct < actives[i][b].minHPct
|
||||
}
|
||||
return actives[i][a].id < actives[i][b].id
|
||||
})
|
||||
kept, keptHashes := 0, make([]uint64, 0, bk.Quota)
|
||||
for _, a := range actives[i] {
|
||||
cand := &dto.AdminCleanImage{ImageId: a.id, Filename: a.filename, Source: a.source,
|
||||
BucketLabel: bk.Label, BoxHeightPct: math.Round(a.hPct*10) / 10,
|
||||
MinHeightPct: math.Round(a.minHPct*10) / 10, TargetCount: a.targetCnt}
|
||||
if kept >= bk.Quota { // 留够配额,其余全进候选
|
||||
res.Candidates = append(res.Candidates, cand)
|
||||
continue
|
||||
}
|
||||
h, ok := imageDHashFile(ctx, imgDir, a.filename)
|
||||
if !ok { // 文件缺失/解码失败无法比较,宁可保留
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
if minHamming(h, keptHashes) > consts.CleanHashHamming { // 与已保留图均拉开距离才留
|
||||
keptHashes = append(keptHashes, h)
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
res.Candidates = append(res.Candidates, cand)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// AdminCleanApply 数据清洗执行:批量置/清 clean_excluded(排除不删文件,只影响训练集打包;
|
||||
// 恢复 = Exclude=false 重提该批 id)。imageIds 须全属该数据集。
|
||||
func (s *datasetService) AdminCleanApply(ctx context.Context, req *dto.AdminDatasetCleanApplyReq) (*dto.AdminDatasetCleanApplyRes, error) {
|
||||
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataset == nil {
|
||||
return nil, gerror.NewCode(common.CodeDatasetNotFound)
|
||||
}
|
||||
ids := make([]int64, 0, len(req.ImageIds))
|
||||
seen := make(map[int64]bool, len(req.ImageIds))
|
||||
for _, id := range req.ImageIds {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
images, err := dao.DatasetImage.GetByIds(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(images) != len(ids) {
|
||||
return nil, gerror.New("部分图片不存在,请刷新后重试")
|
||||
}
|
||||
for _, img := range images {
|
||||
if img.DatasetId != dataset.Id {
|
||||
return nil, gerror.Newf("图片 %s 不属于该数据集", img.Filename)
|
||||
}
|
||||
}
|
||||
flag := 0
|
||||
if req.Exclude {
|
||||
flag = 1
|
||||
}
|
||||
if err := common.Serial().Submit(ctx, func() error {
|
||||
return dao.DatasetImage.UpdateCleanExcluded(ctx, ids, flag)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
excluded, err := dao.DatasetImage.CountExcludedByDataset(ctx, dataset.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdminDatasetCleanApplyRes{Applied: len(ids), Excluded: excluded}, nil
|
||||
}
|
||||
|
||||
// imageDHashFile 读图并算整图 64 位 dHash(读失败/解码失败返回 ok=false,调用方宁可保留)
|
||||
func imageDHashFile(ctx context.Context, dir, filename string) (uint64, bool) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, filename))
|
||||
if err != nil {
|
||||
g.Log().Debugf(ctx, "数据清洗读图失败 %s: %+v", filename, err)
|
||||
return 0, false
|
||||
}
|
||||
h, err := common.ImageDHash64(data)
|
||||
if err != nil {
|
||||
g.Log().Debugf(ctx, "数据清洗解码失败 %s: %+v", filename, err)
|
||||
return 0, false
|
||||
}
|
||||
return h, true
|
||||
}
|
||||
|
||||
// minHamming 与已保留哈希集的最小汉明距离(空集 = 无可比对象,返回 65 恒过阈值)
|
||||
func minHamming(h uint64, hashes []uint64) int {
|
||||
min := 65
|
||||
for _, x := range hashes {
|
||||
if d := bits.OnesCount64(h ^ x); d < min {
|
||||
min = d
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
// cleanBucketIdx 框高占比(%)落档(下含上不含;float 边界未命中回退最大档)
|
||||
func cleanBucketIdx(pct float64) int {
|
||||
for i, b := range consts.CleanBuckets {
|
||||
if pct >= b.MinPct && pct < b.MaxPct {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(consts.CleanBuckets) - 1
|
||||
}
|
||||
|
||||
@@ -183,8 +183,12 @@ func (s *labelTaskService) AutoLabel(ctx context.Context, dataset *entity.Datase
|
||||
return err
|
||||
}
|
||||
|
||||
// autoSupplement 自动补标:任务成功完成后,该数据集仍有未标注图(忙时入库的图/人工清空的图)
|
||||
// 则续一轮只标未标注图;失败任务不续(防配置故障时无限重试)。
|
||||
// autoSupplement 自动补标:任务成功完成后,该数据集仍有从未扫描过的图(忙时入库等)则续一轮
|
||||
// 只扫未扫描图;失败任务不续(防配置故障时无限重试)。
|
||||
// 池语义 = labels 为 null/”(见 ListUnlabeledByDataset),已扫过的图(含空检出写 '[]')不入池——
|
||||
// 空检出图若留池中,每轮完成续一轮只会轮到别的空图、轮完又轮回来(2026-09-02 兔子 3 张空检出
|
||||
// 实测 {9}→{10,17}→{9}… 乒乓死循环,RNPHE_202 单张案例侥幸被上一版「排除本轮图」挡住);
|
||||
// 空检出图重扫结果不变,确需重扫由人工触发(AdminStartLabelTask 指定文件名/全量)。
|
||||
func (s *labelTaskService) autoSupplement(ctx context.Context, dataset *entity.Dataset) {
|
||||
unlabeled, err := dao.DatasetImage.ListUnlabeledByDataset(ctx, dataset.Id)
|
||||
if err != nil || len(unlabeled) == 0 {
|
||||
@@ -245,11 +249,11 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
|
||||
cands = append(cands, box)
|
||||
}
|
||||
boxes := suppressOverlap(cands, client.OverlapThreshold)
|
||||
// 生成时已指定目标数量上限:自动标注按置信度裁剪到该数
|
||||
// (生成校验已保证图内目标 ≤N,宁多勿漏阈值带出的低置信噪声框在此去除)
|
||||
if img.AnimalCount > 0 && len(boxes) > img.AnimalCount {
|
||||
// 单图预标上限:去重后仍超 3 个按置信度取前 3(生成图可能实际含多个目标,
|
||||
// 2026-09-02 由「裁剪 ≤ 声明数 animal_count」改固定上限,手动图同限)
|
||||
if len(boxes) > consts.MaxPrelabelPerImage {
|
||||
sort.Slice(boxes, func(a, b int) bool { return boxes[a].Confidence > boxes[b].Confidence })
|
||||
boxes = boxes[:img.AnimalCount]
|
||||
boxes = boxes[:consts.MaxPrelabelPerImage]
|
||||
}
|
||||
results[i] = boxes
|
||||
return nil
|
||||
@@ -286,7 +290,8 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
|
||||
return
|
||||
}
|
||||
_ = dao.LabelTask.Finish(bgCtx, taskId, "")
|
||||
// 成功完成:自动补标该数据集未标注图(忙时入库的图等),失败任务不续
|
||||
// 成功完成:自动补标该数据集从未扫描过的图(忙时入库的图等兜底),失败任务不续;
|
||||
// 空检出 '[]' 图不入池(见 ListUnlabeledByDataset),每张图同链条最多被自动扫一次
|
||||
s.autoSupplement(bgCtx, dataset)
|
||||
}()
|
||||
}
|
||||
@@ -561,6 +566,7 @@ func (s *labelTaskService) AdminLabelSave(ctx context.Context, req *dto.AdminLab
|
||||
|
||||
// prepareYoloSet 训练前组装内存 YOLO 训练集包:已标注图(labels_json 非空)按 80/20 拆 train/val,
|
||||
// 标注 txt 内存生成、原图仅记源路径(由训练通道读取,不落本地暂存盘);无标注报错。
|
||||
// clean_excluded=1 的图跳过(数据清洗排除,见技术设计.md「数据清洗」)。
|
||||
// data.yaml 由训练发起方追加进包(path 需指向训练机)。
|
||||
func (s *labelTaskService) prepareYoloSet(ctx context.Context, dataset *entity.Dataset) (*common.YoloPackage, error) {
|
||||
images, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id)
|
||||
@@ -573,6 +579,9 @@ func (s *labelTaskService) prepareYoloSet(ctx context.Context, dataset *entity.D
|
||||
}
|
||||
var kept []item
|
||||
for _, img := range images {
|
||||
if img.CleanExcluded == 1 {
|
||||
continue // 数据清洗排除图不进训练集(图片/标注保留,可恢复)
|
||||
}
|
||||
if img.LabelsJson == "" || img.LabelsJson == "[]" {
|
||||
continue // 空标注图剔除
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/jpeg" // 注册解码器(dHash 全量解码用)
|
||||
_ "image/png"
|
||||
)
|
||||
|
||||
// ImageDHash64 整图 64 位 dHash(9x8 灰度采样,与 ffmpeg scale=9:8 同语义)。
|
||||
// 两图相似度 = 哈希异或的位 1 数(汉明距离),≤ CleanHashHamming 阈值视为近重复
|
||||
// (数据清洗桶内多样性保留用,见 consts.CleanBuckets)。
|
||||
func ImageDHash64(data []byte) (uint64, error) {
|
||||
src, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return hashOf(src)
|
||||
}
|
||||
|
||||
// hashOf 图像 dHash:采样 9 列 x 8 行(全域拉伸),逐行比较相邻列明暗得 64 位
|
||||
func hashOf(src image.Image) (uint64, error) {
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w < 1 || h < 1 {
|
||||
return 0, image.ErrFormat
|
||||
}
|
||||
var gray color.Gray
|
||||
shade := func(x, y int) uint32 {
|
||||
gray = color.GrayModel.Convert(src.At(b.Min.X+x, b.Min.Y+y)).(color.Gray)
|
||||
return uint32(gray.Y)
|
||||
}
|
||||
var h64 uint64
|
||||
for r := 0; r < 8; r++ {
|
||||
y := (h - 1) * r / 7
|
||||
for c := 0; c < 8; c++ {
|
||||
xl := (w - 1) * c / 8
|
||||
xr := (w - 1) * (c + 1) / 8
|
||||
h64 <<= 1
|
||||
if shade(xl, y) < shade(xr, y) {
|
||||
h64 |= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return h64, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math/bits"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func noiseImg(seed int64) *image.Gray {
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
img := image.NewGray(image.Rect(0, 0, 160, 120))
|
||||
for i := range img.Pix {
|
||||
img.Pix[i] = uint8(rng.Intn(256))
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func hamming(a, b uint64) int {
|
||||
return bits.OnesCount64(a ^ b)
|
||||
}
|
||||
|
||||
// TestImageDHash64:同图哈希恒等,异噪声图哈希距离应远超相似阈值(8)
|
||||
func TestImageDHash64(t *testing.T) {
|
||||
h1, err := hashOf(noiseImg(1))
|
||||
if err != nil {
|
||||
t.Fatalf("hashOf noise: %v", err)
|
||||
}
|
||||
h2, err := hashOf(noiseImg(1))
|
||||
if err != nil {
|
||||
t.Fatalf("hashOf same noise: %v", err)
|
||||
}
|
||||
if h1 != h2 {
|
||||
t.Fatalf("same image hash mismatch: %d vs %d", h1, h2)
|
||||
}
|
||||
h3, err := hashOf(noiseImg(2))
|
||||
if err != nil {
|
||||
t.Fatalf("hashOf other noise: %v", err)
|
||||
}
|
||||
if d := hamming(h1, h3); d <= 8 { // 64 位随机噪声期望距离 ~32
|
||||
t.Fatalf("unrelated images too close: hamming=%d", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageDHash64Uniform:纯色图哈希为 0(全等帧),与另一纯色图判为同图属预期
|
||||
func TestImageDHash64Uniform(t *testing.T) {
|
||||
u := image.NewGray(image.Rect(0, 0, 64, 64))
|
||||
for i := range u.Pix {
|
||||
u.Pix[i] = 128
|
||||
}
|
||||
h, err := hashOf(u)
|
||||
if err != nil {
|
||||
t.Fatalf("hashOf uniform: %v", err)
|
||||
}
|
||||
if h != 0 {
|
||||
t.Fatalf("uniform image hash = %d, want 0", h)
|
||||
}
|
||||
}
|
||||
|
||||
// 灰度渐变图(列渐变:左暗右亮)
|
||||
func gradientImg() *image.Gray {
|
||||
img := image.NewGray(image.Rect(0, 0, 90, 80))
|
||||
for y := 0; y < 80; y++ {
|
||||
for x := 0; x < 90; x++ {
|
||||
img.SetGray(x, y, color.Gray{Y: uint8(x * 255 / 89)})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// TestImageDHash64Gradient:行渐变每对相邻列左<右,64 位全 1
|
||||
func TestImageDHash64Gradient(t *testing.T) {
|
||||
h, err := hashOf(gradientImg())
|
||||
if err != nil {
|
||||
t.Fatalf("hashOf gradient: %v", err)
|
||||
}
|
||||
if bits.OnesCount64(h) != 64 {
|
||||
t.Fatalf("gradient hash bits = %d, want 64 (all 1s)", bits.OnesCount64(h))
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
|
||||
// LocalAi RF-DETR 检测服务客户端(local-ai 兼容 /v1/detection 协议):
|
||||
// POST {baseUrl}/v1/detection,body {"model","image":"data:image/jpeg;base64,...","threshold"},
|
||||
// 响应 {"detections":[{"x","y","width","height","confidence","class_name"}]},坐标单位 = 提交图片像素。
|
||||
// 响应 {"detections":[{"x","y","width","height","confidence","class_name"}]},
|
||||
// 坐标单位 = 提交图片像素,且 x/y 为框中心坐标(RF-DETR 输出约定,2026-09-02 实验确认)。
|
||||
type LocalAi struct {
|
||||
BaseUrl string
|
||||
Model string
|
||||
@@ -33,7 +34,7 @@ type LocalAi struct {
|
||||
InputSize int
|
||||
}
|
||||
|
||||
// Detection RF-DETR 单目标检测结果(提交图比例尺下的像素坐标)
|
||||
// Detection RF-DETR 单目标检测结果;Detect 返回前已映射为原图像素左上角(X/Y)+ 宽高
|
||||
type Detection struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
@@ -52,8 +53,8 @@ func LocalAiClient(ctx context.Context) *LocalAi {
|
||||
return &LocalAi{
|
||||
BaseUrl: strings.TrimRight(base, "/"),
|
||||
Model: g.Cfg().MustGet(ctx, "localAi.model", "rfdetr-xlarge").String(),
|
||||
Threshold: g.Cfg().MustGet(ctx, "localAi.threshold", 0.08).Float64(),
|
||||
ConfConfirmed: g.Cfg().MustGet(ctx, "localAi.confConfirmed", 0.2).Float64(),
|
||||
Threshold: g.Cfg().MustGet(ctx, "localAi.threshold", 0.3).Float64(),
|
||||
ConfConfirmed: g.Cfg().MustGet(ctx, "localAi.confConfirmed", 0.5).Float64(),
|
||||
OverlapThreshold: g.Cfg().MustGet(ctx, "localAi.overlapThreshold", 0.3).Float64(),
|
||||
InputSize: g.Cfg().MustGet(ctx, "localAi.inputSize", 700).Int(),
|
||||
}
|
||||
@@ -153,8 +154,12 @@ func (c *LocalAi) Detect(ctx context.Context, data []byte, mime string, imgW, im
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 坐标从提交图比例尺映射回原图
|
||||
// 响应 x/y 是框中心:先转左上角,再从提交图比例尺映射回原图像素。
|
||||
// 不做此转换会把中心当左上角,下游再加 w/2 时整框偏移半宽半高
|
||||
// (实测 RNPHE_133: 响应(403,664) 即目标中心,旧逻辑落库 cx 偏 +w/2)。
|
||||
for _, d := range out.Detections {
|
||||
d.X -= d.Width / 2
|
||||
d.Y -= d.Height / 2
|
||||
d.X /= scale
|
||||
d.Y /= scale
|
||||
d.Width /= scale
|
||||
|
||||
+2
-2
@@ -85,8 +85,8 @@ localAi:
|
||||
baseUrl: "http://192.168.3.210:18080" # 如 http://127.0.0.1:18080(local-ai 兼容 /v1/detection)
|
||||
model: rfdetr-xlarge
|
||||
vlmModel: qwen3-vl-8b # VLM 藏匿位补检模型(Qwen3-VL-8B 多模态 GGUF+mmproj,2026-09-02 起与 z-image/rfdetr 三模型共存)
|
||||
threshold: 0.08 # 候选置信度阈值(宁多勿漏)
|
||||
confConfirmed: 0.2 # 高于此视为确认(class 0),否则疑似(class 1)
|
||||
threshold: 0.2 # 候选置信度下限:0.2 以下直接丢弃(不入库);0.08 实测过低——COCO 通用 rfdetr 对类鸟纹理在 0.08~0.2 区间输出大量噪声候选
|
||||
confConfirmed: 0.5 # 置信度 ≥ 0.5 视为确认(class 0 真目标);0.2~0.49 落疑似(class 1)待人工复核(2026-09-02 RNPHE_257 实测调定:误检可达 0.46~0.5 且与真目标仅差 ~0.1,误检落 class 0 会污染训练正样本)
|
||||
overlapThreshold: 0.3 # 重叠去重阈值(交叠/较小框面积,NMS 风格;RF-DETR 同目标常输出一大一小两框,minIoU 比 IoU 更能命中)
|
||||
inputSize: 700 # 提交前整图等比缩放最长边;RF-DETR 模型包固定 700x700 输入(stretch 拉伸、不支持动态尺寸,见模型包 inference_config.json),提交更大最终也拉伸到 700 无增益,2026-09-01 实测回退
|
||||
# 标注类别名(写入 data.yaml,随 result.json 存模型 labels)从数据集表取:
|
||||
|
||||
Binary file not shown.
+16
-6
@@ -333,7 +333,7 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不
|
||||
- **生成异步化(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) 以「图片+物种(数据集单物种 gen_species 注入;2026-09-02 prompt 不再存储,原「prompt 含物种名才注入」匹配逻辑删除)+已确认框坐标(排除)」推理藏匿位,输出归一化 bbox JSON(范围校验+与已有框重叠去重),追加 class=1 疑似框进同一 labels_json(**2026-09-02 上限动态化**:单图疑似框总数 ≤ `consts.VlmMaxSuspectPerImage`(3),每次可追加数 = 3 − 图内已有疑似框数,≤0 直接跳过模型调用;提示词「最多 N 个」与落库硬截断均按此数);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(范围校验+与已有框重叠去重),追加 class=1 疑似框进同一 labels_json(**2026-09-02 上限动态化**:单图疑似框总数 ≤ `consts.VlmMaxSuspectPerImage`(上限定义于 consts.go),每次可追加数 = 上限 − 图内已有疑似框数,≤0 直接跳过模型调用;提示词「最多 N 个」与落库硬截断均按此数);VL 与 z-image 显存互斥(12G 装不下两者),批量补检在生成队列空闲后执行;qwen3.6-35b-a3b yaml 自带 mmproj 实为多模态模型(生成校验);**预标单图上限(2026-09-02:曾按置信度降序裁 ≤ animal_count 列值;生成声明 1 个目标不代表图内只有 1 个,改去重后按置信度取前 `consts.MaxPrelabelPerImage` 个,与补标上限同量级;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)
|
||||
|
||||
@@ -369,6 +369,7 @@ training:
|
||||
|
||||
- **模型与分辨率升级(2026-09-01,用户定案)**:基座 `yolov8n.pt` → `yolov8s.pt`、`imgsz` 704 → **1280**——场景为远距离小目标 + 遮挡多,nano 容量不足且 704 输入下小目标仅剩 5~15 像素;s@1280 是端侧延迟可接受内的最优效果(中端机 70-120ms/帧,预览不卡、框 8-14fps 更新;高端机 30-45ms 流畅)。改动链路:`config.yml`(training.model/imgsz)→ task.json 传 model → `train_server.py` 读 task model → 导出 litert 输入 NCHW [1,3,1280,1280] → App 端 `TfliteDetector` 输入尺寸从模型形状动态读取(`defaultInputSize` 兜底 1280)。相机侧无需改:Android 分析帧固定 16:9 ≤1920(典型 1920x1080)、拍照原图 4000px+,letterbox 后均满足 1280。显存评估:12G 跑 s@1280 batch 8~16 可行(yolov8s 11.2M 参数)。端侧量化保持 fp16(int8 对 <10px 小目标掉点明显)。
|
||||
- **预标注模型固定 700x700(2026-09-01 实测确认)**:local-ai 的 rfdetr-xlarge 模型包 `inference_config.json` 声明固定输入 700x700(`training_input_size` 700、`dynamic_spatial_size_supported:false`、`resize_mode:stretch`),提交任何分辨率最终都拉伸到 700x700 推理——`localAi.inputSize` 只是提交前等比缩放,改大无增益(曾误改 1280,实测后回退 700)。**标注框精度天花板 = 700 分辨率**:极小目标(<5px)框不准是标注侧固有噪声;训练 imgsz=1280 与标注 700 并存可行(框坐标按比例映射到 1280 图),突破标注精度需换支持高分辨率输入的检测模型(RT-DETR / YOLO 动态输入 ONNX),未实施。
|
||||
- **/v1/detection 响应 x/y = 框中心坐标(2026-09-02 实验确认并修复)**:local-ai 对 rfdetr-xlarge 原样透出模型输出约定——响应 `{"x","y","width","height"}` 中 **x/y 是中心(cx,cy)而非左上角**,width/height 为宽高。旧代码按左上角处理、下游归一化时再加 w/2,整框偏移 (+w/2,+h/2)(实测复现:RNPHE_133 目标中心 (406,661)px,接口返 (403,664) ≈ 中心;canvas 已知位置贴图实验同——2026-09-02 曾误判「700x700 stretch 导致偏移」,实验否证:任意提交尺寸返回归一化坐标不变,纯为格式误读)。**修复**:`common/localai.go Detect` 收到响应先 `x -= w/2; y -= h/2` 转左上角,再按 scale 映射回原图像素——框尺寸不受影响(w/h 本身正确,用户报告「w/h 相同仅位置偏」即为该特征);已在库的错误框(如 RNPHE_133 手动修正后入库的 0.577/0.530 等)重跑预标即可覆盖修正。
|
||||
|
||||
- **训练参数默认走配置(2026-08-26)**:imgsz/epochs/batch/device 不随管理端请求传(界面一键开始),由 `training` 节点统一配置——device 取决于训练机硬件、imgsz 必须与端侧推理对齐、epochs 取决于算力预期,均为部署级参数;任务记录仍存各值(`model_training.imgsz/epochs/batch/device`)供列表展示
|
||||
|
||||
@@ -383,7 +384,7 @@ training:
|
||||
|
||||
### 模型版本(每数据集一个模型,多模型体系)
|
||||
|
||||
**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注。**合并去重(2026-09-01 用户实测修订)**:单模型 NMS 与跨模型合并统一按 **minIoU**(交叠/较小框面积,阈值 0.45)**全局去重**(不分标签)——实测多模型会对同一目标检出不同类别、单模型会输出一大一小两框(标准 IoU=小/大 会漏判),重叠一律取高分框;与 server 标注端 `localAi.overlapThreshold`(0.3,minIoU 风格)同思路。远处真实多目标互不重叠,正常保留。
|
||||
**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并**——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注。**合并去重(2026-09-01 用户实测修订)**:单模型 NMS 与跨模型合并统一按 **minIoU**(交叠/较小框面积,阈值 0.45)**全局去重**(不分标签)——实测多模型会对同一目标检出不同类别、单模型会输出一大一小两框(标准 IoU=小/大 会漏判),重叠一律取高分框;与 server 标注端 `localAi.overlapThreshold`(minIoU 风格,取值以 config.yml 为准)同思路。远处真实多目标互不重叠,正常保留。
|
||||
|
||||
- 版本号规则:`m<major>.<minor>.<patch>`,**同一数据集内**每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);`UNIQUE(dataset_id, version)` 防重复
|
||||
- 文件布局(2026-08-27 重构;命名 2026-08-28 改):`workspace/trainings/<文件名前缀>.tflite` 即当前生效模型唯一位(前缀空回退数据集名——存量数据集无前缀;改名/改前缀时模型文件随命名迁移)——训练成功时从训练机直写(原子覆盖),客户端固定下载该文件;**无 `<version>.tflite` 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚)**;每数据集一个文件互不影响
|
||||
@@ -394,13 +395,22 @@ 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` 接口保留(服务端 `filenames` 参数支持指定图批量重标,2026-09-02 前端入口收敛后仍可全量——不传 filenames 即全量扫描)
|
||||
- `localAi` 配置节点:键与取值以 config.yml 为准,文档不复制具体数值(曾多次调整阈值致双源漂移,2026-09-02 起删除此类描述)——**部署前提:RF-DETR 服务须从 Go 服务器可达**(现跑在 Mac 上,部署时搬服务器/训练机;**未配置时图片上传/生成接口直接报错**——标注是强语义,不允许产生无标注图片;配置调不通则任务 failed,页顶错误条展示,不自动重试);**端点为配置唯一来源,无运行时覆盖**
|
||||
- **预标注自动触发(2026-08-26 决策,无手动按钮)**:手动上传/AI 生成图片入库成功后,自动对**本次新增图**发起标注;**`localAi` 未配置 → 上传/生成接口直接报错**(同步检查,生成场景在第一张生成前检查避免付费资产生成后标不了);**已有 running 标注任务(忙)→ 不报错**:上传/生成照常成功,新图由「任务成功完成后自动补标未标注图」机制兜底(每轮任务成功结束时检查该数据集**从未扫描过**的图(labels 为 null/''),有则自动续一轮——失败任务不续,防配置坏时无限重试;**2026-09-02 空检出图不入自动补标池**:检出为空的图写 `labels_json='[]'` 后仍被视作未标注(管理端未标注 tab 照常展示等人工画框),但不入自动池——空检出图若留池,每轮自动续一轮只会扫到别的空图、扫完又轮回来:RNPHE_202(阈值调高后 0 检出,低阈下 12 个候选全为噪声,无一过丢弃线)单张被上一版「排除本轮已扫图」恰好挡住未暴露,2026-09-02 兔子批量 3 张空检出实测 {9}→{10,17}→{9}… 无限交替、每轮白打一次 RF-DETR 方暴露;现自动链条只扫从未扫描的图,每张图至多被自动扫一次,空检出图确需重扫由人工触发(按文件名批量/全量任务,见下));service 逐张调 local-ai 推理(common 池并行,`label_task` 记录 total/done 进度)→ 扫描结果(YOLO 归一化 xywh + 置信度 + 建议类别)**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**;**置信度分档(2026-09-02 起按两档语义调定,具体数值以 config.yml 为准不在此复制)**:conf ≥ confConfirmed 落 class 0 真目标,threshold ≤ conf < confConfirmed 落 class 1 疑似待人工复核,低于 threshold 不入库;背景(RNPHE_257 实测):COCO 通用 rfdetr 对类鸟纹理误检置信度可达 0.46~0.5 且与真目标仅差 ~0.1(真目标 0.562、两误检 0.477/0.462 曾全落 class 0),低置信段是纯噪声候选(该图 21 个原始检出中 17 个落此区间);单阈值无法分离真/假目标(类别名也无用——全部输出 "bird");误检落 class 0 会作为雉鸡正样本直接污染训练集,危害大于真目标降级疑似(后者人工一键确认即可);**重叠去重(2026-08-26 修订)**:NMS 风格、按置信度降序依次保留,与已保留框**重叠比(minIoU = 交叠面积/两框较小面积)> `localAi.overlapThreshold`**的框剔除(同目标被重复检出只留置信度最高者,跨 class 去重;人工画框不参与去重)——**用 minIoU 而非 IoU**:RF-DETR 对同一目标常输出一大一小两个框(标准 IoU 仅 0.3~0.5 会漏杀,实测红黄重叠即此形态),大框套小框时小框被覆盖比例高,minIoU 命中;相邻目标两框互有外露,minIoU 通常 < 0.3;**全图扫描**(项目既定规则:不套用生成规格的位置裁剪,候选宁多勿漏);**单图框数上限(2026-09-02,定义于 consts.go `consts.MaxPrelabelPerImage`)**:去重后仍超上限按置信度取前 N(曾按生成声明数 animal_count 裁剪致漏目标——声明 1 个不代表图内只有 1 个;手动上传图同限);不设候选确认两阶段(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,与工作台同一绘制逻辑)
|
||||
- **详情页布局(2026-09-02 卡片化改版后快照)**:图片以缩略图卡片网格展示(每页 18 张,点卡片进弹窗编辑器);页顶标注/AI 生成任务进度条;**图片列表三 tab(2026-09-02)**:「未标注」(默认,待处理优先)、「已标注」、**「已清洗」**各带计数——已标注判定 = labels_json 非空(空检出 `[]` 的图仍在未标注,等人工画框);**已清洗 tab = clean_excluded=1 的图(数据清洗执行后从已标注 tab 移入,卡片角标同 tab 语义二选一),内提供逐张/批量恢复(= 清除标记,回「已标注」)**,恢复仅动作无删除;「数据清洗」按钮在已标注 tab 工具条(分析/执行入口,见下节);切 tab/翻页均清空勾选(防跨 tab 误标),编辑器「上一张/下一张」仅在当前 tab 列表内导航(连续标注不串到另一分类);**卡片上不再叠「已标注/未标注」角标**(2026-09-02,分类由 tab 表达),仅保留 AI 来源角标(付费资产提示)
|
||||
- **弹窗标注**:点击原图/标注图打开放大弹窗(大 canvas,1152x2048 原尺寸),支持两点画框/点框删除/清空/类别切换(确认 0/疑似 1)/上下一张/保存——**AI 自动标注与人工框同层可编辑**(含清理 AI 框);保存即整体覆写 `dataset_image.labels_json`(JSON 数组,YOLO 归一化 xywh+类别+置信度,空=清空标注);未保存修改切换图片有确认
|
||||
- 无历史标注任务时详情页工作台数据源为 `GET /admin/label-workbench?dataset_id=`(图片 + 全量标注框),避免依赖人工先跑一次任务
|
||||
- 训练前自动整理(prepare_yolo 逻辑服务端,**内存组装不落本地盘**):数据集有 labels → 按 80/20 拆 train/val 生成 YOLO 训练集包(标注 txt 内存生成、原图只记数据集目录源路径)→ 随同步推训练机(subprocess 直写 `workdir/datasetDir/yolo/<name>/`;ssh 走 **tar 流式管道**本地不落盘);**自动标注图直接进训练集**(人工修改/清理后即为训练数据);本地无暂存目录(旧 `workspace/datasets/yolo/` 与 `labels/` 已删除)
|
||||
- 训练前自动整理(prepare_yolo 逻辑服务端,**内存组装不落本地盘**):数据集有 labels → 按 80/20 拆 train/val 生成 YOLO 训练集包(标注 txt 内存生成、原图只记数据集目录源路径)→ 随同步推训练机(subprocess 直写 `workdir/datasetDir/yolo/<name>/`;ssh 走 **tar 流式管道**本地不落盘);**自动标注图直接进训练集**(人工修改/清理后即为训练数据),**2026-09-02 起跳过 `clean_excluded=1` 的图(数据清洗排除,见下)**;本地无暂存目录(旧 `workspace/datasets/yolo/` 与 `labels/` 已删除)
|
||||
|
||||
### 数据清洗(训练集去冗余,2026-09-02)
|
||||
|
||||
- **语义(区别于视觉判重)**:不是删「长得像」的图,而是按标注算目标尺寸、按**尺寸档配额**去重——易档(中近距离)堆量、难档(<2% 极远)稀缺是当前数据集冗余的真实形态(2026-09-02 实测:dHash 整图/目标裁剪双重判定,生成图逐张随机 scene/action 几乎无近重复,严格阈值可去近 0 张;而 环颈雉 280 张实拍中 4-8% 档 109 张占 39%——同场地连拍帧为主,删冗余几乎无损)
|
||||
- **目标尺寸口径**:`labels_json` 归一化框高 h 即目标占图高比例(**不需解码图片**);多框取最大 class 0 框定档(候选行展示的主尺寸);**档内保留取舍参考图内最小确认目标**(2026-09-02:多目标图里的小目标样本比主目标稀缺,同档冗余取舍先保它们);像素高 ≈ h×1280(训练尺度,等比近似);**仅 class 1 疑似图与空检出 `[]` 图不入清单**(无确认目标,无从定尺寸,等人工确认后自然进入统计)
|
||||
- **档位**:按图高占比细档 <2% / 2-3 / 3-4 / 4-6 / 6-8 / 8-12 / 12-20 / >20%——宽档(如 4-8%)跨 2 倍尺度,直接按宽档配额会把半档删光,须细档分别设配额;**<2% 极远档固定豁免**(recall 瓶颈档,只缺不多);各档配额请求可传,缺省用代码默认(consts.go)
|
||||
- **桶内多样性保留**:超配桶内保留谁不随机——**先按图内最小确认目标(class 0)尺寸升序扫描**(小目标样本稀缺,同档图里带更小目标的先占保留名额;同最小尺寸按 id 保持稳定),整图 dHash(common 层,9x8 灰度 64 位)与已保留图最小汉明距离大于阈值才留,**留够配额即停,其余全进候选清单**(连拍帧天然相邻近哈希,只留首帧;不同场景同尺寸的目标尽量都保);候选行展示主尺寸 + 最小目标占比 + 目标数,供人工判断取舍
|
||||
- **执行语义(排除而非删除)**:候选清单人工过目后打 `dataset_image.clean_excluded=1`(EnsureColumn 迁移)——**只影响训练集打包**(prepare_yolo 跳过),图片/标注不动、列表/标注/预标全流程照常可见;**可恢复**(清除标记即回训练集,付费资产原则:清洗默认不删文件,物理删除仍走人工逐删)
|
||||
- 接口:`POST /admin/datasets/clean/preview`(datasetId + 可选 quotas → 各档分布/配额/超配候选清单 + 已排除列表);`POST /admin/datasets/clean/apply`(imageIds + exclude bool 批量置/清标记);**管理端详情页承载:「数据清洗」按钮在已标注 tab 工具条**(配额表可编辑 + 候选清单默认全选 → 执行排除),**已排除的图集中到「已清洗」tab 展示,逐张/批量恢复(apply exclude=false)**,图片列表接口返回 cleanExcluded 供前端归类
|
||||
|
||||
### 模型热更新与多模型推理(客户端 Flutter)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Minus, MagicStick, Search } from '@element-plus/icons-vue'
|
||||
@@ -12,14 +12,34 @@ const dataset = ref(null)
|
||||
|
||||
// ---------- 数据 ----------
|
||||
const detail = ref(null) // 工作台数据:images[{filename,url,width,height,boxes,labeled}]
|
||||
const imageMeta = ref({}) // filename -> {id, source}(来自 /datasets/images,行内展示用)
|
||||
const imageMeta = ref({}) // filename -> {id, source, cleanExcluded}(来自 /datasets/images,行内展示用)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 18
|
||||
// 列表 tab:未标注(默认,待处理优先) | 已标注 | 已清洗(clean_excluded=1,数据清洗排除,可恢复)——
|
||||
// 卡片上不再叠「已标注/未标注」角标,分类由 tab 表达(2026-09-02)
|
||||
const listTab = ref('unlabeled')
|
||||
const tabCounts = computed(() => {
|
||||
const imgs = detail.value?.images || []
|
||||
const cleaned = imgs.filter((it) => meta(it).cleanExcluded).length
|
||||
const labeled = imgs.filter((it) => it.labeled && !meta(it).cleanExcluded).length
|
||||
return { labeled, cleaned, unlabeled: imgs.length - labeled - cleaned }
|
||||
})
|
||||
const visibleImages = computed(() => {
|
||||
const imgs = detail.value?.images || []
|
||||
return imgs.filter((it) => {
|
||||
if (meta(it).cleanExcluded) return listTab.value === 'cleaned'
|
||||
return (listTab.value === 'labeled') === !!it.labeled
|
||||
})
|
||||
})
|
||||
const pagedImages = computed(() => {
|
||||
if (!detail.value || !detail.value.images) return []
|
||||
const start = (page.value - 1) * pageSize
|
||||
return detail.value.images.slice(start, start + pageSize).map((item, i) => ({ item, gi: start + i }))
|
||||
return visibleImages.value.slice(start, start + pageSize).map((item, i) => ({ item, gi: start + i }))
|
||||
})
|
||||
const emptyTip = computed(() => {
|
||||
if (!tabCounts.unlabeled && !tabCounts.labeled && !tabCounts.cleaned) return '暂无图片,请上传或 AI 生成'
|
||||
if (listTab.value === 'cleaned') return '暂无已清洗图片(数据清洗排除的图在此,可勾选恢复)'
|
||||
return listTab.value === 'unlabeled' ? '暂无未标注图片' : '暂无已标注图片'
|
||||
})
|
||||
|
||||
function meta(item) {
|
||||
@@ -45,9 +65,18 @@ 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 }
|
||||
for (const i of imgs?.list || []) imageMeta.value[i.filename] = { id: i.id, source: i.source, cleanExcluded: i.cleanExcluded || 0 }
|
||||
// 记录刷新前编辑中的图:刷新可能使其移出当前 tab(预标完成/删除/人工保存等),
|
||||
// 原 editIndex 会漂移到邻图,继续编辑保存会把框存错图——按 filename 重定位,找不到即关弹窗
|
||||
const editFn = editorVisible.value && currentImage.value ? currentImage.value.filename : null
|
||||
detail.value = wb || { images: [] }
|
||||
if (editIndex.value >= (detail.value.images || []).length) editIndex.value = 0
|
||||
if (editFn) {
|
||||
const idx = visibleImages.value.findIndex((it) => it.filename === editFn)
|
||||
if (idx >= 0) editIndex.value = idx
|
||||
else editorVisible.value = false
|
||||
} else if (editIndex.value >= visibleImages.value.length) {
|
||||
editIndex.value = 0
|
||||
}
|
||||
// 刷新时恢复进行中的自动标注任务
|
||||
const tasks = await request.get('/label-tasks', { params: { page: 1, size: 100 } }).catch(() => null)
|
||||
const running = (tasks?.list || []).find((t) => t.datasetId === datasetId.value && t.status === 'running')
|
||||
@@ -60,6 +89,9 @@ async function loadAll() {
|
||||
} else if (gt && gt.error) {
|
||||
genTask.value = gt
|
||||
}
|
||||
} catch (e) {
|
||||
// 任一请求失败会中断本轮刷新(列表/计数停留旧值),明示原因避免误以为操作未生效
|
||||
ElMessage.error(`刷新失败:${e?.message || e}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -168,6 +200,22 @@ function stopLabelPoll() {
|
||||
// 勾选标注:重扫勾选图片,覆盖各图已有标注(含人工修改/清理的框)。
|
||||
// 标注入口仅此一处(2026-09-02 决策):无单图/全量按钮,勾选后点顶栏「标注 N 张」。
|
||||
const selected = ref([])
|
||||
// 翻页/切 tab 即清空勾选:预标/补标按选中集执行,跨页/跨 tab 残留会导致误标其他分类图片(2026-09-02)
|
||||
watch(page, () => {
|
||||
selected.value = []
|
||||
})
|
||||
watch(listTab, () => {
|
||||
page.value = 1
|
||||
selected.value = []
|
||||
})
|
||||
// 当前 tab 列表缩水(删除/标注状态变更后重载)导致页码越界时回钳
|
||||
watch(
|
||||
() => visibleImages.value.length,
|
||||
() => {
|
||||
const max = Math.max(1, Math.ceil(visibleImages.value.length / pageSize))
|
||||
if (page.value > max) page.value = max
|
||||
},
|
||||
)
|
||||
// 单选框:受控模式(el-checkbox 单独用 v-model 绑数组会在勾选时把数组替换成布尔值,故手动维护)
|
||||
function toggleOne(filename, checked) {
|
||||
const s = selected.value
|
||||
@@ -204,6 +252,7 @@ function startLabelTask() {
|
||||
.then(() => request.post('/label-tasks', { datasetId: datasetId.value, filenames: selected.value }))
|
||||
.then((data) => {
|
||||
ElMessage.success('已发起预标,进度见页顶进度条')
|
||||
selected.value = [] // 任务异步跑完才迁移分类,勾选使命已结束,立即清空防「幽灵勾选」残留计数
|
||||
pollLabelTask(data.id)
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -242,6 +291,7 @@ async function vlmBatchReview() {
|
||||
ElMessage.info(note || 'VLM 未发现可能的藏匿位')
|
||||
}
|
||||
await loadAll()
|
||||
selected.value = [] // 补标完成即清勾选:这批已扫描过,避免按钮残留计数重复补检
|
||||
// 标注弹窗开着时刷新当前图的标注
|
||||
if (editorVisible.value) nextTick(resetEditor)
|
||||
} catch (e) {
|
||||
@@ -273,10 +323,125 @@ function deleteSelected() {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 恢复勾选的已清洗图片(清 clean_excluded 标记,回训练集;「已清洗」tab 专用动作)
|
||||
function restoreSelected() {
|
||||
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
|
||||
ElMessageBox.confirm(`确定恢复选中的 ${ids.length} 张图片?恢复后重新计入训练集打包(图片/标注不动)。`, '恢复图片', {
|
||||
confirmButtonText: '恢复',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => request.post('/datasets/clean/apply', { datasetId: datasetId.value, imageIds: ids, exclude: false }))
|
||||
.then((d) => {
|
||||
ElMessage.success(`已恢复 ${d.applied} 张`)
|
||||
selected.value = []
|
||||
loadAll()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// ---------- 数据清洗(训练集去冗余:按目标尺寸档配额 + 桶内 dHash 多样性保留出候选,
|
||||
// 排除仅打标记不删文件,被排除图集中在「已清洗」tab,见技术设计.md「数据清洗」) ----------
|
||||
const cleanVisible = ref(false)
|
||||
const cleanLoading = ref(false)
|
||||
const cleanApplying = ref(false)
|
||||
const cleanBuckets = ref([]) // 档分布[{label,exempt,total,excluded,quota,over}]
|
||||
const cleanCand = ref([]) // 超配桶候选清单
|
||||
const cleanTotal = ref(0)
|
||||
const cleanExcludedCount = ref(0)
|
||||
const cleanQuotas = ref([]) // 配额输入(与档位一一对应;豁免档禁用)
|
||||
const quotaTouched = ref(false)
|
||||
const cleanSel = ref([]) // 勾选的候选 imageId(默认全选)
|
||||
const candAllChecked = computed(
|
||||
() => cleanCand.value.length > 0 && cleanCand.value.every((c) => cleanSel.value.includes(c.imageId)),
|
||||
)
|
||||
|
||||
async function loadCleanPreview() {
|
||||
cleanLoading.value = true
|
||||
try {
|
||||
const body =
|
||||
cleanQuotas.value.length === 8 ? { datasetId: datasetId.value, quotas: cleanQuotas.value } : { datasetId: datasetId.value }
|
||||
const d = await request.post('/datasets/clean/preview', body)
|
||||
cleanBuckets.value = d.buckets || []
|
||||
cleanCand.value = d.candidates || []
|
||||
cleanTotal.value = d.total || 0
|
||||
cleanExcludedCount.value = d.excluded || 0
|
||||
// 首次打开(用户未动过配额)用后端默认回填输入框
|
||||
if (!quotaTouched.value && cleanQuotas.value.length !== 8) {
|
||||
cleanQuotas.value = cleanBuckets.value.map((b) => b.quota)
|
||||
}
|
||||
cleanSel.value = cleanCand.value.map((c) => c.imageId) // 候选默认全选
|
||||
} finally {
|
||||
cleanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 配额改动防抖重算候选(输入数字即实时预览)
|
||||
let cleanTimer = null
|
||||
function onQuotaChange() {
|
||||
quotaTouched.value = true
|
||||
if (cleanTimer) clearTimeout(cleanTimer)
|
||||
cleanTimer = setTimeout(loadCleanPreview, 350)
|
||||
}
|
||||
|
||||
function openClean() {
|
||||
cleanVisible.value = true
|
||||
cleanQuotas.value = []
|
||||
quotaTouched.value = false
|
||||
loadCleanPreview()
|
||||
}
|
||||
|
||||
function toggleCleanAll() {
|
||||
if (candAllChecked.value) cleanSel.value = []
|
||||
else cleanSel.value = cleanCand.value.map((c) => c.imageId)
|
||||
}
|
||||
|
||||
// 候选单选框(受控模式同 toggleOne)
|
||||
function toggleCleanOne(id, checked) {
|
||||
const s = cleanSel.value
|
||||
if (checked) {
|
||||
if (!s.includes(id)) s.push(id)
|
||||
} else {
|
||||
const i = s.indexOf(id)
|
||||
if (i >= 0) s.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanThumbUrl(c) {
|
||||
return imgUrl(`/api/v1/admin/datasets/image?datasetId=${datasetId.value}&filename=${encodeURIComponent(c.filename)}`)
|
||||
}
|
||||
|
||||
function applyClean() {
|
||||
if (!cleanSel.value.length) return
|
||||
ElMessageBox.confirm(
|
||||
`将排除选中的 ${cleanSel.value.length} 张图片(仅移出训练集打包,不删文件不删标注;被排除图在「已清洗」tab,可随时恢复)。确定排除?`,
|
||||
'数据清洗',
|
||||
{ confirmButtonText: '排除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
.then(() => {
|
||||
cleanApplying.value = true
|
||||
return request.post('/datasets/clean/apply', { datasetId: datasetId.value, imageIds: cleanSel.value, exclude: true })
|
||||
})
|
||||
.then((d) => {
|
||||
ElMessage.success(`已排除 ${d.applied} 张,可在「已清洗」tab 查看/恢复`)
|
||||
cleanVisible.value = false
|
||||
selected.value = []
|
||||
loadAll()
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
cleanApplying.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 标注编辑弹窗(canvas 画框) ----------
|
||||
const editorVisible = ref(false)
|
||||
const editIndex = ref(0)
|
||||
const currentImage = computed(() => (detail.value && detail.value.images ? detail.value.images[editIndex.value] : null))
|
||||
// 编辑器导航基于当前 tab 可见列表(visibleImages):tab 内连续标注不串到另一分类
|
||||
const currentImage = computed(() => visibleImages.value[editIndex.value] || null)
|
||||
const canvasEl = ref(null)
|
||||
const canvasWrap = ref(null)
|
||||
const imgEl = ref(null)
|
||||
@@ -381,6 +546,7 @@ async function switchEdit(idx) {
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
if (!editorVisible.value) return // 刷新类回调可能已在 loadAll 内关弹窗,避免对空画布操作
|
||||
stopHighlight()
|
||||
dirty.value = false
|
||||
confirmed.value = []
|
||||
@@ -688,16 +854,22 @@ function clearAllBoxes() {
|
||||
async function saveCurrent() {
|
||||
if (!currentImage.value) return
|
||||
saving.value = true
|
||||
const fn = currentImage.value.filename
|
||||
try {
|
||||
const data = await request.post('/label-tasks/save', {
|
||||
datasetId: datasetId.value,
|
||||
filename: currentImage.value.filename,
|
||||
filename: fn,
|
||||
boxes: confirmed.value,
|
||||
})
|
||||
ElMessage.success(`已保存(该数据集已标注 ${data.labeledCount} 张)`)
|
||||
dirty.value = false
|
||||
currentImage.value.boxes = confirmed.value.map((b) => ({ ...b }))
|
||||
currentImage.value.labeled = confirmed.value.length > 0
|
||||
// 保存翻转 labeled 后该行会移出当前 tab(未标注→已标注等),visibleImages 收缩使
|
||||
// currentImage 静默漂移到邻图,继续编辑再保存会把框存到别的图——检测到漂移立即关弹窗
|
||||
if (!currentImage.value || currentImage.value.filename !== fn) {
|
||||
editorVisible.value = false
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -747,7 +919,8 @@ onBeforeUnmount(() => {
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 图片工具 -->
|
||||
<!-- 图片工具:未标注/已标注 tab 走勾选预标/补标/删除;已标注 tab 另有数据清洗入口;
|
||||
已清洗 tab(clean_excluded=1)工具条改为恢复驱动(2026-09-02) -->
|
||||
<div class="img-toolbar">
|
||||
<el-button
|
||||
:disabled="!pagedImages.length"
|
||||
@@ -755,15 +928,28 @@ onBeforeUnmount(() => {
|
||||
>{{ allSelected ? '取消全选' : '全选' }}</el-button>
|
||||
<el-button type="primary" :icon="Plus" :disabled="genRunning" @click="openAdd">添加</el-button>
|
||||
<el-button
|
||||
v-if="listTab === 'labeled'"
|
||||
:disabled="cleanLoading"
|
||||
@click="openClean"
|
||||
>数据清洗<span v-if="tabCounts.cleaned" class="toolbar-note">(已排除 {{ tabCounts.cleaned }})</span></el-button>
|
||||
<el-button
|
||||
v-if="listTab !== 'cleaned'"
|
||||
:icon="MagicStick"
|
||||
:disabled="labelRunning || !selected.length"
|
||||
@click="startLabelTask"
|
||||
>预标{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
v-if="listTab !== 'cleaned'"
|
||||
:icon="Search"
|
||||
:disabled="vlmReviewing || !selected.length"
|
||||
@click="vlmBatchReview"
|
||||
>补标{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
v-if="listTab === 'cleaned'"
|
||||
type="primary"
|
||||
:disabled="!selected.length"
|
||||
@click="restoreSelected"
|
||||
>恢复选中{{ selected.length ? ` ${selected.length} 张` : '' }}</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:disabled="!selected.length"
|
||||
@@ -799,9 +985,19 @@ onBeforeUnmount(() => {
|
||||
AI 生成未完成:{{ genTask.error }}
|
||||
</div>
|
||||
|
||||
<!-- 图片列表 tab:未标注(默认) | 已标注 | 已清洗(clean_excluded=1,数据清洗排除,勾选可恢复);
|
||||
标注状态由 tab 表达,卡片上不再叠角标(2026-09-02) -->
|
||||
<div class="list-tabs">
|
||||
<el-radio-group v-model="listTab" size="small">
|
||||
<el-radio-button value="unlabeled">未标注 {{ tabCounts.unlabeled }}</el-radio-button>
|
||||
<el-radio-button value="labeled">已标注 {{ tabCounts.labeled }}</el-radio-button>
|
||||
<el-radio-button value="cleaned">已清洗 {{ tabCounts.cleaned }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 图片卡片:点击缩略图直接进标注弹窗(有标注框直接显示在图上,可继续画框) -->
|
||||
<div v-loading="loading" class="rows-area">
|
||||
<template v-if="detail && detail.images.length">
|
||||
<template v-if="pagedImages.length">
|
||||
<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)">
|
||||
@@ -813,7 +1009,6 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<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>
|
||||
@@ -822,13 +1017,13 @@ onBeforeUnmount(() => {
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="detail.images.length"
|
||||
:total="visibleImages.length"
|
||||
layout="prev, pager, next, total"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else-if="!loading" description="暂无图片,请上传或 AI 生成" />
|
||||
<el-empty v-else-if="!loading" :description="emptyTip" />
|
||||
</div>
|
||||
|
||||
<!-- 添加图片:AI 生成 -->
|
||||
@@ -854,6 +1049,74 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 数据清洗(训练集去冗余):已标注 tab 工具条入口;候选默认全选,执行 = 打排除标记(不删文件) -->
|
||||
<el-dialog v-model="cleanVisible" title="数据清洗" width="min(980px, 96vw)" append-to-body :close-on-click-modal="false">
|
||||
<div v-loading="cleanLoading">
|
||||
<div class="clean-tip">
|
||||
按标注目标占画面高度分档统计,超配档内整图 dHash 贪心保留多样性(同场景连拍帧只留 1 张),其余进候选;
|
||||
调整配额即实时重算。<b>排除 ≠ 删除</b>:仅移出训练集打包(图片/标注不动),被排除图集中到「已清洗」tab 可随时恢复。
|
||||
</div>
|
||||
<div class="clean-bucket-head clean-bucket-row">
|
||||
<span class="cb-label">目标尺寸档</span>
|
||||
<span class="cb-num">现有</span>
|
||||
<span class="cb-num">已排除</span>
|
||||
<span class="cb-num cb-quota">保留配额</span>
|
||||
<span class="cb-num">超配可排</span>
|
||||
</div>
|
||||
<div v-for="(b, i) in cleanBuckets" :key="b.label" class="clean-bucket-row">
|
||||
<span class="cb-label">
|
||||
{{ b.label }}
|
||||
<el-tag v-if="b.exempt" size="small" type="info" effect="plain">极远档豁免</el-tag>
|
||||
</span>
|
||||
<span class="cb-num">{{ b.total }}</span>
|
||||
<span class="cb-num">{{ b.excluded }}</span>
|
||||
<span class="cb-num cb-quota">
|
||||
<el-input-number
|
||||
v-if="!b.exempt"
|
||||
v-model="cleanQuotas[i]"
|
||||
:min="0"
|
||||
:max="5000"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
@change="onQuotaChange"
|
||||
/>
|
||||
<span v-else class="cb-exempt">不适用</span>
|
||||
</span>
|
||||
<span class="cb-num" :class="{ 'cb-over': b.over > 0 }">{{ b.over || 0 }}</span>
|
||||
</div>
|
||||
<div class="clean-summary">参与统计 {{ cleanTotal }} 张 · 已排除 {{ cleanExcludedCount }} 张(见「已清洗」tab)</div>
|
||||
|
||||
<div v-if="cleanCand.length" class="clean-cand-head">
|
||||
<span class="clean-cand-title">建议排除候选({{ cleanCand.length }} 张,默认全选)</span>
|
||||
<el-button link type="primary" size="small" @click="toggleCleanAll">{{ candAllChecked ? '取消全选' : '全选' }}</el-button>
|
||||
</div>
|
||||
<div v-else class="clean-cand-empty">当前配额下无超配候选,无需排除(<2% 极远档固定豁免)</div>
|
||||
<div class="clean-cand-list">
|
||||
<label v-for="c in cleanCand" :key="c.imageId" class="clean-cand">
|
||||
<el-checkbox
|
||||
:model-value="cleanSel.includes(c.imageId)"
|
||||
@click.stop
|
||||
@change="(v) => toggleCleanOne(c.imageId, v)"
|
||||
/>
|
||||
<img class="clean-thumb" :src="cleanThumbUrl(c)" :alt="c.filename" loading="lazy" />
|
||||
<span class="clean-cand-name" :title="c.filename">{{ c.filename }}</span>
|
||||
<span class="clean-cand-meta">
|
||||
<span v-if="c.source === 'ai'" class="badge-mini">AI</span>
|
||||
<span class="clean-pct" :title="'主目标 ' + c.boxHeightPct + '%'">主 {{ c.boxHeightPct }}%
|
||||
<template v-if="c.targetCount > 1"> · 最小 {{ c.minHeightPct }}% · {{ c.targetCount }} 目标</template>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :disabled="cleanLoading" @click="cleanVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="cleanApplying" :disabled="!cleanSel.length" @click="applyClean">
|
||||
排除选中{{ cleanSel.length ? ` ${cleanSel.length} 张` : '' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 图片标注(全屏查看 + 标注编辑):已标注图直接在其上显示框,可确认疑似/删除误检,可手动画框补标 -->
|
||||
<el-dialog
|
||||
v-model="editorVisible"
|
||||
@@ -906,8 +1169,8 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="nav-bar">
|
||||
<el-button size="small" :disabled="editIndex <= 0" @click="switchEdit(editIndex - 1)">上一张</el-button>
|
||||
<span class="nav-info">{{ editIndex + 1 }}/{{ detail?.images.length }}</span>
|
||||
<el-button size="small" :disabled="editIndex >= detail?.images.length - 1" @click="switchEdit(editIndex + 1)">下一张</el-button>
|
||||
<span class="nav-info">{{ editIndex + 1 }}/{{ visibleImages.length }}</span>
|
||||
<el-button size="small" :disabled="editIndex >= visibleImages.length - 1" @click="switchEdit(editIndex + 1)">下一张</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" :loading="saving" @click="confirmEditor">确定</el-button>
|
||||
@@ -1055,6 +1318,9 @@ onBeforeUnmount(() => {
|
||||
.rows-area {
|
||||
min-height: 200px;
|
||||
}
|
||||
.list-tabs {
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
@@ -1105,9 +1371,6 @@ onBeforeUnmount(() => {
|
||||
right: auto;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.badge.ok {
|
||||
background: rgba(103, 194, 58, 0.9);
|
||||
}
|
||||
.card-name {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
@@ -1123,6 +1386,132 @@ onBeforeUnmount(() => {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* 数据清洗:配额表 + 候选清单(2026-09-02) */
|
||||
.toolbar-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.clean-tip {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: #f4f4f5;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.clean-tip b {
|
||||
color: #303133;
|
||||
}
|
||||
.clean-bucket-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.clean-bucket-head {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.cb-label {
|
||||
flex: 0 0 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #303133;
|
||||
}
|
||||
.cb-num {
|
||||
flex: 0 0 110px;
|
||||
color: #606266;
|
||||
}
|
||||
.cb-quota {
|
||||
flex: 0 0 170px;
|
||||
}
|
||||
.cb-exempt {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.cb-over {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.clean-summary {
|
||||
margin: 8px 8px 14px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.clean-cand-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 2px 0 8px;
|
||||
}
|
||||
.clean-cand-title {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
.clean-cand-empty {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.clean-cand-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.clean-cand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.clean-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clean-cand-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.clean-cand-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.badge-mini {
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: rgba(64, 158, 255, 0.85);
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.clean-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 标注编辑(全屏弹窗:画布区占满剩余高度,画布等比例居中,可放大缩小) */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user