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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user