diff --git a/server/README.md b/server/README.md index a150dcc..9a587d2 100644 --- a/server/README.md +++ b/server/README.md @@ -186,6 +186,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow | POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","namePrefix":"pheasant","source":"manual"\|"ai","cover":"<文件名>"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名;物种=数据集名(单物种规则),创建时同步调 VLM(qwen3.6-35b-a3b) 自动生成物种/场景/动作/遮挡/站高/类别名等生成参数池,响应含 `poolsGenerated`/`poolError`——VLM 失败不阻断创建,参数可事后用 gen-pools 补生成;封面优先用 cover 参数(新建对话框预生成封面回传,跳过自动生成),否则参数池成功后自动生成 16:9(1248x704)封面(1 雄 1 雌并排,响应含 `coverGenerated`/`coverError`,失败可在编辑模式重新生成);模型生成图统一转 jpg 落盘) | | GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs);**排除负样本库**(source=negative,仅「负样本」tab 展示) | | POST | `/admin/datasets/negative` | 获取负样本库(2026-09-07):不存在则自动创建(source=negative 固定名 `__negative__`),返回数据集记录;其 id 供既有 `/admin/datasets/upload`、`/admin/datasets/images`、`/admin/datasets/images/delete`、`/admin/datasets/image` 复用(负样本无标注/审核/清洗流程) | +| POST | `/admin/datasets/negative/generate` | 负样本批量生成 `{"count":100}`(异步任务,2026-09-07):config.yml 内置场景池按序循环组装提示词(negativeScenes 空场景/negativeAnimals 混淆动物两池两模板),逐张生成后**自动过 RF-DETR 全图检测,有检出即剔除不入库**;进度复用 GET `/admin/datasets/gen-task`(rejected=剔除数),入口在管理端「负样本」tab | | POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"name":"新名","namePrefix":"pheasant","description":"...","cover":"a.jpg"}`:名称(改名)/文件名前缀/描述/封面,空值字段不覆盖原值;gen_* 生成参数池不在此维护(仅 VLM 生成,见 gen-pools);改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 | | POST | `/admin/datasets/gen-pools` | 重新生成数据集生成参数池 `{"datasetId":1}`:按数据集名(物种)调 VLM(qwen3.6-35b-a3b) 生成轮廓色/站高/场景/动作/遮挡并写表(覆盖旧值);失败报错保留旧值 | | POST | `/admin/datasets/images/vlm-review` | VLM 藏匿位补检 `{"datasetId":1,"imageId":5}`(两阶段标注第二阶段,**须与图像生成显存互斥**):qwen3.6-35b-a3b(+mmproj) 排除已确认框,按环境/季节/时间/天气/光线/地形/习性综合判读画面推理藏身位,追加 ≤3 个疑似框(class 1)进同一 labels_json | @@ -216,7 +217,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow | POST | `/admin/annotate-tasks` | 下发标注任务 `{"datasetId":1,"name":"...","imageIds":[...]}`(2026-09-07 图片粒度下发):勾选的未标注图批量占用(`annotate_task_id` 写任务 id;校验属本数据集/未标注/未占用,任一非法整单拒绝),**已下发图即从「未标注」tab 消失**;同数据集已有 published 任务时拒绝 | | GET | `/admin/annotate-tasks` | 标注任务列表:`datasetId` 可选过滤(**无独立菜单——「标注任务」tab 内嵌数据训练详情页,任务图集网格展示勾选下发的具体图片,头部任务概要条/停用**),含数据集名/池余量/各状态记录数/状态 | | POST | `/admin/annotate-tasks/stop` | 停用任务 `{"id":1}`:整体不可再领取(已领取未提交的可继续提交);**释放未被领取过的图**(清 `annotate_task_id`,回到「未标注」tab 可再次下发) | -| POST | `/admin/annotate-review` | 批量审核 `{"imageIds":[...],"approve":true}`(详情页「待审核」tab):通过 → review_status=2;拒绝 → 清 labels_json 回未标注池 + 对应 submitted 记录置 rejected(计入该用户低质统计:通过比例 <80% 且已审核样本 ≥5 → 冻结 24h,自动解冻) | +| POST | `/admin/annotate-review` | 批量审核 `{"imageIds":[...],"approve":true}`(详情页「待审核」tab):通过 → review_status=2 已标注(**全部框原样保留,含疑似框——通过即人工背书**,2026-09-07 定案);拒绝 → 清 labels_json 回未标注池 + 对应 submitted 记录置 rejected(计入该用户低质统计:通过比例 <80% 且已审核样本 ≥5 → 冻结 24h,自动解冻) | | GET | `/admin/annotate-records` | 用户标注记录分页:`datasetId/phone/taskId/status` 过滤,每用户每张图的领取/提交快照/审核结果(**「标注记录」tab 同样内嵌详情页**) | | POST | `/admin/annotate-unfreeze` | 手动解冻 `{"phone":"..."}`:清冻结标记与统计基线 | diff --git a/server/biz/controller/admin.go b/server/biz/controller/admin.go index 3f9f994..e5e404f 100644 --- a/server/biz/controller/admin.go +++ b/server/biz/controller/admin.go @@ -87,6 +87,11 @@ func (c *cAdmin) NegativeLibrary(ctx context.Context, req *dto.AdminDatasetNegat return service.Dataset.AdminNegativeLibrary(ctx, req) } +// NegativeGenerate 负样本批量生成(异步任务,含 RF-DETR 空检剔除) +func (c *cAdmin) NegativeGenerate(ctx context.Context, req *dto.AdminNegativeGenerateReq) (*dto.AdminNegativeGenerateRes, error) { + return service.Dataset.AdminNegativeGenerate(ctx, req) +} + // UploadImages 上传图片 func (c *cAdmin) UploadImages(ctx context.Context, req *dto.AdminDatasetUploadReq) (*dto.AdminDatasetUploadRes, error) { return service.Dataset.AdminUploadImages(ctx, req) diff --git a/server/biz/dao/gen_task.go b/server/biz/dao/gen_task.go index 06779b3..c2e430f 100644 --- a/server/biz/dao/gen_task.go +++ b/server/biz/dao/gen_task.go @@ -24,6 +24,7 @@ func init() { status TEXT NOT NULL DEFAULT 'running', total INTEGER NOT NULL DEFAULT 0, done INTEGER NOT NULL DEFAULT 0, + rejected INTEGER NOT NULL DEFAULT 0, error TEXT, created_at TEXT NOT NULL, finished_at TEXT @@ -31,6 +32,8 @@ func init() { if err != nil { panic(err) } + // 存量库迁移:负样本生成空检剔除计数列(2026-09-07) + common.EnsureColumn(ctx, consts.TableGenTask, "rejected", "rejected INTEGER NOT NULL DEFAULT 0") } // Insert 创建生成任务,返回自增 id @@ -98,6 +101,13 @@ func (d *genTaskDao) UpdateProgress(ctx context.Context, id int64, done int) err return err } +// UpdateRejected 更新空检剔除计数(负样本生成收尾时一次性覆盖写) +func (d *genTaskDao) UpdateRejected(ctx context.Context, id int64, rejected int) error { + _, err := g.DB().Model(consts.TableGenTask).Ctx(ctx).Where("id", id). + Data(g.Map{"rejected": rejected}).Update() + return err +} + // Finish 完成任务(done 状态 + 完成时间 + 失败原因;errMsg 非空时为 failed) func (d *genTaskDao) Finish(ctx context.Context, id int64, errMsg string) error { status := consts.GenTaskDone diff --git a/server/biz/model/dto/training.go b/server/biz/model/dto/training.go index 97869d2..c538b46 100644 --- a/server/biz/model/dto/training.go +++ b/server/biz/model/dto/training.go @@ -137,6 +137,18 @@ type AdminDatasetNegativeRes struct { ImageCount int64 `json:"imageCount"` } +// AdminNegativeGenerateReq 负样本库批量生成(异步任务):config.yml 内置场景池按序循环组装提示词 +// (negativeScenes/negativeAnimals 池),逐张生成后自动过 RF-DETR 全图检测,有检出即剔除不入库 +type AdminNegativeGenerateReq struct { + g.Meta `path:"/datasets/negative/generate" method:"post" summary:"负样本批量生成(异步任务)" tags:"管理端"` + Count int `json:"count" v:"required|integer|min:1|max:1000" dc:"生成张数 1-1000"` +} + +type AdminNegativeGenerateRes struct { + TaskId int64 `json:"taskId"` // 生成任务 id(复用 GET /datasets/gen-task 按 datasetId 轮询进度) + Total int `json:"total"` +} + // AdminDatasetUploadReq 上传图片(multipart 多文件;重名跳过并计数) type AdminDatasetUploadReq struct { g.Meta `path:"/datasets/upload" method:"post" summary:"上传图片" tags:"管理端" mime:"multipart/form-data"` @@ -182,6 +194,7 @@ type AdminGenTaskQueryRes struct { Status string `json:"status"` // running | done | failed Total int `json:"total"` Done int `json:"done"` + Rejected int `json:"rejected"` // 空检剔除张数(负样本生成专用,其余任务恒 0) Error string `json:"error"` CreatedAt *gtime.Time `json:"createdAt"` FinishedAt *gtime.Time `json:"finishedAt"` diff --git a/server/biz/model/entity/gen_task.go b/server/biz/model/entity/gen_task.go index 6bfb908..bfd81cd 100644 --- a/server/biz/model/entity/gen_task.go +++ b/server/biz/model/entity/gen_task.go @@ -8,7 +8,8 @@ type GenTask struct { DatasetId int64 `json:"datasetId" orm:"dataset_id" description:"所属数据集"` Status string `json:"status" orm:"status" description:"running|done|failed"` Total int `json:"total" orm:"total" description:"计划生成张数"` - Done int `json:"done" orm:"done" description:"已生成张数"` + Done int `json:"done" orm:"done" description:"已处理张数(含剔除)"` + Rejected int `json:"rejected" orm:"rejected" description:"空检剔除张数(负样本生成专用,RF-DETR 检出即剔除)"` Error string `json:"error" orm:"error" description:"失败原因(部分失败/全部失败)"` CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` FinishedAt *gtime.Time `json:"finishedAt" orm:"finished_at" description:"完成时间"` diff --git a/server/biz/service/annotate.go b/server/biz/service/annotate.go index 03e2a0e..d69bbf0 100644 --- a/server/biz/service/annotate.go +++ b/server/biz/service/annotate.go @@ -271,6 +271,7 @@ func (s *annotateService) AdminReview(ctx context.Context, req *dto.AdminAnnotat err = common.Serial().Submit(ctx, func() error { return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { if req.Approve { + // 通过 = 已标注(全部框原样保留——通过即人工背书,疑似框是合法的 class 1 训练标注) if err := dao.DatasetImage.SetReviewStatus(ctx, ids, consts.ReviewImageApproved); err != nil { return err } diff --git a/server/biz/service/dataset.go b/server/biz/service/dataset.go index f3393be..64478a0 100644 --- a/server/biz/service/dataset.go +++ b/server/biz/service/dataset.go @@ -578,6 +578,19 @@ func (s *datasetService) AdminCreateDataset(ctx context.Context, req *dto.AdminD // 特殊数据集,不存在则 Serial 内懒创建(防并发重建)+ 建目录;返回 id 与图片数, // 管理端「负样本」tab 复用既有上传/图片列表/删除接口(负样本无标注/审核/清洗/生成流程) func (s *datasetService) AdminNegativeLibrary(ctx context.Context, req *dto.AdminDatasetNegativeReq) (*dto.AdminDatasetNegativeRes, error) { + lib, err := s.ensureNegativeLibrary(ctx) + if err != nil { + return nil, err + } + cnt, err := dao.DatasetImage.CountByDataset(ctx, lib.Id) + if err != nil { + return nil, err + } + return &dto.AdminDatasetNegativeRes{Id: lib.Id, Name: lib.Name, ImageCount: cnt}, nil +} + +// ensureNegativeLibrary 懒创建并返回负样本库数据集(Serial 内防并发重建;幂等) +func (s *datasetService) ensureNegativeLibrary(ctx context.Context) (*entity.Dataset, error) { now := gtime.Now() var lib *entity.Dataset err := common.Serial().Submit(ctx, func() error { @@ -608,11 +621,144 @@ func (s *datasetService) AdminNegativeLibrary(ctx context.Context, req *dto.Admi if err := os.MkdirAll(common.DatasetImagesDir(ctx, lib.Name), 0o755); err != nil { return nil, gerror.Wrap(err, "创建负样本库目录失败") } - cnt, err := dao.DatasetImage.CountByDataset(ctx, lib.Id) + return lib, nil +} + +// AdminNegativeGenerate 负样本批量生成(异步任务):提示词从 config.yml 内置场景池按序循环组装 +// (negativeScenes 空场景 + negativeAnimals 混淆动物,保证每个短语均匀覆盖);逐张生成后自动过 +// RF-DETR 全图检测,**有检出即剔除不入库**(图里有像目标的东西就不能当背景喂给训练)—— +// 检测服务不可达时 fail-open 保留该图并记日志(付费资产原则:不因质检通道故障丢弃已生成图)。 +// 进度复用 GET /datasets/gen-task 按 datasetId 轮询(rejected 列为剔除计数)。 +func (s *datasetService) AdminNegativeGenerate(ctx context.Context, req *dto.AdminNegativeGenerateReq) (*dto.AdminNegativeGenerateRes, error) { + provider := common.ImageGen(ctx) + if provider == nil { + return nil, gerror.NewCode(common.CodeImageGenNotConfigured) + } + tpl := g.Cfg().MustGet(ctx, "imageGen.negativePromptTemplate").String() + scenes := g.Cfg().MustGet(ctx, "imageGen.negativeScenes").Strings() + animalTpl := g.Cfg().MustGet(ctx, "imageGen.negativeAnimalPromptTemplate").String() + animals := g.Cfg().MustGet(ctx, "imageGen.negativeAnimals").Strings() + if tpl == "" || len(scenes) == 0 { + return nil, gerror.New("未配置负样本生成模板/场景池(imageGen.negativePromptTemplate/negativeScenes)") + } + lib, err := s.ensureNegativeLibrary(ctx) if err != nil { return nil, err } - return &dto.AdminDatasetNegativeRes{Id: lib.Id, Name: lib.Name, ImageCount: cnt}, nil + now := gtime.Now() + var taskId int64 + if err := common.Serial().Submit(ctx, func() error { + running, err := dao.GenTask.GetRunningByDataset(ctx, lib.Id) + if err != nil { + return err + } + if running != nil { + return gerror.NewCode(common.CodeGenTaskRunning) + } + taskId, err = dao.GenTask.Insert(ctx, &entity.GenTask{ + DatasetId: lib.Id, + Status: consts.GenTaskRunning, + Total: req.Count, + CreatedAt: now, + }) + return err + }); err != nil { + return nil, err + } + s.runNegativeGenTask(taskId, lib, req.Count, tpl, scenes, animalTpl, animals, provider, now) + return &dto.AdminNegativeGenerateRes{TaskId: taskId, Total: req.Count}, nil +} + +// runNegativeGenTask 负样本生成执行协程(生命周期任务,同 runGenTask 模式):池内逐张生成 → +// 转 jpg → RF-DETR 空检 → 合格才落盘入库。done 计已处理张数(含剔除),rejected 计剔除张数。 +func (s *datasetService) runNegativeGenTask(taskId int64, lib *entity.Dataset, count int, tpl string, scenes []string, animalTpl string, animals []string, provider common.ImageGenProvider, now *gtime.Time) { + bgCtx := context.Background() + go func() { + dir := common.DatasetImagesDir(bgCtx, lib.Name) + qa := common.LocalAiClient(bgCtx) // 空检通道(可能为 nil:fail-open 全保留并记日志) + combined := make([]string, 0, len(scenes)+len(animals)) + combined = append(combined, scenes...) + animalStart := len(combined) + combined = append(combined, animals...) + generated := 0 + rejected := 0 + processed := 0 + for i := 0; i < count; i++ { + // 按序循环取池:每个短语均匀覆盖,不漏条目 + entry := combined[i%len(combined)] + prompt := tpl + if animalTpl != "" && i%len(combined) >= animalStart { + prompt = animalTpl // 混淆动物条目:否定句只排除目标物种 + } + prompt = strings.Replace(prompt, "{scene}", entry, 1) + var data []byte + err := common.GenTaskPoolInstance().Submit(bgCtx, func(ctx context.Context) error { + d, e := provider.Generate(ctx, prompt, "704x1248") + data = d + return e + }) + if err != nil { + _ = dao.GenTask.Finish(bgCtx, taskId, fmt.Sprintf("第 %d 张生成失败(已入库 %d 张): %v", i+1, generated, err)) + return + } + data, jErr := ensureJpeg(data) + if jErr != nil { + _ = dao.GenTask.Finish(bgCtx, taskId, fmt.Sprintf("第 %d 张转 jpg 失败(已入库 %d 张): %v", i+1, generated, jErr)) + return + } + // 空检质检:RF-DETR 全图检测有候选即剔除(负样本必须全空);检测失败 fail-open 保留 + if qa != nil { + w, h := imageSize(data) + if w > 0 && h > 0 { + dets, dErr := qa.Detect(bgCtx, data, "image/jpeg", w, h) + if dErr != nil { + g.Log().Warningf(bgCtx, "负样本空检失败(fail-open 保留): %v", dErr) + } else if len(dets) > 0 { + rejected++ + processed++ + _ = common.Serial().Submit(bgCtx, func() error { + if rErr := dao.GenTask.UpdateRejected(bgCtx, taskId, rejected); rErr != nil { + return rErr + } + return dao.GenTask.UpdateProgress(bgCtx, taskId, processed) + }) + continue + } + } + } + filename := fmt.Sprintf("neg_%s_%04d.jpg", now.Time.Format("20060102150405"), i) + if err := common.WriteFileAtomic(filepath.Join(dir, filename), data); err != nil { + _ = dao.GenTask.Finish(bgCtx, taskId, fmt.Sprintf("第 %d 张保存失败(已入库 %d 张): %v", i+1, generated, err)) + return + } + if insErr := common.Serial().Submit(bgCtx, func() error { + _, err := dao.DatasetImage.Insert(bgCtx, &entity.DatasetImage{ + DatasetId: lib.Id, + Filename: filename, + Source: "ai", + CreatedAt: now, + }) + return err + }); insErr != nil { + _ = dao.GenTask.Finish(bgCtx, taskId, fmt.Sprintf("第 %d 张入库失败(已入库 %d 张): %v", i+1, generated, insErr)) + return + } + generated++ + processed++ + _ = common.Serial().Submit(bgCtx, func() error { + return dao.GenTask.UpdateProgress(bgCtx, taskId, processed) + }) + } + if err := common.Serial().Submit(bgCtx, func() error { + if err := dao.GenTask.UpdateRejected(bgCtx, taskId, rejected); err != nil { + return err + } + return dao.Dataset.UpdateCounters(bgCtx, lib.Id, int64(generated), -1, "") + }); err != nil { + g.Log().Errorf(bgCtx, "负样本生成任务 %d 收尾更新失败: %+v", taskId, err) + } + _ = dao.GenTask.Finish(bgCtx, taskId, "") + }() } // ensureJpeg 模型生成的图片统一转 jpg:解码校验 + 非 jpeg(png/webp 等)转 jpeg(Quality 92)。 @@ -1445,6 +1591,7 @@ func (s *datasetService) AdminGenTaskQuery(ctx context.Context, req *dto.AdminGe Status: t.Status, Total: t.Total, Done: t.Done, + Rejected: t.Rejected, Error: t.Error, CreatedAt: t.CreatedAt, FinishedAt: t.FinishedAt, @@ -1567,16 +1714,16 @@ func (s *datasetService) ImageFile(ctx context.Context, datasetId int64, filenam // ---------- 数据清洗(训练集去冗余;语义与档位见技术设计.md「数据清洗」/consts.CleanBuckets) ---------- -// cleanImageStat 参与统计的单图(labels_json 含 class 0 框:面积最大者定档,其框高为展示主尺寸; -// 档内保留取舍看 minHPct——小目标样本稀缺,先保最小确认目标更小的图) +// cleanImageStat 参与统计的单图(labels_json 含有效框即入统计,2026-09-07 起不区分确认/疑似类别: +// 面积最大者定档,其框高为展示主尺寸;档内保留取舍看 minHPct——小目标样本稀缺,先保最小目标更小的图) type cleanImageStat struct { id int64 filename string source string - area float64 // 最大 class0 框面积 w*h(找最大框用) + area float64 // 最大框面积 w*h(找最大框用) hPct float64 // 该框高占图高百分比(定档与主尺寸展示) - minHPct float64 // 图内最小 class0 框占比(优先保留依据;单目标图 = hPct) - targetCnt int // class0 目标总数 + minHPct float64 // 图内最小框占比(优先保留依据;单目标图 = hPct) + targetCnt int // 框总数 bucket int } @@ -1624,7 +1771,7 @@ func (s *datasetService) AdminCleanPreview(ctx context.Context, req *dto.AdminDa for i, b := range consts.CleanBuckets { res.Buckets[i] = &dto.AdminCleanBucketRes{Label: b.Label, Exempt: i == 0, Quota: quotas[i]} } - // 第一遍:定档统计(仅含 class 0 的图),已排除清单全量收集(当前无 class 0 标注的也列出,恢复不丢) + // 第一遍:定档统计(含任意有效框的图,不区分确认/疑似),已排除清单全量收集(当前无框的也列出,恢复不丢) actives := make([][]*cleanImageStat, len(consts.CleanBuckets)) // 桶内未排除图,保持 id 升序 for _, img := range images { if img.CleanExcluded == 1 { @@ -1636,8 +1783,8 @@ func (s *datasetService) AdminCleanPreview(ctx context.Context, req *dto.AdminDa var boxes []*dto.AdminLabelBox if json.Unmarshal([]byte(img.LabelsJson), &boxes) == nil { for _, b := range boxes { - if b.Class != 0 || b.W <= 0 || b.H <= 0 { - continue // 只认确认框(class 0);疑似框(class 1)不入尺寸统计 + if b.W <= 0 || b.H <= 0 { + continue // 无效框不入尺寸统计(确认/疑似均参与,2026-09-07 定案) } pct := b.H * 100 stat.targetCnt++ @@ -1652,7 +1799,7 @@ func (s *datasetService) AdminCleanPreview(ctx context.Context, req *dto.AdminDa } } } - if stat.area <= 0 { // 无确认目标(未标注/空检出/仅 class 1):不入档 + if stat.area <= 0 { // 无任何有效框(未标注/空检出):不入档 if img.CleanExcluded == 1 { res.ExcludedImages = append(res.ExcludedImages, info) } diff --git a/server/config.yml b/server/config.yml index a3497ce..ddc5cfb 100644 --- a/server/config.yml +++ b/server/config.yml @@ -57,6 +57,29 @@ imageGen: # 迫使模型把目标画大(2026-08-31 实测两次失败后定案)——只留「远到看不清任何细节的小点」 promptTemplateTiny: "野外大远景实拍:手机原相机主摄不变焦,{scene},{position}有且仅有{count}{species}{sizeHint},远到看不清任何细节,只是隐约可辨的{tone}小点,画面绝大部分是{scene},没有任何靠近镜头的动物,{light},写实野外摄影,真实照片质感" lights: [清晨柔和阳光, 正午明亮天光, 傍晚金色斜阳, 阴天柔和散射光] + # 负样本生成(技术设计.md「负样本库」):场景池按「三物种各自易被误认的形状/机理」系统穷举 + # (细长轮廓→雉鸡 / 圆拱形→野兔 / 高处停息悬挂→斑鸠 + 室内/户外空镜),模板与正样本同相机词 + # 同质感词、不含任何目标词;{scene} 按序循环取池(保证每个短语均匀覆盖)。 + # 混淆动物单独模板——否定句只排除目标物种(图里的猫狗麻雀正是要教的"非目标")。 + # 生成后自动过 RF-DETR 全图检测,有检出即剔除不入库(空检合格才进负样本库) + negativePromptTemplate: "写实摄影:手机原相机主摄不变焦,{scene},光线自然,画面中没有任何动物、飞鸟和人物,真实照片质感" + negativeScenes: [ + 靠墙立着的扫把和拖把, 门口的门框与墙角线, 桌角与椅腿的斜线构图, 靠墙的雨伞和登山杖, + 花园里斜插的竹竿和植物支架, 生锈的铁栅栏立柱, 盘绕在地面上的花园水管, 靠墙放着的铝梯, + 晾衣杆上挂着一排衣架的剪影, 斜靠在树干上的长树枝, 篮球架的立柱与篮筐, 路边的电线杆与斜拉钢缆, + 路边圆圆的石头和土块, 老树桩与盘结的树根, 装满东西的麻袋编织袋, 门口系好的垃圾袋, + 成排摆放的花盆与陶罐, 沙发上摞着的靠垫抱枕, 草地上的干草垛, 屋顶堆放的旧瓦片, + 地上放着的双肩包, 花园角落的堆肥堆, 电线上的绝缘子和一串小灯泡, 缠挂在电线上的旧风筝, + 屋檐下挂着的灯笼和玉米串, 挂满柿子的果树梢, 建筑外墙的空调外机与管线, 树枝上成簇的松果, + 悬在树杈间的大草团, 路灯灯头与监控探头, 阳台上悬挂的衣物, 树杈间卡着的塑料袋, + 客厅一角布艺沙发与茶几, 卧室床铺与床头柜, 厨房台面灶具与悬挂的锅铲, 卫生间瓷砖与毛巾架, + 书桌电脑键盘与零散杂物, 车库里堆放的纸箱工具和靠墙的自行车, 楼道里的消防栓与管道, + 阳台栏杆与洗衣盆, 餐厅餐桌餐椅与吊灯, 空旷的开阔草地, 茂密的灌丛坡地, + 林地边缘的树木与草地交界, 积雪覆盖的开阔旷野, 河边大片卵石滩, 收割后的农田田埂, + 茂密的芦苇丛, 城市人行道与斑马线, 露天停车场的车辆间距, 公园步道与路边长椅 + ] + negativeAnimalPromptTemplate: "写实摄影:手机原相机主摄不变焦,{scene},光线自然,画面中没有雉鸡、野兔和斑鸠,真实照片质感" + negativeAnimals: [趴在院子里的家猫, 蹲坐在路边的家犬, 电线上落着的一排麻雀, 广场上觅食的鸽子群, 树枝上的喜鹊] # 目标尺寸提示(sizeHint):按「体高 × focalPx ÷ 表单距离」算出目标在图中的像素高与占比, # 注入模板 {sizeHint}——把「厘米」换算成「米」再参与投影公式;focalPx = 图高/2 ÷ tan(vfov/2); # 站高取数据集表 gen_heights(创建数据集时 VLM 生成,界面不维护,代码/配置不硬编码) diff --git a/server/data/observer.db b/server/data/observer.db index b244138..cad3d7d 100644 Binary files a/server/data/observer.db and b/server/data/observer.db differ diff --git a/server/技术设计.md b/server/技术设计.md index f2b8484..703fa97 100644 --- a/server/技术设计.md +++ b/server/技术设计.md @@ -407,7 +407,7 @@ training: - `localAi` 配置节点:键与取值以 config.yml 为准,文档不复制具体数值(曾多次调整阈值致双源漂移,2026-09-02 起删除此类描述)——**部署前提:RF-DETR 服务须从 Go 服务器可达**(现跑在 Mac 上,部署时搬服务器/训练机;**未配置时图片上传/生成接口直接报错**——标注是强语义,不允许产生无标注图片;配置调不通则任务 failed,页顶错误条展示,不自动重试);**端点为配置唯一来源,无运行时覆盖** - **预标注自动触发(2026-08-26 决策,无手动按钮)**:手动上传/AI 生成图片入库成功后,自动对**本次新增图**发起标注;**`localAi` 未配置 → 上传/生成接口直接报错**(同步检查,生成场景在第一张生成前检查避免付费资产生成后标不了);**已有 running 标注任务(忙)→ 不报错**:上传/生成照常成功,新图由「任务成功完成后自动补标未标注图」机制兜底(每轮任务成功结束时检查该数据集**从未扫描过**的图(labels 为 null/''),有则自动续一轮——失败任务不续,防配置坏时无限重试;**2026-09-02 空检出图不入自动补标池**:检出为空的图写 `labels_json='[]'` 后仍被视作未标注(管理端未标注 tab 照常展示等人工画框),但不入自动池——空检出图若留池,每轮自动续一轮只会扫到别的空图、扫完又轮回来:RNPHE_202(阈值调高后 0 检出,低阈下 12 个候选全为噪声,无一过丢弃线)单张被上一版「排除本轮已扫图」恰好挡住未暴露,2026-09-02 兔子批量 3 张空检出实测 {9}→{10,17}→{9}… 无限交替、每轮白打一次 RF-DETR 方暴露;现自动链条只扫从未扫描的图,每张图至多被自动扫一次,空检出图确需重扫由人工触发(按文件名批量/全量任务,见下));service 逐张调 local-ai 推理(common 池并行,`label_task` 记录 total/done 进度)→ 扫描结果(YOLO 归一化 xywh + 置信度 + 建议类别)**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**;**置信度分档(2026-09-02 起按两档语义调定,具体数值以 config.yml 为准不在此复制)**:conf ≥ confConfirmed 落 class 0 真目标,threshold ≤ conf < confConfirmed 落 class 1 疑似待人工复核,低于 threshold 不入库;背景(RNPHE_257 实测):COCO 通用 rfdetr 对类鸟纹理误检置信度可达 0.46~0.5 且与真目标仅差 ~0.1(真目标 0.562、两误检 0.477/0.462 曾全落 class 0),低置信段是纯噪声候选(该图 21 个原始检出中 17 个落此区间);单阈值无法分离真/假目标(类别名也无用——全部输出 "bird");误检落 class 0 会作为雉鸡正样本直接污染训练集,危害大于真目标降级疑似(后者人工一键确认即可);**重叠去重(2026-08-26 修订)**:NMS 风格、按置信度降序依次保留,与已保留框**重叠比(minIoU = 交叠面积/两框较小面积)> `localAi.overlapThreshold`**的框剔除(同目标被重复检出只留置信度最高者,跨 class 去重;人工画框不参与去重)——**用 minIoU 而非 IoU**:RF-DETR 对同一目标常输出一大一小两个框(标准 IoU 仅 0.3~0.5 会漏杀,实测红黄重叠即此形态),大框套小框时小框被覆盖比例高,minIoU 命中;相邻目标两框互有外露,minIoU 通常 < 0.3;**全图扫描**(项目既定规则:不套用生成规格的位置裁剪,候选宁多勿漏);**单图框数上限(2026-09-02,定义于 consts.go `consts.MaxPrelabelPerImage`)**:去重后仍超上限按置信度取前 N(曾按生成声明数 animal_count 裁剪致漏目标——声明 1 个不代表图内只有 1 个;手动上传图同限);不设候选确认两阶段(2026-08-26 撤销 candidates_json);`POST /admin/label-tasks` 接口保留(服务端 `filenames` 参数支持指定图批量重标,2026-09-02 前端入口收敛后仍可全量——不传 filenames 即全量扫描) - **预标注四级漏斗(2026-09-04,树栖藏匿难例)**:树冠藏匿生成图(树栖物种目标仅占画面高 ~2%)在全图扫描下不可检——模型包固定 700x700 stretch 输入(见「预标注模型固定 700x700」,不支持动态分辨率,提高提交分辨率无增益),占图高 2% 的目标等比缩入后仅 ~15px 落在检出盲区。单张处理升级为逐级漏斗,**检测只信 RF-DETR,VLM 永不直接出框(只提议候选区)**:①**全图扫描**(现有 `Detect`,`localAi.threshold`)有候选即收(绝大多数图到此命中,不触发后级);②空检出 → **切片扫描**:`common.TileRegions` 滑窗切块(块长边/重叠比 = `localAi.tileSize`/`tileOverlap`,边缘块贴边收口保证全覆盖)逐块 `DetectRegion` 按低阈值(`localAi.tileThreshold`,小目标置信度天然偏低,量级取 RF-DETR-S 实测经验;低于全图丢弃线的候选仍入库落疑似 class 1,宁多勿漏)检测,块内坐标在 DetectRegion 内映射回原图像素,跨块重复检出由既有 minIoU 去重兜住;③仍空 → **VLM 提议候选区**:qwen3-vl-8b 按「宁可指错不可遗漏」提示词输出 ≤`consts.VlmLocateMaxRegions` 个归一化候选区(与手动藏匿位补检不同:此阶段图内无已确认框,无排除集;物种注入同 vlm-review——`gen_species` 空回退数据集名),逐区扩大 `consts.VlmRegionExpand` 倍(VLM 坐标偏粗,扩大给 RF-DETR 足够上下文,钳制图片边界)后 `DetectRegion` 精修,精修命中的框才采纳、未命中丢弃(VLM 误报被 RF-DETR 否掉);**VLM 失败/超时/输出非法一律跳过该级不阻塞任务**(三模型共存 + local-ai 服务端排队串行,无需显存互斥检查,2026-09-02 已放开);④全空 → 写 `'[]'`(语义不变:未标注 tab 展示等人工画框、不入自动补标池防乒乓)。漏斗各级候选同通道处理:conf ≥ confConfirmed 落 class 0、否则 class 1、minIoU 去重、单图上限 `consts.MaxPrelabelPerImage`,全图扫描不套生成规格的项目规则不变。单图超时 120s → `consts.LabelImageTimeoutSec`(切片 ~20 块串行 + VLM 兜底;切片在单图池任务内串行执行,不嵌套协程池) -- **标注审核状态机(2026-09-04)**:`dataset_image.review_status`(EnsureColumn 幂等补列):`0=未标注, 1=待审核, 2=已审核`;存量迁移——labels_json 非空数组→2(既有标注视为已审定稿)、null/''/'[]'→0。流转:预标注完成→1(**空检出保持 0**——无框可审,「确认无动物」应由人工画框/清洗表达,避免空图混入已审核口径)、App 用户提交→1、管理端工作台保存(AdminLabelSave)→2、审核「通过」→2、「拒绝」→ 清 labels_json + 回 0(图重新进任务池);**训练集打包(prepareYoloSet)只收 review_status=2**(标签质量闸门,原「labels_json 非空即收」作废);管理端图片 tab 由三变四:未标注(0)/待审核(1)/已标注(2)/已清洗,另加「标注任务」「标注记录」两个管理面板 tab(同在详情页,无独立菜单,2026-09-07 用户定案)——**任务面板=任务图集网格**(任务即勾选下发的具体图片,选几张展示几张,每张角标=任务 id+状态;头部仅任务概要条与停用;下发入口在未标注 tab 勾选图片顶栏,2026-09-07 修订),记录面板=本数据集众包记录查询/解冻(两列表接口支持 `datasetId` 过滤;`annotate_record` 领取时冗余 `dataset_id` 列,单表过滤不跨表);labeled_count 与 dataset.status='labeled' 口径同步改为 review_status=2 计数(2 必然非空框,无歧义) +- **标注审核状态机(2026-09-04)**:`dataset_image.review_status`(EnsureColumn 幂等补列):`0=未标注, 1=待审核, 2=已审核`;存量迁移——labels_json 非空数组→2(既有标注视为已审定稿)、null/''/'[]'→0。流转:预标注完成→1(**空检出保持 0**——无框可审,「确认无动物」应由人工画框/清洗表达,避免空图混入已审核口径)、App 用户提交→1、管理端工作台保存(AdminLabelSave)→2、审核「通过」→2、「拒绝」→ 清 labels_json + 回 0(图重新进任务池);**疑似框语义(2026-09-07 用户定案)**:审核「通过」= 人工背书,**全部框原样保留进已标注**(含 class 1 疑似框——suspect 是合法的第二训练类别),通过不再回退未标注;存量一次性清理(已执行 2026-09-07):仅疑似框且未经人工确认的已审核图清空回未标注(兔子 781 等,混合标注图不动);**训练集打包(prepareYoloSet)只收 review_status=2**(标签质量闸门,原「labels_json 非空即收」作废);管理端图片 tab 由三变四:未标注(0)/待审核(1)/已标注(2)/已清洗,另加「标注任务」「标注记录」两个管理面板 tab(同在详情页,无独立菜单,2026-09-07 用户定案)——**任务面板=任务图集网格**(任务即勾选下发的具体图片,选几张展示几张,每张角标=任务 id+状态;头部仅任务概要条与停用;下发入口在未标注 tab 勾选图片顶栏,2026-09-07 修订),记录面板=本数据集众包记录查询/解冻(两列表接口支持 `datasetId` 过滤;`annotate_record` 领取时冗余 `dataset_id` 列,单表过滤不跨表);labeled_count 与 dataset.status='labeled' 口径同步改为 review_status=2 计数(2 必然非空框,无歧义) - **自动标注退场(2026-09-04 用户定案)**:删除上传入库与生成完成(genTaskAutoLabel)两处 AutoLabel 触发及 autoSupplement 自动补标机制(补标池/空检出乒乓问题随机制一并消亡);**`localAi` 未配置不再阻断上传/生成**(标注改纯管理端手动动作,「不允许产生无标注图」强语义随自动标注退场删除);标注唯一入口 = 管理端勾选图片「预标」(AdminStartLabelTask 保留) - **App 标注众包与时长激励(2026-09-04;图片粒度下发 2026-09-07 用户定案)**:App 用户处理标注任务换取使用时长,全链路进待审核,服务端记录每用户每张图的处理明细。**下发粒度(2026-09-07 修订)**:任务 = 管理端**勾选的具体图片集合**(不再是数据集级动态池)——`dataset_image.annotate_task_id`(EnsureColumn 幂等补列,默认 0=未下发)作占用标记:下发时 Serial 内批量写占用(校验属本数据集/未标注/未占用),**已下发图即从管理端「未标注」tab 消失**(tab 只显示 annotate_task_id=0);领取池 = 本任务占用且 review_status=0 且 clean_excluded=0 的图;停用任务时**释放未被领取过的图**(清 annotate_task_id,回到未标注 tab 可再下发),已领取未提交的可继续提交(有 annotate_record 的图不释放)。**表**:`annotate_task`(下发任务:dataset_id/name/status published|stopped/created_at);`annotate_record`(用户标注记录:phone_num/task_id/image_id/labels_json 提交快照/status pending|submitted|approved|rejected/created_at/submitted_at/reviewed_at——pending 即领取锁(partial unique index 同图仅一条 pending,超时在领取时惰性释放;**并发防重**:挑图 + 插锁全程在 Serial 临界区内串行执行,并发领取时后读方一定看到先读方刚插入的锁,同一张图不会分给多人,索引为跨实例兜底),UNIQUE(phone_num,image_id) 一人一图仅一次(拒后图仍在本任务池内可被他人再标),拒绝不删记录=惩罚统计依据);`reward_log`(发放流水:phone_num/minutes/task_id/granted_at——日上限按自然日求和);`license.annotate_frozen_until` + `license.annotate_stats_since`(EnsureColumn):冻结到期标记(>now 即冻结,时间戳对比天然自动解冻,无定时任务)与统计基线(冻结时重置,实现解冻后重新累计)。**规则**:领取 = 每次 `annotateReward.claimSize` 张(池内排除该用户已有 annotate_record 的图,冻结中/停用不可领);提交 = 框写 labels_json + review_status=1 + 记录置 submitted,每累计 `perImages`(10) 张 submitted 发 `minutes`(30) 分钟(license.expires_at = max(now,expires_at)+30min 分钟级顺延),当日已发 +30 > `dailyCapMinutes`(120) 则本次不发(App 展示今日已达上限);审核 = 管理端批量通过/拒绝,每次审核后重算该用户通过比例(reviewed_at ≥ stats_since 的 approved/(approved+rejected)),已审核样本 ≥ `minReviewed`(5) 且比例 < `freezeRatio`(0.8) → annotate_frozen_until=now+`freezeHours`(24h) 且 stats_since=now(惩罚不追溯扣已到账时长)。**接口**:admin 下发(传 `imageIds` 勾选图)/列表/停用任务、批量审核(通过/拒绝)、用户标注记录分页;App(/api/v1) 任务列表(含我的统计/冻结态)、领取、提交、我的统计——App 契约不变(仍按 taskId 领取),仅池语义变为任务占用集。**配置**:`config.yml annotateReward` 节点(数值以 config.yml 为准不在此复制),阈值类缺失回退 consts 默认 - **手动标注入口收敛(2026-09-02 决策)**:详情页删除「全量标注」按钮与图片卡片「审核」按钮,**标注入口仅两处(勾选图片驱动,顶栏)**:①「预标」= POST `/admin/label-tasks` 传选中图 `filenames`(只扫选中图,覆盖各图已有标注);②「补标」= 对选中图逐张 POST `/datasets/images/vlm-review`(VLM 藏匿位补检,追加疑似框,批量=前端循环单图接口,依赖已确认框作为排除基准)——VLM 补检按钮从标注编辑器迁出(2026-09-02);点卡片缩略图仍进人工编辑器(确认/清理/画框,编辑器内不再有 VLM 按钮),编辑器文案「图片审核」→「图片标注」;服务端全量重标能力保留但不再有 UI 入口 @@ -419,7 +419,7 @@ training: ### 数据清洗(训练集去冗余,2026-09-02) - **语义(区别于视觉判重)**:不是删「长得像」的图,而是按标注算目标尺寸、按**尺寸档配额**去重——易档(中近距离)堆量、难档(<2% 极远)稀缺是当前数据集冗余的真实形态(2026-09-02 实测:dHash 整图/目标裁剪双重判定,生成图逐张随机 scene/action 几乎无近重复,严格阈值可去近 0 张;而 环颈雉 280 张实拍中 4-8% 档 109 张占 39%——同场地连拍帧为主,删冗余几乎无损) -- **目标尺寸口径**:`labels_json` 归一化框高 h 即目标占图高比例(**不需解码图片**);多框取最大 class 0 框定档(候选行展示的主尺寸);**档内保留取舍参考图内最小确认目标**(2026-09-02:多目标图里的小目标样本比主目标稀缺,同档冗余取舍先保它们);像素高 ≈ h×1280(训练尺度,等比近似);**仅 class 1 疑似图与空检出 `[]` 图不入清单**(无确认目标,无从定尺寸,等人工确认后自然进入统计) +- **目标尺寸口径**:`labels_json` 归一化框高 h 即目标占图高比例(**不需解码图片**);多框取最大框定档(**2026-09-07 起不区分确认/疑似类别**——已审核图的疑似框是人工背书的合法标注,统计须覆盖全部已审核图);**档内保留取舍参考图内最小目标**(2026-09-02:多目标图里的小目标样本比主目标稀缺,同档冗余取舍先保它们);像素高 ≈ h×1280(训练尺度,等比近似);**仅空检出 `[]`/无框图不入清单**(无框无从定尺寸) - **档位**:按图高占比细档 <2% / 2-3 / 3-4 / 4-6 / 6-8 / 8-12 / 12-20 / >20%——宽档(如 4-8%)跨 2 倍尺度,直接按宽档配额会把半档删光,须细档分别设配额;**<2% 极远档固定豁免**(recall 瓶颈档,只缺不多);各档配额请求可传,缺省用代码默认(consts.go);**配额随目标尺寸单调递减(2026-09-02 用户定案)**:尺寸越大越易检出、单张边际训练价值越低,冗余切得越狠——小档护稀缺硬样本。**档位↔距离映射(2026-09-04 定案,主摄 4:3、垂直 FOV≈55°,r ≈ 框高/(距离×0.96))**:同一占比对不同体型动物对应不同距离——2-3% 档 ≈ 雉鸡(框高~0.5m) 17-26m / 野兔(~0.35m) 12-18m / 斑鸠(~0.18m) 6-9m;<2% 档 = 雉鸡 >26m / 野兔 >18m / 斑鸠 >9.4m。**物理上限**:s档 imgsz 1280 有效图高 960px,占比 0.5% ≈ 5px 低于可靠检出线——100m 处三物种框高均 2~5px 不可检,90% 识别的现实半径(s 档)为雉鸡 ~40-50m / 野兔 ~30m / 斑鸠 ~12-15m(n 档 @704 有效高 528px 再减半,远距只有 s 档谈得上)。**默认配额按每物种 800 张训练规模设定(2026-09-04 方案B,覆盖 5m-100m 目标)**:2-3%:110 / 3-4%:100 / 4-6%:90 / 6-8%:70 / 8-12%:60 / 12-20%:50 / >20%:40(可控档合计 520 + <2% 豁免档主动供给 ~300 且集中在 1-2% 子带(≈雉鸡 26-52m),≈800/物种;0.5% 以下(雉鸡 >52m)低于可检线,不值得花生成预算;500 规模前的旧小数据集各档总量 < 配额时清洗不裁剪,不受影响。配额仅裁剪超配档,未超配档全留——想净留 800 建议准备 900~1000 张再按此表跑清洗)。曾用 225 规模默认(60/50/40/30/20/15/10)、500 规模方案A(100/90/80/60/55/50/35)已随规模上调替换 - **桶内多样性保留**:超配桶内保留谁不随机——**先按图内最小确认目标(class 0)尺寸升序扫描**(小目标样本稀缺,同档图里带更小目标的先占保留名额;同最小尺寸按 id 保持稳定),整图 dHash(common 层,9x8 灰度 64 位)与已保留图最小汉明距离大于阈值才留,**留够配额即停,其余全进候选清单**(连拍帧天然相邻近哈希,只留首帧;不同场景同尺寸的目标尽量都保);候选行展示主尺寸 + 最小目标占比 + 目标数,供人工判断取舍 - **执行语义(排除而非删除)**:候选清单人工过目后打 `dataset_image.clean_excluded=1`(EnsureColumn 迁移)——**只影响训练集打包**(prepare_yolo 跳过),图片/标注不动、列表/标注/预标全流程照常可见;**可恢复**(清除标记即回训练集,付费资产原则:清洗默认不删文件,物理删除仍走人工逐删) @@ -431,7 +431,8 @@ training: - **实现决策:不建新表**——负样本库是一个特殊数据集:`dataset.source='negative'`、固定保留名 `__negative__`(consts.NegativeDatasetName,目录名同名;AdminCreateDataset 拒绝用户使用该保留名),完全复用 `dataset`/`dataset_image` 表与上传/删除/图片网格能力。首次进入管理端「负样本」tab 时 `POST /admin/datasets/negative` 懒创建(Serial 内防并发重建)。 - **管理端「数据训练」页双 tab(2026-09-07 用户定案)**:「数据集」tab = 现有数据集卡片列表(列表接口排除负样本库),「负样本」tab = 负样本库图片网格(复用既有上传/删除接口,按负样本库 datasetId 调用;无标注/审核/清洗/预标/生成/众包流程)。 - **训练打包混入(prepareYoloSet)**:打包主数据集后查负样本库,存在且有图则全部图片(不看 review_status/clean_excluded——负样本库无审核语义)加入训练包:文件名统一加 `neg_` 前缀(防与主数据集图重名覆盖),label txt 写空内容(= 背景图),随主数据集按同一 80/20 逻辑拆 train/val(val 含负样本可观测背景误检率);`data.yaml` 类别不变(负样本不引入新类)。无负样本库/库为空 → 行为与原来完全一致。 -- **入口防护**:预标注(AdminStartLabelTask)、AI 生成(AdminGenerateImages)、众包下发(AdminCreateTask)、发起训练(AdminStartTraining)对 `source='negative'` 数据集一律拒绝(负样本库单独训练无意义、其图片不需要标注)。 +- **入口防护**:预标注(AdminStartLabelTask)、众包下发(AdminCreateTask)、发起训练(AdminStartTraining)对 `source='negative'` 数据集一律拒绝(负样本库单独训练无意义、其图片不需要标注)。 +- **负样本批量生成(2026-09-07)**:`POST /admin/datasets/negative/generate`(count 1-1000,异步任务,复用 gen_task 表按 datasetId 轮询进度,新增 `rejected` 剔除计数列)——提示词从 config.yml 内置池按序循环组装(保证每个短语均匀覆盖):`imageGen.negativeScenes`(~50 条,按「三物种易被误认的形状机理」穷举:细长轮廓→雉鸡/圆拱形→野兔/高处停息悬挂→斑鸠 + 室内/户外空镜)走 `negativePromptTemplate`(无任何目标词 + 显式否定「没有任何动物」);`imageGen.negativeAnimals`(混淆动物:猫/狗/麻雀/鸽子/喜鹊)走 `negativeAnimalPromptTemplate`(否定句只排除目标物种)。**逐张生成后自动过 RF-DETR 全图检测,有检出即剔除不入库**(图里有像目标的东西就不能当背景喂训练);检测服务不可达 fail-open 保留该图记日志(付费资产原则)。尺寸与正样本一致 704x1248。 - **删除防护**:AdminDeleteDataset 拒绝删除负样本库(防误删整库);单张图片删除照常可用。 ### 模型热更新与多模型推理(客户端 Flutter) diff --git a/server_admin/src/views/Datasets.vue b/server_admin/src/views/Datasets.vue index ae56093..b730bca 100644 --- a/server_admin/src/views/Datasets.vue +++ b/server_admin/src/views/Datasets.vue @@ -1,5 +1,5 @@ @@ -603,36 +680,81 @@ onBeforeUnmount(() => { - + + + + + + + + +

