This commit is contained in:
2026-09-09 11:36:36 +08:00
parent e519e1d71d
commit 8c2c9ad77d
9 changed files with 118 additions and 23 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow
| POST | `/admin/app-versions` | 下发新版本(multipart/form-data):`notes` + `file`APK 文件,仅接受 `.apk`);**版本号从文件名识别**,文件须命名为 `observer-x.y.z.apk`(如 `observer-1.0.1.apk`),格式不符拒绝;版本号不可重复,APK 上传覆盖 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);检测到新版本即强制更新 |
| POST | `/admin/app-versions/delete` | 删除版本记录 `{"id":1}`:删**最新版本**时联动删除 APK 文件(客户端不再提示更新、下载 404);删历史版本只删记录不动文件 |
| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","namePrefix":"pheasant","source":"manual"\|"ai","cover":"<文件名>"}`(name ≤50 字唯一,目录自动建;namePrefix=AI 生成图文件名前缀,生成图按 `<前缀>_<两位序号>.jpg` 顺序命名;物种=数据集名(单物种规则),创建时同步调 VLM(qwen3.6-35b-a3b) 自动生成物种/场景/动作/遮挡/站高/类别名等生成参数池,响应含 `poolsGenerated`/`poolError`——VLM 失败不阻断创建,参数可事后用 gen-pools 补生成;封面优先用 cover 参数(新建对话框预生成封面回传,跳过自动生成),否则参数池成功后自动生成 16:9(1248x704)封面(1 雄 1 雌并排,响应含 `coverGenerated`/`coverError`,失败可在编辑模式重新生成);模型生成图统一转 jpg 落盘) |
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs);**排除负样本库**source=negative,仅「负样本」tab 展示) |
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs/etaMinutes——预计剩余分钟,running 且已完成 ≥1 轮才 >0);**排除负样本库**source=negative,仅「负样本」tab 展示) |
| POST | `/admin/datasets/negative` | 获取负样本库(2026-09-07):不存在则自动创建(source=negative 固定名 `__negative__`),返回数据集记录;其 id 供既有 `/admin/datasets/upload``/admin/datasets/images``/admin/datasets/images/delete``/admin/datasets/image` 复用(负样本无标注/审核/清洗流程) |
| POST | `/admin/datasets/negative/generate` | 负样本批量生成 `{"count":100}`(异步任务,2026-09-07):config.yml 内置场景池按序循环组装提示词(negativeScenes 空场景 + negativeHumans 人物/衣物两池两模板;**动物不进统一负样本**——它们将来可能是正式识别目标),空场景图**自动过 RF-DETR 空检、有检出即剔除不入库**(人物图不做空检);进度复用 GET `/admin/datasets/gen-task`(rejected=剔除数),入口在管理端「负样本」tab |
| POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"name":"新名","namePrefix":"pheasant","description":"...","cover":"a.jpg"}`:名称(改名)/文件名前缀/描述/封面,空值字段不覆盖原值;gen_* 生成参数池不在此维护(仅 VLM 生成,见 gen-pools);改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 |
+2
View File
@@ -30,6 +30,7 @@ type AdminDatasetTrainBrief struct {
Published bool `json:"published"` // 该训练是否已发布过版本(已发布不再显示发布按钮)
CurrentEpoch int `json:"currentEpoch"`
TotalEpochs int `json:"totalEpochs"`
EtaMinutes int `json:"etaMinutes"` // 预计剩余分钟(running 且已完成 ≥1 轮才 >0,按已用均值×剩余轮数估算)
}
// AdminDatasetItem 数据集条目(卡片展示用;AI 端点/训练机 SSH 走 config.yml 全局配置(localAi / training.ssh);
@@ -306,6 +307,7 @@ type AdminTrainingItem struct {
Device string `json:"device"`
CurrentEpoch int `json:"currentEpoch"`
TotalEpochs int `json:"totalEpochs"`
EtaMinutes int `json:"etaMinutes"` // 预计剩余分钟(running 且已完成 ≥1 轮才 >0
Metrics string `json:"metrics"`
Error string `json:"error"`
StartedAt *gtime.Time `json:"startedAt"`
+1
View File
@@ -120,6 +120,7 @@ func (s *datasetService) AdminListDatasets(ctx context.Context, req *dto.AdminDa
Published: published[t.Id],
CurrentEpoch: t.CurrentEpoch,
TotalEpochs: t.TotalEpochs,
EtaMinutes: trainingEtaMinutes(t),
})
}
items = append(items, item)
+17
View File
@@ -243,10 +243,24 @@ func (s *trainingService) buildJob(ctx context.Context, t *entity.ModelTraining,
Python: cfg.Python,
Workdir: cfg.Workdir,
DatasetDir: cfg.DatasetDir,
Pid: t.Pid,
}
return job, nil
}
// trainingEtaMinutes 预计剩余时长(分钟):running 且已完成 ≥1 轮时按「已用均值 × 剩余轮数」估算
// (已用含打包/同步开销,随轮数增加自行摊薄,前几轮偏悲观);其余情况返回 0(页面不展示)
func trainingEtaMinutes(t *entity.ModelTraining) int {
if t.Status != consts.TrainingStatusRunning || t.CurrentEpoch < 1 || t.StartedAt == nil {
return 0
}
if t.TotalEpochs <= t.CurrentEpoch {
return 0
}
per := time.Since(t.StartedAt.Time) / time.Duration(t.CurrentEpoch)
return int((time.Duration(t.TotalEpochs-t.CurrentEpoch) * per).Minutes()) + 1
}
// parseEpochTail 从日志尾部解析最后一条 epoch 进度行({"epoch":N,"total":M,"metrics":{...}}
func parseEpochTail(tail string) (epoch, total int, metrics string) {
lines := strings.Split(tail, "\n")
@@ -311,6 +325,7 @@ func (s *trainingService) AdminListTrainings(ctx context.Context, req *dto.Admin
Device: v.Device,
CurrentEpoch: v.CurrentEpoch,
TotalEpochs: v.TotalEpochs,
EtaMinutes: trainingEtaMinutes(v),
Metrics: v.Metrics,
Error: v.Error,
StartedAt: v.StartedAt,
@@ -544,6 +559,7 @@ func (s *trainingService) prepareAndLaunch(ctx context.Context, taskId int64) {
Python: cfg.Python,
Workdir: cfg.Workdir,
DatasetDir: cfg.DatasetDir,
Pid: t.Pid,
}
model := cfg.Model
if t.Variant == consts.TrainingVariantN {
@@ -645,6 +661,7 @@ func (s *trainingService) AdminTrainingDetail(ctx context.Context, req *dto.Admi
Device: t.Device,
CurrentEpoch: t.CurrentEpoch,
TotalEpochs: t.TotalEpochs,
EtaMinutes: trainingEtaMinutes(t),
Metrics: t.Metrics,
Error: t.Error,
StartedAt: t.StartedAt,
+38 -11
View File
@@ -66,6 +66,7 @@ type TrainingJob struct {
Python string // 训练机 venv python 路径
Workdir string // 训练机工作目录(train_server.py / yolov8s.pt 所在)
DatasetDir string // 训练机数据集根目录(相对 workdir)
Pid int // DB 持久化的进程 pidGo 重启后 subprocess 内存 map 丢失,按此兜底探活/取消)
}
// Runner 按 config.yml training.mode 返回训练通道(未配置返回 nil,调用方判 CodeTrainingNotConfigured
@@ -128,7 +129,18 @@ func (r *subprocessRunner) Start(ctx context.Context, job *TrainingJob) (int, er
cmd.Dir = job.Workdir
// 独立进程组:取消时 kill 整个组,连带 ultralytics 的子进程
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// stdout/stderr 落盘:进程异常退出时 traceback 可查(此前被吞,失败零证据)
_ = os.MkdirAll(filepath.Join(job.Workdir, "logs"), 0o755)
out, err := os.OpenFile(filepath.Join(job.Workdir, "logs", fmt.Sprintf("%d.out", job.TaskId)),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err == nil {
cmd.Stdout = out
cmd.Stderr = out
}
if err := cmd.Start(); err != nil {
if out != nil {
_ = out.Close()
}
return 0, gerror.Wrap(err, "启动训练进程失败")
}
r.mu.Lock()
@@ -137,6 +149,17 @@ func (r *subprocessRunner) Start(ctx context.Context, job *TrainingJob) (int, er
}
r.cmds[job.TaskId] = cmd
r.mu.Unlock()
// Wait 收尸防僵尸进程(轮询模式不 Wait 的话,退出的子进程成 zombie、Signal(0) 恒成功);
// 收尸后删 mapIsAlive 落到 job.Pid 兜底探测
go func() {
_ = cmd.Wait()
if out != nil {
_ = out.Close()
}
r.mu.Lock()
delete(r.cmds, job.TaskId)
r.mu.Unlock()
}()
return cmd.Process.Pid, nil
}
@@ -144,28 +167,32 @@ func (r *subprocessRunner) IsAlive(ctx context.Context, job *TrainingJob) (bool,
r.mu.Lock()
cmd := r.cmds[job.TaskId]
r.mu.Unlock()
if cmd == nil || cmd.Process == nil {
return false, nil
}
err := cmd.Process.Signal(syscall.Signal(0))
if err != nil {
if err == os.ErrProcessDone {
return false, nil
if cmd != nil && cmd.Process != nil {
if err := cmd.Process.Signal(syscall.Signal(0)); err == nil {
return true, nil
}
return false, nil
}
return true, nil
// Go 重启后内存 map 丢失但训练进程仍在跑:按 DB 持久化 pid 探活(负 pid = 进程组)
if job.Pid > 0 {
return syscall.Kill(-job.Pid, syscall.Signal(0)) == nil, nil
}
return false, nil
}
func (r *subprocessRunner) Cancel(ctx context.Context, job *TrainingJob) error {
r.mu.Lock()
cmd := r.cmds[job.TaskId]
r.mu.Unlock()
if cmd == nil || cmd.Process == nil {
if cmd != nil && cmd.Process != nil {
// 杀进程组(负 pid),覆盖 python + ultralytics 子进程
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
return nil
}
// 杀进程组(负 pid),覆盖 python + ultralytics 子进程
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
// Go 重启后兜底:按 DB pid 杀进程
if job.Pid > 0 {
_ = syscall.Kill(-job.Pid, syscall.SIGKILL)
}
return nil
}
+4 -4
View File
@@ -97,7 +97,7 @@ imageGen:
# 轮询在 running 结束后自动按创建顺序晋级启动,一次一个):
# mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步)
training:
mode: ssh # subprocess | ssh
mode: subprocess # subprocess | ssh2026-09-09 本机 M1 训练:subprocess;正式训练机切回 ssh
ssh:
# host: "192.168.3.210" # 训练机地址(mode=ssh 必填)
host: "192.168.3.210"
@@ -105,8 +105,8 @@ training:
port: 22
privateKeyPath: "" # 私钥路径与 password 二选一
password: "123"
workdir: /opt/pheasant_data # 训练机工作目录(train_server.py / yolov8s.pt / yolov8n.pt 所在)
venvPython: /opt/pheasant_data/venv/bin/python
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 小时)
# 双档位(2026-09-03 用户定案):s 档(高识别,默认)与 n 档(高性能)各自基座/分辨率;
@@ -118,7 +118,7 @@ training:
imgszN: 704 # n 档训练/导出分辨率(704² 端侧计算量约 s@1280 的 1/8,速度优先)
epochs: 150 # 训练轮数(patience 30 早停,设大可自动停)
batch: 16 # 批大小(按训练机显存调整)
device: "0" # GPU 编号(cpu 用 cpu
device: "mps" # GPU 编号(cpu 用 cpu;M1 本机用 mps,训练机切回时改回 "0"
# RF-DETR 预标注服务(管理端标注工作台):需从服务器可达,未配置时预标注接口返回「标注服务未配置」
localAi:
Binary file not shown.
+48 -7
View File
@@ -34,6 +34,10 @@ import sys
import traceback
from pathlib import Path
# MPSApple Silicon)训练:默认水位上限把可用统一内存压在 ~2/3 且缓存不归还,16GB 机器易 OOM;
# 须在 torch 加载前取消上限,配合每 epoch 手动 empty_cache 归还(Go 侧 device=mps 时生效)
os.environ.setdefault("PYTORCH_MPS_HIGH_WATERMARK_RATIO", "0.0")
# 抗尺度漂移(与人工训练基线一致):随机缩放输入 0.5~1.5x
MULTI_SCALE = 0.5
# 早停耐心(连续 N 轮无提升即停)
@@ -241,6 +245,7 @@ def main():
epochs = int(task.get("epochs") or 150)
batch = int(task.get("batch") or 16)
device = task.get("device") or "0"
is_mps = str(device).strip().lower() == "mps"
data = os.path.join(task["yolo"], "dataset.yaml")
names = read_names(task["yolo"])
@@ -248,13 +253,49 @@ def main():
from ultralytics import YOLO
model = YOLO(task.get("model") or "yolov8s.pt")
register_callback(model)
model.train(
data=data, imgsz=imgsz, epochs=epochs,
patience=PATIENCE, batch=batch, device=device, workers=4,
# project 必须绝对路径,相对路径会被拼到默认 runs/detect 下造成双层嵌套
project=task_path("project"), name="train",
exist_ok=True, plots=True, multi_scale=MULTI_SCALE,
)
if is_mps:
def _mps_cache(_trainer):
# MPS 分配器缓存不自动归还,每 epoch 清一次防碎片化 OOM
try:
import torch
torch.mps.empty_cache()
except Exception:
pass
model.add_callback("on_fit_epoch_end", _mps_cache)
# batch OOM 自适应:统一内存/显存不足时 batch 减半重试(最低 1),速度换可跑
cur_batch = batch
while True:
try:
model.train(
data=data, imgsz=imgsz, epochs=epochs,
patience=PATIENCE, batch=cur_batch, device=device,
# macOS dataloader worker 为 spawn 子进程、各占一份内存,MPS 直接不用
workers=0 if is_mps else 4,
# project 必须绝对路径,相对路径会被拼到默认 runs/detect 下造成双层嵌套
project=task_path("project"), name="train",
exist_ok=True, plots=True,
# 多尺度随机放大输入(最高 1.5x imgsz)是 MPS 内存尖峰来源,关闭
multi_scale=0 if is_mps else MULTI_SCALE,
)
break
except Exception as e:
low = f"{e}".lower()
oom = ("out of memory" in low
or "outofmemory" in type(e).__name__.lower()
or ("mps" in low and "memory" in low))
if not oom or cur_batch <= 1:
raise
cur_batch = max(1, cur_batch // 2)
print(f"[train] 内存不足(OOM),batch 降为 {cur_batch} 重试", file=sys.stderr)
try:
import torch
if torch.backends.mps.is_available():
torch.mps.empty_cache()
elif torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
trainer = getattr(model, "trainer", None)
if trainer is None:
raise RuntimeError("训练完成但无法获取 trainersave_dir 未知)")
+7
View File
@@ -537,6 +537,12 @@ function trainPercent(t) {
return Math.min(100, Math.round((t.currentEpoch / t.totalEpochs) * 100))
}
// 预计剩余时长文案:≥90 分钟按小时展示(服务端按已用均值×剩余轮数估算,随轮数增加趋准)
function etaText(mins) {
if (mins >= 90) return `${(mins / 60).toFixed(1)} 小时`
return `${mins} 分钟`
}
// 发布模型版本(按训练任务档位:s/n 各自的生效版本互不影响,版本号数据集内共用自增)
function publishTrain(row, t) {
const v = variantMeta[t.variant]
@@ -657,6 +663,7 @@ onBeforeUnmount(() => {
<template v-if="t.status === 'running'">
<el-progress :percentage="trainPercent(t)" :stroke-width="6" :show-text="false" class="ds-train-bar" />
<span class="ds-train-text">{{ t.currentEpoch || 0 }}/{{ t.totalEpochs || '-' }} </span>
<span v-if="t.etaMinutes" class="ds-train-text">· 剩余约 {{ etaText(t.etaMinutes) }}</span>
</template>
<el-tooltip v-else-if="t.status === 'failed' && t.error" :content="t.error" placement="top">
<el-tag :type="trainStatusTag[t.status] || 'info'" size="small">{{ trainStatusMap[t.status] }}</el-tag>