- 后端:/admin/trainings/combined 发起(≥2 数据集、类别重映射、防重名、负样本单份); model_training/model_version 加 kind+dataset_ids(迁移 v14),综合任务 dataset_id=0、 文件基名 combined(_n)、版本序列独立;训练列表补 published 标记 - 管理端:数据训练页工具栏发起综合训练;横幅常驻进行中任务 + 每档最近一条已结束任务, 成功未发布给「发布模型」入口(可关闭收起) - App:目录解析 kind/datasetIds、激活覆盖互斥、自动更新退场改目标档待办横幅手动一键下载
950 lines
34 KiB
Go
950 lines
34 KiB
Go
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<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/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
|
||
}
|