package service import ( "bytes" "context" "encoding/json" "fmt" "image" _ "image/jpeg" _ "image/png" "math" "os" "path/filepath" "regexp" "sort" "strings" "time" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "observer-server/biz/consts" "observer-server/biz/dao" "observer-server/biz/model/dto" "observer-server/biz/model/entity" "observer-server/common" ) // labelTaskService 预标注工作台后端:RF-DETR 全图扫描,AI 标注结果直写 // dataset_image.labels_json(与人工标注同存同编辑),人工可修改/清理全部框; // 训练前整理 YOLO 训练集。 // 全图扫描规则(项目既定):不套用生成规格的位置裁剪,候选宁多勿漏。 type labelTaskService struct{} var LabelTask = &labelTaskService{} // recoverLabelTasks 服务重启恢复:孤儿 running 预标注任务置 done + 错误提示 // (RF-DETR 检测无状态,重新发起即可重新生成标注)。 func (s *labelTaskService) recoverLabelTasks(ctx context.Context) { list, err := dao.LabelTask.ListRunning(ctx) if err != nil { g.Log().Errorf(ctx, "恢复预标注任务失败: %+v", err) return } for _, t := range list { if err := dao.LabelTask.Finish(ctx, t.Id, "服务重启,任务中断,可重新发起"); err != nil { g.Log().Errorf(ctx, "恢复预标注任务 %d 失败: %+v", t.Id, err) } } } // AdminListLabelTasks 预标注任务分页(组装数据集名) func (s *labelTaskService) AdminListLabelTasks(ctx context.Context, req *dto.AdminLabelTaskListReq) (*dto.AdminLabelTaskListRes, error) { page, size := common.NormalizePage(req.Page, req.Size) list, total, err := dao.LabelTask.Page(ctx, page, size) if err != nil { return nil, err } names := Training.datasetNameMap(ctx) items := make([]*dto.AdminLabelTaskItem, 0, len(list)) for _, v := range list { items = append(items, &dto.AdminLabelTaskItem{ Id: v.Id, DatasetId: v.DatasetId, DatasetName: names[v.DatasetId], Status: v.Status, Total: v.Total, Done: v.Done, Error: v.Error, CreatedAt: v.CreatedAt, FinishedAt: v.FinishedAt, }) } return &dto.AdminLabelTaskListRes{Total: total, List: items}, nil } // AdminStartLabelTask 发起预标注:串行检查(数据集存在 + 无 running 任务)→ 插任务 → // 池内逐张调 RF-DETR(全图扫描,AI 端点/模型取 config.yml localAi 节点), // 完成后标注直写 dataset_image.labels_json 置 done;任一图片失败则任务置 done + error。 // 多选批量:req.Filenames 非空时只扫选中图。 func (s *labelTaskService) AdminStartLabelTask(ctx context.Context, req *dto.AdminLabelTaskStartReq) (*dto.AdminLabelTaskStartRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } if dataset.Source == consts.DatasetSourceNegative { return nil, gerror.New("负样本库图片无需标注(训练打包时以空标签作为背景参与)") } if common.LocalAiClient(ctx) == nil { return nil, gerror.NewCode(common.CodeLocalAiNotConfigured) } images, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id) if err != nil { return nil, err } if len(images) == 0 { return nil, gerror.New("数据集暂无图片") } // 多选批量:只处理选中图片(缺省全量) subset := len(req.Filenames) > 0 filenamesJSON := "" if subset { sel := make(map[string]bool, len(req.Filenames)) for _, f := range req.Filenames { sel[f] = true } filtered := make([]*entity.DatasetImage, 0, len(sel)) for _, img := range images { if sel[img.Filename] { filtered = append(filtered, img) } } if len(filtered) == 0 { return nil, gerror.New("选中的图片不在该数据集内") } images = filtered names := make([]string, 0, len(images)) for _, img := range images { names = append(names, img.Filename) } if raw, jErr := json.Marshal(names); jErr == nil { filenamesJSON = string(raw) } } taskId, err := s.startDetection(ctx, dataset, images, filenamesJSON) if err != nil { return nil, err } return &dto.AdminLabelTaskStartRes{Id: taskId}, nil } // startDetection 发起预标注任务:Serial 内检查并发(已有 running 任务报错)→ 插任务 → 启动检测协程 func (s *labelTaskService) startDetection(ctx context.Context, dataset *entity.Dataset, images []*entity.DatasetImage, filenamesJSON string) (int64, error) { now := gtime.Now() var taskId int64 err := common.Serial().Submit(ctx, func() error { running, err := dao.LabelTask.GetRunningByDataset(ctx, dataset.Id) if err != nil { return err } if running != nil { return gerror.NewCode(common.CodeLabelTaskRunning) } taskId, err = dao.LabelTask.Insert(ctx, &entity.LabelTask{ DatasetId: dataset.Id, Status: consts.LabelTaskRunning, Total: len(images), Filenames: filenamesJSON, CreatedAt: now, }) return err }) if err != nil { return 0, err } s.runDetection(ctx, taskId, dataset, images, common.LocalAiClient(ctx)) return taskId, nil } // AutoLabel 已随自动标注退场删除(2026-09-04 用户定案):上传/生成入库不再触发标注, // 标注唯一入口 = 管理端勾选图片「预标」(AdminStartLabelTask); // 原 autoSupplement 自动补标机制一并删除(空检出乒乓问题随机制消亡)。 // runDetection 预标注执行协程(生命周期任务):池内逐张检测,进度经 Serial 更新。 // 每张:读图 → 全图检测(等比缩放提交,坐标映射回原图归一化)→ 标注(conf≥confConfirmed 为 class 0)。 // 全部成功后 Serial 内逐张覆写 labels_json(重跑覆盖该图标注)。 func (s *labelTaskService) runDetection(ctx context.Context, taskId int64, dataset *entity.Dataset, images []*entity.DatasetImage, client *common.LocalAi) { // 生命周期任务必须脱离请求 ctx:请求结束即取消,会让 Submit 秒退 + Finish 静默失败 → 任务悬挂 bgCtx := context.Background() go func() { if client == nil { _ = dao.LabelTask.Finish(bgCtx, taskId, "标注服务未配置") return } dir := common.DatasetImagesDir(bgCtx, dataset.Name) results := make([][]*dto.AdminLabelBox, len(images)) failed := "" doneCount := 0 for i, img := range images { g.Log().Infof(bgCtx, "预标 %s (%d/%d): %s", dataset.Name, i+1, len(images), img.Filename) // 单图漏斗总超时兜底(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 { return gerror.Wrapf(err, "读取图片失败") } w, h := imageSize(data) if w <= 0 || h <= 0 { return gerror.New("无法识别图片尺寸") } mime := imageMime(img.Filename) detections, err := s.detectFunnel(ctx, client, data, mime, w, h, dataset) if err != nil { return err } cands := make([]*dto.AdminLabelBox, 0, len(detections)) for _, d := range detections { // 坐标映射回原图像素后归一化,越界轻微裁剪 box := &dto.AdminLabelBox{ Cx: clamp01((d.X + d.Width/2) / float64(w)), Cy: clamp01((d.Y + d.Height/2) / float64(h)), W: clamp01(d.Width / float64(w)), H: clamp01(d.Height / float64(h)), Confidence: d.Confidence, Class: 1, } if d.Confidence >= client.ConfConfirmed { box.Class = 0 } cands = append(cands, box) } boxes := suppressOverlap(cands, client.OverlapThreshold) // 单图预标上限:去重后仍超 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[:consts.MaxPrelabelPerImage] } results[i] = boxes return nil }) cancel() if err != nil { failed = fmt.Sprintf("第 %d 张(%s)检测失败: %v", doneCount+1, img.Filename, err) break } doneCount++ _ = common.Serial().Submit(bgCtx, func() error { return dao.LabelTask.UpdateProgress(bgCtx, taskId, doneCount) }) } if failed != "" { _ = dao.LabelTask.Finish(bgCtx, taskId, failed) return } // 全部成功:Serial 内逐张覆写标注(重跑覆盖该图标注); // 有检出框 → 待审核(1),空检出 → 未标注(0)(无框可审,等人工画框) err := common.Serial().Submit(bgCtx, func() error { for i := range results { raw, jErr := json.Marshal(results[i]) if jErr != nil { return gerror.Wrap(jErr, "标注序列化失败") } status := consts.ReviewImageNone if len(results[i]) > 0 { status = consts.ReviewImagePending } if uErr := dao.DatasetImage.UpdateLabelsAndReview(bgCtx, images[i].Id, string(raw), status); uErr != nil { return uErr } } return nil }) if err != nil { _ = dao.LabelTask.Finish(bgCtx, taskId, err.Error()) return } _ = dao.LabelTask.Finish(bgCtx, taskId, "") }() } // 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:滑窗切块逐块 DetectRegion(tileThreshold——小目标置信度天然偏低, // 低于全图丢弃线的候选仍入库落疑似 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_json(RF-DETR/人工框)为排除集,调 qwen3.6-35b-a3b(+mmproj) 按「环境/季节/ // 时间/天气/光线/地形 + 物种习性」综合判读画面,推理可能藏身的位置,取前 3 个追加为疑似框(class 1)。 func (s *labelTaskService) AdminImageVlmReview(ctx context.Context, req *dto.AdminImageVlmReviewReq) (*dto.AdminImageVlmReviewRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } // 显存互斥:该数据集有生成任务进行中(z-image 占满显存)时拒绝 VLM 补检 running, err := dao.GenTask.GetRunningByDataset(ctx, dataset.Id) if err != nil { return nil, err } if running != nil { return nil, gerror.NewCode(common.CodeGenTaskRunning) } img, err := dao.DatasetImage.GetById(ctx, req.ImageId) if err != nil { return nil, err } if img == nil || img.DatasetId != dataset.Id { return nil, gerror.NewCode(common.CodeImageNotFound) } data, err := os.ReadFile(filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), img.Filename)) if err != nil { return nil, gerror.Wrap(err, "读取图片失败") } var existing []*dto.AdminLabelBox if strings.TrimSpace(img.LabelsJson) != "" { if jErr := json.Unmarshal([]byte(img.LabelsJson), &existing); jErr != nil { return nil, gerror.Wrap(jErr, "解析已有标注失败") } } // 排除集:已有框坐标(VLM 不得输出与这些矩形重叠的框) exclude := "无" if len(existing) > 0 { parts := make([]string, 0, len(existing)) for _, b := range existing { parts = append(parts, fmt.Sprintf("(%.3f,%.3f,%.3f,%.3f)", b.Cx, b.Cy, b.W, b.H)) } exclude = strings.Join(parts, " ") } // 可追加上限:单图疑似框(class=1)总数 ≤ VlmMaxSuspectPerImage, // 本次最多补 maxAdd = 上限 - 已有疑似框数;已达上限则跳过模型调用 suspectCount := 0 for _, b := range existing { if b.Class == 1 { suspectCount++ } } maxAdd := consts.VlmMaxSuspectPerImage - suspectCount if maxAdd <= 0 { return &dto.AdminImageVlmReviewRes{ Note: fmt.Sprintf("该图已有 %d 个疑似框(上限 %d),无需补标", suspectCount, consts.VlmMaxSuspectPerImage), }, nil } // 物种:数据集单物种(gen_species)直接注入;提示词不再存储(2026-09-02 清理), // 无法从提示词匹配,交给 VLM 从已确认框自行判断 speciesLine := "从已确认目标框自行判断物种" if dataset.GenSpecies != "" { speciesLine = dataset.GenSpecies } prompt := fmt.Sprintf( "你是野生动物监测照片分析助手。\n物种:%s\n已确认目标位置(归一化 cx,cy,w,h):%s\n"+ "不要输出与已有位置矩形重叠的框。\n"+ "任务:寻找画面中还可能藏匿同类的位置。先综合判读画面条件——季节(植被状态/积雪等物候)、时间(影长/色温/明暗)、天气(阴晴/雨雾/雪)、光线(顺逆光/阴影分布)、地形(草地/灌丛/林地/岩石/水域边缘等),再结合该物种的习性(昼夜活动规律、喜藏身处:灌丛草丛/树影/岩缝/沟坎/林地边缘等)推理除已确认位置外还可能藏身的位置。\n"+ "输出归一化 bbox 的 JSON 数组:[{\"cx\":0.5,\"cy\":0.5,\"w\":0.2,\"h\":0.15}],最多 %d 个,按可能性从高到低;确无可能则输出 []。只输出 JSON 数组本身,不要其他文字。", speciesLine, exclude, maxAdd) g.Log().Infof(ctx, "补标 %s: %s(可追加 %d 个疑似框)", dataset.Name, img.Filename, maxAdd) content, err := common.QwenVL(ctx, data, imageMime(img.Filename), prompt) if err != nil { return nil, gerror.Wrap(err, "VLM 推理失败") } // 提取首个 JSON 数组并校验 m := regexp.MustCompile(`\[[\s\S]*?\]`).FindString(content) res := &dto.AdminImageVlmReviewRes{} if m == "" { res.Note = "VLM 未输出坐标数组" return res, nil } var raw []struct { Cx, Cy, W, H float64 } if err := json.Unmarshal([]byte(m), &raw); err != nil { res.Note = "VLM 坐标解析失败" return res, nil } client := common.LocalAiClient(ctx) overlap := 0.3 if client != nil { overlap = client.OverlapThreshold } for _, b := range raw { if len(res.Boxes) >= maxAdd { break } if b.Cx <= 0 || b.Cx >= 1 || b.Cy <= 0 || b.Cy >= 1 || b.W <= 0 || b.W >= 1 || b.H <= 0 || b.H >= 1 { continue } nb := &dto.AdminLabelBox{Cx: b.Cx, Cy: b.Cy, W: b.W, H: b.H, Confidence: 0.1, Class: 1} dup := false for _, e := range existing { if boxOverlap(nb, e) > overlap { dup = true break } } if dup { continue } res.Boxes = append(res.Boxes, nb) } if len(res.Boxes) == 0 { res.Note = "VLM 未给出有效的藏匿位坐标" return res, nil } // 追加为疑似框并落库(与 RF-DETR/人工框同层,工作台可编辑) appended := append(existing, res.Boxes...) if err := common.Serial().Submit(ctx, func() error { rawJson, jErr := json.Marshal(appended) if jErr != nil { return jErr } return dao.DatasetImage.UpdateLabels(ctx, img.Id, string(rawJson)) }); err != nil { return nil, err } res.Added = len(res.Boxes) return res, nil } // AdminLabelTaskDetail 预标注任务详情:标注(labels_json)输出,供工作台 canvas 叠框; // 图片尺寸读取文件头。 func (s *labelTaskService) AdminLabelTaskDetail(ctx context.Context, req *dto.AdminLabelTaskDetailReq) (*dto.AdminLabelTaskDetailRes, error) { t, err := dao.LabelTask.GetById(ctx, req.Id) if err != nil { return nil, err } if t == nil { return nil, gerror.New("标注任务不存在") } dataset, err := dao.Dataset.GetById(ctx, t.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } items, err := s.buildWorkbenchItems(ctx, dataset) if err != nil { return nil, err } return &dto.AdminLabelTaskDetailRes{ TaskId: t.Id, DatasetId: t.DatasetId, DatasetName: dataset.Name, Status: t.Status, Total: t.Total, Done: t.Done, Error: t.Error, Images: items, }, nil } // buildWorkbenchItems 工作台单张图数据:全部标注(labels_json,AI 自动标注与人工框同层); // 尺寸统一读文件头。 func (s *labelTaskService) buildWorkbenchItems(ctx context.Context, dataset *entity.Dataset) ([]*dto.AdminLabelImageItem, error) { images, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id) if err != nil { return nil, err } imgDir := common.DatasetImagesDir(ctx, dataset.Name) items := make([]*dto.AdminLabelImageItem, 0, len(images)) for _, img := range images { item := &dto.AdminLabelImageItem{ Filename: img.Filename, Url: datasetImageUrl(ctx, dataset.Id, img.Filename), } if img.LabelsJson != "" && img.LabelsJson != "[]" { var boxes []*dto.AdminLabelBox if json.Unmarshal([]byte(img.LabelsJson), &boxes) == nil && len(boxes) > 0 { item.Boxes = boxes item.Labeled = true } } item.ReviewStatus = img.ReviewStatus if data, rErr := os.ReadFile(filepath.Join(imgDir, img.Filename)); rErr == nil { item.Width, item.Height = imageSize(data) } items = append(items, item) } return items, nil } // AdminLabelWorkbench 标注工作台数据:数据集无历史任务时直接输出图片 + 全部标注, // 标注读 dataset_image.labels_json(与任务详情同一组装)。 func (s *labelTaskService) AdminLabelWorkbench(ctx context.Context, req *dto.AdminLabelWorkbenchReq) (*dto.AdminLabelWorkbenchRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } items, err := s.buildWorkbenchItems(ctx, dataset) if err != nil { return nil, err } return &dto.AdminLabelWorkbenchRes{ DatasetId: dataset.Id, DatasetName: dataset.Name, Images: items, }, nil } // AdminLabelSave 保存单张图标注:校验坐标 → json.Marshal 覆写 labels_json(空框=清空), // 并刷新数据集 labeled_count(labels_json 非空数组的图片数)。 func (s *labelTaskService) AdminLabelSave(ctx context.Context, req *dto.AdminLabelSaveReq) (*dto.AdminLabelSaveRes, error) { dataset, err := dao.Dataset.GetById(ctx, req.DatasetId) if err != nil { return nil, err } if dataset == nil { return nil, gerror.NewCode(common.CodeDatasetNotFound) } img, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, req.Filename) if err != nil { return nil, err } if img == nil { return nil, gerror.NewCode(common.CodeImageNotFound) } for _, box := range req.Boxes { if box.Cx < 0 || box.Cy < 0 || box.W <= 0 || box.H <= 0 || box.Cx > 1 || box.Cy > 1 { return nil, gerror.New("标注框坐标非法(需 0~1 归一化)") } } // 空框 = 清空标注;人工保存即审核定稿(有框→2 已审核,空框→0 未标注回池) raw := "" reviewStatus := consts.ReviewImageNone if len(req.Boxes) > 0 { b, err := json.Marshal(req.Boxes) if err != nil { return nil, gerror.Wrap(err, "标注序列化失败") } raw = string(b) reviewStatus = consts.ReviewImageApproved } var labeled int64 if err := common.Serial().Submit(ctx, func() error { if err := dao.DatasetImage.UpdateLabelsAndReview(ctx, img.Id, raw, reviewStatus); err != nil { return err } labeled, err = dao.DatasetImage.CountLabeledByDataset(ctx, dataset.Id) if err != nil { return err } status := "" if labeled > 0 { status = consts.DatasetStatusLabeled } return dao.Dataset.UpdateCounters(ctx, dataset.Id, 0, labeled, status) }); err != nil { return nil, err } return &dto.AdminLabelSaveRes{LabeledCount: labeled}, nil } // prepareCombinedYoloSet 综合训练打包(2026-09-09 多物种合并模型):多数据集合并为一个 // 全类训练包。全局类别表 = 各数据集物种名按 datasetIds 传入顺序(gen_species 回退数据集名) // + 共享 suspect 置末位;各数据集标注 class 0(本物种)重映射到该数据集物种下标、 // class 1(suspect)重映射到末位下标;图片/标签名加 d_ 前缀防跨数据集重名; // 负样本库只混一份(与单物种语义一致);80/20 拆分在合并后总池上做。 // 返回训练包与类别名表(data.yaml names 由发起方用该表生成)。 func (s *labelTaskService) prepareCombinedYoloSet(ctx context.Context, datasetIds []int64) (*common.YoloPackage, []string, error) { type src struct { dataset *entity.Dataset images []*entity.DatasetImage imgDir string } var names []string var sources []src for _, id := range datasetIds { d, err := dao.Dataset.GetById(ctx, id) if err != nil { return nil, nil, err } if d == nil { return nil, nil, gerror.Newf("数据集 %d 不存在", id) } if d.Source == consts.DatasetSourceNegative { return nil, nil, gerror.New("负样本库不参与综合训练(打包时自动混入)") } images, err := dao.DatasetImage.ListByDataset(ctx, d.Id) if err != nil { return nil, nil, err } species := strings.TrimSpace(d.GenSpecies) if species == "" { species = d.Name } names = append(names, species) sources = append(sources, src{dataset: d, images: images, imgDir: common.DatasetImagesDir(ctx, d.Name)}) } names = append(names, "suspect") suspectIdx := len(names) - 1 type item struct { filename string lines string imgPath string } var kept []item for si, sc := range sources { valid := 0 for _, img := range sc.images { if img.CleanExcluded == 1 { continue // 数据清洗排除图不进训练集 } if img.ReviewStatus != consts.ReviewImageApproved { continue // 训练集只收人工审核通过的图 } if img.LabelsJson == "" || img.LabelsJson == "[]" { continue // 空框(确认无目标)不进训练集 } var boxes []*dto.AdminLabelBox if json.Unmarshal([]byte(img.LabelsJson), &boxes) != nil || len(boxes) == 0 { continue } var b strings.Builder for _, box := range boxes { cls := suspectIdx if box.Class <= 0 { cls = si // class 0 = 本物种 → 该数据集物种下标;其余(suspect)→ 末位 } fmt.Fprintf(&b, "%d %.6f %.6f %.6f %.6f\n", cls, box.Cx, box.Cy, box.W, box.H) } kept = append(kept, item{ filename: fmt.Sprintf("d%d_%s", sc.dataset.Id, img.Filename), lines: strings.TrimSpace(b.String()), imgPath: filepath.Join(sc.imgDir, img.Filename), }) valid++ } if valid == 0 { return nil, nil, gerror.Newf("数据集 %s 无有效标注,请先完成标注审核", sc.dataset.Name) } } // 固定随机种子 + 20% val(至少 1 张,语义同单物种 prepareYoloSet) idx := make([]int, len(kept)) for i := range idx { idx[i] = i } randShuffle(idx) nVal := len(kept) / 5 if nVal < 1 { nVal = 1 } pkg := &common.YoloPackage{} addSplit := func(split string, items []item) { for _, it := range items { pkg.Files = append(pkg.Files, common.YoloFile{Name: filepath.Join("images", split, it.filename), ImagePath: it.imgPath}, common.YoloFile{Name: filepath.Join("labels", split, strings.TrimSuffix(it.filename, filepath.Ext(it.filename))+".txt"), Content: []byte(it.lines + "\n")}, ) } } var trainItems, valItems []item for i, it := range kept { if i < nVal { valItems = append(valItems, it) } else { trainItems = append(trainItems, it) } } addSplit("train", trainItems) addSplit("val", valItems) // 负样本库只混一份(空标签 = 背景图) if err := s.appendNegatives(ctx, pkg); err != nil { return nil, nil, err } return pkg, names, nil } // 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) if err != nil { return nil, err } type item struct { filename string lines string } var kept []item for _, img := range images { if img.CleanExcluded == 1 { continue // 数据清洗排除图不进训练集(图片/标注保留,可恢复) } if img.ReviewStatus != consts.ReviewImageApproved { continue // 训练集只收人工审核通过的图(标签质量闸门,2026-09-04 审核状态机) } if img.LabelsJson == "" || img.LabelsJson == "[]" { continue // 空框(确认无目标)不进训练集:维持原负样本不入包行为 } var boxes []*dto.AdminLabelBox if json.Unmarshal([]byte(img.LabelsJson), &boxes) != nil || len(boxes) == 0 { continue } var b strings.Builder for _, box := range boxes { fmt.Fprintf(&b, "%d %.6f %.6f %.6f %.6f\n", box.Class, box.Cx, box.Cy, box.W, box.H) } kept = append(kept, item{filename: img.Filename, lines: strings.TrimSpace(b.String())}) } if len(kept) == 0 { return nil, gerror.New("数据集无有效标注,请先在标注工作台完成标注") } // 固定随机种子 + 20% val(至少 1 张,语义沿用原 prepare_yolo.py) idx := make([]int, len(kept)) for i := range idx { idx[i] = i } randShuffle(idx) nVal := len(kept) / 5 if nVal < 1 { nVal = 1 } imgSrc := common.DatasetImagesDir(ctx, dataset.Name) pkg := &common.YoloPackage{} addSplit := func(split string, items []item) { for _, it := range items { pkg.Files = append(pkg.Files, common.YoloFile{Name: filepath.Join("images", split, it.filename), ImagePath: filepath.Join(imgSrc, it.filename)}, common.YoloFile{Name: filepath.Join("labels", split, strings.TrimSuffix(it.filename, filepath.Ext(it.filename))+".txt"), Content: []byte(it.lines + "\n")}, ) } } var trainItems, valItems []item for i, it := range kept { if i < nVal { valItems = append(valItems, it) } else { trainItems = append(trainItems, it) } } addSplit("train", trainItems) addSplit("val", valItems) // 混入负样本库(技术设计.md「负样本库」):全部物种数据集共用一套统一背景样本, // 库不存在/为空时行为与无负样本完全一致 if err := s.appendNegatives(ctx, pkg); err != nil { return nil, err } return pkg, nil } // appendNegatives 训练包混入负样本库图片:空标签 txt = 背景图(ultralytics 自动按背景学习, // 无需改训练脚本),文件名加 neg_ 前缀防与主数据集图重名覆盖;负样本无审核语义 // (不看 review_status/clean_excluded),同一 80/20 逻辑拆 train/val(val 含负样本 // 可在训练指标中观测背景误检);label txt 写空内容。 func (s *labelTaskService) appendNegatives(ctx context.Context, pkg *common.YoloPackage) error { lib, err := dao.Dataset.GetByName(ctx, consts.NegativeDatasetName) if err != nil { return err } if lib == nil { return nil } images, err := dao.DatasetImage.ListByDataset(ctx, lib.Id) if err != nil { return err } if len(images) == 0 { return nil } idx := make([]int, len(images)) for i := range idx { idx[i] = i } randShuffle(idx) nVal := len(idx) / 5 srcDir := common.DatasetImagesDir(ctx, lib.Name) for i, j := range idx { split := "train" if i < nVal { split = "val" } name := "neg_" + images[j].Filename pkg.Files = append(pkg.Files, common.YoloFile{Name: filepath.Join("images", split, name), ImagePath: filepath.Join(srcDir, images[j].Filename)}, common.YoloFile{Name: filepath.Join("labels", split, strings.TrimSuffix(name, filepath.Ext(name))+".txt"), Content: []byte{}}, ) } return nil } // randShuffle Fisher-Yates 伪随机(固定种子,沿用原 prepare_yolo.py random.seed(42) 语义) func randShuffle(n []int) { state := uint32(42) seed := func() uint32 { state = state*1664525 + 1013904223 return state } for i := len(n) - 1; i > 0; i-- { j := int(seed() % uint32(i+1)) n[i], n[j] = n[j], n[i] } } // suppressOverlap 重叠去重(NMS 风格):按置信度降序依次保留,与已保留框重叠比 > overlapThreshold // 的框剔除(RF-DETR 同一目标重复检出时多个高度重叠框,只留置信度最高者;跨 class 去重)。 func suppressOverlap(boxes []*dto.AdminLabelBox, overlapThreshold float64) []*dto.AdminLabelBox { if len(boxes) <= 1 { return boxes } sorted := append([]*dto.AdminLabelBox(nil), boxes...) sort.Slice(sorted, func(i, j int) bool { return sorted[i].Confidence > sorted[j].Confidence }) kept := make([]*dto.AdminLabelBox, 0, len(sorted)) for i := range sorted { dup := false for _, k := range kept { if boxOverlap(sorted[i], k) > overlapThreshold { dup = true break } } if !dup { kept = append(kept, sorted[i]) } } return kept } // boxOverlap 两个归一化框(cx,cy,w,h)的重叠比(minIoU):交叠面积 / 两框较小面积。 // 用 minIoU 而非 IoU:RF-DETR 对同一目标常输出一大一小两个框(IoU 仅 0.3~0.5), // 大框套小框时小框被覆盖比例高(0.3~0.9)能命中;相邻目标两框互有外露,minIoU 通常 < 0.3。 func boxOverlap(a, b *dto.AdminLabelBox) float64 { ax1, ay1, ax2, ay2 := a.Cx-a.W/2, a.Cy-a.H/2, a.Cx+a.W/2, a.Cy+a.H/2 bx1, by1, bx2, by2 := b.Cx-b.W/2, b.Cy-b.H/2, b.Cx+b.W/2, b.Cy+b.H/2 ix1, iy1 := math.Max(ax1, bx1), math.Max(ay1, by1) ix2, iy2 := math.Min(ax2, bx2), math.Min(ay2, by2) if ix2 <= ix1 || iy2 <= iy1 { return 0 } inter := (ix2 - ix1) * (iy2 - iy1) minArea := math.Min(a.W*a.H, b.W*b.H) if minArea <= 0 { return 0 } return inter / minArea } // imageSize 读取图片尺寸(文件头,不解码全图) func imageSize(data []byte) (int, int) { cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return 0, 0 } return cfg.Width, cfg.Height } func imageMime(filename string) string { switch strings.ToLower(filepath.Ext(filename)) { case ".png": return "image/png" default: return "image/jpeg" } } func clamp01(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v }