diff --git a/server/README.md b/server/README.md index 54d3a1e..d5f5805 100644 --- a/server/README.md +++ b/server/README.md @@ -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);改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 | diff --git a/server/biz/model/dto/training.go b/server/biz/model/dto/training.go index c538b46..feface8 100644 --- a/server/biz/model/dto/training.go +++ b/server/biz/model/dto/training.go @@ -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"` diff --git a/server/biz/service/dataset.go b/server/biz/service/dataset.go index be07231..e8ceb51 100644 --- a/server/biz/service/dataset.go +++ b/server/biz/service/dataset.go @@ -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) diff --git a/server/biz/service/training.go b/server/biz/service/training.go index 5994a7a..22340bc 100644 --- a/server/biz/service/training.go +++ b/server/biz/service/training.go @@ -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, diff --git a/server/common/training_runner.go b/server/common/training_runner.go index a7f0ca2..1b44ce9 100644 --- a/server/common/training_runner.go +++ b/server/common/training_runner.go @@ -66,6 +66,7 @@ type TrainingJob struct { Python string // 训练机 venv python 路径 Workdir string // 训练机工作目录(train_server.py / yolov8s.pt 所在) DatasetDir string // 训练机数据集根目录(相对 workdir) + Pid int // DB 持久化的进程 pid(Go 重启后 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) 恒成功); + // 收尸后删 map,IsAlive 落到 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 } diff --git a/server/config.yml b/server/config.yml index 7d02853..8ad172a 100644 --- a/server/config.yml +++ b/server/config.yml @@ -97,7 +97,7 @@ imageGen: # 轮询在 running 结束后自动按创建顺序晋级启动,一次一个): # mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步) training: - mode: ssh # subprocess | ssh + mode: subprocess # subprocess | ssh(2026-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 # 训练机数据集根目录(相对 workdir,yolo/ 为子目录) 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: diff --git a/server/data/observer.db b/server/data/observer.db index 603b20e..f9b5792 100644 Binary files a/server/data/observer.db and b/server/data/observer.db differ diff --git a/server/training/train_server.py b/server/training/train_server.py index 44c9f20..cfe6edb 100644 --- a/server/training/train_server.py +++ b/server/training/train_server.py @@ -34,6 +34,10 @@ import sys import traceback from pathlib import Path +# MPS(Apple 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("训练完成但无法获取 trainer(save_dir 未知)") diff --git a/server_admin/src/views/Datasets.vue b/server_admin/src/views/Datasets.vue index e0d3b2d..a2fb301 100644 --- a/server_admin/src/views/Datasets.vue +++ b/server_admin/src/views/Datasets.vue @@ -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(() => { {{ trainStatusMap[t.status] }}