This commit is contained in:
2026-09-10 09:41:13 +08:00
parent c590f74b1e
commit 4b56d0b87b
16 changed files with 7381 additions and 74 deletions
+1
View File
@@ -0,0 +1 @@
3ed706f5-3e9a-4198-9625-4a35b60652c6
+3 -3
View File
@@ -12,7 +12,7 @@
| 订单确认 | 客户端支付成功后 `POST /api/v1/orders/{orderId}/confirm` 幂等通知,加速授权刷新 |
| 授权查询 | `GET /api/v1/license`Bearer token)返回授权状态,App 识别入口强制服务端校验 |
| 标注众包赚时长 | App 用户处理管理端下发的标注任务获取使用时长:任务 = 管理端**勾选的具体图片集合**(图片粒度下发,2026-09-07;已下发图即从「未标注」tab 消失,停用任务释放未领取图回池),每次领取 10 张(锁定向、超时自动释放、已处理过的图不再分配),手机上画框提交 → 图片进「待审核」;每有效提交 10 张**即时到账 30 分钟**(license 到期时间分钟级顺延),**每日上限 2 小时**;质量惩罚:审核通过比例 <80%(已审核样本 ≥5)自动冻结领取资格 24 小时(时间戳对比天然自动解冻、统计重新累计),管理端可手动解冻;服务端 `annotate_record` 记录每用户每张图的处理明细(领取/提交快照/审核结果) |
| 假目标上报(负样本回流,2026-09-08) | App 识别页一键「误报上报」:上报瞬间按一次快照帧(**与喂给 YOLO 的帧同源同尺寸**,整图,jpg q85 重编码剥离全部元数据)+ 当前全部检测框快照(归一化坐标/类别/置信度/来源模型,`detections` JSON),无任何额外选择步骤;服务端落 `false_target_report` 待审(每用户**每日上限 50 条**防灌水);管理端「数据训练 → 假目标上报」tab 审核(radio 带待审数)——**通过 = 整图迁移入负样本库**(`__negative__` 数据集,训练打包自动混入当背景学习,压制同类误报),拒绝 = 删文件;**合规**:首次使用单独同意弹窗(可拒绝且不影响识别),用户协议含反馈图训练使用授权条款 |
| 假目标上报(负样本回流,2026-09-08) | App 识别页一键「误报上报」:上报瞬间按一次快照帧(**与喂给 YOLO 的帧同源同尺寸**,整图,jpg q85 重编码剥离全部元数据)+ 当前全部检测框快照(归一化坐标/类别/置信度/来源模型,`detections` JSON),无任何额外选择步骤;服务端落 `false_target_report` 待审(每用户**每日上限 50 条**防灌水);管理端「数据训练 → 假目标上报」tab 审核(radio 带待审数)——**通过 = 整图迁移入负样本库**(`__negative__` 数据集,训练打包自动混入当背景学习,压制同类误报),拒绝 = 删文件;**上报查重(2026-09-09**:整帧 dHash 与已上报记录近重复(汉明 ≤8)直接拒绝入库不占日限额;**RF-DETR 疑似真目标预判**:上报后异步检测,高分检出标记「疑似真目标」供审核把关;**合规**:首次使用单独同意弹窗(可拒绝且不影响识别),用户协议含反馈图训练使用授权条款 |
| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**1000 / 5600 / 18000 |
| 后台管理端 | `server_admin/`Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token` |
| 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
@@ -59,7 +59,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| `annotate_task` | 标注众包任务(2026-09-042026-09-07 图片粒度下发) | `id`(PK)、`dataset_id``name``status`(published/stopped)、`created_at`(任务图集 = `dataset_image.annotate_task_id` 占用本任务 id 的图,不落任务表) |
| `annotate_record` | 用户标注记录(2026-09-04 | `id`(PK)、`phone_num``task_id``dataset_id`(领取时冗余,详情页过滤用)、`image_id``labels_json`(提交快照)、`status`(pending 领取锁/submitted/approved/rejected)、`created_at``submitted_at``reviewed_at`UNIQUE(phone_num,image_id)partial unique(image_id) WHERE status='pending' |
| `reward_log` | 标注时长发放流水(2026-09-04 | `id`(PK)、`phone_num``minutes``task_id``granted_at`(日上限 = 自然日求和) |
| `false_target_report` | 假目标上报(2026-09-08 | `id`(PK)、`phone_num``file`(uuid.jpg 分析帧整图)、`labels_json`(检测框快照:label/class/score/model/归一化 xywh)、`species`(来源模型名去重拼接)、`status`(pending/approved**拒绝即删记录与文件,无 rejected 存量**)、`created_at``reviewed_at`(文件落 `datasetDir/false_targets/`,审核通过迁移入负样本库图片目录) |
| `false_target_report` | 假目标上报(2026-09-08 | `id`(PK)、`phone_num``file`(uuid.jpg 分析帧整图)、`labels_json`(检测框快照:label/class/score/model/归一化 xywh)、`species`(来源模型名去重拼接)、`status`(pending/approved**拒绝即删记录与文件,无 rejected 存量**)、`image_hash`(整帧 dHash 64 位,上报查重用,0=未算/存量回填前)、`suspect_real`(0/1,RF-DETR 高分检出疑似真目标预判标记,2026-09-09)、`created_at``reviewed_at`(文件落 `datasetDir/false_targets/`,审核通过迁移入负样本库图片目录) |
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
@@ -237,7 +237,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
### POST /api/v1/feedback/false-target
假目标上报(需 Bearer token`multipart/form-data`):App 识别页一键上报当前画面。字段:`file`(上报瞬间的分析帧整图 jpg,与推理帧同源同尺寸,客户端重编码已剥离元数据)+ `detections`(当前全部检测框快照 JSON 数组 `[{label,class,score,model,cx,cy,w,h}]`,归一化坐标,可空)+ `sourceW`/`sourceH`(分析帧宽高)。每用户每日上限 50 条,超限报错。响应 `data: {id}`
假目标上报(需 Bearer token`multipart/form-data`):App 识别页一键上报当前画面。字段:`file`(上报瞬间的分析帧整图 jpg,与推理帧同源同尺寸,客户端重编码已剥离元数据)+ `detections`(当前全部检测框快照 JSON 数组 `[{label,class,score,model,cx,cy,w,h}]`,归一化坐标,可空)+ `sourceW`/`sourceH`(分析帧宽高)。每用户每日上限 50 条,超限报错;与已上报记录整帧 dHash 近重复(汉明 ≤8)报「相似样本已存在,无需重复上报」(2026-09-09;查重先于落盘——重复上报不产生文件、不占限额,2026-09-10)。响应 `data: {id}`
### 管理端假目标上报(`/api/v1/admin`X-Admin-Token 鉴权;页面入口 = 数据训练页第三个 tab)
+4
View File
@@ -27,6 +27,10 @@ const (
// 假目标上报每用户每日上限(防灌水/滥用)
FalseTargetDailyLimit = 50
// 假目标上报整帧 dHash 查重阈值:与已上报记录最小汉明距离 ≤ 该值视为近重复拒绝
// (同 CleanHashHamming;同机位连续帧近距通常 <82026-09-09
FalseTargetDupHamming = 8
// dataset_image.review_status 审核三态(技术设计.md「标注审核状态机」):
// 预标/App 提交 → 待审核;人工保存/审核通过 → 已审核;拒绝清标注 → 未标注(回任务池)。
// 不变式:review_status=0 ⟹ labels_json 无框;训练集只收 ReviewApproved
+53 -8
View File
@@ -9,6 +9,7 @@ import (
"observer-server/biz/consts"
"observer-server/biz/model/entity"
"observer-server/common"
)
// FalseTargetReport 假目标上报表 DAO(技术设计.md「假目标上报」)。
@@ -19,18 +20,23 @@ var FalseTargetReport = &falseTargetReportDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS false_target_report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_num TEXT NOT NULL,
file TEXT NOT NULL,
labels_json TEXT NOT NULL DEFAULT '',
species TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved(拒绝即删记录,无 rejected 存量)
created_at TEXT NOT NULL,
reviewed_at TEXT
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_num TEXT NOT NULL,
file TEXT NOT NULL,
labels_json TEXT NOT NULL DEFAULT '',
species TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved(拒绝即删记录,无 rejected 存量)
image_hash INTEGER NOT NULL DEFAULT 0, -- 整帧 dHash 64 位(上报查重;0=未算/存量回填前)
suspect_real INTEGER NOT NULL DEFAULT 0, -- RF-DETR 高分检出疑似真目标预判标记(0/1)
created_at TEXT NOT NULL,
reviewed_at TEXT
)`)
if err != nil {
panic(err)
}
// 存量库迁移:查重/预判两列(2026-09-09;新库建表已含列,幂等跳过)
common.EnsureColumn(ctx, consts.TableFalseTargetReport, "image_hash", "image_hash INTEGER NOT NULL DEFAULT 0")
common.EnsureColumn(ctx, consts.TableFalseTargetReport, "suspect_real", "suspect_real INTEGER NOT NULL DEFAULT 0")
}
// Insert 插入上报记录并返回自增 id
@@ -41,6 +47,7 @@ func (d *falseTargetReportDao) Insert(ctx context.Context, m *entity.FalseTarget
"labels_json": m.LabelsJson,
"species": m.Species,
"status": m.Status,
"image_hash": m.ImageHash,
"created_at": m.CreatedAt,
}).Insert()
if err != nil {
@@ -97,3 +104,41 @@ func (d *falseTargetReportDao) DeleteByIds(ctx context.Context, ids []int64) err
_, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).WhereIn("id", ids).Delete()
return err
}
// ListHashes 全表非零 dHash(上报查重比对集;0=未算/回填前不参与比对)。
// 条件必须是 != 0 而非 > 0dHash 最高位为 1 时 int64 存为负数(SQLite INTEGER 有符号),
// 用 > 0 会把约一半的 hash 排除在比对集外,查重静默失效(2026-09-10 实测)
func (d *falseTargetReportDao) ListHashes(ctx context.Context) ([]int64, error) {
all, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).
Fields("image_hash").Where("image_hash != 0").Array("image_hash")
if err != nil {
return nil, err
}
out := make([]int64, 0, len(all))
for _, v := range all {
out = append(out, v.Int64())
}
return out, nil
}
// ListHashMissing 存量回填待办:image_hash=0 的记录
func (d *falseTargetReportDao) ListHashMissing(ctx context.Context) ([]*entity.FalseTargetReport, error) {
var list []*entity.FalseTargetReport
err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).
Where("image_hash", 0).OrderAsc("id").Scan(&list)
return list, err
}
// UpdateHash 回填整帧 dHash
func (d *falseTargetReportDao) UpdateHash(ctx context.Context, id int64, hash int64) error {
_, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Where("id", id).
Data("image_hash", hash).Update()
return err
}
// SetSuspectReal 预判命中置疑似真目标标记(幂等,只置 1 不清 0)
func (d *falseTargetReportDao) SetSuspectReal(ctx context.Context, id int64) error {
_, err := g.DB().Model(consts.TableFalseTargetReport).Ctx(ctx).Where("id", id).
Data("suspect_real", 1).Update()
return err
}
+11 -10
View File
@@ -58,16 +58,17 @@ type FalseTargetSnapshot struct {
// AdminFalseTargetItem 上报条目(附裁剪图预览地址与检测框快照)
type AdminFalseTargetItem struct {
Id int64 `json:"id"`
PhoneNum string `json:"phoneNum"`
Species string `json:"species"`
Status string `json:"status"`
ImageUrl string `json:"imageUrl"`
Boxes []*FalseTargetBox `json:"boxes"`
SourceW int `json:"sourceW"`
SourceH int `json:"sourceH"`
CreatedAt *gtime.Time `json:"createdAt"`
ReviewedAt *gtime.Time `json:"reviewedAt"`
Id int64 `json:"id"`
PhoneNum string `json:"phoneNum"`
Species string `json:"species"`
Status string `json:"status"`
ImageUrl string `json:"imageUrl"`
Boxes []*FalseTargetBox `json:"boxes"`
SourceW int `json:"sourceW"`
SourceH int `json:"sourceH"`
SuspectReal int `json:"suspectReal"` // RF-DETR 高分检出疑似真目标预判(0/1,审核辅助标记)
CreatedAt *gtime.Time `json:"createdAt"`
ReviewedAt *gtime.Time `json:"reviewedAt"`
}
type AdminFalseTargetListRes struct {
+10 -8
View File
@@ -5,12 +5,14 @@ import "github.com/gogf/gf/v2/os/gtime"
// FalseTargetReport 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流,
// 客户端仅上传误报框裁剪图(剥离元数据);管理端审核通过后迁移入负样本库当背景图训练。
type FalseTargetReport struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"上报用户手机号"`
File string `json:"file" orm:"file" description:"裁剪图文件名(uuid.jpg)"`
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"检测框快照(label/class/score/归一化xywh)"`
Species string `json:"species" orm:"species" description:"上报时来源模型名(=数据集名)"`
Status string `json:"status" orm:"status" description:"pending|approved"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"上报时间"`
ReviewedAt *gtime.Time `json:"reviewedAt" orm:"reviewed_at" description:"审核时间"`
Id int64 `json:"id" orm:"id" description:"自增主键"`
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"上报用户手机号"`
File string `json:"file" orm:"file" description:"裁剪图文件名(uuid.jpg)"`
LabelsJson string `json:"labelsJson" orm:"labels_json" description:"检测框快照(label/class/score/归一化xywh)"`
Species string `json:"species" orm:"species" description:"上报时来源模型名(=数据集名)"`
Status string `json:"status" orm:"status" description:"pending|approved"`
ImageHash int64 `json:"imageHash" orm:"image_hash" description:"整帧 dHash 64 位(上报查重;0=未算/存量回填前)"`
SuspectReal int `json:"suspectReal" orm:"suspect_real" description:"RF-DETR 高分检出疑似真目标预判标记(0/1,2026-09-09)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"上报时间"`
ReviewedAt *gtime.Time `json:"reviewedAt" orm:"reviewed_at" description:"审核时间"`
}
+129 -20
View File
@@ -1,11 +1,13 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
_ "image/jpeg"
"io"
"os"
"path/filepath"
"strings"
@@ -23,8 +25,9 @@ import (
)
// falseTargetService 假目标上报(技术设计.md「假目标上报」):App 识别端误报一键回流,
// 客户端仅上传误报框裁剪图(jpg 重编码已剥离全部元数据,最小必要);管理端审核通过
// 上传识别瞬间的分析帧整图(jpg 重编码已剥离全部元数据,最小必要);管理端审核通过
// 迁移入负样本库当背景图训练,拒绝删文件。不变式:pending/approved 记录 ⟺ 文件存在。
// 上报即算整帧 dHash 查重(近重复拒绝),落库后异步跑 RF-DETR 疑似真目标预判(2026-09-09)。
type falseTargetService struct{}
var FalseTarget = &falseTargetService{}
@@ -34,8 +37,9 @@ const falseTargetMaxBytes = 8 << 20
// ---------- App ----------
// AppReport 上报假目标:校验图片 → UUID 命名落盘待审目录 → Serial 内查日限额 + 插记录
// (入库失败删已落盘文件,保证记录存在 ⟺ 文件存在)
// 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 {
@@ -52,11 +56,19 @@ func (s *falseTargetService) AppReport(ctx context.Context, req *dto.FalseTarget
if err != nil {
return nil, gerror.Wrap(err, "读取上传文件失败")
}
_, _, err = image.DecodeConfig(f)
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
@@ -71,20 +83,12 @@ func (s *falseTargetService) AppReport(ctx context.Context, req *dto.FalseTarget
boxes = append(boxes, b)
}
if err := os.MkdirAll(common.FalseTargetsDir(ctx), 0o755); err != nil {
return nil, gerror.Wrap(err, "创建上报目录失败")
}
name := common.UuidV4() + ".jpg"
if err := saveUploadAs(req.File, common.FalseTargetsDir(ctx), name); err != nil {
return nil, gerror.Wrap(err, "图片保存失败")
}
snapshot, err := json.Marshal(dto.FalseTargetSnapshot{
SourceW: req.SourceW,
SourceH: req.SourceH,
Boxes: boxes,
})
if err != nil {
_ = os.Remove(filepath.Join(common.FalseTargetsDir(ctx), name))
return nil, gerror.Wrap(err, "快照序列化失败")
}
// 来源模型(=数据集名):快照内去重后逗号拼接(多模型并行推理时可能多个)
@@ -98,8 +102,23 @@ func (s *falseTargetService) AppReport(ctx context.Context, req *dto.FalseTarget
}
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
@@ -107,23 +126,112 @@ func (s *falseTargetService) AppReport(ctx context.Context, req *dto.FalseTarget
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(common.FalseTargetsDir(ctx), name))
_ = 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)
@@ -152,13 +260,14 @@ func (s *falseTargetService) AdminList(ctx context.Context, req *dto.AdminFalseT
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),
CreatedAt: r.CreatedAt,
ReviewedAt: r.ReviewedAt,
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
+36 -7
View File
@@ -35,6 +35,7 @@ func (s *trainingService) StartBackgroundJobs(ctx context.Context) {
if err := dao.Training.FailUnstarted(ctx); err != nil {
g.Log().Errorf(ctx, "恢复未启动训练任务失败: %+v", err)
}
go FalseTarget.startupHashBackfill(ctx) // 存量假目标整帧 dHash 回填(单遍自退出)
go s.pollTrainings(ctx)
}
@@ -101,13 +102,16 @@ func (s *trainingService) pollOne(ctx context.Context, runner common.TrainingRun
}
alive = a
}
// 超时判死(started_at 起算;含发起准备阶段)
// 超时判死(started_at 起算;含发起准备阶段)。timeoutMinutes<=0 = 不限时
// 2026-09-10 用户定案:训练时长不受限,大_epochs/慢机训练不被误杀)
cfg, _ := common.TrainingConfigOf(ctx)
timeout := time.Duration(cfg.TimeoutMins) * time.Minute
if t.StartedAt != nil && time.Since(t.StartedAt.Time) > timeout {
_ = runner.Cancel(ctx, job)
_ = s.finishFailed(ctx, t, "训练超时(超过 %d 分钟无结果,已终止)", cfg.TimeoutMins)
return
if cfg.TimeoutMins > 0 {
timeout := time.Duration(cfg.TimeoutMins) * time.Minute
if t.StartedAt != nil && time.Since(t.StartedAt.Time) > timeout {
_ = runner.Cancel(ctx, job)
_ = s.finishFailed(ctx, t, "训练超时(超过 %d 分钟无结果,已终止)", cfg.TimeoutMins)
return
}
}
// pid 未落 = 发起准备阶段(写任务参数/同步数据集/启动进程)尚未完成,不判死
if t.Pid == 0 {
@@ -222,6 +226,7 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
if err := dao.Training.Finish(ctx, t.Id, consts.TrainingStatusSuccess, metricsJSON, truncateTail(tail), ""); err != nil {
g.Log().Errorf(ctx, "训练 %d 置成功失败: %+v", t.Id, err)
}
s.cleanupTrainingDataset(ctx, t)
}
// mergeNamesIntoMetrics 把 names 数组并入 metrics JSONnames 字段供发布解析类别名)
@@ -243,7 +248,31 @@ func mergeNamesIntoMetrics(metricsJSON, namesJSON string) string {
func (s *trainingService) finishFailed(ctx context.Context, t *entity.ModelTraining, format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
g.Log().Errorf(ctx, "训练 %d 置失败: %s", t.Id, msg)
return dao.Training.Finish(ctx, t.Id, consts.TrainingStatusFailed, "", "", msg)
err := dao.Training.Finish(ctx, t.Id, consts.TrainingStatusFailed, "", "", msg)
s.cleanupTrainingDataset(ctx, t)
return err
}
// cleanupTrainingDataset 任务终态清理训练机上的数据集目录(成功/失败/取消经 finishFailed/finishSuccess
// 收尾均触达;用户取消走 AdminCancelTraining→finishFailed)。best effort:通道未配置/数据集已删/
// 远端命令失败仅记日志不阻断终态;取消发生在数据集同步进行中的竞态窗口可能残留半截目录,
// 由下次同数据集同步开头的 RemoveAll 自愈
func (s *trainingService) cleanupTrainingDataset(ctx context.Context, t *entity.ModelTraining) {
runner := common.Runner(ctx)
if runner == nil {
return
}
cfg, ok := common.TrainingConfigOf(ctx)
if !ok {
return
}
job, err := s.buildJob(ctx, t, cfg)
if err != nil {
return // 数据集已删等场景无目录可清
}
if err := runner.CleanupYoloDataset(ctx, job); err != nil {
g.Log().Errorf(ctx, "训练 %d 清理训练机数据集目录失败: %+v", t.Id, err)
}
}
// buildJob 组装训练机路径布局的 runner 任务。
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -67,12 +67,17 @@ func LocalAiClient(ctx context.Context) *LocalAi {
}
}
// QwenVL 调 local-ai 的多模态 VLMqwen3.6-35b-a3b + mmprojllama.cpp 后端)做图片推理
// 注意:VL 与 z-image 生成不能同时驻留 12G 显存(OOM),调用方须保证阶段互斥。
// QwenVL 调多模态 VLM 做图片推理(OpenAI 兼容 /v1/chat/completions
// 2026-09-10 起默认走 omlxlocalAi.vlmBaseUrlMLX 多模态模型);未配置 vlmBaseUrl
// 时回落 localAi.baseUrl(旧 local-ai 部署同机混部形态)。
func QwenVL(ctx context.Context, imgData []byte, mime, prompt string) (string, error) {
base := strings.TrimRight(g.Cfg().MustGet(ctx, "localAi.baseUrl").String(), "/")
base := g.Cfg().MustGet(ctx, "localAi.vlmBaseUrl").String()
if base == "" {
return "", fmt.Errorf("localAi.baseUrl 未配置")
base = g.Cfg().MustGet(ctx, "localAi.baseUrl").String()
}
base = strings.TrimRight(base, "/")
if base == "" {
return "", fmt.Errorf("localAi.vlmBaseUrl/localAi.baseUrl 未配置")
}
model := g.Cfg().MustGet(ctx, "localAi.vlmModel", "qwen3.6-35b-a3b").String()
b64 := base64.StdEncoding.EncodeToString(imgData)
@@ -96,6 +101,9 @@ func QwenVL(ctx context.Context, imgData []byte, mime, prompt string) (string, e
return "", err
}
req.Header.Set("Content-Type", "application/json")
if key := g.Cfg().MustGet(ctx, "localAi.vlmApiKey").String(); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
+30 -6
View File
@@ -42,8 +42,10 @@ type TrainingRunner interface {
// PushArtifact 把服务器本地文件推到训练机(增量训练基座权重,2026-09-09;
// remoteName 相对训练机 workdir
PushArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error
// SyncYoloDataset 把训练集包落到训练机(subprocess 直写 workdirssh 走 tar 流式管道,本地不落盘)
// SyncYoloDataset 把训练集包落到训练机(subprocess 直写 workdir,原图硬链接引用;ssh 走 tar 流式管道,本地不落盘)
SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error
// CleanupYoloDataset 删除训练机上的数据集目录(任务终态调用:成功/失败/取消,防 GB 级残留)
CleanupYoloDataset(ctx context.Context, job *TrainingJob) error
// WriteTaskJson 把任务参数文件写到训练机(随 Start 前的准备阶段调用)
WriteTaskJson(ctx context.Context, job *TrainingJob, content string) error
}
@@ -106,7 +108,7 @@ func TrainingConfigOf(ctx context.Context) (TrainingConfig, bool) {
Workdir: g.Cfg().MustGet(ctx, "training.workdir").String(),
DatasetDir: g.Cfg().MustGet(ctx, "training.datasetDir", "datasets").String(),
Python: g.Cfg().MustGet(ctx, "training.venvPython").String(),
TimeoutMins: g.Cfg().MustGet(ctx, "training.timeoutMinutes", 600).Int(),
TimeoutMins: g.Cfg().MustGet(ctx, "training.timeoutMinutes", 0).Int(),
Model: g.Cfg().MustGet(ctx, "training.model", "yolov8s.pt").String(),
Imgsz: g.Cfg().MustGet(ctx, "training.imgsz", 1280).Int(),
ModelN: g.Cfg().MustGet(ctx, "training.modelN").String(),
@@ -235,25 +237,41 @@ func (r *subprocessRunner) PushArtifact(ctx context.Context, job *TrainingJob, r
}
func (r *subprocessRunner) SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error {
// 同机训练:训练进程直接读 workdir 下文件,包直写目标目录(无中间暂存)
// 同机训练:训练进程直接读 workdir 下文件,包直写目标目录(无中间暂存)
// 原图用硬链接引用项目数据集目录(同卷 0 空间占用,训练机=开发机架构下原图只存一份),
// 跨卷等 os.Link 失败场景回退拷贝
dst := filepath.Join(job.Workdir, job.DatasetDir, "yolo", job.DatasetName)
_ = os.RemoveAll(dst)
for _, f := range pkg.Files {
data := f.Content
dstPath := filepath.Join(dst, filepath.FromSlash(f.Name))
if f.ImagePath != "" {
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
if err := os.Link(f.ImagePath, dstPath); err == nil {
continue
}
imgData, rErr := os.ReadFile(f.ImagePath)
if rErr != nil {
return gerror.Wrapf(rErr, "读取原图失败: %s", f.ImagePath)
}
data = imgData
if err := WriteFileAtomic(dstPath, imgData); err != nil {
return err
}
continue
}
if err := WriteFileAtomic(filepath.Join(dst, filepath.FromSlash(f.Name)), data); err != nil {
if err := WriteFileAtomic(dstPath, f.Content); err != nil {
return err
}
}
return nil
}
func (r *subprocessRunner) CleanupYoloDataset(ctx context.Context, job *TrainingJob) error {
dst := filepath.Join(job.Workdir, job.DatasetDir, "yolo", job.DatasetName)
return os.RemoveAll(dst)
}
func (r *subprocessRunner) WriteTaskJson(ctx context.Context, job *TrainingJob, content string) error {
dir := filepath.Join(job.Workdir, "tasks")
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -493,6 +511,12 @@ func writeYoloTar(w io.Writer, datasetName string, pkg *YoloPackage) error {
return nil
}
func (r *sshRunner) CleanupYoloDataset(ctx context.Context, job *TrainingJob) error {
dst := fmt.Sprintf("%s/%s/yolo/%s", job.Workdir, job.DatasetDir, job.DatasetName)
_, err := r.runCmd(ctx, job, fmt.Sprintf("rm -rf %s", dst))
return err
}
func (r *sshRunner) WriteTaskJson(ctx context.Context, job *TrainingJob, content string) error {
// 通过 ssh stdin 管道写文件(避免命令行转义地狱)
client, err := r.dial(ctx)
+6 -3
View File
@@ -108,7 +108,7 @@ training:
workdir: /Users/zhangbin/yolo-workdir # 训练机工作目录(train_server.py / yolov8s.pt / yolov8n.pt 所在)
venvPython: /Users/zhangbin/yolo-venv/bin/python
datasetDir: datasets # 训练机数据集根目录(相对 workdiryolo/<name> 为子目录)
timeoutMinutes: 240 # 训练超时判死(started_at 起算;实测 100 张约 3 分钟,千图级约 1 小时
timeoutMinutes: 0 # 训练超时判死(started_at 起算;0 = 不限时,2026-09-10 用户定案训练时长不受限。存活探测失败仍按进程退出判死
# 双档位(2026-09-03 用户定案):s 档(高识别,默认)与 n 档(高性能)各自基座/分辨率;
# 一次「开始训练」按档位各建一条任务(请求 variants 可限定单档);epochs/batch/device 双档共用。
# n 档未配置(modelN/imgszN 缺一)时发起含 n 档的请求报错
@@ -121,10 +121,13 @@ training:
device: "mps" # GPU 编号(cpu 用 cpu;M1 本机用 mps,训练机切回时改回 "0"
# RF-DETR 预标注服务(管理端标注工作台):需从服务器可达,未配置时预标注接口返回「标注服务未配置」
# 2026-09-10 本机架构:检测=local-ai(18082, rfdetr-xlarge)VLM=omlx(18081, 多模态 MLX 模型)
localAi:
baseUrl: "http://192.168.3.210:18080" # 如 http://127.0.0.1:18080local-ai 兼容 /v1/detection
baseUrl: "http://127.0.0.1:18082" # local-ai/v1/detectionLaunchAgent 自启 ~/localai/start.sh
model: rfdetr-xlarge
vlmModel: qwen3-vl-8b # VLM 藏匿位补检模型(Qwen3-VL-8B 多模态 GGUF+mmproj2026-09-02 起与 z-image/rfdetr 三模型共存
vlmBaseUrl: "http://127.0.0.1:18081" # omlxOpenAI 兼容 /v1/chat/completions;空则回落 baseUrl
vlmApiKey: "wenwu901" # omlx api_key~/.omlx/settings.json auth 节点)
vlmModel: Qwen3.5-9B-MLX-4bit # VLM 模型(omlx 多模态 MLX 4bit;备选 gemma-4-E4B-mlx-4bit,空闲 10 分钟自动卸载)
threshold: 0.2 # 候选置信度下限:0.2 以下直接丢弃(不入库);0.08 实测过低——COCO 通用 rfdetr 对类鸟纹理在 0.08~0.2 区间输出大量噪声候选
confConfirmed: 0.5 # 置信度 ≥ 0.5 视为确认(class 0 真目标)0.2~0.49 落疑似(class 1)待人工复核(2026-09-02 RNPHE_257 实测调定:误检可达 0.46~0.5 且与真目标仅差 ~0.1,误检落 class 0 会污染训练正样本)
overlapThreshold: 0.3 # 重叠去重阈值(交叠/较小框面积,NMS 风格;RF-DETR 同目标常输出一大一小两框,minIoU 比 IoU 更能命中)
+1
View File
@@ -0,0 +1 @@
2e308d32-5f16-4b46-ba2b-991e14ae68f0
Binary file not shown.
+12 -5
View File
@@ -164,7 +164,7 @@ CREATE INDEX IF NOT EXISTS idx_model_training_status ON model_training(status);
**决策(2026-08-26**AI 标注端点(`localAi`)与训练机 SSH 凭据(`training.ssh`)是全局配置、与数据集无关——**不设独立存储**(曾尝试 `app_config` KV 表 + 管理端「训练配置」入口,2026-08-26 撤销):标注与训练直接读 `config.yml``localAi` / `training.ssh` 节点,改配置需重启服务。数据集表原 6 个覆盖列(ai_endpoint 等)已随 v12 删除。
- AI 客户端:`common.LocalAiClient(ctx)` 直读 `localAi.baseUrl`/`model`,未配置返回 nil(预标注接口报「标注服务未配置」)
- AI 客户端:`common.LocalAiClient(ctx)` 直读 `localAi.baseUrl`/`model`,未配置返回 nil(预标注接口报「标注服务未配置」)VLM 调用(`common.QwenVL`2026-09-10 起读 `localAi.vlmBaseUrl`/`vlmApiKey`omlx OpenAI 兼容服务,空则回落 `baseUrl` 同机混部形态)
- SSH 凭据:`common/training_runner.go` 的 sshRunner 直读 `training.ssh.host`/`user`/`port`/`privateKeyPath`/`password`,未配置 host 报「training.ssh 未配置 host」
## 账号体系(注册/登录)
@@ -365,7 +365,7 @@ training:
workdir: /opt/pheasant_data # 训练机工作目录
venvPython: /opt/pheasant_data/venv/bin/python
datasetDir: datasets # 训练机数据集根目录(相对 workdir,数据集为子目录)
timeoutMinutes: 240 # 超时判死(started_at 起算;100 张实测约 3 分钟
timeoutMinutes: 0 # 超时判死(started_at 起算;0 = 不限时,2026-09-10 用户定案训练时长不受限
model: yolov8s.pt # s 档(高识别)基座权重(须已同步到训练机 workdir)
imgsz: 1280 # s 档训练/导出分辨率(须与 App 端推理输入对齐)
modelN: yolov8n.pt # n 档(高性能)基座权重(2026-09-03 双档位;须已同步到训练机 workdir)
@@ -385,7 +385,7 @@ training:
- **训练脚本**`server/training/train_server.py`,随项目迁移):支持 `--task-json <file>`(含 dataset/imgsz/epochs/batch/device/project 名),每 epoch 输出一行机器可读 JSON 到 `--log-file``{"epoch":1,"total":150,"metrics":{...}}`),结束写 `result.json`(最终指标);Go 侧解析日志行更新进度、轮询日志尾部截断 N KB 存 `model_training.log_tail`
- **tflite 产物自检**2026-08-26):`inspect_tflite.py` 的 flatbuffer 解析逻辑内嵌进 `train_server.py``check_tflite`),训练收尾定位 `best.tflite` 后自动校验并写 `result.json``tflite_check` 字段:`{"ok":bool,"reason":string,"inputs":[{"name","shape","type"}],"outputs":[...]}`;校验规则 = 输入恰 1 张且 4 维、元素总数 == imgsz²×3(兼容 NCHW/NHWC)、输出 ≥ 1 张且 batch 维 = 1`ok=false`(如 shape 漂移、导出异常)时 Go 侧在拉产物前直接置训练失败并带出 reason,杜绝坏产物进入发布链路;`dump_graph.py` 保留作训练机人工深度调试
- **双档位训练(2026-09-03 用户定案)**:每数据集并行维护两个档位模型——**s 档(高识别)**:s@1280(基座 `training.model`/`imgsz`,见 2026-09-01 升级条),精度优先;**n 档(高性能)**:n@704(基座 `training.modelN`/`imgszN`),速度优先(中端机每帧 ~10ms 量级,远小于 s 的 70-120ms)。一次「开始训练」按档位各建一条任务:请求 `variants:["s","n"]` 限定(省略=双档;**只补跑高性能档传 `["n"]`**——存量 s 训练不重复发起);epochs/batch/device 双档共用 `training` 节点。任务表带 `variant` 列快照,训练机侧 `task.json` 按档位传 model/imgsztrain_server.py 本就参数化,无需改)。n 档权重须已同步到训练机 workdir(yolov8n.pt);n 档配置缺失(modelN/imgszN 未配)时发起含 n 档的请求报错。**选型背景**:s@1280 是当前最优精度形态,n@704 是端侧实时性的兜底形态(曾同规格 @704 训练:识别快、小目标误漏多),双档并存让用户在精度/速度间切换(App 端全局识别档位切换,见 flutter_app/README
- **任务生命周期**`queued → running → success/failed`;取消 = 杀进程(ssh 模式远程 kill pidqueued 无进程直接置 failed);超时无心跳判死;**Go 服务重启后启动扫描** running 任务按 pid 存活探测(subprocess 本机、ssh 远程 `kill -0`),进程已死则置 failed(queued 任务落库即持久,重启后由轮询继续晋级,无需恢复处理)
- **任务生命周期**`queued → running → success/failed`;取消 = 杀进程(ssh 模式远程 kill pidqueued 无进程直接置 failed);超时判死默认关闭(`timeoutMinutes: 0` 不限时,2026-09-10 用户定案训练时长不受限——此前 240 分钟曾误杀长任务;>0 时 started_at 起算超时杀进程);**Go 服务重启后启动扫描** running 任务按 pid 存活探测(subprocess 本机、ssh 远程 `kill -0`),进程已死则置 failed(queued 任务落库即持久,重启后由轮询继续晋级,无需恢复处理)
- **发起训练异步化(2026-08-27**:发起请求仅做校验(数据集存在 / `prepareYoloSet` 有标注 / Serial 内并发检查)+ 落任务记录即返回(毫秒级);训练机侧准备(写任务参数 → ssh tar 同步数据集 → 启动进程,耗时可达分钟级)在后台协程执行(`context.Background()`,与预标注 `runDetection` 同模式),任何一步失败经 `finishFailed` 置任务 failed 由列表/轮询呈现——此前同步执行超过管理端 axios 10s 超时,出现「任务已落库但前端报 timeout」的不一致
- **GPU 独占排队(2026-09-03,替代「并发度 1 拒绝」)**:训练机单 GPU 无法并行两任务(显存),并发度 1 语义不变——已有 running 时新任务**不再拒绝**,落 `queued` 排队;10s 轮询在 running 结束后自动晋级最老 queued 为 running(CAS 防竞态后起后台协程做训练机准备);双档/多数据集可一次发起一串,训练机串行逐个执行。**同数据集同档位防重**:发起时检查该 (数据集,档位) 是否已有 running/queued 任务,有则拒绝(防双击/重复请求——排队不再拒绝后双档各自独立排队,同档重复提交会白跑两轮)。**发起校验与晋级都重新 `prepareYoloSet`**:请求时校验有标注即可(立即报错),晋级时重新打包(取发起后新标注,拆分 80/20 随任务时刻新鲜);数据集改名/删图期间排队任务晋级失败即置 failed 由列表呈现
- 产物拉取(2026-08-27 重构;命名 2026-08-28 改;**双档位 2026-09-03**):成功后只拉 `best.tflite` 直写服务器 `workspace/trainings/<文件名前缀>.tflite`s 档,前缀空回退数据集名)或 `<文件名前缀>_n.tflite`(n 档);原子覆盖,无 per-task 存档、不再打包 zip。s 档文件名与存量一致(存量已发布文件/旧 App 下载地址不变),n 档 `_n` 后缀区分
@@ -415,7 +415,7 @@ training:
- **详情页布局(2026-09-02 卡片化改版后快照)**:图片以缩略图卡片网格展示(每页 18 张,点卡片进弹窗编辑器);页顶标注/AI 生成任务进度条;**图片列表三 tab(2026-09-02)**:「未标注」(默认,待处理优先)、「已标注」、**「已清洗」**各带计数——已标注判定 = labels_json 非空(空检出 `[]` 的图仍在未标注,等人工画框);**未标注 tab 只显示未下发的图(annotate_task_id=02026-09-07 众包图片粒度下发,已下发图从 tab 消失,停用任务释放后回来)****已清洗 tab = clean_excluded=1 的图(数据清洗执行后从已标注 tab 移入,卡片角标同 tab 语义二选一),内提供逐张/批量恢复(= 清除标记,回「已标注」)**,恢复仅动作无删除;「数据清洗」按钮在已标注 tab 工具条(分析/执行入口,见下节);切 tab/翻页均清空勾选(防跨 tab 误标),编辑器「上一张/下一张」仅在当前 tab 列表内导航(连续标注不串到另一分类);**卡片上不再叠「已标注/未标注」角标**(2026-09-02,分类由 tab 表达),仅保留 AI 来源角标(付费资产提示)
- **弹窗标注**:点击原图/标注图打开放大弹窗(大 canvas,1152x2048 原尺寸),支持两点画框/点框删除/清空/类别切换(确认 0/疑似 1)/上下一张/保存——**AI 自动标注与人工框同层可编辑**(含清理 AI 框);保存即整体覆写 `dataset_image.labels_json`JSON 数组,YOLO 归一化 xywh+类别+置信度,空=清空标注);未保存修改切换图片有确认
- 无历史标注任务时详情页工作台数据源为 `GET /admin/label-workbench?dataset_id=`(图片 + 全量标注框),避免依赖人工先跑一次任务
- 训练前自动整理(prepare_yolo 逻辑服务端,**内存组装不落本地盘**):数据集有 labels → 按 80/20 拆 train/val 生成 YOLO 训练集包(标注 txt 内存生成、原图只记数据集目录源路径)→ 随同步推训练机(subprocess 直写 `workdir/datasetDir/yolo/<name>/`ssh **tar 流式管道**本地不落盘);**自动标注图直接进训练集**(人工修改/清理后即为训练数据),**2026-09-02 起跳过 `clean_excluded=1` 的图(数据清洗排除,见下)**;本地无暂存目录(旧 `workspace/datasets/yolo/``labels/` 已删除)
- 训练前自动整理(prepare_yolo 逻辑服务端,**内存组装不落本地盘**):数据集有 labels → 按 80/20 拆 train/val 生成 YOLO 训练集包(标注 txt 内存生成、原图只记数据集目录源路径)→ 随同步推训练机(subprocess 直写 `workdir/datasetDir/yolo/<name>/`**2026-09-09 起原图用硬链接引用**(同机 0 空间占用、免 GB 级重复拷贝,跨卷等 `os.Link` 失败自动回退拷贝;训练机=开发机同一台 Mac 的现架构下原图只在项目数据集目录存一份)ssh 仍为**tar 流式管道**实拷贝,本地不落盘);**任务终态清理(2026-09-09)**:成功/失败/取消均由 `CleanupYoloDataset` 删训练机数据集目录(best effort 失败仅记日志;下次同步自带 RemoveAll 自愈)——取消发生在数据集同步进行中的竞态窗口可能残留半截目录(下次同数据集训练时清掉);**自动标注图直接进训练集**(人工修改/清理后即为训练数据),**2026-09-02 起跳过 `clean_excluded=1` 的图(数据清洗排除,见下)**;本地无暂存目录(旧 `workspace/datasets/yolo/``labels/` 已删除)
### 数据清洗(训练集去冗余,2026-09-02)
@@ -465,7 +465,14 @@ CREATE TABLE IF NOT EXISTS false_target_report (
);
```
**数据流**:App 一键上报,无选择步骤——按一次快照帧(**与喂给 YOLO 的帧同源同尺寸**,保证所见即所传)+ 当前全部检测框快照(`detections` JSONlabel/class/score/model/归一化 xywh,非法框服务端直接丢弃不拦上报)→ 单独同意(首次)→ isolate 内**整帧 jpg q85 重编码**(天然剥离 EXIF/GPS 等全部元数据)→ multipart 上传 → 服务端落 `datasetDir/false_targets/<uuid>.jpg` + 记录 pending(Serial 内查日限额,每用户每日 50 条)→ 管理端「数据训练 → 假目标上报」tab 审核:**通过 = 文件迁移入 `datasets/__negative__/`os.Rename 同盘直迁)+ dataset_image 入库(source=manual,负样本库无标注/审核流程)+ image_count++****拒绝 = 记录与文件同删(无 rejected 状态)**。保证「记录存在 ⟺ 文件存在」不变式(入库失败回滚删文件)。整帧入负样本库的误判风险(画面中真目标未检出却被当背景)由人工审核借助检测框快照把关。
**数据流**:App 一键上报,无选择步骤——按一次快照帧(**与喂给 YOLO 的帧同源同尺寸**,保证所见即所传)+ 当前全部检测框快照(`detections` JSONlabel/class/score/model/归一化 xywh,非法框服务端直接丢弃不拦上报)→ 单独同意(首次)→ isolate 内**整帧 jpg q85 重编码**(天然剥离 EXIF/GPS 等全部元数据)→ multipart 上传 → 服务端 Serial 内查重 + 查日限额(每用户每日 50 条)→ 均通过才`datasetDir/false_targets/<uuid>.jpg` + 记录 pending → 管理端「数据训练 → 假目标上报」tab 审核:**通过 = 文件迁移入 `datasets/__negative__/`os.Rename 同盘直迁)+ dataset_image 入库(source=manual,负样本库无标注/审核流程)+ image_count++****拒绝 = 记录与文件同删(无 rejected 状态)**。保证「记录存在 ⟺ 文件存在」不变式(入库失败回滚删文件)。整帧入负样本库的误判风险(画面中真目标未检出却被当背景)由人工审核借助检测框快照把关。
**上报查重与疑似真目标预判(2026-09-09)**
- **dHash 查重**:上传整帧算 64 位 dHash(复用 `common.ImageDHash64`,数据清洗同款),Serial 事务内与全表已有 hashpending+approved0=未算跳过)比最小汉明距离,≤ `consts.FalseTargetDupHamming`=8,与数据清洗 `CleanHashHamming` 同值,可标定调整)→ 报错「相似样本已存在,无需重复上报」,不落盘不占日限额。动机:同机位连续帧重复上报是审核队列堆积 + 负样本库同场景过采样的主源;整帧(同机位同光线)是 dHash 判别力最强的形态。查重范围=上报表即可传递覆盖负样本库主体(approved 记录与库内条目 1:1 迁移、文件名不变);负样本库另两口(管理端手动上传/AI 生成)为管理员 curated/AI 天然多样,v1 不查重。
- **查重先于落盘(2026-09-10**:查重、日限额、落盘、入库全部在同一个 Serial 块内按序执行——重复上报不产生文件(原先先落盘再查重、拒绝时删文件;查重与落盘分处两块时并发同图上报会各自通过查重)。副作用:`saveUploadAs` 先按原文件名暂存(App 端固定 `frame.jpg`)再改名,移入串行块后并发同名暂存竞态一并消除。
- **image_hash 有符号存储陷阱(2026-09-10 e2e 实测)**dHash 最高位为 1 时 `int64(hash)` 为负数(SQLite INTEGER 有符号),比对集查询必须用 `image_hash != 0` 而非 `> 0`,否则约半数 hash 被排除、查重静默失效;读写用 `uint64(int64)` 比特原样往返,无需额外编码。
- **存量 hash 回填**:启动后台一次性协程遍历 image_hash=0 记录,pending 从 `false_targets/`、approved 从 `datasets/__negative__/`(文件名迁移不变)读图算 hash 回写;文件缺失跳过(留 0,不参与比对)。
- **RF-DETR 疑似真目标预判**:上报落库成功后异步(复用 LabelTaskPool)对整帧跑 `common.LocalAiClient` 全图检测,最高置信度 ≥ `localAi.confConfirmed`(真目标确认线,同预标注分档语义)置 `suspect_real=1`;**未配置/失败静默跳过**(fail-open,不阻塞上报,与预标注付费资产同哲学)。语义:用户把真目标误标为假目标的辅助识别——真目标混入负样本库当背景是训练毒药,标记供管理端审核把关,**审核动作本身不自动拦截**。
**合规三约束**(PIPL + 单独同意):
1. **客户端重编码 jpg** 剥离全部元数据(GPS/设备信息),服务端不接收原始帧
+7
View File
@@ -116,6 +116,13 @@ defineExpose({ load })
<el-table-column label="检测框" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ boxText(row) }}</template>
</el-table-column>
<el-table-column label="预判" width="100">
<template #default="{ row }">
<!-- RF-DETR 上报后异步预判高分检出标疑似真目标画面里可能是真动物慎通过 -->
<el-tag v-if="row.suspectReal === 1" type="danger" size="small">疑似真目标</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="(statusMap[row.status] || {}).type || 'info'" size="small">{{ (statusMap[row.status] || {}).text || row.status }}</el-tag>