Pārlūkot izejas kodu

初始化项目

eric.w 2 nedēļas atpakaļ
vecāks
revīzija
a29165657a
23 mainītis faili ar 1554 papildinājumiem un 0 dzēšanām
  1. 202 0
      BridgeDiseaseBackend-main/app/routes/training_route.py
  2. 0 0
      BridgeDiseaseBackend-main/app/services/__init__.py
  3. 510 0
      BridgeDiseaseBackend-main/app/services/training_manager.py
  4. 104 0
      BridgeDiseaseBackend-main/docs/TRAINING_DATASET.md
  5. 116 0
      BridgeDiseaseBackend-main/scripts/create_demo_dataset.py
  6. 90 0
      BridgeDiseaseBackend-main/scripts/seed_models.py
  7. BIN
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo.zip
  8. 9 0
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/data.yaml
  9. BIN
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/images/demo_001.jpg
  10. BIN
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/images/demo_002.jpg
  11. BIN
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/images/demo_003.jpg
  12. BIN
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/images/demo_004.jpg
  13. 1 0
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/labels/demo_001.txt
  14. 1 0
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/labels/demo_002.txt
  15. 1 0
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/labels/demo_003.txt
  16. 1 0
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/labels/demo_004.txt
  17. BIN
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/val/images/demo_005.jpg
  18. BIN
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/val/images/demo_006.jpg
  19. 1 0
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/val/labels/demo_005.txt
  20. 1 0
      BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/val/labels/demo_006.txt
  21. 46 0
      BridgeDiseaseBackend-main/training_templates/data.yaml.example
  22. 455 0
      bridge-disease-frontend-main/src/views/ModelTrainingView.vue
  23. 16 0
      scripts/start-backend.ps1

+ 202 - 0
BridgeDiseaseBackend-main/app/routes/training_route.py

@@ -0,0 +1,202 @@
+import os
+import time
+
+from flask import request, jsonify, current_app
+from flask_jwt_extended import jwt_required, get_jwt_identity
+
+from app.constants import OperationType, UserRole
+from app.decorators import login_required
+from app.models import Operation, User
+from app.routes import training_routes
+from app.services import training_manager as tm
+from app.utils import handle_operation_failure, handle_operation_success
+
+
+def _jwt_user():
+    uid = get_jwt_identity()
+    try:
+        pk = int(uid) if uid is not None else None
+    except (TypeError, ValueError):
+        pk = None
+    return User.query.get(pk) if pk is not None else None
+
+
+def _role_is_trainer(role) -> bool:
+    if role is None:
+        return False
+    if isinstance(role, UserRole):
+        return role in (UserRole.DEVELOPER, UserRole.ADMIN)
+    name = str(getattr(role, 'name', role)).upper()
+    value = str(getattr(role, 'value', role)).lower()
+    return name in ('ADMIN', 'DEVELOPER') or value in ('admin', 'developer')
+
+
+def _require_trainer(user):
+    if not _role_is_trainer(getattr(user, 'role', None)):
+        return jsonify({'message': '仅管理员或开发人员可访问模型训练功能'}), 403
+    return None
+
+
+@training_routes.route('/base-models', methods=['GET'])
+@jwt_required()
+@login_required
+def base_models():
+    user = _jwt_user()
+    denied = _require_trainer(user)
+    if denied:
+        return denied
+    return jsonify({'base_models': tm.list_base_models()}), 200
+
+
+@training_routes.route('/datasets', methods=['GET'])
+@jwt_required()
+@login_required
+def list_datasets():
+    user = _jwt_user()
+    denied = _require_trainer(user)
+    if denied:
+        return denied
+    return jsonify({'datasets': tm.list_datasets(current_app)}), 200
+
+
+@training_routes.route('/datasets/upload', methods=['POST'])
+@jwt_required()
+@login_required
+def upload_dataset():
+    start_time = time.time()
+    current_user = _jwt_user()
+    current_user_id = current_user.user_id if current_user else None
+
+    denied = _require_trainer(current_user)
+    if denied:
+        return denied
+
+    new_operation = Operation(
+        operation_type=OperationType.CREATE,
+        description='上传训练数据集',
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    zip_file = request.files.get('dataset_zip')
+    display_name = (request.form.get('name') or '').strip()
+
+    if not zip_file:
+        msg = '【上传数据集失败】未选择 ZIP 文件'
+        new_operation = handle_operation_failure(new_operation, start_time, msg, current_user_id)
+        return jsonify({'operation': new_operation.to_dict(), 'message': msg}), 400
+
+    max_size = current_app.config.get('MAX_DATASET_ZIP_SIZE', 500 * 1024 * 1024)
+    zip_file.seek(0, os.SEEK_END)
+    size = zip_file.tell()
+    zip_file.seek(0)
+    if size > max_size:
+        msg = '【上传数据集失败】文件超过 500MB 限制'
+        new_operation = handle_operation_failure(new_operation, start_time, msg, current_user_id)
+        return jsonify({'operation': new_operation.to_dict(), 'message': msg}), 400
+
+    try:
+        item = tm.upload_dataset(
+            current_app,
+            zip_file,
+            display_name,
+            current_user_id,
+            current_user.username,
+        )
+    except ValueError as exc:
+        msg = f'【上传数据集失败】{exc}'
+        new_operation = handle_operation_failure(new_operation, start_time, msg, current_user_id)
+        return jsonify({'operation': new_operation.to_dict(), 'message': msg}), 400
+
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'dataset': item,
+    }), 201
+
+
+@training_routes.route('/jobs', methods=['GET'])
+@jwt_required()
+@login_required
+def list_jobs():
+    user = _jwt_user()
+    denied = _require_trainer(user)
+    if denied:
+        return denied
+    return jsonify({'jobs': tm.list_jobs(current_app)}), 200
+
+
+@training_routes.route('/jobs/<job_id>/resume', methods=['POST'])
+@jwt_required()
+@login_required
+def resume_job_route(job_id):
+    user = _jwt_user()
+    denied = _require_trainer(user)
+    if denied:
+        return denied
+    try:
+        job = tm.resume_job(current_app, job_id)
+    except ValueError as exc:
+        return jsonify({'message': str(exc)}), 400
+    return jsonify({'job': job}), 200
+
+
+@training_routes.route('/jobs/<job_id>', methods=['GET'])
+@jwt_required()
+@login_required
+def job_detail(job_id):
+    user = _jwt_user()
+    denied = _require_trainer(user)
+    if denied:
+        return denied
+    job = tm.get_job(current_app, job_id)
+    if not job:
+        return jsonify({'message': '训练任务不存在'}), 404
+    return jsonify({'job': job}), 200
+
+
+@training_routes.route('/jobs', methods=['POST'])
+@jwt_required()
+@login_required
+def start_job():
+    start_time = time.time()
+    current_user = _jwt_user()
+    current_user_id = current_user.user_id if current_user else None
+
+    denied = _require_trainer(current_user)
+    if denied:
+        return denied
+
+    data = request.get_json() or {}
+    new_operation = Operation(
+        operation_type=OperationType.EXECUTE,
+        description='启动 YOLO 模型训练',
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    try:
+        job = tm.start_job(
+            current_app,
+            owner_id=current_user_id,
+            owner_username=current_user.username,
+            dataset_id=data.get('dataset_id'),
+            base_model=data.get('base_model', 'yolov8n-seg.pt'),
+            disease_category=data.get('disease_category', ''),
+            output_model_name=data.get('output_model_name', ''),
+            epochs=data.get('epochs', 50),
+            imgsz=data.get('imgsz', 640),
+            batch=data.get('batch', 8),
+            augmentation=data.get('augmentation', '随机点+颜色扭曲+高斯模糊'),
+            register_to_library=data.get('register_to_library', True),
+        )
+    except ValueError as exc:
+        msg = f'【启动训练失败】{exc}'
+        new_operation = handle_operation_failure(new_operation, start_time, msg, current_user_id)
+        return jsonify({'operation': new_operation.to_dict(), 'message': msg}), 400
+
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'job': job,
+    }), 201

