This commit is contained in:
2026-09-02 16:28:02 +08:00
parent fcf5947545
commit 9dd90779c1
19 changed files with 1028 additions and 124 deletions
+4 -2
View File
@@ -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/12026-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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视野管理端</title>
<script type="module" crossorigin src="/admin/assets/index-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>
+31
View File
@@ -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,异场景帧通常 >122026-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},
}
+10
View File
@@ -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)
+30 -2
View File
@@ -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
}
+64 -7
View File
@@ -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 模型目录(客户端登录态):全部数据集的当前生效模型
+8 -7
View File
@@ -5,11 +5,12 @@ import "github.com/gogf/gf/v2/os/gtime"
// DatasetImage 数据集图片:文件在 datasets/<数据集名>/<filename>
// 标注为 JSON 数组存本表(元素同 dto.AdminLabelBoxclass/cx/cy/w/h/confidenceYOLO 归一化)。
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:"创建时间"`
}
+240 -6
View File
@@ -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
}
+16 -7
View File
@@ -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 // 空标注图剔除
}
+47
View File
@@ -0,0 +1,47 @@
package common
import (
"bytes"
"image"
"image/color"
_ "image/jpeg" // 注册解码器(dHash 全量解码用)
_ "image/png"
)
// ImageDHash64 整图 64 位 dHash9x8 灰度采样,与 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
}
+81
View File
@@ -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))
}
}
+10 -5
View File
@@ -19,7 +19,8 @@ import (
// LocalAi RF-DETR 检测服务客户端(local-ai 兼容 /v1/detection 协议):
// POST {baseUrl}/v1/detectionbody {"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
View File
@@ -85,8 +85,8 @@ localAi:
baseUrl: "http://192.168.3.210:18080" # 如 http://127.0.0.1:18080local-ai 兼容 /v1/detection
model: rfdetr-xlarge
vlmModel: qwen3-vl-8b # VLM 藏匿位补检模型(Qwen3-VL-8B 多模态 GGUF+mmproj2026-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
View File
@@ -333,7 +333,7 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不
- **生成异步化(2026-08-28count 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 参数)。端侧量化保持 fp16int8 对 <10px 小目标掉点明显)。
- **预标注模型固定 700x7002026-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.3minIoU 风格)同思路。远处真实多目标互不重叠,正常保留。
**核心决策:每个数据集训练一个模型,模型按数据集独立版本化,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 保持稳定),整图 dHashcommon 层,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)