This commit is contained in:
2026-08-26 18:37:24 +08:00
parent 28220899fb
commit c323ca9031
10 changed files with 38 additions and 82 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
| 后台管理端 | `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 |
| 模型训练 | 从数据集卡片「开始训练」触发:参数(数据集/imgsz/epochs/batch)、进度/日志/指标监控(每 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` 留作训练机人工深度调试 |
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 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/models/<数据集>/latest.tflite` + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/models/<数据集>/latest.tflite`;**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 |
| 标注 | **图片入库自动触发**:手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 全图扫描(`label_task` 记录进度,页顶进度条展示;**localAi 未配置 → 上传/生成接口直接报错;已有标注任务在跑(忙)→ 不报错**,当前任务成功完成后自动补标未标注图)→ 扫描结果(**重叠去重**:NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除——重叠比 = 交叠面积/两框较小面积,RF-DETR 同目标常输出一大一小两框,此判据能命中,同目标只留置信度最高者)**直接写 `dataset_image.labels_json`**(覆盖该图已有标注,即重标语义);点击弹窗放大后在 canvas 上画框/点框删除/清空/改类别 → 保存即整体覆写 `dataset_image.labels_json`YOLO 归一化 JSON 数组,AI 与人工框同存,人工可修改/清理);`POST /admin/label-tasks` 详情页「全量标注」按钮入口(另有自动触发),可发起全量/指定图重标;自动/手动/混合并存,训练前自动整理(prepare_yolo 逻辑在服务端) |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视野管理端</title>
<script type="module" crossorigin src="/admin/assets/index-jKHdJYxb.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-B0ehGsu8.css">
<script type="module" crossorigin src="/admin/assets/index-CIe9te1H.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BaD1qigE.css">
</head>
<body>
<div id="app"></div>
+3 -7
View File
@@ -207,15 +207,11 @@ type AdminTrainingListRes struct {
List []*AdminTrainingItem `json:"list"`
}
// AdminTrainingStartReq 发起训练(并发度 1:已有 running 任务时拒绝)
// AdminTrainingStartReq 发起训练(并发度 1:已有 running 任务时拒绝;训练参数走 config.yml training 节点,name 空则自动生成
type AdminTrainingStartReq struct {
g.Meta `path:"/trainings" method:"post" summary:"发起训练" tags:"管理端"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
Name string `json:"name" v:"required|length:1,50" dc:"任务名称"`
Imgsz int `json:"imgsz" v:"integer|min:64|max:2048" dc:"输入尺寸,默认 704"`
Epochs int `json:"epochs" v:"integer|min:1|max:1000" dc:"训练轮数,默认 150"`
Batch int `json:"batch" v:"integer|min:1|max:128" dc:"批大小,默认 16"`
Device string `json:"device" v:"length:0,16" dc:"设备,默认 0"`
DatasetId int64 `json:"datasetId" v:"required|min:1" dc:"数据集 id"`
Name string `json:"name" v:"length:0,50" dc:"任务名称,空自动生成"`
}
type AdminTrainingStartRes struct {
+7 -16
View File
@@ -317,21 +317,12 @@ func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.Admin
if err != nil {
return nil, err
}
imgsz := req.Imgsz
if imgsz <= 0 {
imgsz = 704
}
epochs := req.Epochs
if epochs <= 0 {
epochs = 150
}
batch := req.Batch
if batch <= 0 {
batch = 16
}
device := req.Device
if device == "" {
device = "0"
// 训练参数为部署级配置(config.yml training 节点,界面不传):device 随训练机硬件、
// imgsz 须与 App 端推理对齐、epochs 随算力预期
imgsz, epochs, batch, device := cfg.Imgsz, cfg.Epochs, cfg.Batch, cfg.Device
name := req.Name
if name == "" {
name = fmt.Sprintf("%s 训练 %s", dataset.Name, gtime.Now().Format("01-02 15:04"))
}
now := gtime.Now()
var taskId int64
@@ -344,7 +335,7 @@ func (s *trainingService) AdminStartTraining(ctx context.Context, req *dto.Admin
return gerror.NewCode(common.CodeTrainingRunning)
}
taskId, err = dao.Training.Insert(ctx, &entity.ModelTraining{
Name: req.Name,
Name: name,
Status: consts.TrainingStatusRunning,
DatasetId: dataset.Id,
Imgsz: imgsz,
+8
View File
@@ -88,6 +88,10 @@ type TrainingConfig struct {
DatasetDir string
Python string
TimeoutMins int
Imgsz int
Epochs int
Batch int
Device string
}
// TrainingConfigOf 读取训练通道配置(未配置返回 ok=false)
@@ -97,6 +101,10 @@ func TrainingConfigOf(ctx context.Context) (TrainingConfig, bool) {
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(),
Imgsz: g.Cfg().MustGet(ctx, "training.imgsz", 704).Int(),
Epochs: g.Cfg().MustGet(ctx, "training.epochs", 150).Int(),
Batch: g.Cfg().MustGet(ctx, "training.batch", 16).Int(),
Device: g.Cfg().MustGet(ctx, "training.device", "0").String(),
}
return cfg, cfg.Workdir != "" && cfg.Python != ""
}
+5 -1
View File
@@ -40,7 +40,7 @@ imageGen:
# 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务):
# mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步)
training:
mode: subprocess # subprocess | ssh
mode: ssh # subprocess | ssh
ssh:
host: "192.168.3.210" # 训练机地址(mode=ssh 必填)
user: "root"
@@ -51,6 +51,10 @@ training:
venvPython: /opt/pheasant_data/venv/bin/python
datasetDir: datasets # 训练机数据集根目录(相对 workdiryolo/<name> 为子目录)
timeoutMinutes: 600 # 训练超时判死
imgsz: 704 # 训练/导出分辨率(须与 App 端推理输入对齐)
epochs: 150 # 训练轮数(patience 30 早停,设大可自动停)
batch: 16 # 批大小(按训练机显存调整)
device: "0" # GPU 编号(cpu 用 cpu
# RF-DETR 预标注服务(管理端标注工作台):需从服务器可达,未配置时预标注接口返回「标注服务未配置」
localAi:
+6
View File
@@ -350,8 +350,14 @@ training:
datasetDir: datasets # 训练机数据集根目录(相对 workdir,数据集为子目录)
concurrency: 1 # GPU 独占:同时仅一个 running,新任务排队
timeoutMinutes: 600 # 超时判死
imgsz: 704 # 训练/导出分辨率(须与 App 端推理输入对齐)
epochs: 150 # 训练轮数(patience 30 早停,设大可自动停)
batch: 16 # 批大小(按训练机显存调整)
device: "0" # GPU 编号(cpu 用 cpu
```
- **训练参数默认走配置(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)``subprocess``ssh` 两个实现,按 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`(最终指标)+ 自动打包 `artifact.zip`best.pt + results.csv + 曲线);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` 保留作训练机人工深度调试
+4 -53
View File
@@ -28,9 +28,7 @@ const coverFile = ref(null) // 新选择的封面文件(el-upload 单文件)
const coverFileList = ref([]) // 封面回显:已有封面(服务端 url)或新选文件(本地预览)
const coverDeleted = ref(false) // 用户删除了已有封面(保存时调删除接口)
const trainVisible = ref(false)
const training = ref(false)
const trainForm = reactive({ datasetId: 0, name: '', imgsz: 704, epochs: 150, batch: 16, device: '0' })
const statusMap = { building: '建设中', labeled: '已标注', synced: '已同步' }
const statusTag = { building: 'info', labeled: 'success', synced: 'primary' }
@@ -133,29 +131,14 @@ async function submitConfig() {
}
}
// ---------- 开始训练 ----------
// ---------- 开始训练(一键发起:任务名自动生成,参数走 config.yml training 节点) ----------
function openTrain(row) {
trainForm.datasetId = row.id
trainForm.name = `${row.name} 训练`
trainForm.imgsz = 704
trainForm.epochs = 150
trainForm.batch = 16
trainForm.device = '0'
trainVisible.value = true
}
function submitTrain() {
if (!trainForm.name.trim()) {
ElMessage.warning('请输入任务名称')
return
}
function startTrain(row) {
training.value = true
request
.post('/trainings', { ...trainForm })
.post('/trainings', { datasetId: row.id })
.then(() => {
ElMessage.success('训练任务已发起,完成后可发布为模型版本')
trainVisible.value = false
load()
})
.catch(() => {})
@@ -268,7 +251,7 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="ds-actions" @click.stop>
<el-button type="primary" size="small" :icon="VideoPlay" :disabled="row.trainingStatus === 'running'" @click="openTrain(row)">
<el-button type="primary" size="small" :icon="VideoPlay" :disabled="row.trainingStatus === 'running'" @click="startTrain(row)">
{{ row.trainingStatus === 'running' ? '训练中' : '开始训练' }}
</el-button>
<el-button size="small" :icon="Setting" @click="openConfig(row)">配置</el-button>
@@ -361,32 +344,6 @@ onBeforeUnmount(() => {
</template>
</el-dialog>
<!-- 开始训练 -->
<el-dialog v-model="trainVisible" title="开始训练" width="min(480px, 92vw)">
<el-form label-width="90px">
<el-form-item label="任务名称">
<el-input v-model="trainForm.name" maxlength="50" show-word-limit placeholder="如:雉鸡数据集 v2 训练" />
</el-form-item>
<el-form-item label="分辨率">
<el-input-number v-model="trainForm.imgsz" :min="64" :max="2048" :step="32" />
<span class="field-tip">与端侧推理对齐默认 704</span>
</el-form-item>
<el-form-item label="轮数">
<el-input-number v-model="trainForm.epochs" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="Batch">
<el-input-number v-model="trainForm.batch" :min="1" :max="128" />
</el-form-item>
<el-form-item label="设备">
<el-input v-model="trainForm.device" maxlength="16" placeholder="GPU 编号 0CPU 填 cpu" />
</el-form-item>
<div class="start-tip">训练前自动整理 80/20 训练/验证集须先完成图片标注训练机并发度 1已有运行中任务会被拒绝</div>
</el-form>
<template #footer>
<el-button @click="trainVisible = false">取消</el-button>
<el-button type="primary" :loading="training" @click="submitTrain">发起训练</el-button>
</template>
</el-dialog>
</el-card>
</template>
@@ -512,12 +469,6 @@ onBeforeUnmount(() => {
font-size: 12px;
color: #909399;
}
.start-tip {
font-size: 12px;
color: #909399;
line-height: 1.6;
margin-left: 90px;
}
.cover-row {
display: flex;
align-items: center;