Files
observer/server/技术设计.md
T
2026-09-07 10:11:36 +08:00

86 KiB
Raw Blame History

视野后端 技术设计

支付与授权后端的实现细节与技术决策。文档驱动:涉及本文档的决策变更须先改文档再写代码。

技术栈与分层

Go + GoFrame(分层规范见 CLAUDE.md):controller → service → daoSQLite 单机存储,Docker Compose 单机部署(前后端一体单端口)。

  • controllerbiz/controllerDTO g.Meta 反射注册路由;支付回调组用 /api/v1/payment 前缀(不做统一响应包装,按渠道应答格式直接返回),客户端组用 /api/v1 前缀(统一响应包装)
  • servicebiz/service,订单创建、回调落授权、授权查询
  • daobiz/dao,单表 CRUD,无业务逻辑
  • 并发:回调处理为 IO 任务,可走 common 池;SQLite 写一律回主 goroutine 串行(无 WAL

套餐配置与数据表 DDL

套餐(静态定价)存 config.yml plans 节点,不建表(决策:套餐常年不变,改价 = 改配置重启,避免数据库表 + 管理端改价接口的维护成本;存量 plan 表由 v3 迁移 DROP)。展示名按 days 派生「N天」,无独立 label 字段。

plans:
  - id: day
    days: 1
    price_cents: 1000
  - id: week
    days: 7
    price_cents: 5600
  - id: month
    days: 30
    price_cents: 18000

SQLite,金额整数分(INT64),时间存 ISO8601 文本(gtime.Time 序列化)。

CREATE TABLE IF NOT EXISTS payment_order (
  order_id        TEXT PRIMARY KEY,       -- O + 时间戳 + 随机后缀,业务唯一键
  phone_num       TEXT NOT NULL,          -- 下单账号(手机号),绑定授权归属
  plan_id         TEXT NOT NULL,
  channel         TEXT NOT NULL,          -- wechat | alipay
  amount_cents    INTEGER NOT NULL,       -- 下单时锁定套餐价格(快照,防套餐改价)
  status          TEXT NOT NULL DEFAULT 'created',  -- created | paid | closed
  wx_trade_no     TEXT UNIQUE,            -- 微信交易号,幂等唯一约束
  alipay_trade_no TEXT UNIQUE,            -- 支付宝交易号,幂等唯一约束
  created_at      TEXT NOT NULL,
  paid_at         TEXT
);

CREATE TABLE IF NOT EXISTS license (
  phone_num  TEXT PRIMARY KEY,            -- 手机号账号,一账号一授权记录,续费更新
  password   TEXT NOT NULL,               -- bcrypt 加盐哈希,不存明文
  expires_at TEXT,                        -- 未充值 NULLNULL/过期即未授权)
  remark     TEXT,                        -- 管理端备注(运营发卡/人工记录,客户端不可见)
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS app_version (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  version    TEXT NOT NULL UNIQUE,        -- 语义化版本号 x.y.z,客户端按数字段比较
  notes      TEXT,                        -- 更新说明(客户端弹窗展示)
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
  -- 无下载地址列:APK 为固定文件 app.apkDir/observer-latest.apk,上传即覆盖,目录永远只有一个文件
);

CREATE TABLE IF NOT EXISTS dataset (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  name          TEXT NOT NULL UNIQUE,     -- 数据集名(≤50 字),同时是服务器目录名
  source        TEXT NOT NULL DEFAULT 'manual',  -- manual | ai
  image_count   INTEGER NOT NULL DEFAULT 0,      -- 图片数(冗余计数,随增删更新)
  labeled_count INTEGER NOT NULL DEFAULT 0,      -- 已标注数(二期标注任务完成累计)
  status        TEXT NOT NULL DEFAULT 'building',-- building | labeled | synced
  cover         TEXT,                     -- 封面文件名(上传自动转 jpg + UUID 命名,卡片展示)
  description   TEXT,                     -- 描述(卡片展示)
  sort_order    INTEGER NOT NULL DEFAULT 0, -- 序号(列表排序主键,升序;同号按 id 倒序;2026-08-31 加,创建/编辑可填)
  created_at    TEXT NOT NULL,
  updated_at    TEXT NOT NULL
  -- 图片文件在 app.datasetDir/datasets/<name>/DB 存元数据 + 标注(dataset_image.labels_json
);
-- 存量库 v8 迁移:逐列检测补列(PRAGMA table_info),新库 CREATE 自带全列

CREATE TABLE IF NOT EXISTS dataset_image (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  dataset_id      INTEGER NOT NULL,            -- → dataset.id
  filename        TEXT NOT NULL,               -- 唯一文件名(防重名加时间戳后缀)
  source          TEXT NOT NULL DEFAULT 'manual',  -- manual | ai
  labels_json     TEXT,                        -- 标注 JSON 数组(YOLO 归一化 xywh+类别+置信度),AI 自动标注直写、人工仅审核确认(2026-08-28:疑似确认/误检删除,不手动画框),null/''/'[]'=未标注
  created_at      TEXT NOT NULL,
  UNIQUE (dataset_id, filename)
);
-- 存量库迁移:EnsureColumn 逐列检测补列(v9);candidates_json 列已随 v10 删除(DROP COLUMN);
-- model_version.model_file 列已随 v11 删除(模型无存档回退机制,只留 latest.tflite);
-- dataset_image.prompt 列已随 2026-09-02 删除(生成提示词不再存储/展示,生成仍临时使用但不落库)

CREATE TABLE IF NOT EXISTS model_training (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  name          TEXT NOT NULL,            -- 任务名(默认「数据集+时间」)
  status        TEXT NOT NULL DEFAULT 'running',  -- queued | running | success | failedqueued=GPU 忙排队,2026-09-03 双档位串行)
  dataset_id    INTEGER NOT NULL,         -- → dataset.id
  variant       TEXT NOT NULL DEFAULT 's', -- s(高识别 1280) | n(高性能 704)2026-09-03 双档位
  imgsz         INTEGER NOT NULL DEFAULT 1280,
  epochs        INTEGER NOT NULL DEFAULT 150,
  batch         INTEGER NOT NULL DEFAULT 16,
  device        TEXT NOT NULL DEFAULT '0',
  current_epoch INTEGER NOT NULL DEFAULT 0,
  total_epochs  INTEGER NOT NULL DEFAULT 0,
  metrics       TEXT,                     -- JSON {p, r, map50}(成功后写)
  log_tail      TEXT,                     -- 日志尾部(截断 N KB,轮询更新)
  pid           INTEGER,                  -- 训练进程 pid(取消/存活探测用)
  error         TEXT,                     -- 失败原因
  started_at    TEXT NOT NULL,
  finished_at   TEXT,
  created_at    TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS model_version (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  dataset_id    INTEGER NOT NULL,         -- → dataset.id**版本序列每数据集全局共用(s/n 交替发布同一序列,2026-09-03**
  variant       TEXT NOT NULL DEFAULT 's', -- s | n 档位(发布来源任务档位;存量行迁移默认 s)
  version       TEXT NOT NULL,            -- m1.0.0 递增(每次发布 patch+1,同数据集内唯一,两档共用不冲突)
  training_id   INTEGER,                  -- 来源训练任务 → model_training.id
  metrics       TEXT,                     -- JSON,与来源任务一致
  labels        TEXT NOT NULL,            -- JSON 类别名数组(随模型下发,App 合并/展示用)
  sha256        TEXT NOT NULL,            -- tflite 文件校验
  size_bytes    INTEGER NOT NULL,
  is_latest     INTEGER NOT NULL DEFAULT 0,   -- 1=该 (数据集,档位) 当前生效(客户端拉取对象),每档位至多一条
  notes         TEXT,
  created_at    TEXT NOT NULL,
  UNIQUE (dataset_id, version)
);

CREATE TABLE IF NOT EXISTS label_task (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  dataset_id  INTEGER NOT NULL,           -- → dataset.id
  filenames   TEXT,                       -- JSON 选中图片列表(多选批量标注);NULL = 全量扫描
  status      TEXT NOT NULL DEFAULT 'running',   -- running | done
  total       INTEGER NOT NULL DEFAULT 0, -- 待标注图片数
  done        INTEGER NOT NULL DEFAULT 0, -- 已标注数
  error       TEXT,                       -- 失败原因(检测中途失败/服务重启中断)
  created_at  TEXT NOT NULL,
  finished_at TEXT
);

CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
CREATE INDEX IF NOT EXISTS idx_dataset_image_dataset ON dataset_image(dataset_id);
CREATE INDEX IF NOT EXISTS idx_model_training_status ON model_training(status);

存量库迁移以 PRAGMA user_version 版本化标记,禁止重复执行。迁移记录:

  • v1 = 建表(各 dao init)+ 种子套餐(v1 已移除)
  • v2 = 删除 license.plan_id 列(该字段无业务语义,只留到期时间;PRAGMA table_info 检测列存在才 ALTER TABLE ... DROP COLUMN,新库与已迁移库跳过)
  • v3 = DROP TABLE IF EXISTS plan(套餐改配置后清残留表,订单快照 payment_order.plan_id 不受影响)
  • v4 = licenseremark 列(管理端备注;PRAGMA table_info 检测列缺失才 ALTER TABLE ... ADD COLUMN remark TEXT,新库直接建表跳过)
  • v5 = app_version 新表(版本管理;dao init CREATE TABLE IF NOT EXISTS 自动建,新库/存量库均无需 user_version 迁移,此处记录 DDL 变更)
  • v6 = app_versionurl 列(下载地址改为固定文件 app.apkDir/observer-latest.apk,表内不再记录;PRAGMA table_info 检测列存在才 ALTER TABLE ... DROP COLUMN url,新库建表已无此列直接跳过)
  • v7 = dataset / dataset_image / model_training / model_version / label_task 新表(模型训练体系;各 dao init CREATE TABLE IF NOT EXISTS 自动建,此处记录 DDL 变更)
  • v8 = dataset 加 9 列(cover/description/ai_endpoint/ai_model/train_host/train_user/train_password/train_key+ label_taskfilenames 列(多选批量标注;PRAGMA table_info 逐列检测缺失才 ALTER TABLE ... ADD COLUMN,新库建表自带跳过)——其中 6 列(ai/train 覆盖字段)为遗留列:AI 标注/训练机 SSH 配置统一走 config.ymllocalAi / training.ssh),新代码不读写,存量库保留不迁移
  • v9 = 标注存储从文件迁移入库:dataset_imagelabels_json/candidates_json 两列(common.EnsureColumn 迁移),启动时把历史 labels/<数据集>/*.txt 解析入 labels_json(幂等:仅未迁移行处理),boxes.json 废弃;历史 labels/ 目录与迁移代码已删除(2026-08-26:数据全部入表后无保留价值)
  • v10 = 标注流程简化(撤销候选确认两阶段):ALTER TABLE dataset_image DROP COLUMN candidates_jsonPRAGMA table_info 检测列存在才 DROP,新库建表已无此列直接跳过;存量候选数据为空直接删)——AI 预标注结果直写 labels_json,人工仅审核确认(2026-08-28 定案:疑似框确认/误检删除,不手动画框)
  • v11 = 移除模型存档回退机制:ALTER TABLE model_version DROP COLUMN model_file(模型文件不落表:发布即写 trainings/<数据集名>.tflite,客户端固定下载)
  • v12 = 清理孤儿字段:dataset 删 6 列(ai_endpoint/ai_model/train_host/train_user/train_password/train_key,配置统一走 config.ymllocalAi/training.ssh,零读写)+ model_versionartifact_filezip 产物布局移除后无人写)+ label_taskboxes_file(标注已入库,候选框文件机制废弃)——均 PRAGMA table_info 检测列存在才 ALTER TABLE ... DROP COLUMN,新库建表已无此列自动跳过
  • v13 = 双档位训练:model_trainingvariant 列 + 允许 queued 状态、model_versionvariant 列(存量行默认 sPRAGMA table_info 检测缺失才 ALTER TABLE ... ADD COLUMN variant TEXT NOT NULL DEFAULT 's',新库建表自带跳过);model_versionUNIQUE(dataset_id, version) 与版本递增逻辑不变——s/n 共用每数据集版本序列,is_latest 按 (数据集, 档位) 各记一条(免去 SQLite 约束重建迁移)

全局训练配置(config.yml 直读)

决策(2026-08-26AI 标注端点(localAi)与训练机 SSH 凭据(training.ssh)是全局配置、与数据集无关——不设独立存储(曾尝试 app_config KV 表 + 管理端「训练配置」入口,2026-08-26 撤销):标注与训练直接读 config.ymllocalAi / training.ssh 节点,改配置需重启服务。数据集表原 6 个覆盖列(ai_endpoint 等)已随 v12 删除。

  • AI 客户端:common.LocalAiClient(ctx) 直读 localAi.baseUrl/model,未配置返回 nil(预标注接口报「标注服务未配置」)
  • SSH 凭据:common/training_runner.go 的 sshRunner 直读 training.ssh.host/user/port/privateKeyPath/password,未配置 host 报「training.ssh 未配置 host」

账号体系(注册/登录)

授权与账号合一:license 表即账号表(决策:不建独立 user 表)——注册插一行(password=bcrypt 哈希,expires_at 为 NULL),充值/手动授权更新同一行。未充值账号存在但无额度,授权判断 expires_at > now(NULL 视为未授权)。下单购买的套餐记录在 payment_order.plan_id(订单快照),license 表不存套餐只存到期时间。

  • POST /api/v1/auth/register{phone, password} → 插入 license 行;phone 已存在报错;password 服务端 bcrypt 加盐哈希存储
  • POST /api/v1/auth/login{phone, password} → bcrypt 校验 → 签发 token登录查库必须绕过缓存license 查询有 TTL 缓存,密码变更需即时生效)
  • tokenHMAC-SHA256 自签名(payload = base64({phone, exp}) + 签名),secret 来自 config.yml auth.secret,有效期 auth.token_ttl(默认 30 天);无状态、服务端不存储,secret 轮换即全员下线
  • 鉴权中间件 common.AuthRequired:校验 Authorization: Bearer <token> → 解出 phone 注入请求上下文;失败返回统一 401(code 61)
  • 需登录态接口:GET /plansPOST /ordersPOST /orders/{orderId}/confirmGET /licensephone 一律从 token 解出,客户端不传)

有效期语义(自然日)

套餐 有效期
day 当天 24:00(服务端时区)失效
week 自生效日起第 7 天 24:00 失效
month 自生效日起第 30 天 24:00 失效
  • expiresAt 一律由服务端按本机时区计算并下发,客户端只做「是否过期」判断,不参与计算
  • 续费叠加:新授权 expiresAt = max(现有 expiresAt, now) 起算套餐天数,即未过期时顺延,已过期时从当前时间起算;禁止覆盖缩短
  • 单位与展示:服务端只下发 expiresAt(ISO8601,含时区),前端自行展示剩余时长

订单创建流程

  1. POST /api/v1/orders 校验 DTOplanId 必填、channel in wechat/alipay+ AuthRequired 解出的 phoneNum
  2. service:查 config.yml plans 锁定 price_centscommon.GetPlan,不存在报"套餐不存在或未配置")→ 生成 orderId → 插 payment_order(status=created, phone_num) → 调支付渠道统一下单
  3. 微信(APP 支付):V3 统一下单 appid 传客户端 AppID → 响应参数(prepay_id、partnerId、nonceStr、timeStamp、sign)组装返回
  4. 支付宝:alipay.trade.app.pay → 返回 orderStr
  5. 下单失败:事务回滚订单(status=closed 或删除),返回错误
  6. 未支付订单回收:创建新订单前,惰性关闭该 phone_num 下超时未支付的 created 订单(超时阈值默认 2 小时、可配置,对齐微信支付有效期,保证用户支付成功后回调必然可落授权);若该账号存在 2 小时内的 created 订单,直接复用返回,禁止重复创建(防重复扣款兜底)

支付回调与幂等

微信/支付宝回调是唯一授权来源confirm 只是加速刷新;回调未到账时 confirm 不落授权。

并发与事务:回调落授权是「查订单 → 算 expiresAt → 更新订单 → 写 license → 清缓存」的读改写链路,微信/支付宝回调与 confirm 可能并发到达同一订单。整条链路必须由 service 在事务 + 单写者下串行执行:SQLite 无 WAL,并发写会 database is locked;串行化同时保证 expiresAt 只按一次现授权状态计算(叠加语义不被并发重复累加)。事务内写方法以 XxxInTx 形式实现,由 service 持有 tx 编排(见 CLAUDE.md 事务规范)。

微信(APIv3POST /api/v1/payment/wechat/notify

  • 验签:Wechatpay-Timestamp/Nonce/Signature + 平台证书 → 验签通过后 AES-GCM 解密
  • 商户校验:解密后校验 appid/mchid 与配置一致(防跨商户重放,对称支付宝的 app_id/seller_id 校验)
  • 落授权:out_trade_no → 查 payment_order → 更新 status=paid + wx_trade_no → 计算 expiresAtlicense
  • 幂等:wx_trade_no UNIQUE 约束兜底,重复回调忽略(返回 200 空响应);订单状态已是 paid 时直接成功返回
  • 应答:成功返回 {"code": "SUCCESS"},失败返回 {"code": "FAIL", "message": ...}(微信会重试)

支付宝POST /api/v1/payment/alipay/notify

  • 验签:RSA2 验签 + 校验 app_id/seller_id 与配置一致、total_amount 与订单 amount_cents(元)一致
  • 落授权:同上;alipay_trade_no UNIQUE 兜底
  • 应答:验签失败返回 failure(支付宝会重试),成功返回 success

客户端 confirmPOST /api/v1/orders/{orderId}/confirmBearer token)幂等 —— 订单已 paid 返回 {"status": "paid"}created 且无回调返回 {"status": "created"}。禁止在 confirm 里直接落授权。

confirm 轮询(客户端对接约定):回调通常 1~5 秒到账。confirm 返回 created 时,客户端按 3s → 10s → 30s 递增重试(最多 3 次,总计 ~43s),期间展示「支付确认中」并禁用重新下单;重试仍 created 则回到授权查询(GET /license),服务端回调到账后自然变为 active。防重复支付:客户端在订单闭环(confirm 返回 paid)前不得为同一套餐创建新订单;服务端兜底见「订单创建流程」第 6 条(2 小时内复用 created 订单)。

授权查询

GET /api/v1/licenseBearer token

  • AuthRequired 解出 phoneNum → 查 licenseexpires_at > now{active: true, expiresAt};否则 {active: false, expiresAt: null}(含未充值 NULL
  • 查询走 DAO 缓存(gdb.CacheOption,TTL 来自配置),落授权后必须清对应缓存(键 license:{phoneNum}),否则「库里已改、查询还是旧值」

安全与风险

方案
回调伪造 微信 APIv3 证书验签+解密;支付宝 RSA2 验签,且校验金额/商户一致,禁止只验签不比对
账号体系 手机号+密码登录,密码 bcrypt 哈希存储;token 自签名(auth.secret 校验),手机号从 token 解出,客户端不可伪造;授权绑账号,同账号多设备共享
下单防刷 金额不由客户端传入(服务端按 planId 查 config.yml plans 锁定),客户端只能选套餐不能改价
订单号防猜测 orderId 加随机后缀,长度 ≥ 20,不可枚举
回调重放 交易号唯一约束 + 订单状态机(created→paid 单向),paid 后忽略一切变更
网络异常 回调失败返回 FAIL/failure 触发渠道重试;客户端 confirm 失败可重试(幂等)

客户端对接要点

  • API 契约:flutter_app/docs/PaymentApi.md(字段与本文档一致,以本文档为准)
  • 客户端配置替换项:flutter_app/lib/config/app_config.dartapiBaseUrlwechatAppIdalipayAppId、URL Scheme / Universal Link
  • 微信开放平台需注册 App 包名 + 签名(Android)与 Universal LinkiOS);支付宝开放平台注册 AppID + 密钥对
  • 账号流程:首次进入 App → 注册/登录页(手机号+密码)→ token 存 flutter_secure_storage → 主页展示到期时间;点击「识别」按钮强制调 GET /license 服务端校验(不走本地缓存),未到期才进摄像头,过期/未授权跳付费墙
  • App 内授权缓存:客户端 flutter_secure_storage 缓存 expiresAt 仅用于主页展示,识别入口一律服务端为准

后台管理端

代码在仓库根 server_admin/Vue3 + Element Plus + Vite),构建产物输出到 server/admin_dist/ 由后端 /admin/ 路径托管(前后端一体单端口,SPA 未命中静态文件的 GET 回退 index.html)。开发期 Vite server.proxy/api 代理到 :8080

鉴权(静态 token:管理接口挂在 /api/v1/admin 组,common.AdminAuth 中间件校验请求头 X-Admin-Tokenconfig.yml admin.token 一致,未配置或失配返回 401 统一错误格式({"code":401,"message":"管理端未授权","data":null})。前端登录页输入 token 后存浏览器 localStorage,请求拦截器自动携带;token 不内嵌构建产物,失配/失效时前端清 token 回登录页。token 属内网管理凭据,server_admin/.env* 已 gitignore(前端不再有 env token)。

接口清单(统一响应包装,/api/v1/admin 前缀):

方法 路径 说明
GET /orders 订单列表:phoneNum/status/startAt/endAt 筛选 + page/size 分页(size 上限 100),金额返回整数分
GET /licenses 账号列表:phoneNum 筛选 + 分页(含未充值账号)
POST /licenses/grant 手动授权 {phoneNum, planId}:与支付回调同语义(自然日叠加、单写者串行事务),提交后清授权缓存
POST /licenses/revoke 撤销授权 {phoneNum}:清空 expires_at 保留账号行(不删密码),清缓存,客户端下次查询即 inactive
GET /app-versions 版本记录列表:分页(size 上限 100),按下发时间倒序
POST /app-versions 下发新版本(multipart/form-datanotes + file APK):版本号从文件名识别,文件名须为 observer-x.y.z.apk(如 observer-1.0.1.apk,正则 ^observer-(\d+\.\d+\.\d+)\.apk$,格式不符拒绝)、notes ≤500、仅接受 .apk 文件;APK 覆盖保存 app.apkDir/observer-latest.apk(目录永远只有一个文件)
POST /app-versions/delete 删除版本 {id}(不存在报错):删最新版本时联动删除 APK 文件(客户端 update 返回空不再提示、下载 404);删历史版本仅删记录、不动文件

结构决策:管理端是跨表业务面,controller 聚合在 biz/controller/admin.go(避免同一 controller 挂客户端组 + 管理组时 group.Bind 重复注册路由),service 按「跨表业务流程归入所属表文件」归入各表文件(订单列表 → service/order.go,授权 grant/revoke/列表 → service/license.go),dto 聚合在 biz/model/dto/admin.go。套餐已配置化(common/plans.go 读取 config.yml,仅客户端登录组 GET /plans 使用),无独立分层文件,管理端不管理套餐。

手动授权语义grant 等同免费发卡,与支付回调 persistPaid 共用自然日叠加规则——base = max(现有到期, now)expiresAt = base 所在日 0 点 + plan.days;已过期账号从当前时间起算。整条链路在 common.Serial() 单写者 + 事务内执行(SQLite 无 WAL),提交后清 license:{phoneNum} 缓存。revoke 清空授权字段、保留账号行(密码不删),同一事务内执行并清缓存。

分页约定page ≥1(默认 1),size 1..100(默认 20),service 内钳制;返回 {total, list}total 为同条件总数(COUNT)。

强制更新

背景:客户端发版后旧版本用户无法感知新版本,bug 修复/安全更新需要强制覆盖。不走 config.yml(避免改配置重启才能下发),管理端页面上传 APK + 版本号维护 app_version 表,客户端启动时主动查询。仅 Android 参与(iOS 不做版本下发,用户从 App Store 自行更新)。

数据流

管理端 POST /admin/app-versionsmultipartnotes + APK 文件,文件名 observer-x.y.z.apk 识别版本号)
      ├─► app_version 表新增记录(version 从文件名解析,UNIQUE 防重复下发)
      └─► APK 保存为 app.apkDir/observer-latest.apk(上传即覆盖,目录永远只有一个文件)
Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不调用)
      └─► 返回最新一条记录(无记录返回空对象)
      └─► 客户端语义化比较 version > 本地版本?
            └─► 是 → 全屏阻塞弹窗(禁返回,仅「立即更新」)→ 打开 <apiBaseUrl>/download/observer-latest.apk
            └─► 否 → 正常进入

APK 存储与下载

  • 目录:config.yml app.apkDir(默认 ./workspace/,与 ./data 平级的运行时数据目录,docker-compose 已挂载持久化);服务启动时自动建目录
  • 文件:固定文件名 observer-latest.apkcommon.ApkFilename 常量),上传流程「先落库 → 再保存临时文件 → os.Rename 原子覆盖」,目录下永远只有最新一个文件;落库失败删临时文件、文件保存失败删记录(补偿),保证「记录存在 ⟺ 文件存在」
  • 下载:后端静态托管 /downloadapp.apkDirURL 固定 /download/observer-latest.apk(绕过统一响应包装,纯二进制流)
  • 客户端打开方式:url_launcher 跳系统浏览器下载安装(避开应用内下载的 FileProvider / 安装权限复杂度)

设计决策

  • 检测到新版本即强制更新(无普通/强制之分):version > 本地版本 即全屏阻塞弹窗,用户必须跳转下载安装才能继续使用。简化管理端操作(不需要判断「这次要不要强制」),客户端语义单一(「有新版本 = 必须更新」)
  • 公开接口:更新检查挂在公开组(无需登录态)——旧版本登录态可能已失效,且登录前的用户也要能收到强制更新
  • iOS 不检查:客户端 UpdateChecker.fetch() 在非 Android 平台直接返回空(iOS 用户从 App Store 更新,应用内无法安装 APK,提示无意义)
  • 版本号语义化比较x.y.z 三段按数字比较(1.10.0 > 1.9.9),禁止字符串比较("1.9.9" > "1.10.0" 会漏判);版本号格式由文件名解析正则校验(observer-x.y.z.apk),DB 层 UNIQUE 兜底防重复
  • 文件名识别版本号:管理端上传不再手工填写版本号,版本号从文件名解析(打包产物即 observer-<versionName>.apk 命名)——消除「填的版本号与包内版本不一致」的人为错误,上传文件名即唯一事实来源
  • 删除联动文件:APK 固定文件永远对应「最新一条记录」,故删除只对最新版本联动删文件(记录存在 ⟺ 文件存在 的删除方向:记录没了文件即失效,客户端不再提示、下载 404);删历史版本不动文件。执行顺序「先删记录、再删文件」——记录删除是核心操作,文件删除失败仅记日志不阻断(残留旧文件无害,客户端不会误提示更新)。不存在删除/修改接口(下发后版本号不可变),删除按 id 定位
  • 写入串行:新增/删除记录均走 common.Serial() 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
  • 写入串行:新增记录走 common.Serial() 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存

模型训练体系(数据集 → 训练 → 模型版本)

背景:识别模型(YOLOv8n → TFLite 704x704)此前在仓库根 training/ 目录人工命令行训练(本机 MPS / GPU 服务器跑 train_server.pybest.tflite 手动拷进 App assets 重打包 APK 下发)。本模块将「数据集管理 → 训练编排 → 模型版本发布 → 客户端热更新」闭环搬进后台管理端,模型迭代不再依赖开发者本机与 APK 发版。Python 训练脚本随项目整合进 server/training/2026-08-26:能 Go 化的已 Go 化——prepare_yolo→prepareYoloSet、analyze_rfdetr→LocalAi.Detect,脚本删除;train_server.py/yolov8n.pt/调试工具迁移入 server/training/yolov8n.pt 权重不进 git,由 .gitignore 排除;2026-08-26 再收口:inspect_tflite.py 并入 train_server.py 自检、dump_graph.py 保留)

整体数据流

管理端 数据集管理(上传 / AI 生成)→ 图片落服务器 workspace/datasets/<name>/
      ├─► 二期:标注工作台(RF-DETR 预标注 + VLM 藏匿位补检,人工仅审核确认)
      └─► 发起训练 → 同步数据集到训练机 → runner 起训练 → 进度/日志/指标
            └─► 产物(best.tflite + best.pt 归档)拉回服务器
            └─► 发布 → model_version + workspace/model-latest.tflitesha256
                  └─► 三期:客户端启动 GET /app/update → 模型热更新下载替换

数据集存储与流转(决策:图片不进 DB、不提交 git)

  • 图片目录:app.datasetDir(默认 ./workspace/)下 datasets/<数据集名>/(图片平铺,文件名唯一防重名);标注存 dataset_image.labels_jsonJSON 数组,每元素 {class,cx,cy,w,h,confidence} 归一化,AI 自动标注直写、人工仅审核确认——2026-08-28:疑似确认/误检删除,不手动画框);历史 labels/ txt 目录与 yolo 暂存目录已删除(2026-08-26,标注全在 DB,训练集流式化不落本地盘)
  • 封面命名规范(2026-08-26:封面文件不得固定命名 cover,上传时解码为 jpg 格式 + UUIDv4 命名common.UuidV4() 生成,crypto/rand 无第三方依赖,落盘 datasets/<name>/<uuid>.jpg,旧封面删除);DB cover 列只存文件名,前端用 DB 值拼 URL 零改动;删除/校验按 isCoverName 正则(UUID v4 + .jpg 后缀)识别,不认固定名——历史 cover.jpg 等存量封面启动时 MigrateLegacyCovers 幂等重命名迁移
  • DB 只存文件名/来源等元数据(dataset / dataset_image;生成提示词不落库,2026-09-02 起),禁止图片进库
  • 生成/上传图片是付费资产:删除接口必须带前端确认文案(提示 AI 生成图有成本);删除 = 删文件 + 删记录,目录清理
  • 训练机与 Go 服务器可能异机:训练前按 training 通道同步(subprocess 同机 cp、ssh 异机 scp);数据集在训练机上的权威路径 training.workdir/training.datasetDir/<name>/DB model_training.dataset 只存数据集名

AI 生成图片(provider 抽象)

  • imageGen 配置节点:provider: dashscope | localai,接口在 common 层抽象 ImageGenProviderGenerate(ctx, prompt, size) ([]byte, error)),config 切换
    • dashscope(通义万相付费 API):apiKey + model: qwen-image-3.0;内部自管 120s 轮询上限
    • localai(本机/训练机 local-aiPOST {baseUrl}/v1/images/generationsOpenAI 兼容):baseUrl + model(local-ai 上加载的模型名,如 qwen-image);返回 url 为容器内 localhost 地址,下载时替换为 baseUrl 的 hoststep/cfg 等采样参数用 local-ai 模型配置默认值,不随接口传;生成耗时取决于模型与硬件,不设调用超时(见下)
    • baseUrl/apiKey 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
  • 尺寸参数由前端传(固定竖版 704x1248,比例与既有训练图 1152x2048 一致、32 倍数对齐);实测竖版图在 local-ai 上极慢(约 26 分钟/张,Qwen-Image 行列注意力对非方形输入效率差,与分辨率关系不大;方形 1024x1024 仅 5 分钟),前端等待按此预估,一次建议 1 张
  • 生成异步化(2026-08-28count 1..1000/datasets/generate 仅做校验(provider/数据集/标注服务/Serial 内并发检查已有 running 生成任务)+ 插 gen_task 落 running 记录即返回 {taskId, total}(毫秒级,前端 10s 超时无忧);后台协程逐张生成(context.Background() 生命周期模式,与预标注 runDetection 同构;imageGen.poolSize 并发池,z-image 显存独占默认 1),每张不设调用超时(2026-08-29)——异步任务超时只会造成假失败:超时仅放弃等待、取消不了 local-ai backend,任务置 failed 而模型仍被占用、poolSize=1 的池被占死、后续请求连环超时;失败判定完全由 provider 真实返回决定(dashscope 内部自限 120s 轮询不受影响);极端情况 local-ai 挂死时任务保持 running(状态诚实),由启动恢复兜底;逐张落盘 + 入库(2026-09-02 起不记录 prompt——生成提示词临时使用不落库)+ Serial 更新进度,完成(或部分失败)后对本次新增图触发自动标注,置 done/failed 由前端轮询 /datasets/gen-task 呈现;中途失败保留已生成图(不删除——付费资产原则);启动恢复 recoverGenTasks 孤儿 running 置 failed;VLM 补检与生成显存互斥(该数据集有 running 生成任务时拒绝)
  • prompt 手填覆盖;留空走 imageGen.promptTemplate 通用模板逐张组装(物种/场景/动作/遮挡/轮廓色/站高池均从数据集表读取(见下节),每张独立组装保持多样性性别每张随机雄/雌(2026-08-28——两性体型大小与外观差异大(雉鸡雄艳雌褐、野鸭雄艳雌素等),随机让训练数据覆盖两性形态,{species} 替换为「雄/雌性+物种」;物种只写名字不写羽毛细节,细节会拉近镜头);位置/镜头描述(2026-08-31 v4 最终定案:手机原相机主摄不变焦)2026-08-28 曾定「不得包含目标位置描述」(上部1/3 弱约束遵循力差、角度与位置耦合沉底);2026-08-31 用户确认恢复距离感锚定,镜头词四版演进:广角(构图先验=前景大主体,用户实测野鸡在画面下半部)→ 长焦(长焦物理放大与 13px 像素数学矛盾,用户实测野鸡占半图宽)→ 全删 → 「手机原相机主摄不变焦」(用户提议;与 13px 像素数学完全自洽——公式即按主摄 vfov 52° 计算;手机拍远处目标天然显小,先验同向;顺带对齐推理端域——App 就是手机拍摄);保留「在{distanceWord}的地平线附近」位置锚定(地平线是远景场景锚点,不同于上部1/3 弱约束);远景小目标模板 promptTemplateTiny2026-08-31:尺寸占比 <2% 时切换——目标小到动作/遮挡/性别细节无法呈现,保留细节描写(低头啄食/只露出头背)会迫使模型把目标画大(语义矛盾:1% 目标不可见细节),只留「远到看不清任何细节,只是隐约可辨的小点」
  • 生成图距离/数量校验(2026-08-28:表单填 distance(米,固定值) 与 animalCount 后逐张启用;距离校验已取消(2026-08-28:生成即入库,无逐张拒检;参数与公式见 git 历史(物理公式 d=体高×focalPx÷像素高)
  • VLM 藏匿位补检(两阶段第二阶段)/datasets/images/vlm-review 单图接口;qwen3.6-35b-a3b(+mmproj) 以「图片+物种(数据集单物种 gen_species 注入;2026-09-02 prompt 不再存储,原「prompt 含物种名才注入」匹配逻辑删除)+已确认框坐标(排除)」按环境/季节/时间/天气/光线/地形/物种习性综合判读画面推理藏身位(2026-09-04 提示词定稿:先判读画面条件(季节物候/影长时间/天气/光线/地形),再结合习性推理——补检目的即综合信息找可能藏身处),输出归一化 bbox JSON(范围校验+与已有框重叠去重),追加 class=1 疑似框进同一 labels_json2026-09-02 上限动态化:单图疑似框总数 ≤ consts.VlmMaxSuspectPerImage(上限定义于 consts.go),每次可追加数 = 上限 − 图内已有疑似框数,≤0 直接跳过模型调用;提示词「最多 N 个」与落库硬截断均按此数);VL 与 z-image 显存互斥(12G 装不下两者),批量补检在生成队列空闲后执行;qwen3.6-35b-a3b yaml 自带 mmproj 实为多模态模型(生成校验);预标单图上限(2026-09-02:曾按置信度降序裁 ≤ animal_count 列值;生成声明 1 个目标不代表图内只有 1 个,改去重后按置信度取前 consts.MaxPrelabelPerImage 个,与补标上限同量级;animal_count 仅信息性存储,与声明数无关),估算距离 = 物种站高 × focalPx ÷ 最大框高像素(focalPx=图高/2/tan(vfov/2),站高按物种查 imageGen.speciesHeights,单位 cm 换算为米,未配置物种代码兜底 35cm;2026-08-28 语义修正speciesHeights 为站高(垂直尺度,鸟=脚到头顶、兽=蹲坐高),勿用体长——与提示词「高度」描述及检测框高自洽;避免跨物种共用一个标定系数)(K=1.8:6%≈30m、4.5%≈40mK 隐含镜头视场角假设);镜头固定主摄(2026-08-28 定案)vfov 固定 imageGen.assumedVfovDeg=52°(手机默认主摄;曾随机 [15,90] 覆盖面广但目标像素波动 7~57px 不可控,曾试超广角 90 亦否决);场景/动作按数据集(2026-08-28imageGen.sceneByDataset/actionByDataset 按数据集名配池(鸭→水面、雉鸡→灌丛草丛、兔→草坡荒地、鹌鹑→草丛),未配置回退通用 scenes/actions遮挡描述(2026-08-28:模板加 {occlusion} 占位(插在「在{action}」之后)——身体完全暴露的个体对识别/标注训练无意义,野外目标必然存在植被等遮挡;imageGen.occlusionByDataset 按数据集配遮挡池(雉鸡→草丛灌丛半掩、兔→荒草只露头耳、鸭→芦苇水面半没、鹌鹑→密草藏身),未配置回退通用 imageGen.occlusions;池内条目覆盖轻(身体半掩)~重(只露头背)遮挡,每张随机取一条,杜绝完全暴露;遮挡物多样(杂草/树叶/枝条,2026-08-28 补树叶)且须与 sceneByDataset 场景自洽;遮挡后可见部分小于站高,检测框高与距离估算按可见部分仍自洽(真实场景同此);拍摄角度(2026-08-28,已废弃角度描述):不带任何拍摄角度描述——演进:固定「上部三分之一」区域描述对文生图模型遵循力弱(生成物落底)→ 加固定俯拍(目标上移成功但构图单一,用户否决)→ 池内随机俯/平/仰(仰拍使贴地目标必然沉底,用户否决)→ 只留俯拍/平视(用户仍嫌啰嗦)→ 完全去掉角度描述2026-08-28 定案):交由模型平视先验自由构图,目标自然落在画面中部/上部;模板只靠「{species}离镜头很远,只是很小的{tone}轮廓」(物种名替代泛称「目标」,不限定鸟类——物种池含兔子等非鸟物种;{tone} 轮廓色词按 imageGen.speciesTone.<物种> 取,默认深色剪影,白雉鸡配浅色——白羽个体用「深色轮廓」描述会与模型渲染矛盾,2026-08-28)维持远景感,手填提示词仅追加数量约束;2026-08-31 改版(最终定案):镜头词全删(广角/长焦实测均失败);「{distanceWord}有且仅有」→「在{distanceWord}的地平线附近有且仅有」;{action} 从句末主体降级至遮挡之后(动作=叙事焦点=视觉焦点,弱化动作降低主体视觉权重);{sizeHint} 由「像素数+占比」改「画面占比分级尺寸词(微小几乎难辨/很小/较小/中等/较大)+占比」(量化像素数与固定物体类比词针尖/米粒/拳头均不跨物种/距离通用,用户否决);远景小目标模板 promptTemplateTiny:占比 <2% 时切换,动作/遮挡/性别细节全删(保留细节描写迫使模型画大);不合格丢弃重生成,单张上限 3 次(consts.GenValidateMaxAttempts),连续不合格整体报错(已合格张数保留)

生成参数存储于数据集表(2026-08-28)

  • 决策per-dataset 生成参数(物种/轮廓色/站高/场景/动作/遮挡/标注类别名)从 config.yml 迁入 dataset 表——配置随数据集走,新建数据集用 VLM(qwen3.6-35b-a3b)自动生成,config.yml 不再兜底(表为空即缺失,不静默回退)
  • 单物种规则(2026-08-28 定案):每数据集只对应一个物种(每数据集训练一个模型,类别固定 [物种, suspect]);生成表单无任何物种/动物名称输入,物种直接取数据集名(生成提示词按数据集名组装);不同物种须拆到不同数据集(近缘种不共数据集)
  • 表结构(4 单值 + 3 JSON 数组,2026-08-28 简化)gen_species(单值 = 数据集物种,即标注类别 0 名)、gen_tone(单值 轮廓色词,白化个体配浅色)、gen_heightsREAL 数值 站高 cm)、gen_scenes/gen_actions/gen_occlusions(JSON 数组,各 ≥3 条,保持多样性)、gen_classes(单值 第二标注类别名 = "suspect",第一类别 = gen_species,写入训练 data.yaml 的 names);简化动机:单物种规则下原 JSON 对象/数组恒为 1 key 1 value[物种]/{物种:值}),两性差异由提示词性别随机词表达,站高单值对距离感公式(像素高 = 站高 × focalPx ÷ 距离)影响 <1 像素量级,无需分键
  • 创建数据集同步生成POST /datasets 不接收任何物种/动物名称参数,物种 = 数据集名(单物种规则,新建对话框无动物名称输入框);调 QwenVL64x64 占位图 + 文本,llama.cpp mmproj 需图片输入)输出严格 JSON {tone, height_cm, scenes[], actions[], occlusions[]},校验(scenes/actions/occlusions ≥3 条、height_cm 数值 10~200、tone 为颜色词)后组装写表:gen_species=物种gen_tone=tonegen_heights=height_cmgen_classes="suspect"站高属数据库数据(gen_heights),代码不硬编码物种→高度——VLM 输出即最终值,界面不维护(2026-08-28 用户定案:gen_* 7 列全部仅由模型生成,编辑界面只读展示,值不准走 gen-pools 重新生成);失败不阻断创建——返回 poolsGenerated:false + poolError,前端提示(可事后 POST /datasets/gen-pools 补生成);2026-09-02 放开整任务拒绝:生成任务进行中不再跳过(VLM 请求与任务图片均由 local-ai 服务端排队串行,此前按整任务显存互斥拒绝);VLM 调用超时 90s→120s 留排队余量;前端创建请求超时放宽 180s
  • 封面自动生成(2026-08-28:参数池生成成功后取数据集物种调 z-image 文生图,16:9 横幅(1248x7042026-08-31 由 1024x576 调整),提示词要求**「一只雄性 X 和一只雌性 X 并排站立」**(1 雄 1 雌),失败不阻断创建(返回 coverGenerated:false + coverError,可编辑模式重新生成);结果 resizeCover 转 jpg(统一 1248x704 兜底)后按封面规范落盘(UUIDv4.jpg,删除旧封面,写 cover 列);2026-09-02 放开整任务拒绝:封面与任务图片同为 z-image 单张请求,由 local-ai 服务端排队串行,生成任务进行中直接调用(单张调用超时 120s→180s 覆盖排队等待)
  • 封面手动生成(2026-08-28:新增 POST /datasets/cover/generate(对话框「AI 生成封面」按钮)——新建模式 datasetId=0 + name 预生成:数据集未创建,封面仅落盘于数据集目录(物种=表单数据集名),创建请求带 cover 回传写库(创建接口 cover 非空且文件存在则跳过自动生成);编辑模式 datasetId 完整链路(物种取 gen_species 空回退数据集名,写库+删旧封面);返回 {cover} 供前端回显;封面访问接口支持 datasetId=0name+filename 直读(filename 须 UUID jpg 规范防路径穿越)
  • 生成图统一转 jpg2026-08-28:凡模型产出的图片(z-image 训练图、封面)入库前经 ensureJpeg 解码校验,非 jpeg 一律重编码 jpgQuality 92)——RF-DETR 按扩展名推断 mime,扩展名与编码不一致会报「Not a JPEG file」类错误;命名统一 .jpg
  • 封面统一尺寸(2026-08-31,用户定案):封面一律 1248x704(16:9 横幅,App 列表卡片展示规格)——AI 生成直接向 provider 指定该尺寸(z-image 接受 16 整除尺寸);手动上传任意尺寸经 resizeCover 中心裁剪缩放统一(上传限制放宽 2MB→10MB);存量封面启动时 CompressExistingCovers 幂等压缩覆盖写(非 1248x704 才重压,目标尺寸跳过)
  • 重新生成接口POST /datasets/gen-pools{datasetId},物种取数据集名)——编辑对话框「VLM 重新生成参数」按钮入口,VLM 失败报错(不覆盖旧值,旧值保留)
  • 读取链(无 config 兜底):物种 = 表 gen_species 单值 → 空则数据集名本身(固定,不随机);场景/动作/遮挡 = 表池随机 → 空则模板组装报错(提示手填提示词或编辑数据集补参数);{tone} = 表 gen_tone 单值 → 空默认「深色」;站高 = 表 gen_heights 数值(创建时 VLM 生成,界面不维护,代码/配置不硬编码)→ ≤0 兜底 35cm(物理默认,非配置回退);标注类别名 = 第一类 gen_species + 第二类 gen_classes(训练 data.yaml / 模型 labels
  • 界面不维护 gen_*2026-08-28 用户定案)gen_species/gen_tone/gen_heights/gen_scenes/gen_actions/gen_occlusions/gen_classes 全部仅由模型生成(创建数据集时 VLM 自动、gen-pools 重新生成),管理端编辑表单对 7 列只读展示、无输入框——POST /datasets/update 不接收任何 gen 字段;config.yml 无任何 per-dataset 生成参数节点(speciesByDataset/speciesTone/speciesHeights/sceneByDataset/actionByDataset/occlusionByDataset/scenes/actions/occlusions/localAi.classNames 已删,代码无引用),保留:promptTemplate(通用模板)与 lights(光线池)、assumedVfovDeg;数据源唯一入口为数据集表(本库存量 15 行已直接改单值 + 真实站高,无启动迁移);雪地场景补齐(2026-08-31,按冬季习性):VLM 生成场景池缺冬季场景(全部为夏秋农田),13 数据集 gen_scenes 直接 SQL 追加 2~3 条雪地条目(农田类雉鸡/鸽子、兔子荒坡、白马鸡蓝马鸡高山林缘);鸭子/鹌鹑不加——北方冬季南迁(水面封冻、候鸟不越冬),雪地场景与习性矛盾(用户拍板);genPoolsWithVLM 提示词已加「按冬季习性配场景:本地活跃物种须含积雪场景,南迁物种不配雪地」防新数据集一刀切;林缘/树栖场景补齐(2026-08-31,按栖息习性):雉类+鸽子共享场景池(10 数据集)缺林地交接处与树上落栖场景,SQL 追加 5 条(2 林缘 + 3 树上)+ 2 条树上动作(枝头停栖/树枝上歇息);石鸡剔除树上条目(用户定案)——岩石地栖不上树,3 树上场景 + 2 树上动作从石鸡池移除(保留 2 林缘);总原则(用户定案):场景与遮挡物必须符合对应动物习性——场景(雪地/林缘/树上/水面)与遮挡物(草丛/树叶/树枝/芦苇)均按物种习性逐条判断,习性与场景矛盾即剔除;遮挡规则(适用于所有具备树上场景的动物):遮挡不一定是草丛,树栖/林缘物种遮挡池须含树叶/树枝类遮挡(雉类池原已含 2 条;白马鸡/蓝马鸡补 2 条;兔子原已有 1 条林缘树叶);genPoolsWithVLM 提示词同步加「树栖物种场景须含林缘与树上落栖」「具备树上/林缘场景的物种遮挡池须含树叶/树枝类遮挡」
  • 场景捆绑组+权重(2026-09-04,斑鸠树栖 80% 定案)gen_scenes 条目在字符串外兼容捆绑组对象 {scene, weight, actions[], occlusions[], position}——场景池加权随机(weight 缺省 1,普通字符串条目权重 1),选中捆绑组时动作/遮挡仅从组内池随机(与场景联动,杜绝「树上场景+地面动作/草丛遮挡」矛盾组合——三池独立随机按模板拼装矛盾率高);组内缺省或普通字符串条目回落全局 gen_actions/gen_occlusions 池。position 覆盖位置锚(2026-09-04 用户实测指出树栖目标锚地平线=语义落地):模板占位符由写死的「在{distanceWord}的地平线附近」抽成 {position}(两模板同改),默认值不变——地面/水面场景目标锚地平线成立;捆绑组 position 覆盖为「在N米外地平线附近…大树的枝头上/枝杈间/粗壮横枝上」(内可用 {distanceWord} 占位),树栖目标随场景锚到枝头。落地(2026-09-04 用户定案「每种动物生境权重不同:野鸡主要在地面、斑鸠在树上、鸭子在水里」): 斑鸠(id 133) gen_scenes = 4 个树上捆绑组(各 weight 8+ 8 条地面字符串(weight 1)→ 树上恰好 80%gen_actions 收敛为纯地面 4 条(枝头/电线类动作移入捆绑组),树栖遮挡含树叶/树枝类; 雉类 8 数据集(环颈雉/红腹锦鸡/白腹锦鸡/白鹇/黄腹角雉/灰胸竹鸡/蓝鹇/褐马鸡)gen_scenes 剔除 4 条树上景观字符串、追加 1 个树上捆绑组 weight 3 → 地面 14/17 ≈ 82%、树上 ≈ 18%(雉类地面觅食为主、树上仅停栖/夜栖),gen_actions 剔除枝头停栖/树枝上歇息(移入捆绑组); 鸭子(108) 全水面场景+游动/潜水动作+芦苇遮挡本就自洽,白马鸡/蓝马鸡 高山地面/林缘自洽,兔子/鹌鹑/石鸡 无树上条目——均不动。 gen-pools VLM 重新生成仍产纯字符串池(会重置捆绑组)——树栖/水栖物种需重新 SQL 治理;解析与选取在 service/dataset.go parseScenePool/pickSceneEntry

训练通道(runner 抽象,决策:同机/异机不确定 → 可配置)

training:
  mode: subprocess            # subprocess | ssh
  ssh: { host: "", user: "", port: 22, privateKeyPath: "", password: "" }
  workdir: /opt/pheasant_data # 训练机工作目录
  venvPython: /opt/pheasant_data/venv/bin/python
  datasetDir: datasets        # 训练机数据集根目录(相对 workdir,数据集为子目录)
  timeoutMinutes: 240         # 超时判死(started_at 起算;100 张实测约 3 分钟)
  model: yolov8s.pt           # s 档(高识别)基座权重(须已同步到训练机 workdir)
  imgsz: 1280                 # s 档训练/导出分辨率(须与 App 端推理输入对齐)
  modelN: yolov8n.pt          # n 档(高性能)基座权重(2026-09-03 双档位;须已同步到训练机 workdir)
  imgszN: 704                 # n 档训练/导出分辨率(704² 端侧计算量约 s@1280 的 1/8,速度优先)
  epochs: 150                 # 训练轮数(patience 30 早停,设大可自动停)
  batch: 16                   # 批大小(按训练机显存调整)
  device: "0"                 # GPU 编号(cpu 用 cpu
  • 模型与分辨率升级(2026-09-01,用户定案):基座 yolov8n.ptyolov8s.ptimgsz 704 → 1280——场景为远距离小目标 + 遮挡多,nano 容量不足且 704 输入下小目标仅剩 515 像素;s@1280 是端侧延迟可接受内的最优效果(中端机 70-120ms/帧,预览不卡、框 8-14fps 更新;高端机 30-45ms 流畅)。改动链路:config.ymltraining.model/imgsz)→ task.json 传 model → train_server.py 读 task model → 导出 litert 输入 NCHW [1,3,1280,1280] → App 端 TfliteDetector 输入尺寸从模型形状动态读取(defaultInputSize 兜底 1280)。相机侧无需改:Android 分析帧固定 16:9 ≤1920(典型 1920x1080)、拍照原图 4000px+letterbox 后均满足 1280。显存评估:12G 跑 s@1280 batch 816 可行(yolov8s 11.2M 参数)。端侧量化保持 fp16int8 对 <10px 小目标掉点明显)。

  • 预标注模型固定 700x7002026-09-01 实测确认)local-ai 的 rfdetr-xlarge 模型包 inference_config.json 声明固定输入 700x700training_input_size 700、dynamic_spatial_size_supported:falseresize_mode:stretch),提交任何分辨率最终都拉伸到 700x700 推理——localAi.inputSize 只是提交前等比缩放,改大无增益(曾误改 1280,实测后回退 700)。标注框精度天花板 = 700 分辨率:极小目标(<5px)框不准是标注侧固有噪声;训练 imgsz=1280 与标注 700 并存可行(框坐标按比例映射到 1280 图),突破标注精度需换支持高分辨率输入的检测模型(RT-DETR / YOLO 动态输入 ONNX),未实施。

  • /v1/detection 响应 x/y = 框中心坐标(2026-09-02 实验确认并修复)local-ai 对 rfdetr-xlarge 原样透出模型输出约定——响应 {"x","y","width","height"}x/y 是中心(cx,cy)而非左上角width/height 为宽高。旧代码按左上角处理、下游归一化时再加 w/2,整框偏移 (+w/2,+h/2)(实测复现:RNPHE_133 目标中心 (406,661)px,接口返 (403,664) ≈ 中心;canvas 已知位置贴图实验同——2026-09-02 曾误判「700x700 stretch 导致偏移」,实验否证:任意提交尺寸返回归一化坐标不变,纯为格式误读)。修复common/localai.go Detect 收到响应先 x -= w/2; y -= h/2 转左上角,再按 scale 映射回原图像素——框尺寸不受影响(w/h 本身正确,用户报告「w/h 相同仅位置偏」即为该特征);已在库的错误框(如 RNPHE_133 手动修正后入库的 0.577/0.530 等)重跑预标即可覆盖修正。

  • 训练参数默认走配置(2026-08-26)imgsz/epochs/batch/device 不随管理端请求传(界面一键开始),由 training 节点统一配置——device 取决于训练机硬件、imgsz 必须与端侧推理对齐、epochs 取决于算力预期,均为部署级参数;任务记录仍存各值(model_training.imgsz/epochs/batch/device)供列表展示

  • service 内 Runner 接口:Start(ctx, *TrainingJob) (pid, error) / FetchLogTail(ctx, job) / IsAlive(ctx, job) bool / Cancel(ctx, job) / FetchArtifacts(ctx, job, destDir)subprocessssh 两个实现,按 config mode 选择;ssh 凭据直接读本节点 training.ssh 配置(见「全局训练配置」节)

  • 训练脚本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.pycheck_tflite),训练收尾定位 best.tflite 后自动校验并写 result.jsontflite_check 字段:{"ok":bool,"reason":string,"inputs":[{"name","shape","type"}],"outputs":[...]};校验规则 = 输入恰 1 张且 4 维、元素总数 == imgsz²×3(兼容 NCHW/NHWC)、输出 ≥ 1 张且 batch 维 = 1ok=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 任务落库即持久,重启后由轮询继续晋级,无需恢复处理)

  • 发起训练异步化(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/<文件名前缀>.tflites 档,前缀空回退数据集名)或 <文件名前缀>_n.tflite(n 档);原子覆盖,无 per-task 存档、不再打包 zip。s 档文件名与存量一致(存量已发布文件/旧 App 下载地址不变),n 档 _n 后缀区分

  • 写操作走 common.Serial() 单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务

模型版本(每数据集每档位一个模型,多模型体系)

核心决策:每个数据集按档位各训一个模型(s/n 两档 2026-09-03),模型按数据集共用版本序列,App 按识别档位加载多模型并行推理合并——用户按需下载若干数据集的模型,加载当前档位全部已下载模型共同推理标注。合并去重(2026-09-01 用户实测修订):单模型 NMS 与跨模型合并统一按 minIoU(交叠/较小框面积,阈值 0.45全局去重(不分标签)——实测多模型会对同一目标检出不同类别、单模型会输出一大一小两框(标准 IoU=小/大 会漏判),重叠一律取高分框;与 server 标注端 localAi.overlapThresholdminIoU 风格,取值以 config.yml 为准)同思路。远处真实多目标互不重叠,正常保留。

  • 版本号规则:m<major>.<minor>.<patch>同一数据集内每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);UNIQUE(dataset_id, version) 防重复——s/n 双档共用序列(不按档位分序列:避免改 UNIQUE 约束触发 SQLite 表重建,版本号对客户端仅同档内比较单调,跨档无比较需求;发布 n 后版本号继续在数据集全局递增)
  • 文件布局(2026-08-27 重构;命名 2026-08-28 改;双档位 2026-09-03):workspace/trainings/<文件名前缀>.tflites 档)与 <文件名前缀>_n.tflite(n 档)即当前生效模型唯一位(前缀空回退数据集名——存量数据集无前缀;改名/改前缀时模型文件随命名迁移)——训练成功时从训练机直写(原子覆盖),客户端固定下载该文件;<version>.tflite 存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚);每 (数据集,档位) 一个文件互不影响,s 档文件名与存量一致(旧 App/旧下载地址不变)
  • 类别名:发布时从训练任务/数据集记录类别(训练脚本 result.json 输出 names),存 model_version.labelsJSON 数组),App 合并推理依赖它
  • 发布POST /admin/trainings/publish):校验任务 success + 对应档位 trainings/<文件名前缀>[_n].tflite 存在(前缀空回退数据集名)→ 读文件算 sha256/size → 插 model_version(带任务档位 variant+ 同档位旧版 is_latest=0(s/n 两档各自独立,发布 n 不影响 s 生效状态);文件已在训练成功时就位,发布仅落版本记录。存量行 variant 迁移默认 s,与既有 s 档文件布局一致,旧记录照常可用
  • 管理端无模型管理界面2026-08-26 决策):删 AdminListModels/AdminActivateModel/AdminDeleteModel 三个管理接口,model_version 表保留——仅支撑客户端下发目录;版本只增不删不回滚(发布即最新)
  • 模型目录(客户端拉取)GET /api/v1/models(公开,登录态即可)返回所有数据集当前生效模型:{datasetId, datasetName, variant(s|n), version, labels, sizeBytes, sha256, notes, publishedAt, downloadUrl}——每数据集最多 2 条 = s/n 两档各自的 is_latestListAllLatest 取 is_latest=1 自然返回两档);下载 URL s 档 /download/trainings/<文件名前缀>.tflite、n 档 <文件名前缀>_n.tflite(前缀空回退数据集名;复用 /download 静态托管,文件名含中文需 URL 编码)。客户端兼容:条目新增 variant 字段,旧 App 忽略未知字段但按 datasetId 存储会与另一档条目互踩——双档目录与新 App 需同步上线(先发 App 再发布 n 档模型)

标注工作台(依赖 local-ai 可达;2026-08-26 布局重构)

  • 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.30.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 永不直接出框(只提议候选区):①全图扫描(现有 DetectlocalAi.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-04dataset_image.review_statusEnsureColumn 幂等补列):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 用户定案)——任务面板=本数据集任务下发/进度/停用,记录面板=本数据集众包记录查询/解冻(两列表接口支持 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_idEnsureColumn 幂等补列,默认 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_sinceEnsureColumn):冻结到期标记(>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 入口
  • 详情页布局(2026-09-02 卡片化改版后快照):图片以缩略图卡片网格展示(每页 18 张,点卡片进弹窗编辑器);页顶标注/AI 生成任务进度条;图片列表三 tab2026-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_jsonJSON 数组,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/ 已删除)

数据清洗(训练集去冗余,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 疑似图与空检出 [] 图不入清单(无确认目标,无从定尺寸,等人工确认后自然进入统计)
  • 档位:按图高占比细档 <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 处三物种框高均 25px 不可检,90% 识别的现实半径(s 档)为雉鸡 ~40-50m / 野兔 ~30m / 斑鸠 ~12-15mn 档 @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 建议准备 9001000 张再按此表跑清洗)。曾用 225 规模默认(60/50/40/30/20/15/10)、500 规模方案A100/90/80/60/55/50/35)已随规模上调替换
  • 桶内多样性保留:超配桶内保留谁不随机——先按图内最小确认目标(class 0)尺寸升序扫描(小目标样本稀缺,同档图里带更小目标的先占保留名额;同最小尺寸按 id 保持稳定),整图 dHashcommon 层,9x8 灰度 64 位)与已保留图最小汉明距离大于阈值才留,留够配额即停,其余全进候选清单(连拍帧天然相邻近哈希,只留首帧;不同场景同尺寸的目标尽量都保);候选行展示主尺寸 + 最小目标占比 + 目标数,供人工判断取舍
  • 执行语义(排除而非删除):候选清单人工过目后打 dataset_image.clean_excluded=1EnsureColumn 迁移)——只影响训练集打包prepare_yolo 跳过),图片/标注不动、列表/标注/预标全流程照常可见;可恢复(清除标记即回训练集,付费资产原则:清洗默认不删文件,物理删除仍走人工逐删)
  • 接口:POST /admin/datasets/clean/previewdatasetId + 可选 quotas → 各档分布/配额/超配候选清单 + 已排除列表);POST /admin/datasets/clean/applyimageIds + exclude bool 批量置/清标记);管理端详情页承载:「数据清洗」按钮在已标注 tab 工具条(配额表可编辑 + 候选清单默认全选 → 执行排除),已排除的图集中到「已清洗」tab 展示,逐张/批量恢复(apply exclude=false,图片列表接口返回 cleanExcluded 供前端归类

负样本库(训练背景样本统一管理,2026-09-07)

  • 背景:训练集为合成图(每张必含目标),模型从未学过「这不是目标」,端侧实拍把非目标动物/纹理误识别为动物。根治 = 训练时混入负样本(无任何标注框的背景图,ultralytics 对空标签图自动按背景学习,无需改训练脚本),负样本对全部物种数据集统一一套(误报源与物种无关:猫狗/树桩/草丛/真实场景背景通用)。
  • 实现决策:不建新表——负样本库是一个特殊数据集: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' 数据集一律拒绝(负样本库单独训练无意义、其图片不需要标注)。
  • 删除防护AdminDeleteDataset 拒绝删除负样本库(防误删整库);单张图片删除照常可用。

模型热更新与多模型推理(客户端 Flutter)

  • GET /api/v1/app/update 扩展:响应追加 models 数组(GET /api/v1/models 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl)——多模型目录,不再有单一 modelUrl;无发布模型时不返回该字段(旧 App 忽略、新 App 兼容旧服务器)
  • 模型管理:App 设置页「模型管理」——列出服务器全部可用模型 + 本地已下载状态(版本/大小/说明),用户自由下载/删除/启用;下载到应用私有目录 .tmp → sha256 校验 → 原子替换;已下载模型跨启动持久
  • 并行推理合并tflite_detector 启动时加载全部已启用且已下载的模型(每模型独立 interpreter + 独立 isolate 线程并行推理);单帧结果按类别名合并 + 跨模型 NMS 去重(IoU 阈值 0.45,同类名才合并);类别名冲突以合并结果展示、标签文本随模型 labels
  • 内置模型兜底:assets model.tflite + labels.txt 作为无任何下载模型时的默认;有下载模型则并行加载下载模型(内置不重复加载,避免重复检测同类别)
  • 非强制:下载失败/校验不过删 tmp 用旧状态,下次启动重试;不弹阻塞窗
  • 性能:N 模型并行推理墙钟 ≈ 单模型 1.2~1.5 倍(多核并行);用户按需下载控制 N;帧率不足时可降采样
  • 版本比较规则同 APK(数字段比较,m 前缀剥离后按 x.y.z)

待办/风险

  • 微信支付需商户号(APP 支付权限)、APIv3 密钥与平台证书;支付宝需商户应用与密钥 —— 当前均未配置,接口按真实 SDK 契约实现,配置走 config.yml 占位
  • iOS App Store 对数字内容强制 IAP,微信/支付宝直连有被拒风险(已决策,记录在案)
  • 管理端静态 token 由登录页输入(浏览器 localStorage),属内网管理凭据:config.yml admin.token 失配/未配置时管理接口全部拒绝;token 不进入前端构建产物与 git
  • 手动授权等同免费发卡,仅限运营/客诉排查场景,前端页面带确认弹窗,后端不限制频率(按需再加审计)
  • 模型训练体系依赖外部环境:训练机 GPU + ultralytics venvtraining.venvPython)、RF-DETR 服务从服务器可达(localAi.baseUrl)、图像生成服务可达(imageGendashscope 需外网 + apiKeylocalai 需训练机 local-ai 可达 + imageGen.baseUrl)——未配置对应节点时相关管理接口返回配置缺失错误,不影响支付/授权主链路
  • subprocess 模式下训练与在线服务同机,训练吃满 GPU 可能影响识别类在线任务(当前在线服务无 GPU 推理,风险低);异机部署切换 training.mode: ssh
  • 训练产物与数据集为磁盘占用大户(图片百 MB~GB、每轮产物几十 MB),workspace/ 已挂载持久化;清理入口:删除数据集(付费资产需确认);模型无存档,仅 latest.tflite 被新发布覆盖,无需单独清理