From 8c2c9ad77db572e47347643115bba74adda5861a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Wed, 9 Sep 2026 11:36:36 +0800 Subject: [PATCH] 1 --- server/README.md | 2 +- server/biz/model/dto/training.go | 2 + server/biz/service/dataset.go | 1 + server/biz/service/training.go | 17 +++++++++ server/common/training_runner.go | 49 +++++++++++++++++++------ server/config.yml | 8 ++-- server/data/observer.db | Bin 2461696 -> 2461696 bytes server/training/train_server.py | 55 ++++++++++++++++++++++++---- server_admin/src/views/Datasets.vue | 7 ++++ 9 files changed, 118 insertions(+), 23 deletions(-) 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 603b20e346fb0acca0955f7099157269ccdbd6b6..f9b5792bdc4ecdee69af6c9c26b05ba4e74c1b8d 100644 GIT binary patch delta 11625 zcmeHNdu&`+nV);^W8#VJd&l;S?HNDDp{1b>lXLF9=RTY*#DLvLRAf^`_fbT~ZXzfN zu{SHUQny+=6j#tz#2c-TAc(};Hd-V~Vu5N2wAwml+XV`ZNlbIY9GAnzto zZ9ZN&Z)=;JV&3k}*gvxW#{P``7xq4T)Q;^fcJD$*MKf)+WvjN4#U1zKF9yTVF$@e7 z!&=y~HG8&Vz8%vYR*l2i;9YDxK3~K*i#g^nzKZb(#$hbH1tbgWvy8f z5qYuN*=7wIz6;3{r>&A9g_NqVL+>>F5MpLJtZqGwJvDIADe7?$s5~&VyLZnAsu=LZ$t{Xv>PKh`8sg$`34v_AuN0 zKS73cd8C#~I#v*HPt8B$l(30NjofW^>24G%u_M=JimK1Uzs?(NmZ-W?M5^yDGg(X! zhOV0X1sl?Z8>tyfE2(q8bPDR9%X)(z$XG4atRBU8iuKpp!#Kp-UH)63KIJGTG<|kO;1_b z0(9;Q)u+L*E}Trwwd<{F_#wE5=ep|AE^CnaaIp(c*^nHjNNf^86Lp)o>gXA#2zv$4 z_y%&12#!J)QLnvZnG9>rpE2O6k?SY!!Td-aIjNW7r|^ooC!B7mW8!tEUGtUA%yvUa zFQR1>OwK*eo&6!bEnP41P(8-_m(M!n=44}X^8hiBZiUBi+r&phj!-&}h4CoxmD8#Z zAqZru3+W;*`Zn9pC=4}la}FBCheZRLggZrQ@rUqA5y7)2UP&Vk&f}_cA2qQOit03K zg_lGN^AQ1wSs-NTB!u{pKma$G1zkYvOGdVhqBe-bKn>TdA%ugM2CeTy$FNFCj}YGE zoy-l?+_#K^j(Nz%;p`-1_#8`R2t?NLFJxg9Y~~rK3yOy`qzaJ;Mban3JO}^5z&gCp zBYm_UJJ{e5PGul1h_Ly3z=RaukAB>|iA5SGOc#TlK(M zNRXsio`Yw^_&#+sd>Gh}?PgJ+w4Yc-$XvzL0FFSIny5DLgu3SiIEqJBCP36Bf@87C zfa^W6sm0_$WjVc)H}ZUd+b=LU%C0Wc$RQ69l;t1gP8K&r(k zHwc`F)Jlh5W?1>-O-31Ro(39=q?$L$K|MEBqbWGWC14nbgmBgPi$Ff#^VG^_8;FbG z6|X>-0V0y(-2n0cgb8}^*3xd1AXvB@Dmj;)Za$Q9q(28ux>yBiQghYFEYKhU;jj+E zEaGX%`YyaXJtTRQo(C|KP@mhDgEj(Hxjol)>3q~<92w$yqniPdC!aLn4hZx7QW7Tg z`oJlx9nlA1ADGNyBM8HRCkbi-EN1Q`9gY&FPCzG`9jikyLTTWzSuh1o+xn3dc-YgD z1*at3oVviu@JV}f%?#p%n zHF)Bkx&Sz>0#7L2VD`Q&PDN-d@lJ_jcD`y;YydMZt4`-QBAl81hD1CRPSzv_D)R09`B7&iNtxxbx0(J*}?H+^E` z^ohpl6HU`6nvb2Zhue@hg`3+nX;h(B_Cx&ER2%;5i3_g0a(i)7}WAVoeWa znw_SH4vF!<@3F(oU|C~KYt=Ft`}#}&VV}}ozlEvpkzCc?8xpo8e3HX@< zW+(D|B$6}jT9*-pNZsaIwDu4ABH+;$z~Zl>A;2(-rI$V)+q1t8lBfz$Y}Fs%wR=zf zG4vne=l9;TY1jC^v5yj%D$%0*x~Os`o&v|g!F{aU3JQvQ(xY6z3y)+{&gv+j)$)wl zX8P3-Rp-?6Mw{CCVAixFiQM$EOqJKQFezIpaW{mA$^i5l?Ftoo?PF$AWuuBIP#Kc% z(F>MW4SdiE#5%Nai5z#Wz==^5cb^*^8{6SD4(@PFftLU=B1M;ajA}9 zG>leAKk+OM057Rz^%#WCz?)8cK8QWB zR*n|rV&*|dSQA!{m_f~gBAZHgs_GFk2T{=)K|quOu}3j{tZoJrZC99KCCEiOaZd=_ zvVk(MULt!~8j2tWJq>)P8RC+J3E~*+J$Id5$fFVr*Dh_U*9sZgk=QZ#8bl@ap&*(Q zBp-t#l8fje;LTwunlUjp0QI+V#K~H5;2OkfBh*k<9Yp!=#Z~9BHi> z_;5z>ODM|W--~*&4KYpiiLOX$HF5~iOa;9U zk`QDBT!Y6~2us32eRj#{f#%_`3Db=59=@Bb=}DmX^C+cVkcPx%>R^|ZShCt?f94o@ zpwJq<358gIqrnLCPZPP~!Mwy^y+aBYNz>k4fA*&z8{5Bi@4&F+Q<-h8nRk~NA<%{m zO--!nw#;p|TE5ThROhl<_D?dOW{+oP9bnKM*ZxZ72o&@rRhBUrNA6vPg6a1>tj21$RR^f49dT=Buro=>5;&}>l{<3RDs5LJ8xmS$>T<`@~ z0U)S1eD6j09#?z6mU`oUzYMFKbFkl+=8yi%`Ag~if0)ki^}94b(jxh5=lA+0%^$|` zfA+lykf+7CIIPdV@LXg5hSp}YVLsEm*mR(ATkiFSd0h4$X(%0dZ-<@HI<*6G+fwhK?kWbBRB zMtM92FpYe76#SCEE0kevH1o|n|4vQ3Y;~!l)qF!oTuBcaiEBn&zw%W*F1&ty{sWq} z;r?5<S~1!%3K&I0uKcj4A8mB(6}=>9mR6xOe3*RN<% zFRWkDCJFgl?uzzzLUgWP%sSvRGM>2OTfeYHr9}6=>le1` z7q3*cf96q`1qmi<2TNZYu18wv~qD@k{YGgEg^b;sApoGX`)o| zEzdMDP@5AbI@qWq@;MNsK}0=U-)b&ufttAJbhL;-t|cPT=m)};+F$aofm=;lWsVMik7BlK`tZG z)YQxLfx2+2cCp1pVRMD3mEBg9ke2DfK-wqmVQ5kT-}|l{SkgcpIpegWIS?cssg0rK zapw-R*oyWI8C@w36fQ{Q6Hgmu6KzVXlq+$$7tsnS&_A-YJX;BMH%JauMOW=FW4@kWRfX zbX!BcO9`D1_8pC52~`W`a%w0?y-29?>fI~o9CXw6tfbd91m(A5IqABEJ*ck}2AW@_ zcAf~_bF!BR?J@P1NVMcoA0bFlEd0ApTiRC{;EG_|c?Y*XSi-CnXBZNY!?bPPl|oufG5pkq$12y3{|CwLv*iE) delta 6040 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 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] }}