This commit is contained in:
2026-09-04 16:59:21 +08:00
parent acd2a8f412
commit 30ff5b830e
7 changed files with 208 additions and 11 deletions
+2 -2
View File
@@ -14,11 +14,11 @@
| 套餐 | `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**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 |
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 四级漏斗标注(全图扫描→空检切片扫描→VLM 提议候选区精修,见技术设计.md「预标注四级漏斗」)**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗为**审核视图(不做手动画框)**:点击框选中,列表可确认疑似框/删除误检框/清空并保存——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` 节点,部署级配置);**双档位(2026-09-03)**:一次发起按档位各建一条任务——高识别档 s(基座 `training.model`、imgsz `training.imgsz`=1280/ 高性能档 n(基座 `training.modelN`=yolov8n.pt、imgsz `training.imgszN`=704),请求传 `variants:["s","n"]` 限定(省略=双档;n 档配置缺失时请求报错),epochs/batch/device 双档共用,任务带 `variant` 快照;**GPU 独占排队(2026-09-03**:并发度 1 不变——已有 running 时新任务落 `queued` 排队(不再拒绝),10s 轮询在 running 结束后自动按创建顺序晋级启动、一次一个(训练机单 GPU 串行跑多档/多数据集),取消 running=杀进程、queued=直接置失败;进度/日志/指标监控(每 epoch 粒度);训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化(task.json 传 model/imgsz),产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
| 模型版本与热更新 | **每数据集每档位一个模型**2026-09-03 双档位):训练成功后一键「发布」(训练任务操作列)——tflite 已由训练成功直写最终位置:s 档 `workspace/trainings/<文件名前缀>.tflite`、n 档 `<文件名前缀>_n.tflite`(前缀空回退数据集名),发布仅落 `model_version` 记录(sha256/大小/指标/类别名,带 `variant` 档位列);版本序列每数据集全局共用 m1.0.0 递增(s/n 交替发布走同一序列,无档位独立序列),`is_latest` 按 (数据集, 档位) 各记一条——发布只清同档位旧记录,s/n 两档互不影响,目录可分别发布、分别下发。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/档位 `variant` s|n/版本/类别/大小/sha256/下载地址;**每数据集最多 2 条 = s/n 两档各自的 is_latest**),下载 URL s 档 `/download/trainings/<文件名前缀>.tflite`、n 档 `/download/trainings/<文件名前缀>_n.tflite`(前缀空回退数据集名);**App 模型管理页**用户自由下载/删除/启用模型,识别时**按当前识别档位(s 高识别 / n 高性能,全局切换)加载该档位已启用模型**并行推理 + 跨模型 NMS 合并(按类别名),内置 assets 模型兜底 |
| 标注 | **图片入库自动触发**:手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 全图扫描(`label_task` 记录进度,页顶进度条展示;**localAi 未配置 → 上传/生成接口直接报错;已有标注任务在跑(忙)→ 不报错**,当前任务成功完成后自动补标未标注图)→ 扫描结果(**重叠去重**:NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除——重叠比 = 交叠面积/两框较小面积,RF-DETR 同目标常输出一大一小两框,此判据能命中,同目标只留置信度最高者)**直接写 `dataset_image.labels_json`**(覆盖该图已有标注,即重标语义);点击弹窗放大进入**审核视图(不做手动画框)**:点击框选中,列表确认疑似框/删除误检框/清空 → 保存即整体覆写 `dataset_image.labels_json`YOLO 归一化 JSON 数组,AI 标注直写、人工仅审核确认);`POST /admin/label-tasks` 详情页「全量标注」按钮入口(另有自动触发),可发起全量/指定图重标;自动/手动/混合并存,训练前自动整理(prepare_yolo 逻辑在服务端) |
| 标注 | **图片入库自动触发**:手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 检测(**四级漏斗(2026-09-04)**:全图扫描→空检自动升级切片扫描→仍空 VLM 提议候选区+RF-DETR 精修→全空写空标注等人工,切片块长边/重叠/切片阈值走 `localAi.tileSize`/`tileOverlap`/`tileThreshold`,见技术设计.md「预标注四级漏斗」;`label_task` 记录进度,页顶进度条展示;**localAi 未配置 → 上传/生成接口直接报错;已有标注任务在跑(忙)→ 不报错**,当前任务成功完成后自动补标未标注图)→ 扫描结果(**重叠去重**:NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除——重叠比 = 交叠面积/两框较小面积,RF-DETR 同目标常输出一大一小两框,此判据能命中,同目标只留置信度最高者)**直接写 `dataset_image.labels_json`**(覆盖该图已有标注,即重标语义);点击弹窗放大进入**审核视图(不做手动画框)**:点击框选中,列表确认疑似框/删除误检框/清空 → 保存即整体覆写 `dataset_image.labels_json`YOLO 归一化 JSON 数组,AI 标注直写、人工仅审核确认);`POST /admin/label-tasks` 详情页「全量标注」按钮入口(另有自动触发),可发起全量/指定图重标;自动/手动/混合并存,训练前自动整理(prepare_yolo 逻辑在服务端) |
## 架构与数据流
+8
View File
@@ -67,6 +67,14 @@ const (
// (与 VlmMaxSuspectPerImage 同量级,单图人工复核工作量可控)
MaxPrelabelPerImage = 3
// 预标注四级漏斗(技术设计.md「预标注四级漏斗」):
// 单图漏斗总超时秒数——L2 切片 ~20 块串行 + L3 VLM 兜底,原 120s 只够单次全图检测
LabelImageTimeoutSec = 300
// L3 VLM 提议候选区数上限(图全空时让 VLM 指可疑位置,RF-DETR 精修确认,宁可指错不可遗漏)
VlmLocateMaxRegions = 3
// L3 候选区扩大倍数(VLM 坐标偏粗,扩大裁剪给 RF-DETR 足够上下文,钳制图片边界)
VlmRegionExpand = 2.0
// 模型版本号前缀(m1.0.0),同数据集内递增
ModelVersionPrefix = "m"
+98 -3
View File
@@ -216,8 +216,8 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
doneCount := 0
for i, img := range images {
g.Log().Infof(bgCtx, "预标 %s (%d/%d): %s", dataset.Name, i+1, len(images), img.Filename)
// 单张 120s 超时兜底(无超时 + 无取消的独立 ctx 下防检测服务悬挂拖死任务)
detCtx, cancel := context.WithTimeout(bgCtx, 120*time.Second)
// 单图漏斗总超时兜底(L2 切片 + L3 VLM 兜底耗时;无超时 + 无取消的独立 ctx 下防检测服务悬挂拖死任务)
detCtx, cancel := context.WithTimeout(bgCtx, consts.LabelImageTimeoutSec*time.Second)
err := common.LabelTaskPoolInstance().Submit(detCtx, func(ctx context.Context) error {
data, err := os.ReadFile(filepath.Join(dir, img.Filename))
if err != nil {
@@ -228,7 +228,7 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
return gerror.New("无法识别图片尺寸")
}
mime := imageMime(img.Filename)
detections, err := client.Detect(ctx, data, mime, w, h)
detections, err := s.detectFunnel(ctx, client, data, mime, w, h, dataset)
if err != nil {
return err
}
@@ -296,6 +296,101 @@ func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, datas
}()
}
// detectFunnel 预标注四级漏斗(技术设计.md「预标注四级漏斗」):检测只信 RF-DETR,VLM 永不直接出框。
// ①全图扫描(threshold)有候选即收(绝大多数图到此命中)→ ②空检则滑窗切片低阈值(tileThreshold)扫描 →
// ③仍空则 VLM 提议候选区逐区裁剪精修 → ④全空返回空(上游写 '[]' 语义不变:等人工画框、不入自动补标池)。
func (s *labelTaskService) detectFunnel(ctx context.Context, client *common.LocalAi, data []byte, mime string, imgW, imgH int, dataset *entity.Dataset) ([]*common.Detection, error) {
detections, err := client.Detect(ctx, data, mime, imgW, imgH)
if err != nil || len(detections) > 0 {
return detections, err
}
g.Log().Infof(ctx, "全图空检,升级切片扫描: %s", dataset.Name)
detections, err = s.detectTiles(ctx, client, data, mime, imgW, imgH)
if err != nil || len(detections) > 0 {
return detections, err
}
species := dataset.GenSpecies
if species == "" {
species = dataset.Name // 单物种规则:物种 = 数据集名
}
return s.detectVlmHints(ctx, client, data, mime, imgW, imgH, species), nil
}
// detectTiles 四级漏斗 L2:滑窗切块逐块 DetectRegiontileThreshold——小目标置信度天然偏低,
// 低于全图丢弃线的候选仍入库落疑似 class 1,宁多勿漏),块内坐标已在 DetectRegion 内映射回
// 原图像素,跨块重复检出由上游 minIoU 去重兜住;单图池任务内串行执行,不嵌套协程池。
func (s *labelTaskService) detectTiles(ctx context.Context, client *common.LocalAi, data []byte, mime string, imgW, imgH int) ([]*common.Detection, error) {
tiles := common.TileRegions(imgW, imgH, client.TileSize, client.TileOverlap)
all := make([]*common.Detection, 0, len(tiles))
for _, t := range tiles {
ds, err := client.DetectRegion(ctx, data, mime, imgW, imgH, t.X, t.Y, t.W, t.H, client.TileThreshold)
if err != nil {
return nil, gerror.Wrapf(err, "切片检测失败(%d,%d,%dx%d)", t.X, t.Y, t.W, t.H)
}
all = append(all, ds...)
}
return all, nil
}
// detectVlmHints 四级漏斗 L3:VLM 提议候选区(只提议位置,永不直接出框)。提示词按
// 「宁可指错不可遗漏」输出 ≤ consts.VlmLocateMaxRegions 个归一化候选区,逐区扩大
// consts.VlmRegionExpand 倍(VLM 坐标偏粗,扩大给 RF-DETR 足够上下文,钳制图片边界)后
// DetectRegion 低阈值精修,精修命中的框才采纳(VLM 误报被 RF-DETR 否掉)。
// VLM 失败/输出非法一律跳过该级返回空,不阻塞任务。
func (s *labelTaskService) detectVlmHints(ctx context.Context, client *common.LocalAi, data []byte, mime string, imgW, imgH int, species string) []*common.Detection {
prompt := fmt.Sprintf(
"你是野生动物监测照片分析助手。\n物种:%s\n"+
"画面中很可能存在该物种动物,它可能极小、大半身体被树叶/草丛/枝干遮挡只露出局部,容易看漏。\n"+
"任务:仔细扫描全图找出它最可能所在的位置,宁可指错不可遗漏——优先检查树冠枝杈间、灌丛草丛、阴影边缘、地面隆起处等可藏身位置。\n"+
"输出归一化 bbox 的 JSON 数组(cx,cy 为区域中心,w,h 为区域宽高,区域范围可比动物本身大一圈):[{\"cx\":0.5,\"cy\":0.3,\"w\":0.25,\"h\":0.2}],最多 %d 个,按可能性从高到低;确无可能才输出 []。只输出 JSON 数组本身,不要其他文字。",
species, consts.VlmLocateMaxRegions)
content, err := common.QwenVL(ctx, data, mime, prompt)
if err != nil {
g.Log().Infof(ctx, "VLM 提议候选区失败(跳过该级): %v", err)
return nil
}
regions := parseVlmRegions(content)
out := make([]*common.Detection, 0, len(regions))
for _, r := range regions {
if r.Cx <= 0 || r.Cx >= 1 || r.Cy <= 0 || r.Cy >= 1 || r.W <= 0 || r.W >= 1 || r.H <= 0 || r.H >= 1 {
continue
}
w := math.Min(r.W*consts.VlmRegionExpand, 1)
h := math.Min(r.H*consts.VlmRegionExpand, 1)
x1 := math.Max(0, r.Cx-w/2)
y1 := math.Max(0, r.Cy-h/2)
x2 := math.Min(1, r.Cx+w/2)
y2 := math.Min(1, r.Cy+h/2)
ds, err := client.DetectRegion(ctx, data, mime, imgW, imgH,
int(x1*float64(imgW)), int(y1*float64(imgH)),
int((x2-x1)*float64(imgW)), int((y2-y1)*float64(imgH)), client.TileThreshold)
if err != nil {
g.Log().Infof(ctx, "候选区精修失败(跳过): %v", err)
continue
}
out = append(out, ds...)
}
return out
}
// vlmRegion VLM 输出的归一化区域(cx,cy 中心 + w,h)
type vlmRegion struct {
Cx, Cy, W, H float64
}
// parseVlmRegions 从 VLM 回复中提取首个 JSON 数组并解析为归一化区域;无数组或解析失败返回 nil
func parseVlmRegions(content string) []vlmRegion {
m := regexp.MustCompile(`\[[\s\S]*?\]`).FindString(content)
if m == "" {
return nil
}
var raw []vlmRegion
if err := json.Unmarshal([]byte(m), &raw); err != nil {
return nil
}
return raw
}
// AdminImageVlmReview 单图 VLM 藏匿位补检(两阶段标注第二阶段,须与 z-image 生成显存互斥):
// 以当前 labels_jsonRF-DETR/人工框)为排除集,调 qwen3.6-35b-a3b(+mmproj) 按「环境/季节/
// 时间/天气/光线/地形 + 物种习性」综合判读画面,推理可能藏身的位置,取前 3 个追加为疑似框(class 1)。
+95 -6
View File
@@ -32,6 +32,10 @@ type LocalAi struct {
OverlapThreshold float64
// 提交前整图等比缩放到的最长边(RF-DETR 对小图更稳)
InputSize int
// 四级漏斗切片级(技术设计.md「预标注四级漏斗」):切片检测置信度下限 / 块长边(原图像素)/ 相邻块重叠比
TileThreshold float64
TileSize int
TileOverlap float64
}
// Detection RF-DETR 单目标检测结果;Detect 返回前已映射为原图像素左上角(X/Y)+ 宽高
@@ -57,6 +61,9 @@ func LocalAiClient(ctx context.Context) *LocalAi {
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(),
TileThreshold: g.Cfg().MustGet(ctx, "localAi.tileThreshold", 0.12).Float64(),
TileSize: g.Cfg().MustGet(ctx, "localAi.tileSize", 1024).Int(),
TileOverlap: g.Cfg().MustGet(ctx, "localAi.tileOverlap", 0.25).Float64(),
}
}
@@ -122,10 +129,52 @@ func QwenVL(ctx context.Context, imgData []byte, mime, prompt string) (string, e
// imgW/imgH 为原图尺寸;返回坐标均为原图像素尺度。
func (c *LocalAi) Detect(ctx context.Context, data []byte, mime string, imgW, imgH int) ([]*Detection, error) {
sub, scale := c.prepare(data, mime, imgW, imgH)
return c.submitDetect(ctx, sub, mime, scale, 0, 0, c.Threshold)
}
// DetectRegion 对原图中指定像素矩形区域做检测(四级漏斗切片级/VLM 候选区精修):
// 裁剪 → 等比缩放至最长边 InputSize 提交 → 坐标映射回原图像素。
// threshold 由调用方给定(切片级用低于全图的阈值,小目标置信度天然偏低);
// region 越界内部钳制到图片边界。返回坐标均为原图像素尺度。
func (c *LocalAi) DetectRegion(ctx context.Context, data []byte, mime string, imgW, imgH int, rx, ry, rw, rh int, threshold float64) ([]*Detection, error) {
rx = maxInt(0, minInt(rx, imgW-1))
ry = maxInt(0, minInt(ry, imgH-1))
rw = minInt(rw, imgW-rx)
rh = minInt(rh, imgH-ry)
if rw <= 0 || rh <= 0 {
return nil, nil
}
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("解码图片失败: %w", err)
}
cropper, ok := src.(interface {
SubImage(image.Rectangle) image.Image
})
if !ok {
return nil, fmt.Errorf("图片格式不支持区域裁剪")
}
sub := cropper.SubImage(image.Rect(rx, ry, rx+rw, ry+rh))
var buf bytes.Buffer
if strings.Contains(mime, "png") {
_ = png.Encode(&buf, sub)
} else {
_ = jpeg.Encode(&buf, sub, &jpeg.Options{Quality: 92})
}
region, scale := c.prepare(buf.Bytes(), mime, rw, rh)
return c.submitDetect(ctx, region, mime, scale, float64(rx), float64(ry), threshold)
}
// submitDetect 提交检测请求并解析:sub 为已缩放的提交图字节,scale 为提交图→原图(或区域)
// 的缩放比,offX/offY 为提交图坐标系到原图坐标系的偏移(全图 0,0,区域检测为区域左上角),
// threshold 为本次候选置信度下限。响应 x/y 是框中心:先转左上角,再按比例尺映射回原图像素。
// 不做中心转左上会把中心当角点,下游再加 w/2 时整框偏移半宽半高
// (实测 RNPHE_133: 响应(403,664) 即目标中心,旧逻辑落库 cx 偏 +w/2)。
func (c *LocalAi) submitDetect(ctx context.Context, sub []byte, mime string, scale, offX, offY, threshold float64) ([]*Detection, error) {
body, err := json.Marshal(map[string]any{
"model": c.Model,
"image": fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(sub)),
"threshold": c.Threshold,
"threshold": threshold,
})
if err != nil {
return nil, err
@@ -154,20 +203,60 @@ 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.X = d.X/scale + offX
d.Y = d.Y/scale + offY
d.Width /= scale
d.Height /= scale
}
return out.Detections, nil
}
// TileRegion 滑窗切片块(原图像素)
type TileRegion struct {
X, Y, W, H int
}
// TileRegions 滑窗切片网格(四级漏斗 L2):tileSize 为块长边(原图像素),tileOverlap 为
// 相邻块重叠比(步长 = tileSize*(1-tileOverlap)),从左上到右下枚举,边缘块贴边收口保证全覆盖。
func TileRegions(imgW, imgH, tileSize int, tileOverlap float64) []TileRegion {
if tileSize <= 0 {
tileSize = imgW
}
if tileSize > imgW {
tileSize = imgW
}
if tileSize > imgH {
tileSize = imgH
}
step := maxInt(1, int(float64(tileSize)*(1-tileOverlap)))
tiles := make([]TileRegion, 0, (imgW/step+1)*(imgH/step+1))
for y := 0; y < imgH; y += step {
ty := y
if ty+tileSize > imgH {
ty = imgH - tileSize
}
th := minInt(tileSize, imgH-ty)
for x := 0; x < imgW; x += step {
tx := x
if tx+tileSize > imgW {
tx = imgW - tileSize
}
tw := minInt(tileSize, imgW-tx)
tiles = append(tiles, TileRegion{X: tx, Y: ty, W: tw, H: th})
if tx+tw >= imgW {
break
}
}
if ty+th >= imgH {
break
}
}
return tiles
}
// prepare 整图等比缩放至最长边 InputSize(等比,不裁剪),返回提交字节与缩放比(原图/提交图)。
func (c *LocalAi) prepare(data []byte, mime string, imgW, imgH int) ([]byte, float64) {
if imgW <= 0 || imgH <= 0 || imgW <= c.InputSize && imgH <= c.InputSize {
+4
View File
@@ -99,6 +99,10 @@ localAi:
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 实测回退
# 预标注四级漏斗(技术设计.md「预标注四级漏斗」):全图扫描空检 → 切片扫描 → VLM 提议候选区精修,键见下
tileThreshold: 0.12 # 切片级候选置信度下限:小目标(切片后 ~40px)置信度天然偏低,量级取 RF-DETR-S 实测经验;低于全图 threshold 的候选仍入库落疑似(class 1)待人工复核,宁多勿漏
tileSize: 1024 # 切片块长边(原图像素):缩至 inputSize 后约 0.68 倍,画面高 2% 的目标(~64px)保住 ~44px 可检,全图直缩只剩 ~15px;块过小则滑窗块数爆炸,1024 为精度/块数平衡
tileOverlap: 0.25 # 相邻块重叠比:跨块目标不因切缝丢失;跨块重复检出由 overlapThresholdminIoU)去重
# 标注类别名(写入 data.yaml,随 result.json 存模型 labels)从数据集表取:
# 第一类 = gen_species(空回退数据集名),第二类 = gen_classes(空回退 class0/class1
Binary file not shown.
+1
View File
@@ -406,6 +406,7 @@ training:
- `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-04,树栖藏匿难例)**:树冠藏匿生成图(树栖物种目标仅占画面高 ~2%)在全图扫描下不可检——模型包固定 700x700 stretch 输入(见「预标注模型固定 700x700」,不支持动态分辨率,提高提交分辨率无增益),占图高 2% 的目标等比缩入后仅 ~15px 落在检出盲区。单张处理升级为逐级漏斗,**检测只信 RF-DETR,VLM 永不直接出框(只提议候选区)**:①**全图扫描**(现有 `Detect``localAi.threshold`)有候选即收(绝大多数图到此命中,不触发后级);②空检出 → **切片扫描**`common.TileRegions` 滑窗切块(块长边/重叠比 = `localAi.tileSize`/`tileOverlap`,边缘块贴边收口保证全覆盖)逐块 `DetectRegion` 按低阈值(`localAi.tileThreshold`,小目标置信度天然偏低,量级取 RF-DETR-S 实测经验;低于全图丢弃线的候选仍入库落疑似 class 1,宁多勿漏)检测,块内坐标在 DetectRegion 内映射回原图像素,跨块重复检出由既有 minIoU 去重兜住;③仍空 → **VLM 提议候选区**qwen3-vl-8b 按「宁可指错不可遗漏」提示词输出 ≤`consts.VlmLocateMaxRegions` 个归一化候选区(与手动藏匿位补检不同:此阶段图内无已确认框,无排除集;物种注入同 vlm-review——`gen_species` 空回退数据集名),逐区扩大 `consts.VlmRegionExpand` 倍(VLM 坐标偏粗,扩大给 RF-DETR 足够上下文,钳制图片边界)后 `DetectRegion` 精修,精修命中的框才采纳、未命中丢弃(VLM 误报被 RF-DETR 否掉);**VLM 失败/超时/输出非法一律跳过该级不阻塞任务**(三模型共存 + local-ai 服务端排队串行,无需显存互斥检查,2026-09-02 已放开);④全空 → 写 `'[]'`(语义不变:未标注 tab 展示等人工画框、不入自动补标池防乒乓)。漏斗各级候选同通道处理:conf ≥ confConfirmed 落 class 0、否则 class 1、minIoU 去重、单图上限 `consts.MaxPrelabelPerImage`,全图扫描不套生成规格的项目规则不变。单图超时 120s → `consts.LabelImageTimeoutSec`(切片 ~20 块串行 + VLM 兜底;切片在单图池任务内串行执行,不嵌套协程池)
- **手动标注入口收敛(2026-09-02 决策)**:详情页删除「全量标注」按钮与图片卡片「审核」按钮,**标注入口仅两处(勾选图片驱动,顶栏)**:①「预标」= POST `/admin/label-tasks` 传选中图 `filenames`(只扫选中图,覆盖各图已有标注);②「补标」= 对选中图逐张 POST `/datasets/images/vlm-review`(VLM 藏匿位补检,追加疑似框,批量=前端循环单图接口,依赖已确认框作为排除基准)——VLM 补检按钮从标注编辑器迁出(2026-09-02);点卡片缩略图仍进人工编辑器(确认/清理/画框,编辑器内不再有 VLM 按钮),编辑器文案「图片审核」→「图片标注」;服务端全量重标能力保留但不再有 UI 入口
- **详情页布局(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+类别+置信度,空=清空标注);未保存修改切换图片有确认