Files
observer/server/biz/service/label_task.go
T
admin 87ab06221c 综合训练(多物种合并模型)全链路:合并打包/独立版本序列/App 覆盖互斥 + 管理端发布入口
- 后端:/admin/trainings/combined 发起(≥2 数据集、类别重映射、防重名、负样本单份);
  model_training/model_version 加 kind+dataset_ids(迁移 v14),综合任务 dataset_id=0、
  文件基名 combined(_n)、版本序列独立;训练列表补 published 标记
- 管理端:数据训练页工具栏发起综合训练;横幅常驻进行中任务 + 每档最近一条已结束任务,
  成功未发布给「发布模型」入口(可关闭收起)
- App:目录解析 kind/datasetIds、激活覆盖互斥、自动更新退场改目标档待办横幅手动一键下载
2026-09-09 15:45:51 +08:00

950 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:滑窗切块逐块 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)。
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_countlabels_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 1suspect)重映射到末位下标;图片/标签名加 d<datasetId>_ 前缀防跨数据集重名;
// 负样本库只混一份(与单物种语义一致);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/valval 含负样本
// 可在训练指标中观测背景误检);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 而非 IoURF-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
}