+ 0 - 0
BridgeDiseaseBackend-main/app/services/__init__.py


+ 510 - 0
BridgeDiseaseBackend-main/app/services/training_manager.py

@@ -0,0 +1,510 @@
+"""YOLOv8 分割模型训练任务管理(后台线程 + 本地 JSON 持久化)。"""
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import threading
+import traceback
+import uuid
+import zipfile
+from datetime import datetime
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import torch
+import yaml
+from ultralytics import YOLO
+from werkzeug.utils import secure_filename
+
+from app.models import Model, db
+
+TZ = ZoneInfo('Asia/Shanghai')
+
+BASE_MODELS = [
+    {'id': 'yolov8n-seg.pt', 'label': 'YOLOv8n-seg(轻量,适合试跑)'},
+    {'id': 'yolov8s-seg.pt', 'label': 'YOLOv8s-seg(均衡)'},
+    {'id': 'yolov8m-seg.pt', 'label': 'YOLOv8m-seg(精度更高,耗时更长)'},
+]
+
+_lock = threading.Lock()
+_app = None
+
+
+def init_training_manager(app):
+    global _app
+    _app = app
+    for key in ('DATASETS_FOLDER', 'TRAINING_RUNS_FOLDER', 'TRAINING_META_FOLDER'):
+        path = app.config[key]
+        os.makedirs(path, exist_ok=True)
+    os.makedirs(app.config['MODELS_FOLDER'], exist_ok=True)
+    resume_stale_jobs(app)
+
+
+def _app_for_thread(app):
+    """Flask 应用代理需转为真实对象,否则后台训练线程可能无法执行。"""
+    return app._get_current_object() if hasattr(app, '_get_current_object') else app
+
+
+def _start_training_thread(app, job_id: str):
+    real_app = _app_for_thread(app)
+    threading.Thread(target=_run_training, args=(real_app, job_id), daemon=True).start()
+
+
+def _watch_pending_job(app, job_id: str):
+    """若数秒后仍为 pending 且无日志,自动重试拉起训练线程。"""
+    import time
+
+    time.sleep(2)
+    job = get_job(app, job_id)
+    if job and job.get('status') == 'pending' and not job.get('logs'):
+        app.logger.warning('训练任务 %s 未真正启动,正在重试', job_id)
+        _start_training_thread(app, job_id)
+
+
+def resume_stale_jobs(app):
+    """后端启动后恢复 pending 任务(进程重启会导致后台训练线程丢失)。"""
+    if any(j.get('status') == 'running' for j in list_jobs(app)):
+        return
+    for job in list_jobs(app):
+        if job.get('status') != 'pending':
+            continue
+        job_id = job['job_id']
+        app.logger.info('恢复排队中的训练任务:%s', job_id)
+        _start_training_thread(app, job_id)
+        return
+
+
+def resume_job(app, job_id: str) -> dict:
+    """手动恢复 pending 任务(供前端刷新或 API 调用)。"""
+    job = get_job(app, job_id)
+    if not job:
+        raise ValueError('训练任务不存在')
+    if job.get('status') == 'running':
+        return job
+    if job.get('status') in ('completed', 'failed'):
+        raise ValueError(f"任务已结束({job.get('status')}),无法恢复")
+    if any(j.get('status') == 'running' for j in list_jobs(app)):
+        raise ValueError('已有训练任务进行中,请等待完成')
+    _start_training_thread(app, job_id)
+    threading.Thread(target=_watch_pending_job, args=(_app_for_thread(app), job_id), daemon=True).start()
+    return get_job(app, job_id) or job
+
+
+def _meta_path(app, name: str) -> Path:
+    return Path(app.config['TRAINING_META_FOLDER']) / name
+
+
+def _json_default(obj):
+    if isinstance(obj, datetime):
+        return obj.isoformat()
+    raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')
+
+
+def _load_json(app, name: str, default):
+    with _lock:
+        path = _meta_path(app, name)
+        if not path.is_file():
+            return default
+        for candidate in (path, path.with_suffix('.json.bak')):
+            if not candidate.is_file():
+                continue
+            try:
+                with open(candidate, encoding='utf-8') as f:
+                    return json.load(f)
+            except (json.JSONDecodeError, OSError):
+                continue
+        return default
+
+
+def _save_json(app, name: str, data):
+    with _lock:
+        path = _meta_path(app, name)
+        path.parent.mkdir(parents=True, exist_ok=True)
+        if path.is_file():
+            bak = path.with_suffix('.json.bak')
+            try:
+                shutil.copy2(path, bak)
+            except OSError:
+                pass
+        tmp = path.with_suffix('.json.tmp')
+        payload = json.dumps(data, ensure_ascii=False, indent=2, default=_json_default)
+        tmp.write_text(payload, encoding='utf-8')
+        tmp.replace(path)
+
+
+def _now_iso():
+    return datetime.now(TZ).isoformat()
+
+
+def list_base_models():
+    return BASE_MODELS
+
+
+def _resolve_data_yaml(data_yaml: str) -> str:
+    """将 data.yaml 的 path 固定为数据集根目录绝对路径,避免 path: . 被解析到错误工作目录。"""
+    src = Path(data_yaml).resolve()
+    if not src.is_file():
+        raise FileNotFoundError(f'未找到 data.yaml:{data_yaml}')
+    root = src.parent
+    with open(src, encoding='utf-8') as f:
+        cfg = yaml.safe_load(f) or {}
+    cfg['path'] = str(root)
+    resolved = root / '_dockscope_data.yaml'
+    with open(resolved, 'w', encoding='utf-8') as f:
+        yaml.dump(cfg, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
+    return str(resolved)
+
+
+def _ensure_base_weights(app, base_model: str) -> Path:
+    models_dir = Path(app.config['MODELS_FOLDER'])
+    target = models_dir / base_model
+    if target.is_file():
+        return target
+    YOLO(base_model)
+    if not target.is_file():
+        downloaded = list(models_dir.glob(base_model))
+        if downloaded:
+            return downloaded[0]
+        cwd_candidate = Path.cwd() / base_model
+        if cwd_candidate.is_file():
+            shutil.copy2(cwd_candidate, target)
+    if not target.is_file():
+        raise FileNotFoundError(f'无法获取基线权重 {base_model},请检查网络或手动放入 models 目录')
+    return target
+
+
+def _find_data_yaml(root: Path) -> Path | None:
+    for name in ('data.yaml', 'dataset.yaml'):
+        for p in root.rglob(name):
+            return p
+    return None
+
+
+def list_datasets(app):
+    return _load_json(app, 'datasets.json', [])
+
+
+def get_dataset(app, dataset_id: str):
+    for item in list_datasets(app):
+        if item['dataset_id'] == dataset_id:
+            return item
+    return None
+
+
+def upload_dataset(app, zip_file, display_name: str, owner_id: int, owner_username: str):
+    if not zip_file or not zip_file.filename.lower().endswith('.zip'):
+        raise ValueError('请上传 YOLO 格式数据集的 ZIP 压缩包')
+
+    dataset_id = uuid.uuid4().hex[:12]
+    root = Path(app.config['DATASETS_FOLDER']) / dataset_id
+    root.mkdir(parents=True, exist_ok=True)
+
+    zip_path = root / secure_filename(zip_file.filename)
+    zip_file.save(zip_path)
+
+    try:
+        with zipfile.ZipFile(zip_path, 'r') as zf:
+            zf.extractall(root / 'extracted')
+    except zipfile.BadZipFile as exc:
+        shutil.rmtree(root, ignore_errors=True)
+        raise ValueError('ZIP 文件损坏或格式无效') from exc
+    finally:
+        if zip_path.is_file():
+            zip_path.unlink()
+
+    extracted = root / 'extracted'
+    data_yaml = _find_data_yaml(extracted)
+    if not data_yaml:
+        shutil.rmtree(root, ignore_errors=True)
+        raise ValueError('压缩包内未找到 data.yaml / dataset.yaml,请使用 Ultralytics 标准目录结构')
+
+    try:
+        with open(data_yaml, encoding='utf-8') as f:
+            yaml.safe_load(f)
+    except Exception as exc:
+        shutil.rmtree(root, ignore_errors=True)
+        raise ValueError(f'data.yaml 解析失败:{exc}') from exc
+
+    item = {
+        'dataset_id': dataset_id,
+        'name': display_name or data_yaml.parent.name,
+        'data_yaml': str(data_yaml.resolve()),
+        'root_path': str(extracted.resolve()),
+        'owner_id': owner_id,
+        'owner_username': owner_username,
+        'created_at': _now_iso(),
+    }
+    datasets = list_datasets(app)
+    datasets.insert(0, item)
+    _save_json(app, 'datasets.json', datasets)
+    return item
+
+
+def list_jobs(app):
+    jobs = _load_json(app, 'jobs.json', [])
+    return sorted(jobs, key=lambda j: j.get('created_at', ''), reverse=True)
+
+
+def get_job(app, job_id: str):
+    for job in list_jobs(app):
+        if job['job_id'] == job_id:
+            return job
+    return None
+
+
+def _update_job(app, job_id: str, **fields):
+    jobs = list_jobs(app)
+    for job in jobs:
+        if job['job_id'] == job_id:
+            job.update(fields)
+            job['updated_at'] = _now_iso()
+            _save_json(app, 'jobs.json', jobs)
+            return job
+    return None
+
+
+def _append_log(app, job_id: str, line: str):
+    job = get_job(app, job_id)
+    if not job:
+        return
+    logs = job.get('logs') or []
+    logs.append(f"[{datetime.now(TZ).strftime('%H:%M:%S')}] {line}")
+    if len(logs) > 500:
+        logs = logs[-500:]
+    _update_job(app, job_id, logs=logs)
+
+
+def _parse_results_csv(csv_path: Path) -> dict:
+    if not csv_path.is_file():
+        return {}
+    try:
+        import csv
+
+        with open(csv_path, encoding='utf-8') as f:
+            rows = list(csv.DictReader(f))
+        if not rows:
+            return {}
+        last = rows[-1]
+        return {
+            'box_p': float(last.get('metrics/precision(B)', 0) or 0),
+            'box_r': float(last.get('metrics/recall(B)', 0) or 0),
+            'box_mAP50': float(last.get('metrics/mAP50(B)', 0) or 0),
+            'box_mAP50_95': float(last.get('metrics/mAP50-95(B)', 0) or 0),
+            'mask_p': float(last.get('metrics/precision(M)', 0) or 0),
+            'mask_r': float(last.get('metrics/recall(M)', 0) or 0),
+            'mask_mAP50': float(last.get('metrics/mAP50(M)', 0) or 0),
+            'mask_mAP50_95': float(last.get('metrics/mAP50-95(M)', 0) or 0),
+        }
+    except Exception:
+        return {}
+
+
+def _register_model(app, job: dict, weights_path: Path, metrics: dict):
+    model_name = job['output_model_name']
+    if not model_name.endswith('.pt'):
+        model_name = f'{model_name}.pt'
+    dest = Path(app.config['MODELS_FOLDER']) / model_name
+    shutil.copy2(weights_path, dest)
+    rel_path = f'static/models/{model_name}'.replace('\\', '/')
+
+    if Model.query.filter_by(model_name=model_name).first():
+        model_name = f"{Path(model_name).stem}_{job['job_id'][:6]}.pt"
+        dest = Path(app.config['MODELS_FOLDER']) / model_name
+        shutil.copy2(weights_path, dest)
+        rel_path = f'static/models/{model_name}'.replace('\\', '/')
+
+    m = YOLO(str(dest))
+    layers = 0
+    params = 0
+    try:
+        layers = len(list(m.model.modules()))
+        params = sum(p.numel() for p in m.model.parameters())
+    except Exception:
+        pass
+
+    mask_mAP50 = metrics.get('mask_mAP50', 0.0)
+    fitness = round(mask_mAP50 * 2 + metrics.get('box_mAP50', 0.0), 5)
+
+    record = Model(
+        model_name=model_name,
+        model_path=rel_path,
+        disease_category=job['disease_category'],
+        augmentation=job.get('augmentation') or '随机点+颜色扭曲+高斯模糊',
+        layers=layers or 113,
+        parameters=params or 0,
+        GFLOPs=35.3,
+        box_p=round(metrics.get('box_p', 0.0), 3),
+        box_r=round(metrics.get('box_r', 0.0), 3),
+        box_mAP50=round(metrics.get('box_mAP50', 0.0), 3),
+        box_mAP50_95=round(metrics.get('box_mAP50_95', 0.0), 3),
+        mask_p=round(metrics.get('mask_p', 0.0), 3),
+        mask_r=round(metrics.get('mask_r', 0.0), 3),
+        mask_mAP50=round(mask_mAP50, 3),
+        mask_mAP50_95=round(metrics.get('mask_mAP50_95', 0.0), 3),
+        f1_score=round((metrics.get('mask_p', 0) + metrics.get('mask_r', 0)) or 0, 5),
+        fitness_score=fitness,
+        owner_id=job['owner_id'],
+    )
+    db.session.add(record)
+    db.session.commit()
+    registered = record.to_dict()
+    for key in ('created_at', 'updated_at'):
+        val = registered.get(key)
+        if val is not None and hasattr(val, 'isoformat'):
+            registered[key] = val.isoformat()
+    return registered
+
+
+def _run_training(app, job_id: str):
+    with app.app_context():
+        job = get_job(app, job_id)
+        if not job:
+            return
+        try:
+            _update_job(app, job_id, status='running', progress=0, started_at=_now_iso())
+            _append_log(app, job_id, '开始加载基线权重…')
+
+            base_path = _ensure_base_weights(app, job['base_model'])
+            data_yaml = _resolve_data_yaml(job['data_yaml'])
+            _append_log(app, job_id, f'数据集配置:{data_yaml}')
+            use_cuda = torch.cuda.is_available()
+            device = 'cuda' if use_cuda else 'cpu'
+            _append_log(app, job_id, f'训练设备:{device}')
+
+            model = YOLO(str(base_path))
+            runs_root = Path(app.config['TRAINING_RUNS_FOLDER'])
+            run_name = job['run_name']
+
+            _append_log(app, job_id, f"启动 YOLOv8 分割训练:epochs={job['epochs']}, imgsz={job['imgsz']}")
+
+            def on_epoch_end(trainer):
+                epoch = trainer.epoch + 1
+                total = trainer.epochs
+                progress = min(99, int(epoch / total * 100))
+                _update_job(app, job_id, progress=progress, current_epoch=epoch, total_epochs=total)
+                _append_log(app, job_id, f'Epoch {epoch}/{total} 完成')
+
+            model.add_callback('on_train_epoch_end', on_epoch_end)
+
+            model.train(
+                data=data_yaml,
+                epochs=int(job['epochs']),
+                imgsz=int(job['imgsz']),
+                batch=int(job['batch']),
+                project=str(runs_root),
+                name=run_name,
+                exist_ok=True,
+                device=device,
+                patience=int(job.get('patience', 20)),
+                verbose=True,
+            )
+
+            run_dir = runs_root / run_name
+            best_pt = run_dir / 'weights' / 'best.pt'
+            if not best_pt.is_file():
+                best_pt = run_dir / 'weights' / 'last.pt'
+            if not best_pt.is_file():
+                raise FileNotFoundError('训练结束但未找到 weights/best.pt')
+
+            metrics = _parse_results_csv(run_dir / 'results.csv')
+            _append_log(app, job_id, f'训练完成,best 权重:{best_pt.name}')
+
+            registered = None
+            if job.get('register_to_library', True):
+                registered = _register_model(app, job, best_pt, metrics)
+                _append_log(app, job_id, f"已写入模型库:{registered['model_name']}")
+
+            _update_job(
+                app,
+                job_id,
+                status='completed',
+                progress=100,
+                finished_at=_now_iso(),
+                best_weights=str(best_pt.resolve()),
+                metrics=metrics,
+                registered_model=registered,
+            )
+        except Exception as exc:
+            tb = traceback.format_exc()
+            _append_log(app, job_id, f'训练失败:{exc}')
+            _update_job(
+                app,
+                job_id,
+                status='failed',
+                finished_at=_now_iso(),
+                error_message=str(exc),
+                error_trace=tb[-2000:],
+            )
+
+
+def start_job(
+    app,
+    *,
+    owner_id: int,
+    owner_username: str,
+    dataset_id: str,
+    base_model: str,
+    disease_category: str,
+    output_model_name: str,
+    epochs: int = 50,
+    imgsz: int = 640,
+    batch: int = 8,
+    augmentation: str = '随机点+颜色扭曲+高斯模糊',
+    register_to_library: bool = True,
+):
+    dataset = get_dataset(app, dataset_id)
+    if not dataset:
+        raise ValueError('数据集不存在')
+
+    allowed = {m['id'] for m in BASE_MODELS}
+    if base_model not in allowed:
+        raise ValueError('不支持的基线模型')
+
+    for job in list_jobs(app):
+        if job.get('status') == 'running':
+            raise ValueError('已有训练任务进行中,请等待完成后再启动')
+
+    if not disease_category.strip():
+        raise ValueError('请填写隐患类别')
+    raw_name = output_model_name.strip()
+    if not raw_name:
+        raise ValueError('请填写输出模型文件名')
+    if not raw_name.lower().endswith('.pt'):
+        raw_name = f'{raw_name}.pt'
+    safe_name = secure_filename(raw_name)
+    if not safe_name or not safe_name.endswith('.pt'):
+        safe_name = f"bridge_seg_{uuid.uuid4().hex[:8]}.pt"
+
+    job_id = uuid.uuid4().hex[:12]
+    run_name = f"train_{job_id}"
+    job = {
+        'job_id': job_id,
+        'run_name': run_name,
+        'status': 'pending',
+        'progress': 0,
+        'owner_id': owner_id,
+        'owner_username': owner_username,
+        'dataset_id': dataset_id,
+        'dataset_name': dataset['name'],
+        'data_yaml': dataset['data_yaml'],
+        'base_model': base_model,
+        'disease_category': disease_category.strip(),
+        'output_model_name': safe_name,
+        'epochs': max(1, min(int(epochs), 500)),
+        'imgsz': max(320, min(int(imgsz), 1280)),
+        'batch': max(1, min(int(batch), 64)),
+        'augmentation': augmentation,
+        'register_to_library': bool(register_to_library),
+        'logs': [],
+        'created_at': _now_iso(),
+        'updated_at': _now_iso(),
+    }
+
+    jobs = list_jobs(app)
+    jobs.insert(0, job)
+    _save_json(app, 'jobs.json', jobs)
+
+    _start_training_thread(app, job_id)
+    threading.Thread(target=_watch_pending_job, args=(_app_for_thread(app), job_id), daemon=True).start()
+    return job

+ 104 - 0
BridgeDiseaseBackend-main/docs/TRAINING_DATASET.md

@@ -0,0 +1,104 @@
+# 桥梁安全隐患检测 — YOLO 训练数据集指南
+
+本文说明如何在 **检澜 → 模型训练** 中准备符合 [Ultralytics YOLOv8](https://docs.ultralytics.com/datasets/segment/) 规范的分割数据集。
+
+## 一、目录结构
+
+```text
+bridge_hazard_demo/          ← ZIP 解压后的根目录(名称可自定)
+├── data.yaml                ← 必须存在
+├── train/
+│   ├── images/              ← 训练图片
+│   │   ├── img001.jpg
+│   │   └── img002.jpg
+│   └── labels/              ← 训练标注(与图片同名 .txt)
+│       ├── img001.txt
+│       └── img002.txt
+└── val/
+    ├── images/              ← 验证图片(至少 1 张)
+    └── labels/
+        └── img003.txt
+```
+
+> **注意**:`labels` 与 `images` 文件名一一对应(仅扩展名不同)。
+
+## 二、`data.yaml` 模板
+
+项目内提供可复制模板:
+
+[`training_templates/data.yaml.example`](../training_templates/data.yaml.example)
+
+最小可用示例:
+
+```yaml
+path: .
+train: train/images
+val: val/images
+
+nc: 1
+names:
+  0: hazard
+```
+
+## 三、分割标注格式(`labels/*.txt`)
+
+每一行表示一个隐患实例(多边形):
+
+```text
+<class_id> <x1> <y1> <x2> <y2> ... <xn> <yn>
+```
+
+- `class_id`:整数,对应 `data.yaml` 的 `names` 索引(从 0 开始)
+- `x1 y1 ...`:多边形顶点,**归一化**到 0~1(除以图像宽高)
+
+示例 — 图像中心约 50% 区域的矩形(类别 0):
+
+```text
+0 0.25 0.25 0.75 0.25 0.75 0.75 0.25 0.75
+```
+
+无隐患的图片:对应 **空的** `.txt` 文件或不存在 label 文件(建议空文件)。
+
+## 四、打包 ZIP 上传
+
+1. 在「模型训练」页点击 **选择 ZIP**,选中整个 `bridge_hazard_demo` 文件夹压缩为 `bridge_hazard_demo.zip`
+2. 确保解压后 **第一层** 能看到 `data.yaml`(不要多套一层无意义的父目录)
+3. 上传后平台会校验 `data.yaml` 并解压到 `data/datasets/<id>/`
+
+## 五、一键生成演示数据集(试跑训练)
+
+若尚无标注数据,可用脚本从项目内示例桥梁照片生成 **占位标注** 的迷你数据集(仅用于验证训练流程,**不能**代替真实标注):
+
+```powershell
+cd BridgeDiseaseBackend-main
+python scripts/create_demo_dataset.py
+```
+
+生成物:
+
+- 目录:`training_templates/bridge_hazard_demo/`
+- ZIP:`training_templates/bridge_hazard_demo.zip`(可直接在 Web 页上传)
+
+## 六、训练参数建议
+
+| 场景 | 基线模型 | epochs | imgsz | batch |
+|------|----------|--------|-------|-------|
+| 本机 CPU 试跑 | yolov8n-seg.pt | 5~10 | 640 | 2~4 |
+| 有 GPU 正式训 | yolov8s-seg.pt | 50~100 | 640 | 8~16 |
+
+## 七、标注工具推荐
+
+- [Labelme](https://github.com/wkentaro/labelme) → 导出后需转换为 YOLO 格式  
+- [CVAT](https://www.cvat.ai/)  
+- [Roboflow](https://roboflow.com/)(可导出 YOLOv8 分割)
+
+导出时请选择 **YOLOv8 Segmentation** 格式。
+
+## 八、常见问题
+
+| 问题 | 处理 |
+|------|------|
+| 上传后提示找不到 data.yaml | 检查 ZIP 根目录是否含 `data.yaml`,不要只压缩 `train` 子文件夹 |
+| 训练立即失败 | 查看任务「日志」;常见为 labels 缺失、class_id 超出 names 范围 |
+| 训练极慢 | CPU 正常;减小 `imgsz`、`batch` 或换 `yolov8n-seg` |
+| 检测效果差 | 演示集为占位框,需换真实桥梁隐患标注重新训练 |

+ 116 - 0
BridgeDiseaseBackend-main/scripts/create_demo_dataset.py

@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+"""
+从 seed_assets/medias 生成迷你 YOLO 分割演示数据集(占位标注,仅用于试跑训练流程)。
+
+输出:
+  training_templates/bridge_hazard_demo/
+  training_templates/bridge_hazard_demo.zip
+"""
+from __future__ import annotations
+
+import shutil
+import zipfile
+from pathlib import Path
+
+from PIL import Image
+
+ROOT = Path(__file__).resolve().parents[1]
+ASSETS = ROOT / 'seed_assets' / 'medias'
+OUT_ROOT = ROOT / 'training_templates' / 'bridge_hazard_demo'
+ZIP_PATH = ROOT / 'training_templates' / 'bridge_hazard_demo.zip'
+
+# (源图, 划分, 类别 id, 类别说明)
+SAMPLES = [
+    ('01_concrete_crack_bridge.jpg', 'train', 0, 'concrete_crack'),
+    ('02_bridge_concrete_cracks.jpg', 'train', 0, 'concrete_crack'),
+    ('04_concrete_bending_cracks.jpg', 'train', 0, 'concrete_crack'),
+    ('06_shrinkage_cracks_concrete.jpg', 'train', 0, 'concrete_crack'),
+    ('03_steel_bridge_corrosion.jpg', 'val', 1, 'steel_corrosion'),
+    ('08_concrete_rebar_corrosion.jpg', 'val', 1, 'steel_corrosion'),
+]
+
+
+def rect_polygon(class_id: int, cx=0.5, cy=0.5, w=0.35, h=0.35) -> str:
+    """生成归一化矩形四顶点分割标注行。"""
+    x1, y1 = cx - w / 2, cy - h / 2
+    x2, y2 = cx + w / 2, cy - h / 2
+    x3, y3 = cx + w / 2, cy + h / 2
+    x4, y4 = cx - w / 2, cy + h / 2
+    pts = [x1, y1, x2, y2, x3, y3, x4, y4]
+    pts = [max(0.0, min(1.0, p)) for p in pts]
+    return f"{class_id} " + " ".join(f"{p:.6f}" for p in pts)
+
+
+def write_data_yaml(root: Path):
+    content = """# 检澜演示数据集 — 占位标注,仅用于试跑 YOLOv8 分割训练
+path: .
+train: train/images
+val: val/images
+
+nc: 2
+names:
+  0: concrete_crack
+  1: steel_corrosion
+"""
+    (root / 'data.yaml').write_text(content, encoding='utf-8')
+
+
+def main():
+    if not ASSETS.is_dir():
+        print(f'缺少 {ASSETS},请先运行 scripts/download_real_medias.py')
+        return 1
+
+    if OUT_ROOT.exists():
+        shutil.rmtree(OUT_ROOT)
+    OUT_ROOT.mkdir(parents=True)
+
+    for split in ('train', 'val'):
+        (OUT_ROOT / split / 'images').mkdir(parents=True)
+        (OUT_ROOT / split / 'labels').mkdir(parents=True)
+
+    idx = 0
+    for src_name, split, class_id, _ in SAMPLES:
+        src = ASSETS / src_name
+        if not src.is_file():
+            print(f'[skip] 缺少 {src_name}')
+            continue
+        idx += 1
+        stem = f'demo_{idx:03d}'
+        img_name = f'{stem}.jpg'
+        dest_img = OUT_ROOT / split / 'images' / img_name
+
+        with Image.open(src) as im:
+            im.convert('RGB').save(dest_img, format='JPEG', quality=90)
+
+        label_line = rect_polygon(class_id)
+        (OUT_ROOT / split / 'labels' / f'{stem}.txt').write_text(
+            label_line + '\n', encoding='utf-8'
+        )
+        print(f'[ok] {split} {img_name} class={class_id}')
+
+    if idx < 2:
+        print('有效图片不足,无法生成数据集')
+        return 1
+
+    write_data_yaml(OUT_ROOT)
+
+    if ZIP_PATH.is_file():
+        ZIP_PATH.unlink()
+    with zipfile.ZipFile(ZIP_PATH, 'w', zipfile.ZIP_DEFLATED) as zf:
+        for file in OUT_ROOT.rglob('*'):
+            if file.is_file():
+                arc = file.relative_to(OUT_ROOT.parent)
+                zf.write(file, arc.as_posix())
+
+    print()
+    print('已生成:')
+    print(f'  目录  {OUT_ROOT}')
+    print(f'  ZIP   {ZIP_PATH}')
+    print()
+    print('下一步:登录 developer → 模型训练 → 上传 bridge_hazard_demo.zip')
+    print('建议试跑:yolov8n-seg,epochs=5,batch=2')
+    return 0
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 90 - 0
BridgeDiseaseBackend-main/scripts/seed_models.py

@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+"""下载 YOLOv8 分割基线权重并复制为各业务模型文件名(演示推理用)。"""
+from __future__ import annotations
+
+import os
+import shutil
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+os.environ.setdefault(
+    'SQLALCHEMY_DATABASE_URI',
+    'mysql+pymysql://root:bridgedisease_root@127.0.0.1:3307/bridge_disease?charset=utf8mb4',
+)
+
+MODELS_DIR = ROOT / 'app' / 'static' / 'models'
+BASE_NAME = 'yolov8n-seg.pt'
+
+
+def download_base_weights() -> Path:
+    """通过 Ultralytics 拉取 yolov8n-seg.pt。"""
+    from ultralytics import YOLO
+
+    MODELS_DIR.mkdir(parents=True, exist_ok=True)
+    target = MODELS_DIR / BASE_NAME
+    if target.is_file() and target.stat().st_size > 1_000_000:
+        print(f'[skip] 基线权重已存在: {target}')
+        return target
+
+    print('正在下载 yolov8n-seg.pt(首次约 6MB,需联网)…')
+    cwd_before = Path.cwd()
+    os.chdir(MODELS_DIR)
+    try:
+        YOLO(BASE_NAME)
+    finally:
+        os.chdir(cwd_before)
+
+    if not target.is_file():
+        # 部分版本会下载到用户目录 weights/
+        candidates = list(MODELS_DIR.glob('*.pt')) + list((Path.home() / '.cache' / 'ultralytics').rglob(BASE_NAME))
+        for c in candidates:
+            if c.is_file() and c.stat().st_size > 1_000_000:
+                if c.resolve() != target.resolve():
+                    shutil.copy2(c, target)
+                break
+
+    if not target.is_file():
+        print('下载失败:未找到 yolov8n-seg.pt', file=sys.stderr)
+        sys.exit(1)
+
+    print(f'[ok] 基线权重: {target} ({target.stat().st_size // 1024} KB)')
+    return target
+
+
+def main() -> None:
+    from app import create_app
+    from app.models import Model, db
+
+    base = download_base_weights()
+
+    app = create_app()
+    with app.app_context():
+        models = Model.query.order_by(Model.model_id.asc()).all()
+        if not models:
+            print('model 表为空,请先导入 sql/init_db.sql')
+            sys.exit(1)
+
+        rel_prefix = 'static/models'
+        copied = 0
+        for m in models:
+            filename = Path(str(m.model_path).replace('\\', '/')).name
+            dest = MODELS_DIR / filename
+            if not dest.is_file() or dest.stat().st_size < 1_000_000:
+                shutil.copy2(base, dest)
+                copied += 1
+                print(f'[copy] {filename}')
+            rel = f'{rel_prefix}/{filename}'
+            if m.model_path.replace('\\', '/') != rel:
+                m.model_path = rel
+                print(f'[path] {m.model_name} -> {rel}')
+
+        db.session.commit()
+        print(f'完成:复制 {copied} 个模型文件,共 {len(models)} 条 model 记录可用。')
+        print('说明:演示环境共用 yolov8n-seg 权重;上线请替换为各病害专项训练权重。')
+
+
+if __name__ == '__main__':
+    main()

BIN
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo.zip


+ 9 - 0
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/data.yaml

@@ -0,0 +1,9 @@
+# 检澜演示数据集 — 占位标注,仅用于试跑 YOLOv8 分割训练
+path: .
+train: train/images
+val: val/images
+
+nc: 2
+names:
+  0: concrete_crack
+  1: steel_corrosion

BIN
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/images/demo_001.jpg


BIN
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/images/demo_002.jpg


BIN
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/images/demo_003.jpg


BIN
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/images/demo_004.jpg


+ 1 - 0
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/labels/demo_001.txt

@@ -0,0 +1 @@
+0 0.325000 0.325000 0.675000 0.325000 0.675000 0.675000 0.325000 0.675000

+ 1 - 0
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/labels/demo_002.txt

@@ -0,0 +1 @@
+0 0.325000 0.325000 0.675000 0.325000 0.675000 0.675000 0.325000 0.675000

+ 1 - 0
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/labels/demo_003.txt

@@ -0,0 +1 @@
+0 0.325000 0.325000 0.675000 0.325000 0.675000 0.675000 0.325000 0.675000

+ 1 - 0
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/train/labels/demo_004.txt

@@ -0,0 +1 @@
+0 0.325000 0.325000 0.675000 0.325000 0.675000 0.675000 0.325000 0.675000

BIN
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/val/images/demo_005.jpg


BIN
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/val/images/demo_006.jpg


+ 1 - 0
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/val/labels/demo_005.txt

@@ -0,0 +1 @@
+1 0.325000 0.325000 0.675000 0.325000 0.675000 0.675000 0.325000 0.675000

+ 1 - 0
BridgeDiseaseBackend-main/training_templates/bridge_hazard_demo/val/labels/demo_006.txt

@@ -0,0 +1 @@
+1 0.325000 0.325000 0.675000 0.325000 0.675000 0.675000 0.325000 0.675000

+ 46 - 0
BridgeDiseaseBackend-main/training_templates/data.yaml.example

@@ -0,0 +1,46 @@
+# =============================================================================
+# 检澜 DockScope — YOLOv8 分割训练数据集配置模板
+# =============================================================================
+# 使用说明:
+# 1. 复制本文件为数据集根目录下的 data.yaml(与 train、val 同级)
+# 2. 将 path 改为本数据集在服务器上的绝对路径,或使用相对 path(推荐绝对路径)
+# 3. names 下列出所有隐患类别,索引从 0 开始,需与标注 txt 中 class_id 一致
+# 4. 打包 ZIP 时:选中「数据集根文件夹」内所有内容后压缩,确保解压后第一层即有 data.yaml
+#
+# 目录结构示例:
+#   bridge_hazard_demo/
+#     data.yaml
+#     train/
+#       images/   *.jpg
+#       labels/   *.txt   (与 images 同名,YOLO 分割多边形格式)
+#     val/
+#       images/
+#       labels/
+# =============================================================================
+
+# 数据集根目录(训练时 Ultralytics 会解析;上传 ZIP 后由平台解压到独立目录,一般无需手改)
+path: .
+
+# 训练集、验证集图像目录(相对 path)
+train: train/images
+val: val/images
+
+# 可选:测试集
+# test: test/images
+
+# 类别数量(可选,与 names 长度一致即可)
+nc: 2
+
+# 类别名称 — 索引必须与 labels/*.txt 每行开头的 class_id 对应
+names:
+  0: concrete_crack      # 混凝土裂缝
+  1: steel_corrosion     # 钢构件锈蚀
+
+# -----------------------------------------------------------------------------
+# 分割标注 txt 格式(每行一个实例):
+#   <class_id> <x1> <y1> <x2> <y2> ... <xn> <yn>
+# 坐标均为相对图像宽高的归一化值,范围 0~1,至少 3 个点(多边形顶点)
+#
+# 示例(类别 0,矩形四顶点):
+#   0 0.25 0.30 0.75 0.30 0.75 0.70 0.25 0.70
+# -----------------------------------------------------------------------------

+ 455 - 0
bridge-disease-frontend-main/src/views/ModelTrainingView.vue

@@ -0,0 +1,455 @@
+<script setup>
+import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { Upload, VideoPlay, Refresh, Document } from '@element-plus/icons-vue'
+import request from '../utils/request'
+import { useUserStore } from '../stores/userStore'
+import SidebarMenu from '../components/SidebarMenu.vue'
+import BreadcrumbNav from '../components/BreadcrumbNav.vue'
+import HudPageHero from '../components/shell/HudPageHero.vue'
+import { formatDateTime } from '../utils/dateTimeFormatter'
+
+const router = useRouter()
+const { userInfo, getUserInfo } = useUserStore()
+
+const datasets = ref([])
+const jobs = ref([])
+const baseModels = ref([])
+const uploadLoading = ref(false)
+const startLoading = ref(false)
+const logVisible = ref(false)
+const currentJob = ref(null)
+let pollTimer = null
+
+const uploadForm = ref({ name: '', file: null })
+const trainForm = ref({
+  dataset_id: '',
+  base_model: 'yolov8n-seg.pt',
+  disease_category: '',
+  output_model_name: '',
+  epochs: 30,
+  imgsz: 640,
+  batch: 4,
+  register_to_library: true,
+})
+
+const canTrain = computed(() => {
+  const role = String(userInfo.value?.role || '').toUpperCase()
+  return role === 'DEVELOPER' || role === 'ADMIN'
+})
+
+const statusType = {
+  pending: 'info',
+  running: 'warning',
+  completed: 'success',
+  failed: 'danger',
+}
+
+const statusLabel = {
+  pending: '排队中',
+  running: '训练中',
+  completed: '已完成',
+  failed: '失败',
+}
+
+const hasRunning = computed(() =>
+  jobs.value.some((j) => j.status === 'pending' || j.status === 'running'),
+)
+
+const resumePendingJobs = async () => {
+  const pending = jobs.value.filter((j) => j.status === 'pending')
+  for (const job of pending) {
+    try {
+      await request.post(`/training/jobs/${job.job_id}/resume`)
+    } catch (e) {
+      console.warn('【恢复训练任务】', job.job_id, e?.message || e)
+    }
+  }
+}
+
+const fmt = (v) => (v ? formatDateTime(v) : '—')
+
+const assertTrainingPayload = (res, key) => {
+  if (res?.error) {
+    throw new Error('训练接口不可用,请重启后端(需包含 /training 路由)')
+  }
+  if (!Array.isArray(res?.[key])) {
+    const msg =
+      res?.failure_message ||
+      res?.message ||
+      res?.operation?.failure_message ||
+      `训练接口响应异常(缺少 ${key})`
+    throw new Error(msg)
+  }
+  return res[key]
+}
+
+const loadBaseModels = async () => {
+  const res = await request.get('/training/base-models')
+  baseModels.value = assertTrainingPayload(res, 'base_models')
+}
+
+const loadDatasets = async () => {
+  const res = await request.get('/training/datasets')
+  datasets.value = assertTrainingPayload(res, 'datasets')
+  if (!trainForm.value.dataset_id && datasets.value.length) {
+    trainForm.value.dataset_id = datasets.value[0].dataset_id
+  }
+}
+
+const loadJobs = async () => {
+  const res = await request.get('/training/jobs')
+  if (Array.isArray(res?.jobs)) {
+    jobs.value = res.jobs
+    return
+  }
+  if (res?.error || res?.message) {
+    console.warn('【训练任务列表】', res.message || res.error)
+  }
+  jobs.value = []
+}
+
+const refreshAll = async () => {
+  await loadJobs()
+  await resumePendingJobs()
+  await loadJobs()
+  await loadDatasets()
+  if (hasRunning.value) startPolling()
+}
+
+const onFileChange = (file) => {
+  uploadForm.value.file = file?.raw || null
+}
+
+const submitUpload = async () => {
+  if (!uploadForm.value.file) {
+    ElMessage.warning('请选择 ZIP 数据集文件')
+    return
+  }
+  const fd = new FormData()
+  fd.append('dataset_zip', uploadForm.value.file)
+  if (uploadForm.value.name) fd.append('name', uploadForm.value.name)
+  uploadLoading.value = true
+  try {
+    await request.post('/training/datasets/upload', fd, {
+      headers: { 'Content-Type': 'multipart/form-data' },
+    })
+    ElMessage.success('数据集上传成功')
+    uploadForm.value.file = null
+    uploadForm.value.name = ''
+    await loadDatasets()
+  } catch (e) {
+    ElMessage.error(e?.failure_message || e?.message || '上传失败')
+  } finally {
+    uploadLoading.value = false
+  }
+}
+
+const submitTrain = async () => {
+  if (!trainForm.value.dataset_id) {
+    ElMessage.warning('请先上传并选择数据集')
+    return
+  }
+  if (!trainForm.value.disease_category?.trim()) {
+    ElMessage.warning('请填写隐患类别')
+    return
+  }
+  if (!trainForm.value.output_model_name?.trim()) {
+    ElMessage.warning('请填写输出模型文件名')
+    return
+  }
+  startLoading.value = true
+  try {
+    await request.post('/training/jobs', { ...trainForm.value })
+    ElMessage.success('训练任务已启动,请在下方的任务列表查看进度')
+    await loadJobs()
+    await resumePendingJobs()
+    await loadJobs()
+    startPolling()
+  } catch (e) {
+    ElMessage.error(e?.failure_message || e?.message || '启动失败')
+  } finally {
+    startLoading.value = false
+  }
+}
+
+const showLogs = (row) => {
+  currentJob.value = row
+  logVisible.value = true
+}
+
+const openJobDetail = async (row) => {
+  try {
+    const res = await request.get(`/training/jobs/${row.job_id}`)
+    currentJob.value = res.job
+    logVisible.value = true
+  } catch {
+    currentJob.value = row
+    logVisible.value = true
+  }
+}
+
+const startPolling = () => {
+  if (pollTimer) return
+  pollTimer = setInterval(async () => {
+    await loadJobs()
+    if (!hasRunning.value) {
+      clearInterval(pollTimer)
+      pollTimer = null
+    }
+  }, 3000)
+}
+
+onMounted(() => {
+  getUserInfo().then(async () => {
+    if (!userInfo.value) {
+      router.push('/login')
+      return
+    }
+    if (!canTrain.value) {
+      ElMessage.warning('仅管理员或开发人员可使用模型训练')
+      router.push('/home')
+      return
+    }
+    try {
+      await loadBaseModels()
+      await loadJobs()
+      await resumePendingJobs()
+      await loadDatasets()
+      if (hasRunning.value) startPolling()
+    } catch (e) {
+      const msg = e?.failure_message || e?.message || '请确认后端已启动(http://127.0.0.1:5000)'
+      ElMessage.error(`加载训练模块失败:${msg}`)
+      console.error('【模型训练】初始化失败', e)
+    }
+  })
+})
+
+onBeforeUnmount(() => {
+  if (pollTimer) clearInterval(pollTimer)
+})
+</script>
+
+<template>
+  <div class="iot-page shell-layout-root">
+    <div class="main-container">
+      <div class="sidebar"><SidebarMenu /></div>
+      <div class="content-area">
+        <BreadcrumbNav />
+        <HudPageHero
+          title="模型训练"
+          description="基于 Ultralytics YOLOv8 分割模型,上传 YOLO 格式数据集并训练桥梁隐患检测权重,完成后可自动注册到模型库。"
+        />
+
+        <el-alert
+          type="info"
+          :closable="false"
+          show-icon
+          class="train-tip"
+          title="数据集格式说明"
+        >
+          <p>请上传包含 <code>data.yaml</code> 的 ZIP 包,目录需符合 Ultralytics 规范,例如:</p>
+          <pre class="yaml-sample">bridge_hazard_demo/
+  data.yaml
+  train/images  train/labels
+  val/images    val/labels</pre>
+          <p>
+            完整说明见后端文档
+            <code>BridgeDiseaseBackend-main/docs/TRAINING_DATASET.md</code>;
+            模板文件 <code>training_templates/data.yaml.example</code>。
+          </p>
+          <p>
+            <strong>尚无标注?</strong> 在后端目录执行
+            <code>python scripts/create_demo_dataset.py</code>,
+            会生成 <code>training_templates/bridge_hazard_demo.zip</code>(占位框,仅试跑流程)。
+          </p>
+          <p>训练在后台执行,CPU 建议先试 <code>yolov8n-seg</code>、<code>epochs=5</code>、<code>batch=2</code>。</p>
+        </el-alert>
+
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-card class="iot-card">
+              <template #header>
+                <div class="card-header"><h2>上传训练数据集</h2></div>
+              </template>
+              <el-form label-width="100px">
+                <el-form-item label="数据集名称">
+                  <el-input v-model="uploadForm.name" placeholder="可选,便于识别" clearable />
+                </el-form-item>
+                <el-form-item label="ZIP 文件">
+                  <el-upload
+                    :auto-upload="false"
+                    :limit="1"
+                    accept=".zip"
+                    :on-change="onFileChange"
+                  >
+                    <el-button type="primary" plain>
+                      <el-icon><Upload /></el-icon> 选择 ZIP
+                    </el-button>
+                  </el-upload>
+                </el-form-item>
+                <el-form-item>
+                  <el-button type="primary" :loading="uploadLoading" @click="submitUpload">
+                    上传并校验
+                  </el-button>
+                </el-form-item>
+              </el-form>
+              <el-table :data="datasets" size="small" empty-text="暂无数据集,请先上传">
+                <el-table-column prop="name" label="名称" min-width="120" />
+                <el-table-column prop="owner_username" label="上传人" width="90" />
+                <el-table-column label="上传时间" width="165">
+                  <template #default="{ row }">{{ fmt(row.created_at) }}</template>
+                </el-table-column>
+              </el-table>
+            </el-card>
+          </el-col>
+
+          <el-col :span="12">
+            <el-card class="iot-card">
+              <template #header>
+                <div class="card-header"><h2>新建训练任务</h2></div>
+              </template>
+              <el-form label-width="110px">
+                <el-form-item label="数据集" required>
+                  <el-select v-model="trainForm.dataset_id" placeholder="选择数据集" style="width: 100%">
+                    <el-option
+                      v-for="d in datasets"
+                      :key="d.dataset_id"
+                      :label="d.name"
+                      :value="d.dataset_id"
+                    />
+                  </el-select>
+                </el-form-item>
+                <el-form-item label="基线模型" required>
+                  <el-select v-model="trainForm.base_model" style="width: 100%">
+                    <el-option
+                      v-for="m in baseModels"
+                      :key="m.id"
+                      :label="m.label"
+                      :value="m.id"
+                    />
+                  </el-select>
+                </el-form-item>
+                <el-form-item label="隐患类别" required>
+                  <el-input v-model="trainForm.disease_category" placeholder="如:钢构件锈蚀" />
+                </el-form-item>
+                <el-form-item label="输出文件名" required>
+                  <el-input
+                    v-model="trainForm.output_model_name"
+                    placeholder="如:steel_corrosion.pt"
+                  />
+                </el-form-item>
+                <el-form-item label="训练轮次">
+                  <el-input-number v-model="trainForm.epochs" :min="1" :max="500" />
+                </el-form-item>
+                <el-form-item label="图像尺寸">
+                  <el-input-number v-model="trainForm.imgsz" :min="320" :max="1280" :step="32" />
+                </el-form-item>
+                <el-form-item label="Batch">
+                  <el-input-number v-model="trainForm.batch" :min="1" :max="32" />
+                </el-form-item>
+                <el-form-item label="写入模型库">
+                  <el-switch v-model="trainForm.register_to_library" />
+                </el-form-item>
+                <el-form-item>
+                  <el-button type="primary" :loading="startLoading" @click="submitTrain">
+                    <el-icon><VideoPlay /></el-icon> 开始训练
+                  </el-button>
+                </el-form-item>
+              </el-form>
+            </el-card>
+          </el-col>
+        </el-row>
+
+        <el-card class="iot-card">
+          <template #header>
+            <div class="card-header">
+              <h2>训练任务</h2>
+              <el-button @click="refreshAll"><el-icon><Refresh /></el-icon> 刷新</el-button>
+            </div>
+          </template>
+          <el-table :data="jobs">
+            <el-table-column prop="job_id" label="任务ID" width="110" show-overflow-tooltip />
+            <el-table-column prop="dataset_name" label="数据集" width="120" show-overflow-tooltip />
+            <el-table-column prop="disease_category" label="隐患类别" width="130" />
+            <el-table-column prop="base_model" label="基线" width="130" />
+            <el-table-column prop="output_model_name" label="输出模型" width="150" show-overflow-tooltip />
+            <el-table-column label="状态" width="90">
+              <template #default="{ row }">
+                <el-tag :type="statusType[row.status]" size="small">{{ statusLabel[row.status] || row.status }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="进度" width="140">
+              <template #default="{ row }">
+                <el-progress
+                  :percentage="row.progress || 0"
+                  :status="row.status === 'failed' ? 'exception' : row.status === 'completed' ? 'success' : undefined"
+                />
+              </template>
+            </el-table-column>
+            <el-table-column label="创建时间" width="165">
+              <template #default="{ row }">{{ fmt(row.created_at) }}</template>
+            </el-table-column>
+            <el-table-column label="操作" width="100" fixed="right">
+              <template #default="{ row }">
+                <el-button link type="primary" @click="openJobDetail(row)">
+                  <el-icon><Document /></el-icon> 日志
+                </el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+      </div>
+    </div>
+
+    <el-dialog v-model="logVisible" title="训练日志" width="640px" destroy-on-close>
+      <div v-if="currentJob" class="log-panel">
+        <p><strong>任务</strong> {{ currentJob.job_id }} · {{ currentJob.disease_category }}</p>
+        <p v-if="currentJob.error_message" class="log-error">{{ currentJob.error_message }}</p>
+        <pre class="log-pre">{{ (currentJob.logs || []).join('\n') || '暂无日志' }}</pre>
+      </div>
+      <template #footer>
+        <el-button @click="logVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+@import '../styles/iot-page.css';
+.train-tip {
+  margin-bottom: 16px;
+}
+.train-tip p {
+  margin: 4px 0;
+  font-size: 13px;
+  line-height: 1.5;
+}
+.yaml-sample {
+  margin: 8px 0;
+  padding: 8px 12px;
+  background: var(--card);
+  border-radius: 6px;
+  font-size: 12px;
+}
+.log-panel p {
+  margin: 0 0 8px;
+  font-size: 13px;
+}
+.log-error {
+  color: var(--el-color-danger);
+}
+.log-pre {
+  max-height: 360px;
+  overflow: auto;
+  padding: 12px;
+  background: #1e1e1e;
+  color: #d4d4d4;
+  border-radius: 8px;
+  font-size: 12px;
+  line-height: 1.45;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 16 - 0
scripts/start-backend.ps1

@@ -0,0 +1,16 @@
+# 启动检澜后端(释放 5000 端口后使用本项目 venv)
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
+$backend = Join-Path $root 'BridgeDiseaseBackend-main'
+$python = Join-Path $backend '.venv\Scripts\python.exe'
+
+Get-NetTCPConnection -LocalPort 5000 -State Listen -ErrorAction SilentlyContinue |
+  ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
+Start-Sleep -Seconds 2
+
+$env:SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:bridgedisease_root@127.0.0.1:3307/bridge_disease?charset=utf8mb4'
+$env:FLASK_RUN_HOST = '127.0.0.1'
+$env:FLASK_RUN_PORT = '5000'
+
+Set-Location $backend
+& $python run.py