Files
observer/server/biz/service/dataset.go
T
2026-08-28 17:45:22 +08:00

936 lines
35 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"
"fmt"
"image"
"image/jpeg"
"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"
"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)
}
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 {
item := &dto.AdminDatasetItem{
Id: v.Id,
Name: v.Name,
Source: v.Source,
ImageCount: v.ImageCount,
LabeledCount: v.LabeledCount,
Status: v.Status,
Cover: v.Cover,
Description: v.Description,
NamePrefix: v.NamePrefix,
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)
if newName != "" && newName != existing.Name {
if err := s.renameDataset(ctx, existing.Id, existing.Name, newName); err != nil {
return nil, err
}
existing.Name = newName
}
// 封面须属于该数据集图片(防伪造文件名指向任意文件)
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)
}
}
prefix := strings.TrimSpace(req.NamePrefix)
if prefix != "" && !datasetNameRe.MatchString(prefix) {
return nil, gerror.New("文件名前缀仅支持字母/数字/下划线/短横线,长度 1-50")
}
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
}
return &dto.AdminDatasetUpdateRes{Id: req.Id}, nil
}
// renameDataset 数据集改名:名称同是磁盘目录名,需同步迁移图片目录与当前生效模型文件。
// 进行中的标注/训练任务持有旧路径,须先等任务结束;改名在 Serial 串行执行,与其他写操作互斥。
func (s *datasetService) renameDataset(ctx context.Context, id int64, oldName, newName 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)
oldModel := common.TrainingModelPath(ctx, oldName)
newModel := common.TrainingModelPath(ctx, newName)
movedImgDir, movedModel := false, false
if _, err := os.Stat(oldImgDir); err == nil {
if err := os.Rename(oldImgDir, newImgDir); err != nil {
return gerror.Wrap(err, "迁移图片目录失败")
}
movedImgDir = true
}
if _, err := os.Stat(oldModel); err == nil {
if err := os.Rename(oldModel, newModel); err != nil {
if movedImgDir {
if rbErr := os.Rename(newImgDir, oldImgDir); rbErr != nil {
g.Log().Errorf(ctx, "回滚图片目录失败: %+v", rbErr)
}
}
return gerror.Wrap(err, "迁移模型文件失败")
}
movedModel = true
}
if err := dao.Dataset.UpdateConfigs(ctx, id, &entity.Dataset{Name: newName}); err != nil {
if movedModel {
if rbErr := os.Rename(newModel, oldModel); rbErr != nil {
g.Log().Errorf(ctx, "回滚模型文件失败: %+v", rbErr)
}
}
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
}
// 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 > 2*1024*1024 {
return nil, gerror.New("封面不能超过 2MB")
}
raw, err := req.File.Open()
if err != nil {
return nil, gerror.Wrap(err, "读取封面文件失败")
}
defer func() { _ = raw.Close() }()
img, _, err := image.Decode(raw)
if err != nil {
return nil, gerror.New("封面文件不是有效图片")
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 92}); err != nil {
return nil, gerror.Wrap(err, "封面转 jpg 失败")
}
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), buf.Bytes()); 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 直写响应体输出)
func (s *datasetService) CoverFile(ctx context.Context, datasetId int64) (string, error) {
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 := filepath.Base(dataset.Cover)
if !isCoverName(name) {
return "", gerror.NewCode(common.CodeImageNotFound)
}
path := filepath.Join(common.DatasetImagesDir(ctx, dataset.Name), name)
if _, err := os.Stat(path); err != nil {
return "", gerror.NewCode(common.CodeImageNotFound)
}
return path, nil
}
// 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
}
// 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
}
// AdminCreateDataset 新建数据集:名称唯一(UNIQUE 兜底)+ 创建图片目录
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")
}
id, err = dao.Dataset.Insert(ctx, &entity.Dataset{
Name: req.Name,
Source: req.Source,
NamePrefix: prefix,
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, "创建数据集目录失败")
}
return &dto.AdminDatasetCreateRes{Id: id}, nil
}
// AdminDeleteDataset 删除数据集:有 running 标注任务 / 该数据集训练进行中 / 已发布模型版本时拒绝
// (训练产物与模型为付费资产,需先删除模型版本再删数据集)。
// 删除 = 删图片/模型目录 + 删记录(标注随图片行删除,Serial 单写者串行)。
func (s *datasetService) AdminDeleteDataset(ctx context.Context, req *dto.AdminDatasetDeleteReq) (*dto.AdminDatasetDeleteRes, error) {
var name 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
// 预标注任务进行中(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, name)}
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
}
// AdminGenOptions 生成表单选项:该数据集的物种池 + 场景池(config 维护,前端下拉用)
func (s *datasetService) AdminGenOptions(ctx context.Context, req *dto.AdminDatasetGenOptionsReq) (*dto.AdminDatasetGenOptionsRes, error) {
dataset, err := dao.Dataset.GetById(ctx, req.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
return &dto.AdminDatasetGenOptionsRes{
Species: g.Cfg().MustGet(ctx, "imageGen.speciesByDataset."+dataset.Name).Strings(),
Scenes: g.Cfg().MustGet(ctx, "imageGen.scenes").Strings(),
}, nil
}
// singleAnimalClause 目标数量约束:每张仅一只动物,手填提示词统一追加(模板已由 {count} 固定 "1只"
const singleAnimalClause = "画面中有且只有这一只动物,没有任何其他动物"
// buildPromptFromTemplate 按 config 通用模板组装提示词:物种/场景优先用表单选择
// (物种只写名字不写羽毛细节——模型对正确名称自带外观先验;场景未选则池内随机),
// 物种未填按数据集名取物种池随机(池也未配置则用数据集名本身);动作/光线池内随机。
func (s *datasetService) buildPromptFromTemplate(ctx context.Context, datasetName, species string, dist int, size string) (string, error) {
tpl := g.Cfg().MustGet(ctx, "imageGen.promptTemplate").String()
if tpl == "" {
return "", gerror.New("未配置通用提示词模板(imageGen.promptTemplate),请手填提示词")
}
if species == "" {
species = datasetName
if pool := g.Cfg().MustGet(ctx, "imageGen.speciesByDataset."+datasetName).Strings(); len(pool) > 0 {
species = pool[rand.Intn(len(pool))]
}
}
// 场景/动作/遮挡按数据集习性取池(鸭水面、雉鸡灌丛……),未配置该数据集回退通用池;
// 遮挡:身体完全暴露的个体对识别标注训练无意义,每张必带(程度轻~重随机)
scene := pickCfgByDataset(ctx, datasetName, "imageGen.sceneByDataset", "imageGen.scenes")
action := pickCfgByDataset(ctx, datasetName, "imageGen.actionByDataset", "imageGen.actions")
occlusion := pickCfgByDataset(ctx, datasetName, "imageGen.occlusionByDataset", "imageGen.occlusions")
light := pickCfg(ctx, "imageGen.lights")
// 轮廓色词按物种(白化个体如白雉鸡配浅色,默认深色剪影)
tone := g.Cfg().MustGet(ctx, "imageGen.speciesTone."+species, "深色").String()
// 距离描述从表单固定值生成(25 → "25米外"
distWord := fmt.Sprintf("%d米外", dist)
// 尺寸提示:按物理公式把表单距离换算成目标在图中的像素高与画面占比
sizeHint, sErr := s.buildSizeHint(ctx, species, dist, size)
if sErr != nil {
return "", sErr
}
// 每张固定 1 个目标(数量词固定,animal_count 列同为 1 供自动标注裁剪)
countWord := "1只"
out := tpl
for k, v := range map[string]string{
"{scene}": scene, "{species}": 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)),
// 输出如「(在画面中高度仅约15像素,占画面高度约1.2%)」。
// 占比向下取整且至少 1%,避免出现 0%
func (s *datasetService) buildSizeHint(ctx context.Context, species string, dist int, size string) (string, error) {
_, imgH, err := parseGenSize(size)
if err != nil {
return "", err
}
// 配置单位为 cm(实际体型),换算成米参与投影公式;未配置物种兜底 35cm
heightCM := g.Cfg().MustGet(ctx, "imageGen.speciesHeights."+species, 35.0).Float64()
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))
}
px := pxAt(dist)
return fmt.Sprintf("(在画面中高度仅约%d像素,占画面高度约%s%%)", px, pct(px)), 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 {
pool := g.Cfg().MustGet(ctx, key).Strings()
if len(pool) == 0 {
return ""
}
return pool[rand.Intn(len(pool))]
}
// pickCfgByDataset 按数据集名取配置池(key 形如 imageGen.sceneByDataset.<数据集名>)随机,
// 该数据集未配置时回退通用池
func pickCfgByDataset(ctx context.Context, datasetName, byDatasetKey, genericKey string) string {
if pool := g.Cfg().MustGet(ctx, byDatasetKey+"."+datasetName).Strings(); len(pool) > 0 {
return pool[rand.Intn(len(pool))]
}
return pickCfg(ctx, genericKey)
}
// AdminGenerateImages AI 生成图片:同步逐张生成(每张超时 imageGen.timeoutSeconds),逐张落盘 + 入库;
// 中途失败返回错误并附已成功张数(已生成的图为付费资产,保留不删除)。
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, "创建图片目录失败")
}
now := gtime.Now()
generated := 0
addedIds := make([]int64, 0, req.Count)
// 每张生成超时走配置(dashscope 默认 120slocalai 训练机约 5min/张,建议 600)
genTimeout := time.Duration(g.Cfg().MustGet(ctx, "imageGen.timeoutSeconds", 120).Int()) * time.Second
handWritten := strings.TrimSpace(req.Prompt) != ""
genOnce := func(prompt string) ([]byte, error) {
genCtx, cancel := context.WithTimeout(ctx, genTimeout)
defer cancel()
return provider.Generate(genCtx, prompt, req.Size)
}
// 命名:数据集属性 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
}
}
rejected := 0
for i := 0; i < req.Count; i++ {
// 部分成功也要标注:报错返回前对已入库图片触发自动标注(外部重试驱动依赖此语义攒量)
failWith := func(err error) (*dto.AdminDatasetGenerateRes, error) {
if generated > 0 && len(addedIds) > 0 {
if newImages, lErr := dao.DatasetImage.GetByIds(ctx, addedIds); lErr == nil {
if aErr := LabelTask.AutoLabel(ctx, dataset, newImages); aErr != nil {
g.Log().Errorf(ctx, "部分成功后自动标注触发失败: %+v", aErr)
}
}
}
return &dto.AdminDatasetGenerateRes{Generated: generated, Rejected: rejected}, err
}
// 提示词:手填优先;未手填走 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
}
}
if !handWritten {
var tErr error
if prompt, tErr = s.buildPromptFromTemplate(ctx, dataset.Name, strings.TrimSpace(req.Species), req.Distance, req.Size); tErr != nil {
return nil, tErr
}
}
data, genErr := genOnce(prompt)
if genErr != nil {
// 付费资产保留原则:已生成的不删除
return failWith(gerror.NewCode(common.CodeImageGenFailed, fmt.Sprintf("第 %d 张生成失败(已生成 %d 张): %v", i+1, generated, genErr)))
}
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)
}
if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil {
return failWith(gerror.Wrapf(err, "第 %d 张保存失败(已生成 %d 张)", i+1, generated))
}
var imgId int64
insErr := common.Serial().Submit(ctx, func() error {
id, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{
DatasetId: dataset.Id,
Filename: filename,
Source: "ai",
Prompt: prompt,
AnimalCount: 1,
CreatedAt: now,
})
imgId = id
return err
})
if insErr != nil {
return failWith(gerror.Wrap(insErr, "生成图片入库失败"))
}
addedIds = append(addedIds, imgId)
generated++
}
if err := common.Serial().Submit(ctx, func() error {
return dao.Dataset.UpdateCounters(ctx, dataset.Id, int64(generated), -1, "")
}); err != nil {
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 {
return &dto.AdminDatasetGenerateRes{Generated: generated, Rejected: rejected},
gerror.Wrap(err, "图片已生成入库,但自动标注触发失败")
}
return &dto.AdminDatasetGenerateRes{Generated: generated, Rejected: rejected}, 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
}