1
This commit is contained in:
+1
-1
@@ -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);改名同步迁移图片目录与模型文件,标注/训练进行中拒绝 |
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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/<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.
@@ -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 未知)")
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user