Files
observer/server/biz/service/false_target.go
T
2026-09-10 09:41:13 +08:00

394 lines
14 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
_ "image/jpeg"
"io"
"os"
"path/filepath"
"strings"
"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"
)
// falseTargetService 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流,
// 上传识别瞬间的分析帧整图(jpg 重编码已剥离全部元数据,最小必要);管理端审核通过
// 迁移入负样本库当背景图训练,拒绝删文件。不变式:pending/approved 记录 ⟺ 文件存在。
// 上报即算整帧 dHash 查重(近重复拒绝),落库后异步跑 RF-DETR 疑似真目标预判(2026-09-09)。
type falseTargetService struct{}
var FalseTarget = &falseTargetService{}
// falseTargetMaxBytes 裁剪图上限(客户端按框 ±15% 裁剪、jpg q85,远小于该值)
const falseTargetMaxBytes = 8 << 20
// ---------- App ----------
// AppReport 上报假目标:校验图片 → Serial 内整帧 dHash 查重 + 日限额 → 通过才 UUID
// 命名落盘待审目录 → 插记录(任一步失败删已落盘文件,保证记录存在 ⟺ 文件存在)。
// 重复上报不落盘(技术设计.md「上报查重与疑似真目标预判」)。
func (s *falseTargetService) AppReport(ctx context.Context, req *dto.FalseTargetReportReq) (*dto.FalseTargetReportRes, error) {
phone := common.PhoneFromCtx(ctx)
if req.File == nil {
return nil, gerror.New("缺少图片文件")
}
ext := strings.ToLower(filepath.Ext(req.File.Filename))
if ext != ".jpg" && ext != ".jpeg" {
return nil, gerror.New("仅支持 jpg 图片")
}
if req.File.Size > falseTargetMaxBytes {
return nil, gerror.New("图片过大")
}
f, err := req.File.Open()
if err != nil {
return nil, gerror.Wrap(err, "读取上传文件失败")
}
data, err := io.ReadAll(f)
_ = f.Close()
if err != nil {
return nil, gerror.Wrap(err, "读取上传文件失败")
}
if _, _, err = image.DecodeConfig(bytes.NewReader(data)); err != nil {
return nil, gerror.New("图片内容无效")
}
// 整帧 dHash(上报查重,2026-09-09):同机位连续帧近距极小,重复上报主源
hash, err := common.ImageDHash64(data)
if err != nil {
return nil, gerror.Wrap(err, "图片解析失败")
}
// 检测框快照:仅作审核参考的元数据,解析失败/坐标非法的框直接丢弃,
// 不阻断图片上报(主体是画面本身)
var allBoxes []*dto.FalseTargetBox
if strings.TrimSpace(req.Detections) != "" {
_ = json.Unmarshal([]byte(req.Detections), &allBoxes)
}
boxes := make([]*dto.FalseTargetBox, 0, len(allBoxes))
for _, b := range allBoxes {
if b == nil || b.Cx < 0 || b.Cy < 0 || b.W <= 0 || b.H <= 0 || b.Cx > 1 || b.Cy > 1 {
continue
}
boxes = append(boxes, b)
}
snapshot, err := json.Marshal(dto.FalseTargetSnapshot{
SourceW: req.SourceW,
SourceH: req.SourceH,
Boxes: boxes,
})
if err != nil {
return nil, gerror.Wrap(err, "快照序列化失败")
}
// 来源模型(=数据集名):快照内去重后逗号拼接(多模型并行推理时可能多个)
modelSet := map[string]bool{}
models := make([]string, 0, len(boxes))
for _, b := range boxes {
if b.Model != "" && !modelSet[b.Model] {
modelSet[b.Model] = true
models = append(models, b.Model)
}
}
now := gtime.Now()
dayStart := gtime.New(now.Format("Y-m-d") + " 00:00:00")
dir := common.FalseTargetsDir(ctx)
name := common.UuidV4() + ".jpg"
var id int64
err = common.Serial().Submit(ctx, func() error {
// dHash 查重(技术设计.md「上报查重与疑似真目标预判」):近重复直接拒绝,不落盘不占限额。
// 查重与落盘必须同串行块——分在两处时并发重复上报会各自通过查重、各自落盘入库
existing, err := dao.FalseTargetReport.ListHashes(ctx)
if err != nil {
return err
}
hashes := make([]uint64, 0, len(existing))
for _, h := range existing {
hashes = append(hashes, uint64(h))
}
if minHamming(hash, hashes) <= consts.FalseTargetDupHamming {
return gerror.New("相似样本已存在,无需重复上报")
}
cnt, err := dao.FalseTargetReport.CountTodayByPhone(ctx, phone, dayStart)
if err != nil {
return err
}
if cnt >= consts.FalseTargetDailyLimit {
return gerror.Newf("今日上报已达上限(%d 条/天)", consts.FalseTargetDailyLimit)
}
// 查重/限额均通过才落盘(重复上报不产生文件)
if err := os.MkdirAll(dir, 0o755); err != nil {
return gerror.Wrap(err, "创建上报目录失败")
}
if err := saveUploadAs(req.File, dir, name); err != nil {
return gerror.Wrap(err, "图片保存失败")
}
id, err = dao.FalseTargetReport.Insert(ctx, &entity.FalseTargetReport{
PhoneNum: phone,
File: name,
LabelsJson: string(snapshot),
Species: strings.Join(models, ","),
Status: consts.FalseTargetPending,
ImageHash: int64(hash),
CreatedAt: now,
})
return err
})
if err != nil {
_ = os.Remove(filepath.Join(dir, name))
return nil, err
}
s.submitSuspectCheck(ctx, id, name)
return &dto.FalseTargetReportRes{Id: id}, nil
}
// submitSuspectCheck 异步疑似真目标预判(2026-09-09):对整帧跑 RF-DETR 全图检测,
// 最高置信度 ≥ confConfirmed(真目标确认线)置 suspect_real=1 供审核把关。
// 未配置 local-ai 直接跳过;检测/更新失败仅记日志不回溯上报本身(fail-open)。
func (s *falseTargetService) submitSuspectCheck(ctx context.Context, id int64, file string) {
client := common.LocalAiClient(ctx)
if client == nil {
return
}
dir := common.FalseTargetsDir(ctx)
// 生命周期协程包池任务(promoteQueued 同模式):LabelTaskPool.Submit 是阻塞语义
go func() {
bgCtx := context.Background()
err := common.LabelTaskPoolInstance().Submit(bgCtx, func(taskCtx context.Context) error {
data, err := os.ReadFile(filepath.Join(dir, file))
if err != nil {
return err
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return err
}
dets, err := client.Detect(taskCtx, data, "image/jpeg", cfg.Width, cfg.Height)
if err != nil {
return err
}
for _, d := range dets {
if d.Confidence >= client.ConfConfirmed {
if err := common.Serial().Submit(taskCtx, func() error {
return dao.FalseTargetReport.SetSuspectReal(taskCtx, id)
}); err != nil {
return err
}
break
}
}
return nil
})
if err != nil {
g.Log().Errorf(bgCtx, "假目标 %d 疑似真目标预判失败: %+v", id, err)
}
}()
}
// startupHashBackfill 存量记录整帧 dHash 回填(2026-09-09 一次性,启动后台协程调用):
// pending 从待审目录、approved 从负样本库目录(审核迁移文件名不变)读图算 hash 回写;
// 文件缺失跳过(留 0 不参与查重)。回填完自退出,新上报均带 hash 无需再跑。
func (s *falseTargetService) startupHashBackfill(ctx context.Context) {
negLib, err := dao.Dataset.GetByName(ctx, consts.NegativeDatasetName)
if err != nil {
g.Log().Errorf(ctx, "假目标 hash 回填查负样本库失败: %+v", err)
return
}
// 单遍扫描:hash=0 只会出现在存量行(新上报均内联算 hash),无需轮询;
// 文件缺失/解码失败跳过(留 0 不参与查重),避免失败行导致死循环
missing, err := dao.FalseTargetReport.ListHashMissing(ctx)
if err != nil {
g.Log().Errorf(ctx, "假目标 hash 回填查询失败: %+v", err)
return
}
for _, r := range missing {
dir := common.FalseTargetsDir(ctx)
if r.Status == consts.FalseTargetApproved && negLib != nil {
dir = common.DatasetImagesDir(ctx, negLib.Name)
}
data, err := os.ReadFile(filepath.Join(dir, r.File))
if err != nil {
continue
}
h, err := common.ImageDHash64(data)
if err != nil {
continue
}
if err := common.Serial().Submit(ctx, func() error {
return dao.FalseTargetReport.UpdateHash(ctx, r.Id, int64(h))
}); err != nil {
g.Log().Errorf(ctx, "假目标 %d hash 回写失败: %+v", r.Id, err)
}
}
}
// saveUploadAs 上传文件落盘为指定文件名(f.Save 随原文件名,先暂存再改名)
func saveUploadAs(f *ghttp.UploadFile, dir, name string) error {
tmp, err := f.Save(dir)
if err != nil {
return err
}
if tmp == name {
return nil
}
if err := os.Rename(filepath.Join(dir, tmp), filepath.Join(dir, name)); err != nil {
_ = os.Remove(filepath.Join(dir, tmp))
return err
}
return nil
}
// ---------- 管理端 ----------
// AdminList 上报分页(附裁剪图预览地址 + 检测框快照解码)
func (s *falseTargetService) AdminList(ctx context.Context, req *dto.AdminFalseTargetListReq) (*dto.AdminFalseTargetListRes, error) {
page, size := common.NormalizePage(req.Page, req.Size)
list, total, err := dao.FalseTargetReport.PageByFilter(ctx, req.PhoneNum, req.Status, page, size)
if err != nil {
return nil, err
}
res := &dto.AdminFalseTargetListRes{Total: total, List: make([]*dto.AdminFalseTargetItem, 0, len(list))}
for _, r := range list {
item := &dto.AdminFalseTargetItem{
Id: r.Id,
PhoneNum: r.PhoneNum,
Species: r.Species,
Status: r.Status,
ImageUrl: fmt.Sprintf("/api/v1/admin/false-targets/image?id=%d", r.Id),
SuspectReal: r.SuspectReal,
CreatedAt: r.CreatedAt,
ReviewedAt: r.ReviewedAt,
}
if r.LabelsJson != "" {
var snap dto.FalseTargetSnapshot
if json.Unmarshal([]byte(r.LabelsJson), &snap) == nil {
item.Boxes = snap.Boxes
item.SourceW = snap.SourceW
item.SourceH = snap.SourceH
}
}
res.List = append(res.List, item)
}
return res, nil
}
// ImageFile 上报画面路径(controller 直写响应体;pending/approved 记录文件必然存在)
func (s *falseTargetService) ImageFile(ctx context.Context, id int64) (string, error) {
reports, err := dao.FalseTargetReport.GetByIds(ctx, []int64{id})
if err != nil {
return "", err
}
if len(reports) == 0 {
return "", gerror.New("上报记录不存在")
}
path := filepath.Join(common.FalseTargetsDir(ctx), reports[0].File)
if _, err := os.Stat(path); err != nil {
return "", gerror.New("图片文件不存在")
}
return path, nil
}
// AdminReview 审核:通过 = 裁剪图迁移入负样本库(__negative__,背景图训练);
// 拒绝 = 删文件。仅 pending 可审(重复审核跳过)。Serial + 逐条迁移,
// 入库失败把已迁移文件迁回待审目录(回滚),保持记录存在 ⟺ 文件存在不变式。
func (s *falseTargetService) AdminReview(ctx context.Context, req *dto.AdminFalseTargetReviewReq) (*dto.AdminFalseTargetReviewRes, error) {
res := &dto.AdminFalseTargetReviewRes{}
reports, err := dao.FalseTargetReport.GetByIds(ctx, req.Ids)
if err != nil {
return nil, err
}
pending := make([]*entity.FalseTargetReport, 0, len(reports))
for _, r := range reports {
if r.Status == consts.FalseTargetPending {
pending = append(pending, r)
}
}
if len(pending) == 0 {
return res, nil
}
now := gtime.Now()
// 负样本库懒创建内部走 Serial,须在本 Serial 块外先行调用(防串行队列嵌套等待死锁)
var lib *entity.Dataset
if req.Approve {
lib, err = Dataset.ensureNegativeLibrary(ctx)
if err != nil {
return nil, err
}
}
err = common.Serial().Submit(ctx, func() error {
fromDir := common.FalseTargetsDir(ctx)
moved := make([]string, 0, len(pending)) // 已迁出文件名(失败回迁)
toDir := ""
if req.Approve {
toDir = common.DatasetImagesDir(ctx, consts.NegativeDatasetName)
if err := os.MkdirAll(toDir, 0o755); err != nil {
return gerror.Wrap(err, "创建负样本库目录失败")
}
}
for _, r := range pending {
if req.Approve {
name := r.File
if _, err := os.Stat(filepath.Join(toDir, name)); err == nil {
// 极端重名兜底:换新 uuid
name = common.UuidV4() + ".jpg"
}
if err := os.Rename(filepath.Join(fromDir, r.File), filepath.Join(toDir, name)); err != nil {
s.rollbackMoves(ctx, fromDir, toDir, moved)
return gerror.Wrap(err, "迁移图片失败")
}
moved = append(moved, r.File)
if _, err := dao.DatasetImage.Insert(ctx, &entity.DatasetImage{
DatasetId: lib.Id,
Filename: name,
Source: "manual",
CreatedAt: now,
}); err != nil {
s.rollbackMoves(ctx, fromDir, toDir, moved)
return err
}
}
if req.Approve {
if err := dao.FalseTargetReport.SetStatus(ctx, r.Id, consts.FalseTargetApproved, now); err != nil {
s.rollbackMoves(ctx, fromDir, toDir, moved)
return err
}
} else {
// 拒绝 = 记录与文件同删(无 rejected 存量状态)
if err := dao.FalseTargetReport.DeleteByIds(ctx, []int64{r.Id}); err != nil {
s.rollbackMoves(ctx, fromDir, toDir, moved)
return err
}
_ = os.Remove(filepath.Join(fromDir, r.File))
}
res.Affected++
}
if req.Approve && res.Affected > 0 {
return dao.Dataset.UpdateCounters(ctx, lib.Id, res.Affected, -1, "")
}
return nil
})
if err != nil {
return nil, err
}
return res, nil
}
// rollbackMoves 审核中途失败:已迁移入负样本库的文件迁回待审目录
func (s *falseTargetService) rollbackMoves(ctx context.Context, fromDir, toDir string, moved []string) {
for _, name := range moved {
if err := os.Rename(filepath.Join(toDir, name), filepath.Join(fromDir, name)); err != nil {
g.Log().Errorf(ctx, "假目标审核回滚迁回失败: %s: %+v", name, err)
}
}
}