1
This commit is contained in:
+3
-3
@@ -14,7 +14,7 @@
|
||||
| 套餐 | `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 文件,删历史版本仅删记录 |
|
||||
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗内 canvas 直接画框/点框删除/清空并保存——AI 自动标注结果直接作为标注,人工可修改/清理全部框;封面(上传自动转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象,默认 qwen-image/DashScope 付费 API);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON |
|
||||
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗内 canvas 直接画框/点框删除/清空并保存——AI 自动标注结果直接作为标注,人工可修改/清理全部框;封面(上传自动转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image,`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON |
|
||||
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置):进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1(GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
|
||||
| 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/trainings/<数据集名>.tflite` + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
|
||||
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/trainings/<数据集名>.tflite`;**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 |
|
||||
@@ -185,7 +185,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
||||
| GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位) |
|
||||
| POST | `/admin/datasets/cover/delete` | 删除数据集封面 `{"datasetId":1}`:删文件 + 清 cover 字段 |
|
||||
| POST | `/admin/datasets/upload` | 上传图片(multipart/form-data:`datasetId` + `files` 多张,仅接受 `.jpg/.jpeg/.png`),存 `app.datasetDir`/`datasets/<name>/`,逐张入库 |
|
||||
| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"prompt":"...","count":1}`(count 1..8,同步执行):调 `imageGen` provider(默认 qwen-image/DashScope)逐张生成落盘 + 入库(记录 prompt),任意失败返回错误 |
|
||||
| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"prompt":"...","count":1}`(count 1..8,同步执行,每张超时 `imageGen.timeoutSeconds`):调 `imageGen` provider(dashscope / localai,config 切换)逐张生成落盘 + 入库(记录 prompt),任意失败返回错误 |
|
||||
| GET | `/admin/datasets/images` | 数据集图片列表 `{"datasetId":1}`,返回图片元数据(文件名/来源/prompt/创建时间) |
|
||||
| GET | `/admin/datasets/image` | 图片文件(静态字节流,`datasetId` + `filename` 定位,供缩略图/查看) |
|
||||
| POST | `/admin/datasets/images/delete` | 删除图片 `{"datasetId":1,"ids":[1,2]}`:删文件 + 删记录(AI 生成图是付费资产,前端确认文案提示) |
|
||||
@@ -223,7 +223,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时)、`imageGen`(AI 生成图片 provider,默认 dashscope + apiKey + model qwen-image-3.0)、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化
|
||||
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时)、`imageGen`(AI 生成图片 provider:`dashscope` 通义万相(apiKey + model qwen-image-3.0)/ `localai` 本地 local-ai(baseUrl + model 如 qwen-image + timeoutSeconds,训练机约 5 分钟/张建议 600))、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化
|
||||
2. `go build ./...` 编译验证
|
||||
3. 本地运行 `go run main.go`;服务层白盒测试:`GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/`(独立测试库,见 `biz/service/testdata/`)
|
||||
4. 后台管理端:`cd server_admin && npm run build`(构建产物输出到 `server/admin_dist/`,由后端 `/admin/` 托管);开发联调 `npm run dev`(Vite 代理 `/api` → `:8080`)。首次访问 `/admin/` 进入登录页,输入 `config.yml admin.token` 对应的管理 token(存浏览器 localStorage,随请求携带;token 不内嵌构建产物)
|
||||
|
||||
@@ -447,7 +447,7 @@ func saveImageFile(f *ghttp.UploadFile, dir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminGenerateImages AI 生成图片:同步逐张生成(每张 2min 超时),逐张落盘 + 入库;
|
||||
// AdminGenerateImages AI 生成图片:同步逐张生成(每张超时 imageGen.timeoutSeconds),逐张落盘 + 入库;
|
||||
// 中途失败返回错误并附已成功张数(已生成的图为付费资产,保留不删除)。
|
||||
func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) {
|
||||
provider := common.ImageGen(ctx)
|
||||
@@ -472,8 +472,10 @@ func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.Admin
|
||||
now := gtime.Now()
|
||||
generated := 0
|
||||
addedIds := make([]int64, 0, req.Count)
|
||||
// 每张生成超时走配置(dashscope 默认 120s;localai 训练机约 5min/张,建议 600)
|
||||
genTimeout := time.Duration(g.Cfg().MustGet(ctx, "imageGen.timeoutSeconds", 120).Int()) * time.Second
|
||||
for i := 0; i < req.Count; i++ {
|
||||
genCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
||||
genCtx, cancel := context.WithTimeout(ctx, genTimeout)
|
||||
data, genErr := provider.Generate(genCtx, req.Prompt, req.Size)
|
||||
cancel()
|
||||
if genErr != nil {
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
@@ -18,13 +20,21 @@ type ImageGenProvider interface {
|
||||
Generate(ctx context.Context, prompt, size string) ([]byte, error)
|
||||
}
|
||||
|
||||
// ImageGen 当前配置的图像生成 provider 单例(未配置返回 nil,调用方判 CodeImageGenNotConfigured)。
|
||||
// ImageGen 当前配置的图像生成 provider 单例(provider 必填项缺失返回 nil,调用方判 CodeImageGenNotConfigured)。
|
||||
func ImageGen(ctx context.Context) ImageGenProvider {
|
||||
if g.Cfg().MustGet(ctx, "imageGen.apiKey").String() == "" {
|
||||
return nil
|
||||
}
|
||||
switch g.Cfg().MustGet(ctx, "imageGen.provider", "dashscope").String() {
|
||||
case "localai":
|
||||
if g.Cfg().MustGet(ctx, "imageGen.baseUrl").String() == "" {
|
||||
return nil
|
||||
}
|
||||
return &localAiImageGen{
|
||||
baseUrl: g.Cfg().MustGet(ctx, "imageGen.baseUrl").String(),
|
||||
model: g.Cfg().MustGet(ctx, "imageGen.model", "qwen-image").String(),
|
||||
}
|
||||
case "dashscope":
|
||||
if g.Cfg().MustGet(ctx, "imageGen.apiKey").String() == "" {
|
||||
return nil
|
||||
}
|
||||
return &dashScopeImageGen{apiKey: g.Cfg().MustGet(ctx, "imageGen.apiKey").String()}
|
||||
default:
|
||||
return nil
|
||||
@@ -158,3 +168,82 @@ func truncateStr(s string, n int) string {
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// localAiImageGen local-ai(OpenAI 兼容 /v1/images/generations)实现:同步生成 → 下载产物图片。
|
||||
// 训练机 local-ai 已加载 qwen-image 文生图;返回 url 为容器内 localhost 地址,需替换为配置 baseUrl 下载。
|
||||
type localAiImageGen struct {
|
||||
baseUrl string
|
||||
model string
|
||||
}
|
||||
|
||||
func (p *localAiImageGen) Generate(ctx context.Context, prompt, size string) ([]byte, error) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": p.model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"n": 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := strings.TrimSuffix(p.baseUrl, "/") + "/v1/images/generations"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "local-ai 生成请求失败")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg := truncateStr(string(raw), 200)
|
||||
if msg == "" {
|
||||
msg = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("local-ai 生成失败: HTTP %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
var m struct {
|
||||
Data []struct {
|
||||
Url string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("local-ai 响应解析失败: %v", err)
|
||||
}
|
||||
if len(m.Data) == 0 || m.Data[0].Url == "" {
|
||||
return nil, fmt.Errorf("local-ai 成功但无图片 URL")
|
||||
}
|
||||
return p.download(ctx, m.Data[0].Url)
|
||||
}
|
||||
|
||||
// download 下载生成产物图片:url 为容器内地址(localhost),替换 scheme/host 为配置 baseUrl 后下载
|
||||
func (p *localAiImageGen) download(ctx context.Context, imageUrl string) ([]byte, error) {
|
||||
base, err := url.Parse(p.baseUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := url.Parse(imageUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Scheme, u.Host = base.Scheme, base.Host
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "下载生成图片失败")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("下载生成图片失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
}
|
||||
|
||||
+6
-4
@@ -31,11 +31,13 @@ app:
|
||||
# 模型训练体系运行时数据根目录(图片/标注/模型/训练产物;与 apkDir 同目录时共用 workspace)
|
||||
datasetDir: "./workspace"
|
||||
|
||||
# 图像生成(管理端「AI 生成图片」):未配置 apiKey 时生成接口返回「图像生成服务未配置」
|
||||
# 图像生成(管理端「AI 生成图片」):provider 必填项缺失时生成接口返回「图像生成服务未配置」
|
||||
imageGen:
|
||||
provider: dashscope # 当前唯一实现
|
||||
apiKey: "" # DashScope API Key
|
||||
model: qwen-image-3.0
|
||||
provider: localai # dashscope | localai
|
||||
baseUrl: "http://192.168.3.210:18080" # localai 必填:训练机 local-ai 地址(/v1/images/generations)
|
||||
model: qwen-image # localai 上加载的模型名;dashscope 用 qwen-image-3.0
|
||||
apiKey: "" # dashscope 必填:DashScope API Key
|
||||
timeoutSeconds: 600 # 每张生成超时(默认 120;localai 训练机约 5min/张,建议 600)
|
||||
|
||||
# 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务):
|
||||
# mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步)
|
||||
|
||||
+6
-3
@@ -324,9 +324,12 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不
|
||||
|
||||
### AI 生成图片(provider 抽象)
|
||||
|
||||
- `imageGen` 配置节点:`provider: dashscope`(当前唯一实现)+ `apiKey` + `model: qwen-image-3.0`;接口在 `common` 层抽象 `ImageGenProvider`(`Generate(ctx, prompt, size) ([]byte, error)`),后续可加本地 SD 等实现,config 切换
|
||||
- `imageGen` 配置节点:`provider: dashscope | localai`,接口在 `common` 层抽象 `ImageGenProvider`(`Generate(ctx, prompt, size) ([]byte, error)`),config 切换
|
||||
- `dashscope`(通义万相付费 API):`apiKey` + `model: qwen-image-3.0`;内部自管 120s 轮询上限
|
||||
- `localai`(本机/训练机 local-ai,`POST {baseUrl}/v1/images/generations`,OpenAI 兼容):`baseUrl` + `model`(local-ai 上加载的模型名,如 qwen-image)+ `timeoutSeconds`(训练机 qwen-image 1024x1024/30 步约 5 分钟,默认 600);返回 url 为容器内 localhost 地址,下载时替换为 `baseUrl` 的 host;step/cfg 等采样参数用 local-ai 模型配置默认值,不随接口传
|
||||
- `baseUrl`/`apiKey` 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
|
||||
- 尺寸参数由前端传(下拉可选,默认 1152x2048 竖图,与既有训练图一致;另提供 704x704 档)
|
||||
- 生成同步执行(count 1..8,超时 2min/张),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则)
|
||||
- 生成同步执行(count 1..8,每张超时 `imageGen.timeoutSeconds`,默认 120s;localai 建议 600),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则)
|
||||
- prompt 由前端预设模板 + 用户微调;**遵守项目提示词规范:不得包含目标位置描述**(位置由模型自行推理,前端模板与校验文案落实此约束)
|
||||
|
||||
### 训练通道(runner 抽象,决策:同机/异机不确定 → 可配置)
|
||||
@@ -393,6 +396,6 @@ training:
|
||||
- iOS App Store 对数字内容强制 IAP,微信/支付宝直连有被拒风险(已决策,记录在案)
|
||||
- 管理端静态 token 由登录页输入(浏览器 localStorage),属内网管理凭据:`config.yml admin.token` 失配/未配置时管理接口全部拒绝;token 不进入前端构建产物与 git
|
||||
- 手动授权等同免费发卡,仅限运营/客诉排查场景,前端页面带确认弹窗,后端不限制频率(按需再加审计)
|
||||
- 模型训练体系依赖外部环境:训练机 GPU + ultralytics venv(`training.venvPython`)、RF-DETR 服务从服务器可达(`localAi.baseUrl`)、DashScope API key(`imageGen.apiKey`)——未配置对应节点时相关管理接口返回配置缺失错误,不影响支付/授权主链路
|
||||
- 模型训练体系依赖外部环境:训练机 GPU + ultralytics venv(`training.venvPython`)、RF-DETR 服务从服务器可达(`localAi.baseUrl`)、图像生成服务可达(`imageGen`:dashscope 需外网 + apiKey,localai 需训练机 local-ai 可达 + `imageGen.baseUrl`)——未配置对应节点时相关管理接口返回配置缺失错误,不影响支付/授权主链路
|
||||
- subprocess 模式下训练与在线服务同机,训练吃满 GPU 可能影响识别类在线任务(当前在线服务无 GPU 推理,风险低);异机部署切换 `training.mode: ssh`
|
||||
- 训练产物与数据集为磁盘占用大户(图片百 MB~GB、每轮产物几十 MB),`workspace/` 已挂载持久化;清理入口:删除数据集(付费资产需确认);模型无存档,仅 latest.tflite 被新发布覆盖,无需单独清理
|
||||
|
||||
Reference in New Issue
Block a user