+ 内置场景池按序循环生成(细长轮廓 / 圆拱形 / 高处停息悬挂 / 室内户外空镜 / 混淆动物,与正样本同相机风格); + 每张生成后自动过 RF-DETR 空检,有检出的自动剔除不入库。约 40 秒/张,进度见负样本 tab 进度条。 +

+ +
+
数据集:{{ startRow?.name }}
@@ -750,22 +872,65 @@ onBeforeUnmount(() => { gap: 10px; min-height: 120px; } -.neg-cell { - aspect-ratio: 4 / 3; - border: 2px solid transparent; - border-radius: 6px; +/* 负样本卡片:与 DatasetDetail 图片网格同款 */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 14px; +} +.img-card { + background: #fff; + border: 1px solid #ebeef5; + border-radius: 8px; + padding: 10px; +} +.card-img { + position: relative; overflow: hidden; - cursor: pointer; - background: #f5f7fa; + border-radius: 6px; + background: #000; + cursor: zoom-in; + border: 1px solid #dcdfe6; } -.neg-cell img { - width: 100%; - height: 100%; - object-fit: cover; +.card-img img { display: block; + width: 100%; + height: auto; + object-fit: contain; } -.neg-cell.is-sel { - border-color: var(--el-color-primary); +.card-select { + position: absolute; + top: 6px; + left: 6px; + z-index: 2; + padding: 2px 3px; + background: rgba(255, 255, 255, 0.9); + border-radius: 4px; +} +.card-name { + margin-top: 6px; + font-size: 13px; + color: #303133; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pager { + display: flex; + justify-content: center; + margin-top: 14px; +} +.neg-progress { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.neg-progress-tip { + font-size: 12px; + color: #909399; + white-space: nowrap; } .toolbar { display: flex;