Files
observer/server/biz/service/dataset.go
T
2026-08-31 18:35:58 +08:00

1471 lines
58 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"
"io"
"math"
"math/rand"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
"golang.org/x/image/draw"
"observer-server/biz/consts"
"observer-server/biz/dao"
"observer-server/biz/model/dto"
"observer-server/biz/model/entity"
"observer-server/common"
)
// datasetService 数据集业务:管理端「数据集管理」模块。
// 图片文件在 app.datasetDir/datasets/<name>/(平铺、文件名唯一),DB 只存元数据;
// AI 生成为付费资产,删除类接口由前端带确认文案(后端不额外拦截)。
type datasetService struct{}
var Dataset = &datasetService{}
// imgExts 允许上传/保存的图片扩展名
var imgExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true}
// datasetNameRe 数据集名称规则(与新建接口 DTO 校验一致:中文/字母/数字/下划线/短横线,1-50)
// 更新接口的名称为可选字段,空值合法,正则不能进 DTO 校验链,放 service 侧判定。
var datasetNameRe = regexp.MustCompile(`^[a-zA-Z0-9_一-龥-]{1,50}$`)
// AdminListDatasets 数据集分页列表(卡片展示:封面/描述/训练配置标记 + 最新训练状态聚合)
func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDatasetListReq) (*dto.AdminDatasetListRes, error) {
page, size := common.NormalizePage(req.Page, req.Size)
var list []*entity.Dataset
var total int64
var err error
if req.Keyword != "" {
list, total, err = dao.Dataset.PageByKeyword(ctx, req.Keyword, page, size)
} else {
list, total, err = dao.Dataset.Page(ctx, page, size)
}
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(list))
for _, v := range list {
ids = append(ids, v.Id)
}
// 图片数/已标注数实时统计(冗余计数列 image_count/labeled_count 曾因生成任务部分失败
// 未回写等路径漂移为负数,展示一律以 dataset_image 实际统计为准)
stats, err := dao.DatasetImage.CountStatByDatasets(ctx, ids)
if err != nil {
return nil, err
}
latest, err := dao.Training.LatestByDatasets(ctx, ids)
if err != nil {
return nil, err
}
// 已发布过的训练不再返回发布按钮(model_version.training_id 反查)
trainingIds := make([]int64, 0, len(latest))
for _, t := range latest {
trainingIds = append(trainingIds, t.Id)
}
published, err := dao.ModelVersion.PublishedByTrainingIds(ctx, trainingIds)
if err != nil {
return nil, err
}
items := make([]*dto.AdminDatasetItem, 0, len(list))
for _, v := range list {
st := stats[v.Id] // 无图片行时为零值(计数 0),不落冗余列
item := &dto.AdminDatasetItem{
Id: v.Id,
Name: v.Name,
Source: v.Source,
ImageCount: st.Count,
LabeledCount: st.Labeled,
Status: v.Status,
Cover: v.Cover,
Description: v.Description,
NamePrefix: v.NamePrefix,
GenSpecies: v.GenSpecies,
GenTone: v.GenTone,
GenHeights: v.GenHeights,
GenScenes: v.GenScenes,
GenActions: v.GenActions,
GenOcclusions: v.GenOcclusions,
GenClasses: v.GenClasses,
SortOrder: v.SortOrder,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}
if t, ok := latest[v.Id]; ok {
item.TrainingId = t.Id
item.TrainingStatus = t.Status
item.TrainingError = t.Error
item.TrainingPublished = published[t.Id]
item.TrainingCurrentEpoch = t.CurrentEpoch
item.TrainingTotalEpochs = t.TotalEpochs
}
items = append(items, item)
}
return &dto.AdminDatasetListRes{Total: total, List: items}, nil
}
// AdminUpdateDataset 更新数据集展示配置(空值字段不覆盖;封面/描述。
// AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh))
func (s *datasetService) AdminUpdateDataset(ctx context.Context, req *dto.AdminDatasetUpdateReq) (*dto.AdminDatasetUpdateRes, error) {
existing, err := dao.Dataset.GetById(ctx, req.Id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
newName := strings.TrimSpace(req.Name)
prefix := strings.TrimSpace(req.NamePrefix)
if prefix != "" && !datasetNameRe.MatchString(prefix) {
return nil, gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50")
}
if newName != "" && newName != existing.Name {
// 改名(可同时改前缀):图片目录 + 模型文件随命名迁移(模型文件名 = 前缀,空回退数据集名)
if err := s.renameDataset(ctx, existing.Id, existing.Name, newName, existing.NamePrefix, prefix); err != nil {
return nil, err
}
existing.Name = newName
} else if prefix != existing.NamePrefix {
// 仅前缀变更:模型文件名随前缀迁移(改名场景已由 renameDataset 处理)
migrateModelFile(ctx, existing.Name, existing.NamePrefix, existing.Name, prefix)
}
// 封面须属于该数据集图片(防伪造文件名指向任意文件)
if req.Cover != "" {
img, err := dao.DatasetImage.GetByFilename(ctx, req.Id, req.Cover)
if err != nil {
return nil, err
}
if img == nil {
return nil, gerror.NewCode(common.CodeImageNotFound)
}
}
if err := dao.Dataset.UpdateConfigs(ctx, req.Id, &entity.Dataset{
Name: newName,
NamePrefix: prefix,
Cover: req.Cover,
}); err != nil {
return nil, err
}
// 描述允许清空(UpdateConfigs 空值不覆盖),单独更新
if err := dao.Dataset.UpdateDescription(ctx, req.Id, strings.TrimSpace(req.Description)); err != nil {
return nil, err
}
// 序号独立更新(指针区分未传与显式 0;0 时按创建时间倒序排)
if req.SortOrder != nil {
if err := dao.Dataset.UpdateSortOrder(ctx, req.Id, *req.SortOrder); err != nil {
return nil, err
}
}
// gen_* 7 列不在此维护:创建数据集时 VLM 生成,重新生成走 gen-pools 接口
return &dto.AdminDatasetUpdateRes{Id: req.Id}, nil
}
// renameDataset 数据集改名:名称同是磁盘目录名,需同步迁移图片目录;
// 模型文件按文件名前缀命名(空回退数据集名),随改名/改前缀迁移(失败仅记日志不阻断——旧文件保留)。
// 进行中的标注/训练任务持有旧路径,须先等任务结束;改名在 Serial 串行执行,与其他写操作互斥。
func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, newName, oldPrefix, newPrefix string) error {
err := common.Serial().Submit(ctx, func() error {
if !datasetNameRe.MatchString(newName) {
return gerror.New("数据集名称仅支持中文/字母/数字/下划线/短横线,长度 1-50")
}
// 预标注任务进行中(RF-DETR 正在按旧目录读图)
if t, err := dao.LabelTask.GetRunningByDataset(ctx, id); err != nil {
return err
} else if t != nil {
return gerror.NewCode(common.CodeLabelTaskRunning)
}
// 训练进行中(同步目录与产物路径以旧名构建)
if t, err := dao.Training.RunningByDataset(ctx, id); err != nil {
return err
} else if t != nil {
return gerror.New("该数据集有训练任务进行中,无法重命名")
}
// 同名占用检查(UNIQUE 约束兜底)
exists, err := dao.Dataset.GetByName(ctx, newName)
if err != nil {
return err
}
if exists != nil {
return gerror.NewCode(common.CodeDatasetNameDuplicate)
}
// 图片目录迁移:失败回滚已迁移项(图片目录可能不存在,缺省跳过)
oldImgDir := common.DatasetImagesDir(ctx, oldName)
newImgDir := common.DatasetImagesDir(ctx, newName)
movedImgDir := false
if _, err := os.Stat(oldImgDir); err == nil {
if err := os.Rename(oldImgDir, newImgDir); err != nil {
return gerror.Wrap(err, "迁移图片目录失败")
}
movedImgDir = true
}
// 模型文件随命名迁移(prefix 空回退数据集名;改名不改前缀时路径不变,内部跳过)
migrateModelFile(ctx, oldName, oldPrefix, newName, newPrefix)
if err := dao.Dataset.UpdateConfigs(ctx, id, &entity.Dataset{Name: newName}); err != nil {
// DB 失败回滚已迁移项(模型文件反向迁移失败仅记日志,旧文件可能留新名)
migrateModelFile(ctx, newName, newPrefix, oldName, oldPrefix)
if movedImgDir {
if rbErr := os.Rename(newImgDir, oldImgDir); rbErr != nil {
g.Log().Errorf(ctx, "回滚图片目录失败: %+v", rbErr)
}
}
return err
}
g.Log().Infof(ctx, "数据集 %d 改名完成: %s -> %s", id, oldName, newName)
return nil
})
return err
}
// migrateModelFile 模型文件随命名变更迁移(基名 = 文件名前缀,空回退数据集名):
// 旧文件不存在或新旧路径相同(前缀未变且非空)直接跳过;失败仅记日志不阻断业务——
// 模型文件为付费训练产物,保留旧名总比删除好(下次训练直写新名覆盖)。
func migrateModelFile(ctx context.Context, oldName, oldPrefix, newName, newPrefix string) {
oldPath := common.TrainingModelPath(ctx, modelFileName(oldName, oldPrefix))
newPath := common.TrainingModelPath(ctx, modelFileName(newName, newPrefix))
if oldPath == newPath {
return
}
if _, err := os.Stat(oldPath); err != nil {
return
}
if err := os.Rename(oldPath, newPath); err != nil {
g.Log().Errorf(ctx, "迁移模型文件 %s → %s 失败: %+v", oldPath, newPath, err)
}
}
// AdminUploadCover 上传数据集封面:解码校验图片 → 转 jpg(Quality 92)→ UUID 命名落盘,
// 删除旧封面文件(UUID 互不覆盖,但 DB 指向切换)→ 更新 cover 字段。
func (s *datasetService) AdminUploadCover(ctx context.Context, req *dto.AdminDatasetCoverUploadReq) (*dto.AdminDatasetCoverUploadRes, 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 req.File == nil {
return nil, gerror.New("缺少封面文件")
}
ext := strings.ToLower(filepath.Ext(req.File.Filename))
if !imgExts[ext] {
return nil, gerror.New("封面仅支持 jpg/jpeg/png")
}
if req.File.Size > 10*1024*1024 {
return nil, gerror.New("封面不能超过 10MB")
}
raw, err := req.File.Open()
if err != nil {
return nil, gerror.Wrap(err, "读取封面文件失败")
}
defer func() { _ = raw.Close() }()
data, err := io.ReadAll(raw)
if err != nil {
return nil, gerror.Wrap(err, "读取封面文件失败")
}
// 统一压缩为 1248x704(任意尺寸源图 → 目标尺寸 jpg)
data, err = resizeCover(data)
if err != nil {
return nil, gerror.Wrap(err, "封面文件不是有效图片")
}
dir := common.DatasetImagesDir(ctx, dataset.Name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, gerror.Wrap(err, "创建图片目录失败")
}
if dataset.Cover != "" {
_ = os.Remove(filepath.Join(dir, filepath.Base(dataset.Cover)))
}
name := common.UuidV4() + ".jpg"
if err := common.WriteFileAtomic(filepath.Join(dir, name), data); err != nil {
return nil, gerror.Wrap(err, "保存封面失败")
}
if err := dao.Dataset.UpdateConfigs(ctx, dataset.Id, &entity.Dataset{Cover: name}); err != nil {
_ = os.Remove(filepath.Join(dir, name))
return nil, err
}
return &dto.AdminDatasetCoverUploadRes{}, nil
}
// coverNameRe 封面命名规范:UUIDv4 + .jpg(固定命名 cover* 为历史遗留,迁移见 MigrateLegacyCovers
var coverNameRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.jpg$`)
// isCoverName 校验 DB cover 值是否符合 UUID jpg 规范(防御库内被写入任意文件名)
func isCoverName(name string) bool {
return coverNameRe.MatchString(filepath.Base(name))
}
// CoverFile 封面文件定位(校验归属;controller 直写响应体输出)。
// datasetId>0 从库取封面名;datasetId=0 时按 name+filename 直读(新建对话框预生成封面回显,
// 数据集未创建无库记录;filename 须符合 UUID jpg 规范防路径穿越)。
func (s *datasetService) CoverFile(ctx context.Context, datasetId int64, name, filename string) (string, error) {
if datasetId > 0 {
dataset, err := dao.Dataset.GetById(ctx, datasetId)
if err != nil {
return "", err
}
if dataset == nil {
return "", gerror.NewCode(common.CodeDatasetNotFound)
}
if dataset.Cover == "" {
return "", gerror.NewCode(common.CodeImageNotFound)
}
name = dataset.Name
filename = dataset.Cover
}
name = filepath.Base(name)
if !datasetNameRe.MatchString(name) {
return "", gerror.NewCode(common.CodeImageNotFound)
}
filename = filepath.Base(filename)
if !isCoverName(filename) {
return "", gerror.NewCode(common.CodeImageNotFound)
}
path := filepath.Join(common.DatasetImagesDir(ctx, name), filename)
if _, err := os.Stat(path); err != nil {
return "", gerror.NewCode(common.CodeImageNotFound)
}
return path, nil
}
// AppCoverFile 公开封面定位(App 模型清单缩略图):按训练文件名前缀查数据集,
// 复用 CoverFile 的归属校验(数据集不存在/无封面/文件缺失 → 404 错误码)。
func (s *datasetService) AppCoverFile(ctx context.Context, namePrefix string) (string, error) {
d, err := dao.Dataset.GetByNamePrefix(ctx, strings.TrimSpace(namePrefix))
if err != nil {
return "", err
}
if d == nil {
return "", gerror.NewCode(common.CodeDatasetNotFound)
}
return s.CoverFile(ctx, d.Id, "", "")
}
// AdminDeleteCover 删除数据集封面:删文件 + 清 cover 字段(列表卡片恢复占位图)
func (s *datasetService) AdminDeleteCover(ctx context.Context, req *dto.AdminDatasetCoverDeleteReq) (*dto.AdminDatasetCoverDeleteRes, 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.Cover != "" && isCoverName(dataset.Cover) {
_ = os.Remove(filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), filepath.Base(dataset.Cover)))
}
if err := dao.Dataset.ClearCover(ctx, dataset.Id); err != nil {
return nil, err
}
return &dto.AdminDatasetCoverDeleteRes{}, nil
}
// AdminGenCover 生成数据集封面(对话框「AI 生成封面」按钮入口):
// datasetId>0 走完整链路(16:9、1 雄 1 雌,物种取 gen_species 空回退数据集名,写库+删旧封面);
// datasetId=0 新建预生成(物种=表单数据集名,仅落盘,创建请求带 cover 回传写库);
// 成功返回新 cover 文件名供前端回显;失败报错不覆盖旧封面。
func (s *datasetService) AdminGenCover(ctx context.Context, req *dto.AdminGenCoverReq) (*dto.AdminGenCoverRes, error) {
datasetName := strings.TrimSpace(req.Name)
species := ""
if req.DatasetId > 0 {
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
datasetName = dataset.Name
species = dataset.GenSpecies
}
if !datasetNameRe.MatchString(datasetName) {
return nil, gerror.New("缺少有效的数据集名称(新建时请先填写名称再生成封面)")
}
cover, err := s.genCoverWithImageGen(ctx, req.DatasetId, datasetName, species)
if err != nil {
return nil, err
}
return &dto.AdminGenCoverRes{Cover: cover}, nil
}
// MigrateLegacyCovers 存量封面迁移:cover 非空且不符合 UUID jpg 规范(历史 cover.jpg/cover.png 等)
// → 重命名为 <uuid>.jpg(保留原图字节)并更新 cover 字段。幂等:已符合规范的行跳过。
func (s *datasetService) MigrateLegacyCovers(ctx context.Context) error {
list, err := dao.Dataset.ListAll(ctx)
if err != nil {
return err
}
migrated := 0
for _, d := range list {
if d.Cover == "" || isCoverName(d.Cover) {
continue
}
oldName := filepath.Base(d.Cover)
src := filepath.Join(common.DatasetImagesDir(ctx, d.Name), oldName)
data, err := os.ReadFile(src)
if err != nil {
g.Log().Warningf(ctx, "数据集 %s 封面迁移跳过(文件不存在: %s): %v", d.Name, src, err)
continue
}
newName := common.UuidV4() + ".jpg"
if err := common.WriteFileAtomic(filepath.Join(common.DatasetImagesDir(ctx, d.Name), newName), data); err != nil {
g.Log().Errorf(ctx, "数据集 %s 封面迁移写新文件失败: %+v", d.Name, err)
continue
}
if err := dao.Dataset.UpdateConfigs(ctx, d.Id, &entity.Dataset{Cover: newName}); err != nil {
g.Log().Errorf(ctx, "数据集 %s 封面迁移更新字段失败: %+v", d.Name, err)
continue
}
_ = os.Remove(src)
migrated++
g.Log().Infof(ctx, "数据集 %s 封面迁移: %s → %s", d.Name, oldName, newName)
}
if migrated > 0 {
g.Log().Infof(ctx, "封面存量迁移完成: 共 %d 个数据集", migrated)
}
return nil
}
// CompressExistingCovers 存量封面尺寸统一迁移:cover 非空且尺寸不是 1248x704 的
// → resizeCover 压缩覆盖写(原子写)。幂等:已是目标尺寸的行跳过,新库空跑。
func (s *datasetService) CompressExistingCovers(ctx context.Context) error {
list, err := dao.Dataset.ListAll(ctx)
if err != nil {
return err
}
compressed := 0
for _, d := range list {
if d.Cover == "" || !isCoverName(d.Cover) {
continue
}
path := filepath.Join(common.DatasetImagesDir(ctx, d.Name), d.Cover)
data, err := os.ReadFile(path)
if err != nil {
continue
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
g.Log().Warningf(ctx, "数据集 %s 封面解码失败,跳过压缩: %v", d.Name, err)
continue
}
if img.Bounds().Dx() == coverWidth && img.Bounds().Dy() == coverHeight {
continue
}
out, err := resizeCover(data)
if err != nil {
g.Log().Errorf(ctx, "数据集 %s 封面压缩失败: %+v", d.Name, err)
continue
}
if err := common.WriteFileAtomic(path, out); err != nil {
g.Log().Errorf(ctx, "数据集 %s 封面压缩写回失败: %+v", d.Name, err)
continue
}
compressed++
g.Log().Infof(ctx, "数据集 %s 封面压缩: %dx%d → %dx%d", d.Name, img.Bounds().Dx(), img.Bounds().Dy(), coverWidth, coverHeight)
}
if compressed > 0 {
g.Log().Infof(ctx, "封面存量压缩完成: 共 %d 个数据集", compressed)
}
return nil
}
// AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录 +
// VLM 同步生成生成参数池(失败不阻断创建,poolsGenerated/poolError 反馈)。
func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminDatasetCreateReq) (*dto.AdminDatasetCreateRes, error) {
now := gtime.Now()
var id int64
err := common.Serial().Submit(ctx, func() error {
exists, err := dao.Dataset.GetByName(ctx, req.Name)
if err != nil {
return err
}
if exists != nil {
return gerror.NewCode(common.CodeDatasetNameDuplicate)
}
prefix := strings.TrimSpace(req.NamePrefix)
if prefix != "" && !datasetNameRe.MatchString(prefix) {
return gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50")
}
sortOrder := int64(0)
if req.SortOrder != nil {
sortOrder = *req.SortOrder
}
id, err = dao.Dataset.Insert(ctx, &entity.Dataset{
Name: req.Name,
Source: req.Source,
NamePrefix: prefix,
SortOrder: sortOrder,
Status: consts.DatasetStatusBuilding,
CreatedAt: now,
UpdatedAt: now,
})
return err
})
if err != nil {
return nil, err
}
if err := os.MkdirAll(common.DatasetImagesDir(ctx, req.Name), 0o755); err != nil {
return nil, gerror.Wrap(err, "创建数据集目录失败")
}
res := &dto.AdminDatasetCreateRes{Id: id}
// VLM 生成参数池:失败不阻断创建(返回原因,可用 gen-pools 补生成/手填提示词)
var species string
if pools, pErr := s.genPoolsWithVLM(ctx, req.Name); pErr != nil {
res.PoolError = pErr.Error()
} else if pools != nil {
if uErr := common.Serial().Submit(ctx, func() error {
return dao.Dataset.UpdatePools(ctx, id, pools)
}); uErr == nil {
res.PoolsGenerated = true
species = pools.GenSpecies
} else {
res.PoolError = "参数写入失败: " + uErr.Error()
}
}
// 封面:新建对话框预生成(req.Cover 非空且文件已落盘)优先——跳过自动生成;否则自动生成
// (16:9、1雄1雌),失败不阻断创建(前端提示可编辑模式重新生成)
if cover := strings.TrimSpace(req.Cover); cover != "" && isCoverName(cover) {
if _, statErr := os.Stat(filepath.Join(common.DatasetImagesDir(ctx, req.Name), filepath.Base(cover))); statErr == nil {
if uErr := common.Serial().Submit(ctx, func() error {
return dao.Dataset.UpdateConfigs(ctx, id, &entity.Dataset{Cover: filepath.Base(cover)})
}); uErr == nil {
res.CoverGenerated = true
return res, nil
} else {
res.CoverError = "封面写入失败: " + uErr.Error()
}
}
}
if _, coverErr := s.genCoverWithImageGen(ctx, id, req.Name, species); coverErr != nil {
res.CoverError = coverErr.Error()
} else {
res.CoverGenerated = true
}
return res, nil
}
// ensureJpeg 模型生成的图片统一转 jpg:解码校验 + 非 jpegpng/webp 等)转 jpeg(Quality 92)。
// 生成服务返回格式不可控,扩展名统一 .jpg,内容须与扩展名一致(RF-DETR 按扩展名推断 mime 提交)。
func ensureJpeg(data []byte) ([]byte, error) {
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, gerror.Wrap(err, "生成图片解码失败")
}
if format == "jpeg" {
return data, nil
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 92}); err != nil {
return nil, gerror.Wrap(err, "生成图片转 jpg 失败")
}
return buf.Bytes(), nil
}
// 封面统一尺寸(2026-08-31 用户定案):生成/上传/存量压缩一律 1248x704(16:9 横幅,App 卡片展示规格)
const (
coverWidth = 1248
coverHeight = 704
)
// resizeCover 封面图片统一压缩为 1248x704:解码校验 → 按目标比例中心裁剪 → CatmullRom 缩放 → jpg(Quality 92)。
// 透明通道(png)先合成白底再缩放(jpg 无 alpha,白底比黑底自然)。
func resizeCover(data []byte) ([]byte, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, gerror.Wrap(err, "封面图片解码失败")
}
src := image.NewRGBA(img.Bounds())
draw.Draw(src, src.Bounds(), image.White, image.Point{}, draw.Src)
draw.Draw(src, src.Bounds(), img, image.Point{}, draw.Over)
// 中心裁剪到 1248:704 比例(源图过宽裁左右,过窄裁上下)
sw, sh := src.Bounds().Dx(), src.Bounds().Dy()
targetRatio := float64(coverWidth) / float64(coverHeight)
if ratio := float64(sw) / float64(sh); ratio > targetRatio {
cw := int(float64(sh) * targetRatio)
src = cropCenter(src, cw, sh)
} else if ratio < targetRatio {
ch := int(float64(sw) / targetRatio)
src = cropCenter(src, sw, ch)
}
dst := image.NewRGBA(image.Rect(0, 0, coverWidth, coverHeight))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 92}); err != nil {
return nil, gerror.Wrap(err, "封面转 jpg 失败")
}
return buf.Bytes(), nil
}
// cropCenter 取源图中心 w×h 区域(src 为 RGBASubImage 返回同类型)
func cropCenter(src *image.RGBA, w, h int) *image.RGBA {
b := src.Bounds()
x0 := b.Min.X + (b.Dx()-w)/2
y0 := b.Min.Y + (b.Dy()-h)/2
return src.SubImage(image.Rect(x0, y0, x0+w, y0+h)).(*image.RGBA)
}
// genCoverWithImageGen 用 imageGen provider 生成数据集封面:16:91248x704)、画面 1 雄 1 雌,
// 同一物种雄性+雌性各一只;UUID 命名落盘,返回新文件名。
// datasetId>0:删旧封面文件 + 更新库 cover 字段(与手动上传同路径);
// datasetId<=0:仅落盘(新建对话框预生成,数据集未创建,创建请求带 cover 回传写库)。
// 封面仅供展示,不入 dataset_image 表(不参与训练);失败保留旧封面。
func (s *datasetService) genCoverWithImageGen(ctx context.Context, datasetId int64, datasetName, species string) (string, error) {
provider := common.ImageGen(ctx)
if provider == nil {
return "", gerror.New("图像生成服务未配置,未生成封面")
}
if species == "" {
species = datasetName
}
// 显存互斥:z-image 生成任务占显存时跳过(同步等待不现实)
running, err := dao.GenTask.ListRunning(ctx)
if err != nil {
return "", err
}
if len(running) > 0 {
return "", gerror.New("生成任务进行中(z-image 占用显存),未生成封面")
}
prompt := fmt.Sprintf("野外实拍照片:一只雄性%s和一只雌性%s并排站立在开阔的自然栖息地中,"+
"雄雌各一只清晰可见,16:9 横幅构图,真实照片质感,光线自然,画面清晰美观", species, species)
genCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
defer cancel()
// 直接指定封面统一尺寸 1248x704(z-image 接受 16 整除尺寸);resizeCover 兜底统一规格
data, err := provider.Generate(genCtx, prompt, "1248x704")
if err != nil {
return "", gerror.Wrap(err, "封面生成失败")
}
if data, err = resizeCover(data); err != nil {
return "", err
}
dir := common.DatasetImagesDir(ctx, datasetName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", gerror.Wrap(err, "创建图片目录失败")
}
if datasetId > 0 {
if dataset, dErr := dao.Dataset.GetById(ctx, datasetId); dErr == nil && dataset != nil && dataset.Cover != "" {
_ = os.Remove(filepath.Join(dir, filepath.Base(dataset.Cover)))
}
}
name := common.UuidV4() + ".jpg"
if err := common.WriteFileAtomic(filepath.Join(dir, name), data); err != nil {
return "", gerror.Wrap(err, "保存封面失败")
}
if datasetId > 0 {
if err := dao.Dataset.UpdateConfigs(ctx, datasetId, &entity.Dataset{Cover: name}); err != nil {
_ = os.Remove(filepath.Join(dir, name))
return "", err
}
}
return name, nil
}
// AdminGenPools 重新生成生成参数池(VLM 覆盖写;失败报错保留旧值——编辑对话框按钮入口)
func (s *datasetService) AdminGenPools(ctx context.Context, req *dto.AdminGenPoolsReq) (*dto.AdminGenPoolsRes, error) {
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
pools, err := s.genPoolsWithVLM(ctx, dataset.Name)
if err != nil {
return nil, err
}
if pools == nil {
return nil, gerror.New("生成参数生成失败,请稍后重试")
}
if err := common.Serial().Submit(ctx, func() error {
return dao.Dataset.UpdatePools(ctx, dataset.Id, pools)
}); err != nil {
return nil, err
}
return &dto.AdminGenPoolsRes{
GenSpecies: pools.GenSpecies, GenTone: pools.GenTone, GenHeights: pools.GenHeights,
GenScenes: pools.GenScenes, GenActions: pools.GenActions, GenOcclusions: pools.GenOcclusions,
GenClasses: pools.GenClasses,
}, nil
}
// genPoolsWithVLM 调 qwen3.6-35b-a3b 生成数据集生成参数池(单物种规则:物种=数据集名,
// VLM 只生成轮廓色/站高/场景/动作/遮挡池)。
// 无 localAi 配置/显存被生成任务占用/JSON 非法/校验不过 → 返回 error(调用方决定是否阻断);
// 校验通过返回 entity.Dataset(仅 7 个池字段,其余空)。
// 纯文本场景下 llama.cpp mmproj 需图片输入,传 64x64 纯灰占位图(提示词声明忽略图片)。
func (s *datasetService) genPoolsWithVLM(ctx context.Context, datasetName string) (*entity.Dataset, error) {
if common.LocalAiClient(ctx) == nil {
return nil, nil // 未配置标注服务:不生成也不报错(池为空,生成图片时提示补参数)
}
// 显存互斥:z-image 生成任务占显存时跳过(同步等待不现实——生成任务可达小时级)
running, err := dao.GenTask.ListRunning(ctx)
if err != nil {
return nil, err
}
if len(running) > 0 {
return nil, gerror.New("生成任务进行中(z-image 占用显存),暂无法生成参数,稍后重试")
}
target := datasetName
prompt := fmt.Sprintf(
"你是野外野生动物监测数据集专家。请为数据集「%s」设计 AI 生成训练图的生成参数池。目标动物:%s。\n"+
"生成图规格:手机远距离拍摄的野外大远景实拍图,每张图有且只有一只目标动物,目标在画面中很小。\n"+
"请输出严格 JSON(只输出 JSON 对象本身,不要任何其他文字或代码块标记):\n"+
"{\n \"tone\": \"深色\",\n \"height_cm\": 30,\n \"scenes\": [\"场景描述\", ...],\n"+
" \"actions\": [\"动作词\", ...],\n \"occlusions\": [\"遮挡描述\", ...]\n}\n"+
"要求:tone 为该物种轮廓色词(绝大多数「深色」,白化/浅色个体配「浅色」);"+
"height_cm 为该物种站高厘米(鸟=脚到头顶、兽=蹲坐/站立肩高,数值 10~200);"+
"scenes 4~6 条野外开阔场景,每条含「延伸向远方/地平线/开阔」等拉开距离的元素,符合该动物栖息习性"+
"(雉鸡→农田灌丛、野鸭→水面芦苇、兔子→草坡荒地);按该动物冬季习性配场景:冬季在本地活跃(留鸟/不南迁,如雉鸡、兔子)须含至少 1 条冬季积雪场景(如雪后田野),冬季南迁物种(如野鸭、鹌鹑)不配雪地;若该物种会落树栖息(如雉鸡、锦鸡、马鸡、鸽子等树上夜栖鸟),场景须含林缘与树上落栖场景;不上树的地栖种(如石鸡、鹌鹑)不配树上场景;actions 4~6 条动作短语(如「觅食」「走动啄食」),不能以「在/于」开头;"+
"occlusions 4~6 条遮挡描述,覆盖轻(身体半掩)到重(只露头背),遮挡物符合该动物栖息环境(草丛/芦苇/树叶/枝条等);具备树上/林缘场景的物种,遮挡池须含树叶/树枝类遮挡(不能只有草丛)。\n"+
"忽略图片内容,只按上述要求输出 JSON。",
datasetName, target)
// 64x64 纯灰占位图(VLM 输入占位,避免空白无法推理)
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 60}); err != nil {
return nil, gerror.Wrap(err, "构造 VLM 输入失败")
}
genCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
content, err := common.QwenVL(genCtx, buf.Bytes(), "image/jpeg", prompt)
if err != nil {
return nil, gerror.Wrap(err, "VLM 生成参数失败")
}
return s.parseGenPoolsJSON(content, target)
}
// parseGenPoolsJSON 解析并校验 VLM 输出的参数池 JSON(容忍代码块/前后缀),
// 物种固定为 species(单物种规则:每数据集一个物种,第一标注类别名=物种,第二类别名="suspect")。
// 校验失败返回 error(宁可报错也不落不合格池——生成图按池组装,池质量决定训练数据质量)。
func (s *datasetService) parseGenPoolsJSON(content string, species string) (*entity.Dataset, error) {
m := regexp.MustCompile(`\{[\s\S]*\}`).FindString(content)
if m == "" {
return nil, gerror.New("VLM 未输出有效 JSON")
}
var raw struct {
Tone string `json:"tone"`
HeightCm float64 `json:"height_cm"`
Scenes []string `json:"scenes"`
Actions []string `json:"actions"`
Occlusions []string `json:"occlusions"`
}
if err := json.Unmarshal([]byte(m), &raw); err != nil {
return nil, gerror.Wrap(err, "VLM 输出 JSON 解析失败")
}
valid := func(list []string, min int, maxLen int) bool {
if len(list) < min {
return false
}
for _, v := range list {
if strings.TrimSpace(v) == "" || len([]rune(v)) > maxLen {
return false
}
}
return true
}
if !valid(raw.Scenes, 3, 100) || !valid(raw.Actions, 3, 30) || !valid(raw.Occlusions, 3, 100) {
return nil, gerror.New("VLM 场景/动作/遮挡池非法(各需至少 3 条)")
}
tone := "深色"
if strings.TrimSpace(raw.Tone) != "" {
tone = strings.TrimSpace(raw.Tone)
}
// 站高存数据集表 gen_heights(数据库维护,用户可在编辑界面手改真实值),VLM 输出为初始值
height := 35.0
if raw.HeightCm >= 10 && raw.HeightCm <= 200 {
height = raw.HeightCm
}
scenesRaw, _ := json.Marshal(raw.Scenes)
actionsRaw, _ := json.Marshal(raw.Actions)
occlRaw, _ := json.Marshal(raw.Occlusions)
// 单值列:物种 / 轮廓色词 / 站高 / 第二类别名(第一类别名=物种,训练 data.yaml names / 模型 labels
return &entity.Dataset{
GenSpecies: species,
GenTone: tone,
GenHeights: height,
GenScenes: string(scenesRaw),
GenActions: string(actionsRaw),
GenOcclusions: string(occlRaw),
GenClasses: "suspect",
}, nil
}
// AdminDeleteDataset 删除数据集:有 running 标注任务 / 该数据集训练进行中 / 已发布模型版本时拒绝
// (训练产物与模型为付费资产,需先删除模型版本再删数据集)。
// 删除 = 删图片/模型目录 + 删记录(标注随图片行删除,Serial 单写者串行)。
func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteReq) (*dto.AdminDatasetDeleteRes, error) {
var name, modelName string
err := common.Serial().Submit(ctx, func() error {
d, err := dao.Dataset.GetById(ctx, req.Id)
if err != nil {
return err
}
if d == nil {
return gerror.NewCode(common.CodeDatasetNotFound)
}
name = d.Name
modelName = modelFileName(d.Name, d.NamePrefix)
// 预标注任务进行中(RF-DETR 正在扫该数据集图片)
if t, err := dao.LabelTask.GetRunningByDataset(ctx, d.Id); err != nil {
return err
} else if t != nil {
return gerror.NewCode(common.CodeLabelTaskRunning)
}
// 该数据集训练进行中(并发度 1,防文件被删训练中断)
if t, err := dao.Training.RunningByDataset(ctx, d.Id); err != nil {
return err
} else if t != nil {
return gerror.New("该数据集有训练任务进行中,无法删除")
}
// 模型版本记录随数据集级联删除(管理端无模型管理界面,2026-08-26 决策;
// 若需保留已下发模型,删除数据集前先确认客户端不再需要)
if err := dao.ModelVersion.DeleteByDataset(ctx, d.Id); err != nil {
return err
}
if err := dao.DatasetImage.DeleteByDataset(ctx, d.Id); err != nil {
return err
}
return dao.Dataset.DeleteById(ctx, d.Id)
})
if err != nil {
return nil, err
}
// 文件清理(图片目录 + 当前生效模型文件;删除失败仅记日志,记录已删)
paths := []string{common.DatasetImagesDir(ctx, name), common.TrainingModelPath(ctx, modelName)}
for _, p := range paths {
if err := os.RemoveAll(p); err != nil {
g.Log().Errorf(ctx, "删除数据集 %s 目录失败: %+v", p, err)
}
}
return &dto.AdminDatasetDeleteRes{}, nil
}
// AdminUploadImages 上传图片(多文件):重名跳过(文件名唯一约束),逐张落盘 + 批量入库。
func (s *datasetService) AdminUploadImages(ctx context.Context, req *dto.AdminDatasetUploadReq) (*dto.AdminDatasetUploadRes, error) {
if len(req.Files) == 0 {
return nil, gerror.New("请选择图片文件")
}
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
dir := common.DatasetImagesDir(ctx, dataset.Name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, gerror.Wrap(err, "创建图片目录失败")
}
res := &dto.AdminDatasetUploadRes{}
now := gtime.Now()
saved := make([]string, 0, len(req.Files)) // 本次已落盘文件名(入库失败时清理)
for _, f := range req.Files {
name := filepath.Base(f.Filename)
if !imgExts[strings.ToLower(filepath.Ext(name))] {
continue
}
exists, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, name)
if err != nil {
return nil, err
}
if exists != nil {
res.Skipped++
continue
}
if err := saveImageFile(f, dir); err != nil {
return nil, gerror.Wrapf(err, "图片 %s 保存失败", name)
}
saved = append(saved, name)
}
if len(saved) == 0 {
return res, nil
}
// 标注强语义:未配置标注服务时不允许产生无标注图(文件已落盘,失败则清掉)
if common.LocalAiClient(ctx) == nil {
for _, name := range saved {
_ = os.Remove(filepath.Join(dir, name))
}
return nil, gerror.NewCode(common.CodeLocalAiNotConfigured)
}
addedIds := make([]int64, 0, len(saved))
err = common.Serial().Submit(ctx, func() error {
for _, name := range saved {
// 重查重(并发上传兜底)+ 入库
exists, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, name)
if err != nil {
return err
}
if exists != nil {
continue
}
id, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{
DatasetId: dataset.Id,
Filename: name,
Source: "manual",
CreatedAt: now,
})
if err != nil {
return err
}
addedIds = append(addedIds, id)
res.Added++
}
return dao.Dataset.UpdateCounters(ctx, dataset.Id, int64(res.Added), -1, "")
})
if err != nil {
// 入库失败:清掉已落盘文件,保证「记录存在 ⟺ 文件存在」
for _, name := range saved {
_ = os.Remove(filepath.Join(dir, name))
}
return nil, err
}
// 自动触发标注:忙(已有 running 任务)不报错,由任务完成后的自动补标轮兜底;其他失败回滚本次入库
newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds)
if err != nil {
return nil, err
}
if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil {
// 触发失败回滚本次入库(manual 上传文件非付费资产,可删)
rollbackIds := make([]int64, 0, len(newImages))
for _, img := range newImages {
rollbackIds = append(rollbackIds, img.Id)
if rErr := os.Remove(filepath.Join(dir, img.Filename)); rErr != nil {
g.Log().Warningf(ctx, "回滚删除图片文件失败: %s: %v", img.Filename, rErr)
}
}
if rErr := common.Serial().Submit(ctx, func() error {
if dErr := dao.DatasetImage.DeleteByIds(ctx, rollbackIds); dErr != nil {
return dErr
}
return dao.Dataset.UpdateCounters(ctx, dataset.Id, -int64(len(rollbackIds)), 0, "")
}); rErr != nil {
g.Log().Errorf(ctx, "自动标注触发失败后的入库回滚失败: %v", rErr)
}
return nil, err
}
return res, nil
}
// saveImageFile 上传文件保存(ghttp.UploadFile.Save 已按原始文件名落盘)
func saveImageFile(f *ghttp.UploadFile, dir string) error {
saved, err := f.Save(dir)
if err != nil {
return err
}
if saved != filepath.Base(f.Filename) {
_ = os.Remove(filepath.Join(dir, saved))
}
return nil
}
// nextImageIndex 生成图顺序编号起点:数据集内 <前缀>_<数字>.jpg 的已有最大序号 + 1
func (s *datasetService) nextImageIndex(ctx context.Context, datasetId int64, prefix string) (int, error) {
list, err := dao.DatasetImage.ListByDataset(ctx, datasetId)
if err != nil {
return 0, err
}
re := regexp.MustCompile(`^` + regexp.QuoteMeta(prefix) + `_(\d+)\.jpg$`)
max := 0
for _, img := range list {
if m := re.FindStringSubmatch(img.Filename); m != nil {
if v, err := strconv.Atoi(m[1]); err == nil && v > max {
max = v
}
}
}
return max + 1, nil
}
// singleAnimalClause 目标数量约束:每张仅一只动物,手填提示词统一追加(模板已由 {count} 固定 "1只"
const singleAnimalClause = "画面中有且只有这一只动物,没有任何其他动物"
// buildPromptFromTemplate 按 config 通用模板组装提示词(单物种规则:物种固定取数据集 gen_species
// 空则数据集名本身——生成表单无物种输入,不随机)。
// 物种只写名字不写羽毛细节(模型对正确名称自带外观先验);场景/动作/遮挡从数据集表池随机
// (创建时 VLM 生成,config.yml 不再兜底;池空报错——生成图按池组装,池质量决定训练数据质量);
// 光线走 config 通用池 lights。
func (s *datasetService) buildPromptFromTemplate(ctx context.Context, dataset *entity.Dataset, dist int, size string) (string, error) {
tpl := g.Cfg().MustGet(ctx, "imageGen.promptTemplate").String()
if tpl == "" {
return "", gerror.New("未配置通用提示词模板(imageGen.promptTemplate),请手填提示词")
}
species := strings.TrimSpace(dataset.GenSpecies)
if species == "" {
species = dataset.Name
}
// 场景/动作/遮挡按数据集习性取池(表存储,VLM 生成;池空报错——生成图按池组装,池质量决定训练数据质量)
poolOf := func(raw string) []string {
var pool []string
if json.Unmarshal([]byte(raw), &pool) != nil {
return nil
}
return pool
}
scene := pickCfgList(poolOf(dataset.GenScenes))
action := pickCfgList(poolOf(dataset.GenActions))
occlusion := pickCfgList(poolOf(dataset.GenOcclusions))
if scene == "" || action == "" || occlusion == "" {
return "", gerror.New("数据集「" + dataset.Name + "」未配置生成参数池(场景/动作/遮挡),请在编辑数据集生成或手填提示词")
}
light := pickCfg(ctx, "imageGen.lights")
// 轮廓色词(表 gen_tone,白化个体如白雉鸡配浅色,未配置默认深色剪影)
tone := strings.TrimSpace(dataset.GenTone)
if tone == "" {
tone = "深色"
}
// 距离描述从表单固定值生成(25 → "25米外"
distWord := fmt.Sprintf("%d米外", dist)
// 尺寸提示:按物理公式把表单距离换算成目标在图中的像素高与画面占比
sizeHint, pctVal, sErr := s.buildSizeHint(ctx, dataset, dist, size)
if sErr != nil {
return "", sErr
}
// 占比 <2% 时切换远景小目标模板:目标小到动作/遮挡/性别细节无法呈现,
// 保留细节描写会迫使模型把目标画大(2026-08-31 实测两次失败后定案)
if pctVal < 2.0 {
if tiny := g.Cfg().MustGet(ctx, "imageGen.promptTemplateTiny").String(); tiny != "" {
tpl = tiny
}
}
// 每张固定 1 个目标(数量词固定,animal_count 列同为 1 供自动标注裁剪)
countWord := "1只"
// 性别随机(50/50):两性体型与外观差异大(雉鸡雄艳雌褐、野鸭雄艳雌素等),
// 每张随机让训练数据覆盖两性形态
sexWord := "雄性"
if rand.Intn(2) == 1 {
sexWord = "雌性"
}
out := tpl
for k, v := range map[string]string{
"{scene}": scene, "{species}": sexWord + species, "{count}": countWord,
"{action}": action, "{occlusion}": occlusion, "{light}": light,
"{distanceWord}": distWord, "{sizeHint}": sizeHint, "{tone}": tone,
} {
out = strings.ReplaceAll(out, k, v)
}
g.Log().Infof(ctx, "模板组装提示词: %s", out)
return out, nil
}
// buildSizeHint 尺寸提示:目标像素高 = 体高 × focalPx ÷ 距离(focalPx = 图高/2 ÷ tan(vfov/2)),
// 尺寸词按「像素高÷图高」画面占比分级(量化像素数对文生图模型无意义;固定物体类比词
// 不跨物种/距离通用,2026-08-31 用户否决),输出如「(在画面中很小的身影,约占画面高度1.0%)」;
// 返回占比供调用方按 <2% 切换远景小目标模板。占比保留一位小数,避免出现 0%
func (s *datasetService) buildSizeHint(ctx context.Context, dataset *entity.Dataset, dist int, size string) (string, float64, error) {
_, imgH, err := parseGenSize(size)
if err != nil {
return "", 0, err
}
// 站高 cm 从数据集表 gen_heights 读(数据库维护,编辑界面手改真实值;<=0 物理兜底 35cm),换算成米参与投影公式
heightCM := dataset.GenHeights
if heightCM <= 0 {
heightCM = 35.0
}
heightM := heightCM / 100
// 镜头固定为手机默认主摄(垂直视场角配置值,默认 52°;2026-08-28 曾随机 [15,90]、曾试超广角,用户最终定为默认镜头)
vfov := g.Cfg().MustGet(ctx, "imageGen.assumedVfovDeg", 52.0).Float64() * math.Pi / 180
focalPx := float64(imgH) / 2 / math.Tan(vfov/2)
pxAt := func(dist int) int {
if dist <= 0 {
dist = 1
}
return int(math.Round(heightM * focalPx / float64(dist)))
}
pct := func(px int) string {
return fmt.Sprintf("%.1f", float64(px)*100/float64(imgH))
}
// 尺寸词按画面占比分级(占比=像素高÷图高,与物种/距离无关:同占比同观感;
// 固定物体类比词(针尖/米粒/拳头)不跨物种通用,用户否决 2026-08-31;
// 站高 0.6m、focalPx≈1280 时:60m≈13px≈1.0%、25m≈31px≈2.5%、100m≈8px≈0.6%
px := pxAt(dist)
pctVal := float64(px) * 100 / float64(imgH)
word := "中等大小的身影"
switch {
case pctVal < 1.0:
word = "微小到几乎难以辨认的身影"
case pctVal < 2.0:
word = "很小的身影"
case pctVal < 5.0:
word = "较小的身影"
case pctVal < 10.0:
word = "中等大小的身影"
default:
word = "较大的身影"
}
return fmt.Sprintf("(在画面中%s,约占画面高度%s%%)", word, pct(px)), pctVal, nil
}
// parseGenSize 解析 "704x1248" 形式尺寸
func parseGenSize(size string) (int, int, error) {
parts := strings.SplitN(size, "x", 2)
if len(parts) != 2 {
return 0, 0, gerror.Newf("非法图片尺寸 %q", size)
}
w, errW := strconv.Atoi(parts[0])
h, errH := strconv.Atoi(parts[1])
if errW != nil || errH != nil || w <= 0 || h <= 0 {
return 0, 0, gerror.Newf("非法图片尺寸 %q", size)
}
return w, h, nil
}
// pickCfg 取字符串数组配置项随机一项(空池返回空串)
func pickCfg(ctx context.Context, key string) string {
return pickCfgList(g.Cfg().MustGet(ctx, key).Strings())
}
// pickCfgList 从字符串数组随机取一项(空池返回空串)
func pickCfgList(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.Intn(len(pool))]
}
// AdminGenerateImages AI 生成图片(异步任务):校验通过后插 gen_task 立即返回 TaskId
// 后台协程逐张生成(不设调用超时,失败由 provider 返回错误决定),进度落库供前端轮询;
// 完成(或部分失败)后对本次新增图触发自动标注(付费资产,失败保留已生成图不删除)。
func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) {
provider := common.ImageGen(ctx)
if provider == nil {
return nil, gerror.NewCode(common.CodeImageGenNotConfigured)
}
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
// 标注强语义:未配置标注服务时不允许开始生成(避免付费资产生成后无法标注)
if common.LocalAiClient(ctx) == nil {
return nil, gerror.NewCode(common.CodeLocalAiNotConfigured)
}
dir := common.DatasetImagesDir(ctx, dataset.Name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, gerror.Wrap(err, "创建图片目录失败")
}
// 命名:数据集属性 name_prefix(创建/编辑表单维护)非空时按 <前缀>_<两位序号>.jpg 顺序编号
// (延续数据集内已有最大序号,参考既有图片目录的连续编号惯例);无前缀按时间戳命名。
// 注意:gtime.Time.Format 是 PHP 风格格式符,Go 数字布局须用 .Time.Format,否则原样透传
prefix := dataset.NamePrefix
nextIdx := 1
if prefix != "" {
if !datasetNameRe.MatchString(prefix) {
return nil, gerror.New("文件名前缀仅支持字母/数字/下划线/短横线")
}
nextIdx, err = s.nextImageIndex(ctx, dataset.Id, prefix)
if err != nil {
return nil, err
}
}
// Serial 内并发检查 + 插任务:同数据集已有 running 生成任务则拒绝
now := gtime.Now()
var taskId int64
if err := common.Serial().Submit(ctx, func() error {
running, err := dao.GenTask.GetRunningByDataset(ctx, dataset.Id)
if err != nil {
return err
}
if running != nil {
return gerror.NewCode(common.CodeGenTaskRunning)
}
taskId, err = dao.GenTask.Insert(ctx, &entity.GenTask{
DatasetId: dataset.Id,
Status: consts.GenTaskRunning,
Total: req.Count,
CreatedAt: now,
})
return err
}); err != nil {
return nil, err
}
s.runGenTask(taskId, dataset, req, provider, now, prefix, nextIdx)
return &dto.AdminDatasetGenerateRes{TaskId: taskId, Total: req.Count}, nil
}
// recoverGenTasks 服务重启恢复:孤儿 running 生成任务置 failed + 错误提示
// (生成中断后已生成图为付费资产保留;重新发起即可补齐,startDetection 并发检查不受孤儿影响)。
func (s *datasetService) recoverGenTasks(ctx context.Context) {
list, err := dao.GenTask.ListRunning(ctx)
if err != nil {
g.Log().Errorf(ctx, "恢复生成任务失败: %+v", err)
return
}
for _, t := range list {
if err := dao.GenTask.Finish(ctx, t.Id, "服务重启,任务中断,可重新发起"); err != nil {
g.Log().Errorf(ctx, "恢复生成任务 %d 失败: %+v", t.Id, err)
}
}
}
// runGenTask 生成执行协程(生命周期任务):脱离请求 ctx(请求结束即取消会让 Submit 秒退 +
// Finish 静默失败 → 任务悬挂),池内逐张生成(imageGen.poolSize 并发,z-image 显存独占默认 1),
// 每张成功后 Serial 更新进度;全部完成 → 更新数据集计数 → 自动标注 → 置 done;
// 中途失败 → 已生成图先触发自动标注 → 置 failed(错误信息带已生成张数)。
func (s *datasetService) runGenTask(taskId int64, dataset *entity.Dataset, req *dto.AdminDatasetGenerateReq, provider common.ImageGenProvider, now *gtime.Time, prefix string, nextIdx int) {
bgCtx := context.Background()
go func() {
dir := common.DatasetImagesDir(bgCtx, dataset.Name)
// 异步任务不设调用超时:超时只放弃等待、取消不了 local-ai backend
// 会造成「任务已失败、模型仍在跑」的假失败并占住 poolSize=1 的池;失败判定交给 provider 真实返回
handWritten := strings.TrimSpace(req.Prompt) != ""
generated := 0
addedIds := make([]int64, 0, req.Count)
failed := ""
for i := 0; i < req.Count; i++ {
// 提示词:手填优先(统一追加数量约束:仅一只动物,拍摄角度不带,
// 交由模型平视先验自由构图);未手填走 config 通用模板逐张组装。距离校验已取消(2026-08-28)
prompt := strings.TrimSpace(req.Prompt)
if handWritten {
prompt = strings.TrimSuffix(prompt, "。") + "。"
if !strings.Contains(prompt, "一只") && !strings.Contains(prompt, "1只") && !strings.Contains(prompt, "唯一") {
prompt += singleAnimalClause
}
} else {
var tErr error
if prompt, tErr = s.buildPromptFromTemplate(bgCtx, dataset, req.Distance, req.Size); tErr != nil {
failed = tErr.Error()
break
}
}
var data []byte
err := common.GenTaskPoolInstance().Submit(bgCtx, func(ctx context.Context) error {
d, e := provider.Generate(ctx, prompt, req.Size)
data = d
return e
})
if err != nil {
// 付费资产保留原则:已生成的不删除
failed = fmt.Sprintf("第 %d 张生成失败(已生成 %d 张): %v", i+1, generated, err)
break
}
filename := ""
if prefix != "" {
filename = fmt.Sprintf("%s_%02d.jpg", prefix, nextIdx)
nextIdx++
} else {
filename = fmt.Sprintf("gen_%s_%d.jpg", now.Time.Format("20060102150405"), i)
}
// 生成图统一转 jpg(模型可能返回 png/webp,扩展名 .jpg 须与内容一致)
data, jErr := ensureJpeg(data)
if jErr != nil {
failed = fmt.Sprintf("第 %d 张转 jpg 失败(已生成 %d 张): %v", i+1, generated, jErr)
break
}
if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil {
failed = fmt.Sprintf("第 %d 张保存失败(已生成 %d 张): %v", i+1, generated, err)
break
}
var imgId int64
if insErr := common.Serial().Submit(bgCtx, func() error {
id, err := dao.DatasetImage.Insert(bgCtx, &entity.DatasetImage{
DatasetId: dataset.Id,
Filename: filename,
Source: "ai",
Prompt: prompt,
AnimalCount: 1,
CreatedAt: now,
})
imgId = id
return err
}); insErr != nil {
failed = fmt.Sprintf("第 %d 张入库失败(已生成 %d 张): %v", i+1, generated, insErr)
break
}
addedIds = append(addedIds, imgId)
generated++
_ = common.Serial().Submit(bgCtx, func() error {
return dao.GenTask.UpdateProgress(bgCtx, taskId, generated)
})
}
if failed != "" {
s.genTaskAutoLabel(bgCtx, dataset, addedIds)
// 部分失败:已入库图按付费资产保留,计数须计入(曾漏更新致 image_count 漂移为负)
if err := common.Serial().Submit(bgCtx, func() error {
return dao.Dataset.UpdateCounters(bgCtx, dataset.Id, int64(generated), -1, "")
}); err != nil {
g.Log().Errorf(bgCtx, "生成任务 %d 部分失败后更新数据集计数失败: %+v", taskId, err)
}
_ = dao.GenTask.Finish(bgCtx, taskId, failed)
return
}
// 全部成功:更新数据集计数 → 自动标注 → 置 done
if err := common.Serial().Submit(bgCtx, func() error {
return dao.Dataset.UpdateCounters(bgCtx, dataset.Id, int64(generated), -1, "")
}); err != nil {
g.Log().Errorf(bgCtx, "生成任务 %d 更新数据集计数失败: %+v", taskId, err)
}
s.genTaskAutoLabel(bgCtx, dataset, addedIds)
_ = dao.GenTask.Finish(bgCtx, taskId, "")
}()
}
// genTaskAutoLabel 生成任务完成后对本次新增图触发自动标注:忙(已有 running 任务)不报错,
// 由标注任务完成后的自动补标轮兜底;其他失败只记日志(付费资产,不可删)。
func (s *datasetService) genTaskAutoLabel(ctx context.Context, dataset *entity.Dataset, addedIds []int64) {
if len(addedIds) == 0 {
return
}
newImages, err := dao.DatasetImage.GetByIds(ctx, addedIds)
if err != nil {
g.Log().Errorf(ctx, "生成任务后读取新增图失败: %+v", err)
return
}
if err := LabelTask.AutoLabel(ctx, dataset, newImages); err != nil {
g.Log().Errorf(ctx, "生成任务后自动标注触发失败: %+v", err)
}
}
// AdminGenTaskQuery 生成任务进度(最近一次;无任务返回 nil)
func (s *datasetService) AdminGenTaskQuery(ctx context.Context, req *dto.AdminGenTaskQueryReq) (*dto.AdminGenTaskQueryRes, error) {
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
t, err := dao.GenTask.LatestByDataset(ctx, dataset.Id)
if err != nil {
return nil, err
}
if t == nil {
return nil, nil
}
return &dto.AdminGenTaskQueryRes{
Id: t.Id,
DatasetId: t.DatasetId,
Status: t.Status,
Total: t.Total,
Done: t.Done,
Error: t.Error,
CreatedAt: t.CreatedAt,
FinishedAt: t.FinishedAt,
}, nil
}
// AdminListImages 数据集图片列表(创建时间正序,标注工作台/网格预览)
func (s *datasetService) AdminListImages(ctx context.Context, req *dto.AdminDatasetImagesReq) (*dto.AdminDatasetImagesRes, error) {
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
list, err := dao.DatasetImage.ListByDataset(ctx, dataset.Id)
if err != nil {
return nil, err
}
items := make([]*dto.AdminImageItem, 0, len(list))
for _, v := range list {
items = append(items, &dto.AdminImageItem{
Id: v.Id,
Filename: v.Filename,
Source: v.Source,
Prompt: v.Prompt,
Url: datasetImageUrl(ctx, dataset.Id, v.Filename),
CreatedAt: v.CreatedAt,
})
}
return &dto.AdminDatasetImagesRes{List: items}, nil
}
// AdminDeleteImages 删除图片:删图片文件 + 记录 + 更新计数
// (付费资产,前端带确认文案;标注/候选框 JSON 随图片行删除)
func (s *datasetService) AdminDeleteImages(ctx context.Context, req *dto.AdminDatasetImagesDeleteReq) (*dto.AdminDatasetImagesDeleteRes, error) {
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
images, err := dao.DatasetImage.GetByIds(ctx, req.Ids)
if err != nil {
return nil, err
}
var del []*entity.DatasetImage
for _, img := range images {
// 只删本数据集内的记录(防跨数据集误删)
if img.DatasetId == dataset.Id {
del = append(del, img)
}
}
if len(del) == 0 {
return &dto.AdminDatasetImagesDeleteRes{}, nil
}
// 文件清理(删除失败仅记日志,记录照删):图片文件(标注/候选随行删除)
imgDir := common.DatasetImagesDir(ctx, dataset.Name)
for _, img := range del {
_ = os.Remove(filepath.Join(imgDir, img.Filename))
}
// Serial 内:删行 → 重算已标注数 → 同步计数与状态
err = common.Serial().Submit(ctx, func() error {
ids := make([]int64, 0, len(del))
for _, img := range del {
ids = append(ids, img.Id)
}
if err := dao.DatasetImage.DeleteByIds(ctx, ids); 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, -int64(len(del)), labeled, status)
})
if err != nil {
return nil, err
}
return &dto.AdminDatasetImagesDeleteRes{}, nil
}
// datasetImageUrl 管理端图片访问地址(controller 直写响应体,见 admin.go Image 方法)
func datasetImageUrl(ctx context.Context, datasetId int64, filename string) string {
return fmt.Sprintf("/api/v1/admin/datasets/image?%s",
url.Values{"datasetId": {fmt.Sprintf("%d", datasetId)}, "filename": {filename}}.Encode())
}
// ImageFile 图片文件定位(校验归属后返回绝对路径;controller 直写响应体)
func (s *datasetService) ImageFile(ctx context.Context, datasetId int64, filename string) (string, error) {
if filename != filepath.Base(filename) || !imgExts[strings.ToLower(filepath.Ext(filename))] {
return "", gerror.NewCode(common.CodeImageNotFound)
}
dataset, err := dao.Dataset.GetById(ctx, datasetId)
if err != nil {
return "", err
}
if dataset == nil {
return "", gerror.NewCode(common.CodeDatasetNotFound)
}
img, err := dao.DatasetImage.GetByFilename(ctx, dataset.Id, filename)
if err != nil {
return "", err
}
if img == nil {
return "", gerror.NewCode(common.CodeImageNotFound)
}
path := filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), filename)
if _, err := os.Stat(path); err != nil {
return "", gerror.NewCode(common.CodeImageNotFound)
}
return path, nil
}