This commit is contained in:
2026-09-09 21:05:25 +08:00
parent 87ab06221c
commit c590f74b1e
14 changed files with 229 additions and 208 deletions
+1 -1
View File
@@ -17,7 +17,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 文件,删历史版本仅删记录 |
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程,**双 tab(2026-09-07**:「数据集」tab = 数据集卡片列表(封面图/描述/图片数/已标注数/**训练状态徽标**),「负样本」tab = 负样本库图片网格(上传/删除,见技术设计.md「负样本库」——训练打包时统一混入全部物种数据集);**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片不自动标注(2026-09-04 自动标注退场)**:标注唯一入口 = 勾选图片顶栏「预标」(RF-DETR 四级漏斗检测,见技术设计.md「预标注四级漏斗」),进度条展示在页顶;**预标完成进「待审核」,人工审核通过才「已标注」**(`dataset_image.review_status` 0 未标注/1 待审核/2 已审核 三态,训练集只收已审核图);点击原图/标注图弹窗放大进入标注编辑器(画框/确认/清理,保存即视为已审核);封面(上传/生成统一 1248x704 转 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**数据清洗(2026-09-02)**:详情页「数据清洗」——按标注目标尺寸细档统计超配,超配桶内整图 dHash 多样性保留、其余进候选清单,执行=打「已排除训练集」标记(可恢复不删图),prepare_yolo 打包跳过 |
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置);**双档位(2026-09-03)**:一次发起按档位各建一条任务——高识别档 s(基座 `training.model`、imgsz `training.imgsz`=1280/ 高性能档 n(基座 `training.modelN`=yolov8n.pt、imgsz `training.imgszN`=704),请求传 `variants:["s","n"]` 限定(省略=双档;n 档配置缺失时请求报错),epochs/batch/device 双档共用,任务带 `variant` 快照;**综合训练(2026-09-09**`POST /admin/trainings/combined` 勾选 ≥2 个数据集 + 档位,多物种合并训练出**一个综合模型**(全类一张 tflite:类别表 = 各物种名按数据集 id 升序 + 共享 suspect 置末位,打包时类别 id 重映射、负样本只混一份、图片名加 d<id>_ 前缀防跨数据集重名),产物/发布/目录下发走现有链路,文件基名 `combined`combined.tflite / combined_n.tflite);单物种训练流程不变,两种模式并存(详见技术设计.md「综合训练」);**GPU 独占排队(2026-09-03**:并发度 1 不变——已有 running 时新任务落 `queued` 排队(不再拒绝),10s 轮询在 running 结束后自动按创建顺序晋级启动、一次一个(训练机单 GPU 串行跑多档/多数据集),取消 running=杀进程、queued=直接置失败;进度/日志/指标监控(每 epoch 粒度);训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化(task.json 传 model/imgsz),产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置);**双档位(2026-09-03)**:一次发起按档位各建一条任务——高识别档 s(基座 `training.model`、imgsz `training.imgsz`=1280/ 高性能档 n(基座 `training.modelN`=yolov8n.pt、imgsz `training.imgszN`=704),请求传 `variants:["s","n"]` 限定(省略=双档;n 档配置缺失时请求报错),epochs/batch/device 双档共用,任务带 `variant` 快照;**综合训练(2026-09-09**`POST /admin/trainings/combined` 勾选 ≥2 个数据集 + 档位,多物种合并训练出**一个综合模型**(全类一张 tflite:类别表 = 各物种名按数据集 id 升序 + 共享 suspect 置末位,打包时类别 id 重映射、负样本只混一份、图片名加 d<id>_ 前缀防跨数据集重名),产物/发布/目录下发走现有链路,文件基名 `combined`combined.tflite / combined_n.tflite);单物种训练流程不变,两种模式并存(详见技术设计.md「综合训练」);**GPU 独占排队(2026-09-03**:并发度 1 不变——已有 running 时新任务落 `queued` 排队(不再拒绝),10s 轮询在 running 结束后自动按创建顺序晋级启动、一次一个(训练机单 GPU 串行跑多档/多数据集),取消 running=杀进程、queued=直接置失败;进度/日志/指标监控(每 epoch 粒度);训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化(task.json 传 model/imgsz),产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;**增量训练(2026-09-09**:单物种/综合任务按档位 lineage 自动热启动——上一次成功的 best.pt 存档于 `workspace/trainings/weights/<基名>.pt`(基名同 tflite:单物种 `<前缀或数据集名>[_n]`、综合 `combined[_n]`),下次训练存在即推训练机作基座、不存在回落 config 基座(首次全量),类别数变化自动重建检测头;删该文件即从零重训;`dump_graph.py` 留作训练机人工深度调试 |
| 模型版本与热更新 | **每数据集每档位一个模型**2026-09-03 双档位):训练成功后一键「发布」(训练任务操作列)——tflite 已由训练成功直写最终位置:s 档 `workspace/trainings/<文件名前缀>.tflite`、n 档 `<文件名前缀>_n.tflite`(前缀空回退数据集名),发布仅落 `model_version` 记录(sha256/大小/指标/类别名,带 `variant` 档位列);版本序列每数据集全局共用 m1.0.0 递增(s/n 交替发布走同一序列,无档位独立序列),`is_latest` 按 (数据集, 档位) 各记一条——发布只清同档位旧记录,s/n 两档互不影响,目录可分别发布、分别下发。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/档位 `variant` s|n/版本/类别/大小/sha256/下载地址;**每数据集最多 2 条 = s/n 两档各自的 is_latest**),下载 URL s 档 `/download/trainings/<文件名前缀>.tflite`、n 档 `/download/trainings/<文件名前缀>_n.tflite`(前缀空回退数据集名);**App 模型管理页**用户自由下载/删除/启用模型,识别时**按当前识别档位(s 高识别 / n 高性能,全局切换)加载该档位已启用模型**并行推理 + 跨模型 NMS 合并(按类别名),内置 assets 模型兜底 |
| 标注 | **无自动标注(2026-09-04 退场,用户定案)**:上传/生成入库不触发任何检测,`localAi` 未配置不再阻断入库;标注唯一入口 = 管理端勾选图片顶栏「预标」→ `POST /admin/label-tasks`RF-DETR **四级漏斗**:全图扫描→空检自动升级切片扫描→仍空 VLM 提议候选区+RF-DETR 精修;切片参数走 `localAi.tileSize`/`tileOverlap`/`tileThreshold`,见技术设计.md「预标注四级漏斗」;扫描结果 minIoU 重叠去重后直写 `dataset_image.labels_json`,空检出写 `[]` 且 review_status 保持未标注);**预标完成 →「待审核」(review_status=1),人工审核通过才「已标注」(=2)**,训练集打包只收已审核图(prepareYoloSet 质量闸门);工作台弹窗人工画框/确认后保存即视为已审核;管理端对待审核图批量「通过/拒绝」(拒绝 = 清标注回未标注池,并计入对应 App 用户的低质统计,见「标注众包赚时长」) |
+44 -11
View File
@@ -170,6 +170,7 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
Metrics map[string]float64 `json:"metrics"`
Names []string `json:"names"`
BestTflite string `json:"best_tflite"`
BestPt string `json:"best_pt"`
TfliteCheck *struct {
OK bool `json:"ok"`
Reason string `json:"reason"`
@@ -204,6 +205,14 @@ func (s *trainingService) finishSuccess(ctx context.Context, runner common.Train
_ = s.finishFailed(ctx, t, "拉取训练产物失败: %v", err)
return
}
// 增量权重存档(2026-09-09):best.pt 回写 trainings/weights/<基名>.pt 供下次热启动;
// 拉取失败仅记日志不置失败——tflite 才是服务产物,权重缺档下次自动回落全量基座
if res.BestPt != "" {
if err := runner.FetchArtifact(ctx, job, res.BestPt,
common.TrainingWeightsPath(ctx, base)); err != nil {
g.Log().Errorf(ctx, "训练 %d 存档增量权重失败(下次回落全量基座): %+v", t.Id, err)
}
}
// 指标尾部带上类别名,发布时解析 labels
if len(res.Names) > 0 {
if names, err := json.Marshal(res.Names); err == nil {
@@ -237,24 +246,29 @@ func (s *trainingService) finishFailed(ctx context.Context, t *entity.ModelTrain
return dao.Training.Finish(ctx, t.Id, consts.TrainingStatusFailed, "", "", msg)
}
// buildJob 组装训练机路径布局的 runner 任务
// buildJob 组装训练机路径布局的 runner 任务
// 综合任务(kind=combined)数据集 id=0:训练机目录/文件基名 fixed combined,不走数据集表
// prepareAndLaunch 晋级时同一定义;轮询重建沿用,否则 pollOne 永不触达综合任务)
func (s *trainingService) buildJob(ctx context.Context, t *entity.ModelTraining, cfg common.TrainingConfig) (*common.TrainingJob, error) {
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
if err != nil {
return nil, err
jobDsName := consts.TrainingCombinedBase
if t.Kind != consts.TrainingKindCombined {
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
if err != nil {
return nil, err
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
jobDsName = dataset.Name
}
if dataset == nil {
return nil, gerror.NewCode(common.CodeDatasetNotFound)
}
job := &common.TrainingJob{
return &common.TrainingJob{
TaskId: t.Id,
DatasetName: dataset.Name,
DatasetName: jobDsName,
Python: cfg.Python,
Workdir: cfg.Workdir,
DatasetDir: cfg.DatasetDir,
Pid: t.Pid,
}
return job, nil
}, nil
}
// trainingEtaMinutes 预计剩余时长(分钟):running 且已完成 ≥1 轮时按「已用均值 × 剩余轮数」估算
@@ -683,6 +697,7 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
var pkg *common.YoloPackage
var classNames []string
var jobDsName string
var fileBase string // 产物文件基名(tflite 与增量权重共用,n 档 _n 后缀)
if t.Kind == consts.TrainingKindCombined {
ids, err := parseCombinedIds(t.DatasetIds)
if err != nil {
@@ -694,6 +709,10 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
_ = s.finishFailed(ctx, t, "%s", err.Error())
return
}
fileBase = consts.TrainingCombinedBase
if t.Variant == consts.TrainingVariantN {
fileBase += consts.TrainingVariantNFileSuffix
}
pkg, classNames, jobDsName = p, cls, consts.TrainingCombinedBase
} else {
dataset, err := dao.Dataset.GetById(ctx, t.DatasetId)
@@ -713,6 +732,7 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
pkg = p
classNames = localAiClassNames(dataset)
jobDsName = dataset.Name
fileBase = modelFileBaseName(dataset.Name, dataset.NamePrefix, t.Variant)
}
job := &common.TrainingJob{
TaskId: t.Id,
@@ -726,6 +746,19 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
if t.Variant == consts.TrainingVariantN {
model = cfg.ModelN
}
// 增量基座(2026-09-09):上次成功权重存档(trainings/weights/<基名>.pt)存在则推训练机
// 热启动;推送失败同样回落全量基座(训练照跑,日志可查)。删除存档文件即从零重训
if fileBase != "" {
weightLocal := common.TrainingWeightsPath(ctx, fileBase)
if _, err := os.Stat(weightLocal); err == nil {
remoteRel := filepath.ToSlash(filepath.Join("trainings", "weights", fileBase+".pt"))
if err := runner.PushArtifact(ctx, job, remoteRel, weightLocal); err != nil {
g.Log().Errorf(ctx, "训练 %d 推送增量权重失败,回落全量基座: %+v", t.Id, err)
} else {
model = remoteRel
}
}
}
// data.yaml 的 path 指向训练机路径,随包一起同步
trainPath := filepath.Join(cfg.Workdir, cfg.DatasetDir, "yolo", jobDsName)
pkg.Files = append(pkg.Files, common.YoloFile{
+34
View File
@@ -39,6 +39,9 @@ type TrainingRunner interface {
FetchResult(ctx context.Context, job *TrainingJob) (string, error)
// FetchArtifact 把训练机产物文件拉回服务器本地路径
FetchArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error
// PushArtifact 把服务器本地文件推到训练机(增量训练基座权重,2026-09-09;
// remoteName 相对训练机 workdir
PushArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error
// SyncYoloDataset 把训练集包落到训练机(subprocess 直写 workdirssh 走 tar 流式管道,本地不落盘)
SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error
// WriteTaskJson 把任务参数文件写到训练机(随 Start 前的准备阶段调用)
@@ -222,6 +225,15 @@ func (r *subprocessRunner) FetchArtifact(ctx context.Context, job *TrainingJob,
return WriteFileAtomic(localPath, data)
}
func (r *subprocessRunner) PushArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error {
data, err := os.ReadFile(localPath)
if err != nil {
return gerror.Wrap(err, "读取待推送文件失败")
}
dst := filepath.Join(job.Workdir, filepath.FromSlash(remoteName))
return WriteFileAtomic(dst, data)
}
func (r *subprocessRunner) SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error {
// 同机训练:训练进程直接读 workdir 下文件,包直写目标目录(无中间暂存)
dst := filepath.Join(job.Workdir, job.DatasetDir, "yolo", job.DatasetName)
@@ -395,6 +407,28 @@ func (r *sshRunner) FetchArtifact(ctx context.Context, job *TrainingJob, remoteN
return WriteFileAtomic(localPath, data)
}
func (r *sshRunner) PushArtifact(ctx context.Context, job *TrainingJob, remoteName, localPath string) error {
client, err := r.dial(ctx)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
session, err := client.NewSession()
if err != nil {
return err
}
defer func() { _ = session.Close() }()
dst := filepath.Join(job.Workdir, filepath.FromSlash(remoteName))
cmd := fmt.Sprintf("mkdir -p %s && cat > %s", filepath.Dir(dst), dst)
f, err := os.Open(localPath)
if err != nil {
return gerror.Wrap(err, "读取待推送文件失败")
}
defer func() { _ = f.Close() }()
session.Stdin = f
return session.Run(cmd)
}
func (r *sshRunner) SyncYoloDataset(ctx context.Context, job *TrainingJob, pkg *YoloPackage) error {
// tar 流式管道:原图直接读数据集目录打包,stdin 推远端解包,本地不落盘
client, err := r.dial(ctx)
+9
View File
@@ -14,6 +14,9 @@ import (
// datasets/<name>/ 数据集图片(平铺,文件名唯一,标注存 DB dataset_image.labels_json
// trainings/<model>.tflite 某数据集当前生效模型(训练成功即直写,客户端固定下载,无存档回退;
// <model> = 数据集文件名前缀 name_prefix,空则回退数据集名,2026-08-28)
// trainings/weights/<基名>.pt 增量训练基座权重存档(2026-09-09:基名同 tflite——单物种
// <前缀或数据集名>[_n]、综合 combined[_n];下次训练推训练机热启动,
// 删除即回落全量基座)
//
// 训练机与 Go 服务器异机时,数据集经 training 通道同步(见 common/training_runner.go)。
@@ -38,6 +41,12 @@ func TrainingModelPath(ctx context.Context, modelName string) string {
return filepath.Join(DatasetDir(ctx), "trainings", modelName+".tflite")
}
// TrainingWeightsPath 增量训练基座权重存档路径(trainings/weights/<基名>.pt,基名同 tflite 基名;
// 存在则下次训练推训练机热启动,删除即回落全量基座)
func TrainingWeightsPath(ctx context.Context, baseName string) string {
return filepath.Join(DatasetDir(ctx), "trainings", "weights", baseName+".pt")
}
// Sha256Hex 计算文件内容 SHA-256 十六进制(模型版本校验用)
func Sha256Hex(data []byte) (string, error) {
sum := sha256.Sum256(data)
Binary file not shown.
+17 -4
View File
@@ -8,7 +8,9 @@
任务参数(Go 侧写入,字段相对训练机 workdir):
workdir 训练机工作目录(脚本 / yolov8s.pt / venv 所在),启动即 chdir
yolo 训练集目录(含 dataset.yaml),相对 workdir
model 训练基座权重文件名(workdir 下,默认 yolov8s.pt
model 训练基座权重文件名(workdir 下,默认 yolov8s.pt;增量训练时为 Go 侧推到
trainings/weights/<基名>.pt 的上次成功权重,类别数不符时 ultralytics
自动重建检测头、backbone 热启动)
imgsz 训练/导出分辨率(默认 1280,与端侧推理对齐)
epochs 训练轮数
batch 批大小
@@ -19,10 +21,13 @@
产物契约:
log_file {"epoch":1,"total":150,"metrics":{"metrics/mAP50(B)":0.87,...}}
result_file {"metrics":{...},"names":["pheasant","suspect"],"best_tflite":"runs/tasks/<id>/weights/best.tflite",
result_file {"metrics":{...},"names":["pheasant","suspect"],
"best_tflite":"runs/tasks/<id>/train/weights/best.tflite",
"best_pt":"runs/tasks/<id>/train/weights/best.pt",
"tflite_check":{"ok":true,"reason":"","inputs":[...],"outputs":[...]}}
result_file 存在 = 训练完成;异常时写 {"error":"..."}Go 侧据以置失败并展示原因。
tflite_check.ok=false(产物 shape 异常)时 Go 侧置训练失败并带出 reason。
best_pt 供 Go 侧存档 workspace/trainings/weights/ 作下次增量基座(2026-09-09)。
"""
import argparse
@@ -251,7 +256,12 @@ def main():
try:
from ultralytics import YOLO
model = YOLO(task.get("model") or "yolov8s.pt")
# 基座权重相对 workdir(增量训练时为 Go 推送的 trainings/weights/<基名>.pt);
# 仅在 workdir 下确有该文件时绝对化,裸名(yolov8s.pt)缺文件仍走 ultralytics 自下载
model_path = task.get("model") or "yolov8s.pt"
if not os.path.isabs(model_path) and os.path.exists(os.path.join(base, model_path)):
model_path = os.path.join(base, model_path)
model = YOLO(model_path)
register_callback(model)
if is_mps:
def _mps_cache(_trainer):
@@ -321,11 +331,14 @@ def main():
if isinstance(v, (int, float)) and math.isfinite(v):
clean_metrics[k] = round(float(v), 5)
# best_tflite 相对 workdirGo 侧按此路径拉取(不再打包 zip,仅回传 tflite
# best_tflite/best_pt 相对 workdir,Go 侧按此路径拉取(不再打包 zip,仅回传 tflite/pt);
# best_pt 存档 trainings/weights/ 作下次增量基座(不存在不致命,缺省空串)
best_pt = save_dir / "weights" / "best.pt"
write_result(result_file, {
"metrics": clean_metrics,
"names": names,
"best_tflite": os.path.relpath(best_tflite, base),
"best_pt": os.path.relpath(best_pt, base) if best_pt.exists() else "",
"tflite_check": tflite_check,
})
except Exception:
+1
View File
@@ -389,6 +389,7 @@ training:
- **发起训练异步化(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` 后缀区分
- **增量训练(2026-09-09)**:单物种与综合任务(s/n 各自独立 lineage,正负样本全集照常打包)自动热启动——任务基名同 tflite 基名规则(单物种 `<前缀或数据集名>[_n]`、综合 `combined[_n]`),服务器存档 `workspace/trainings/weights/<基名>.pt`:发起晋级时该文件存在则经 runner **PushArtifact**(新增接口:subprocess 本机拷贝 / ssh stdin 管道)推到训练机 `trainings/weights/<基名>.pt`task.json `model` 指向它;不存在回落 config 基座(yolov8s/n.pt,首次全量)。训练脚本 result.json 增 `best_pt` 字段,finishSuccess 拉 tflite 后顺带拉 best.pt 原子覆盖回存档——**pt 拉取失败仅记日志不置失败**(tflite 才是服务产物,权重缺档下次自动回落全量)。类别数/类别名变化无需特判:ultralytics 加载旧权重 nc 不匹配自动重建检测头、backbone 热启动。从零重训 = 删 `trainings/weights/<基名>.pt` 即回落,无独立开关
- 写操作走 `common.Serial()` 单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务
### 模型版本(每数据集每档位一个模型,多模型体系)