Add deep training acceptance checks

This commit is contained in:
2026-06-30 13:42:30 +08:00
parent 7d6e1692b1
commit cf920e97c3
8 changed files with 179 additions and 11 deletions

View File

@@ -7,4 +7,5 @@ SEG_MMSEG_CONDA_ENV=seg_mmcv
SEG_BACKEND_CONDA_ENV=seg_smp SEG_BACKEND_CONDA_ENV=seg_smp
SEG_WEIGHT_MODE=copy SEG_WEIGHT_MODE=copy
SEG_ENABLE_SHELL_TASKS=1 SEG_ENABLE_SHELL_TASKS=1
SEG_VALIDATE_DEEP=1
VITE_API_BASE=http://localhost:8010 VITE_API_BASE=http://localhost:8010

View File

@@ -64,6 +64,12 @@ weight discovery. MMSeg full-model readiness is validated in
`SEG_MMSEG_CONDA_ENV` by importing `mmcv._ext` and building a local MMSeg `SEG_MMSEG_CONDA_ENV` by importing `mmcv._ext` and building a local MMSeg
`EncoderDecoder` from the existing config tree. `EncoderDecoder` from the existing config tree.
For stronger runtime proof, `POST /api/acceptance/deep` runs minimal training
loops for the three model families: one SegModel optimizer step, one YOLO
segmentation epoch on a synthetic 64x64 dataset, and one MMSeg optimizer step
through the full `mmcv._ext` runtime. The latest report is available from
`GET /api/acceptance/deep/latest` and is surfaced in the coverage panel.
Current `seg_smp` uses `mmcv-lite` because no `torch 2.6/cu124` full `mmcv` Current `seg_smp` uses `mmcv-lite` because no `torch 2.6/cu124` full `mmcv`
wheel is available on this machine and `nvcc` is not installed for source wheel is available on this machine and `nvcc` is not installed for source
builds. A dedicated `seg_mmcv` environment is used for MMSeg tasks and has builds. A dedicated `seg_mmcv` environment is used for MMSeg tasks and has
@@ -138,4 +144,5 @@ The validation agent checks catalog coverage, the `seg_smp` task env, the
`seg_mmcv` MMSeg env, GPU visibility, no-weight Git safety, backend tests, `seg_mmcv` MMSeg env, GPU visibility, no-weight Git safety, backend tests,
frontend build, and live backend/frontend endpoints when the services are frontend build, and live backend/frontend endpoints when the services are
running. With live validation enabled it also runs the lightweight acceptance running. With live validation enabled it also runs the lightweight acceptance
smoke above. smoke above. By default it also runs the deep training acceptance; set
`SEG_VALIDATE_DEEP=0` when a quick non-training validation pass is needed.

View File

@@ -62,6 +62,66 @@ MMSEG_FULL_BUILD_SNIPPET = (
) )
SEGMODEL_TRAIN_STEP_SNIPPET = (
"import torch, segmentation_models_pytorch as smp; "
"torch.manual_seed(7); "
"model=smp.Unet(encoder_name='resnet18', encoder_weights=None, classes=2).train(); "
"inputs=torch.randn(2,3,64,64); "
"targets=torch.randint(0,2,(2,64,64)); "
"optimizer=torch.optim.SGD(model.parameters(), lr=1e-3); "
"outputs=model(inputs); "
"loss=torch.nn.functional.cross_entropy(outputs, targets); "
"loss.backward(); optimizer.step(); "
"print('loss', round(float(loss.detach()), 6), 'shape', tuple(outputs.shape))"
)
def _yolo_tiny_train_snippet(root: Path, weight: Path) -> str:
return (
"import shutil, cv2, numpy as np; "
"from pathlib import Path; "
"from ultralytics import YOLO; "
f"root=Path({str(root)!r}); weight={str(weight)!r}; "
"shutil.rmtree(root, ignore_errors=True); "
"[ (root / item).mkdir(parents=True, exist_ok=True) for item in ['images/train','images/val','labels/train','labels/val','runs'] ]; "
"image=np.zeros((64,64,3), dtype=np.uint8); "
"cv2.rectangle(image, (16,16), (48,48), (255,255,255), -1); "
"label='0 0.25 0.25 0.75 0.25 0.75 0.75 0.25 0.75\\n'; "
"\nfor split in ['train','val']:\n"
" cv2.imwrite(str(root / 'images' / split / 'sample.jpg'), image)\n"
" (root / 'labels' / split / 'sample.txt').write_text(label, encoding='utf-8')\n"
"(root / 'data.yaml').write_text('path: '+str(root)+'\\ntrain: images/train\\nval: images/val\\nnc: 1\\nnames:\\n 0: object\\n', encoding='utf-8'); "
"model=YOLO(weight); "
"model.train(data=str(root/'data.yaml'), epochs=1, imgsz=64, batch=1, workers=0, device='cpu', project=str(root/'runs'), name='tiny', exist_ok=True, verbose=False, plots=False, val=False); "
"results=root/'runs'/'tiny'/'results.csv'; best=root/'runs'/'tiny'/'weights'/'best.pt'; "
"assert results.exists() and results.stat().st_size > 0; "
"assert best.exists() and best.stat().st_size > 0; "
"print('results', results, results.stat().st_size, 'best', best.stat().st_size)"
)
def _mmseg_train_step_snippet(config_path: Path) -> str:
return (
"import torch; "
"from mmengine.config import Config; "
"from mmengine.structures import PixelData; "
"from mmseg.registry import MODELS; "
"from mmseg.structures import SegDataSample; "
"from mmseg.utils import register_all_modules; "
"register_all_modules(init_default_scope=True); "
f"cfg=Config.fromfile({str(config_path)!r}); "
"cfg.model.backbone.init_cfg=None; cfg.model.pretrained=None; "
"model=MODELS.build(cfg.model).train(); "
"sample=SegDataSample(); "
"sample.gt_sem_seg=PixelData(data=torch.randint(0,19,(1,64,64), dtype=torch.long)); "
"losses=model(torch.randn(1,3,64,64), [sample], mode='loss'); "
"loss=sum(value if torch.is_tensor(value) else sum(value) for value in losses.values()); "
"optimizer=torch.optim.SGD(model.parameters(), lr=1e-4); "
"loss.backward(); optimizer.step(); "
"print('loss', round(float(loss.detach()), 6), sorted(losses.keys()))"
)
def _request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: int = 10) -> dict[str, Any]: def _request_json(method: str, url: str, payload: dict[str, Any] | None = None, timeout: int = 10) -> dict[str, Any]:
data = None data = None
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
@@ -252,6 +312,56 @@ def latest_acceptance_report() -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8")) return json.loads(path.read_text(encoding="utf-8"))
def latest_deep_acceptance_report() -> dict[str, Any]:
path = settings.project_root / "var" / "acceptance" / "deep_latest.json"
if not path.exists():
return {"available": False, "path": str(path)}
return json.loads(path.read_text(encoding="utf-8"))
def run_deep_acceptance() -> dict[str, Any]:
"""Run minimal training loops for each model family without full datasets."""
acceptance_root = settings.project_root / "var" / "acceptance"
run_id = uuid.uuid4().hex[:8]
fixture_root = acceptance_root / f"deep_{run_id}"
fixture_root.mkdir(parents=True, exist_ok=True)
yolo_weight = settings.source_root / "Seg_All_In_One_YoloModel" / "yolo11n-seg.pt"
mmseg_config = settings.source_root / "Seg_All_In_One_MMSeg" / "configs" / "fcn" / "fcn_r18-d8_4xb2-80k_cityscapes-512x1024.py"
checks = [
{
"name": "segmodel_tiny_train_step",
"passed": False,
"detail": _run_snippet(SEGMODEL_TRAIN_STEP_SNIPPET, timeout=90),
},
{
"name": "yolo_tiny_segment_train_epoch",
"passed": False,
"detail": _run_snippet(_yolo_tiny_train_snippet(fixture_root / "yolo_tiny", yolo_weight), timeout=180),
},
{
"name": "mmseg_tiny_train_step",
"passed": False,
"detail": _run_conda_snippet(settings.mmseg_conda_env, _mmseg_train_step_snippet(mmseg_config), timeout=120),
},
]
for check in checks:
check["passed"] = bool(check["detail"].get("passed"))
report = {
"available": True,
"run_id": run_id,
"fixture_root": str(fixture_root),
"passed": all(item["passed"] for item in checks),
"checks": checks,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
latest = acceptance_root / "deep_latest.json"
latest.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return report
def run_live_acceptance(base_url: str = "http://127.0.0.1:8010") -> dict[str, Any]: def run_live_acceptance(base_url: str = "http://127.0.0.1:8010") -> dict[str, Any]:
"""Run a lightweight end-to-end smoke against the live API and job runner.""" """Run a lightweight end-to-end smoke against the live API and job runner."""
acceptance_root = settings.project_root / "var" / "acceptance" acceptance_root = settings.project_root / "var" / "acceptance"

View File

@@ -42,6 +42,8 @@ def evaluate_project() -> dict:
"loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower() and "CurvePanel" in frontend_text, "loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower() and "CurvePanel" in frontend_text,
"dataset_api": "/api/datasets" in backend_text and "api_upload_dataset_files" in backend_text, "dataset_api": "/api/datasets" in backend_text and "api_upload_dataset_files" in backend_text,
"curve_api": "/api/results/curves" in backend_text, "curve_api": "/api/results/curves" in backend_text,
"deep_acceptance_api": "/api/acceptance/deep" in backend_text,
"deep_acceptance_ui": "runDeepAcceptance" in frontend_text and "深度训练" in frontend_text,
"coverage_api": "/api/coverage" in backend_text and coverage["task_build_passed"], "coverage_api": "/api/coverage" in backend_text and coverage["task_build_passed"],
"visual_tools": "visual.yolo11_heatmap_v2" in catalog["task_types"] and "visual.fps" in catalog["task_types"], "visual_tools": "visual.yolo11_heatmap_v2" in catalog["task_types"] and "visual.fps" in catalog["task_types"],
"yolo_dataset_tools": "dataset.yolo_txt_sort" in catalog["task_types"] and "dataset.yolo_resize" in catalog["task_types"], "yolo_dataset_tools": "dataset.yolo_txt_sort" in catalog["task_types"] and "dataset.yolo_resize" in catalog["task_types"],

View File

@@ -8,7 +8,7 @@ import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from ..acceptance import run_live_acceptance from ..acceptance import run_deep_acceptance, run_live_acceptance
from ..catalog import get_catalog from ..catalog import get_catalog
from ..config import settings from ..config import settings
from ..coverage import get_coverage_report from ..coverage import get_coverage_report
@@ -108,6 +108,9 @@ def validate_project(run_build: bool = False) -> dict:
if os.getenv("SEG_VALIDATE_ACCEPTANCE", "1") == "1": if os.getenv("SEG_VALIDATE_ACCEPTANCE", "1") == "1":
acceptance = run_live_acceptance(backend_url) acceptance = run_live_acceptance(backend_url)
checks.append({"name": "live_acceptance_smoke", "passed": acceptance["passed"], "detail": acceptance}) checks.append({"name": "live_acceptance_smoke", "passed": acceptance["passed"], "detail": acceptance})
if os.getenv("SEG_VALIDATE_DEEP", "1") == "1":
deep_acceptance = run_deep_acceptance()
checks.append({"name": "deep_training_acceptance", "passed": deep_acceptance["passed"], "detail": deep_acceptance})
if run_build: if run_build:
tests = _run(["conda", "run", "-n", settings.backend_conda_env, "python", "-m", "pytest", "-q"], cwd=settings.project_root, timeout=120) tests = _run(["conda", "run", "-n", settings.backend_conda_env, "python", "-m", "pytest", "-q"], cwd=settings.project_root, timeout=120)

View File

@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from . import db from . import db
from .acceptance import latest_acceptance_report, run_live_acceptance from .acceptance import latest_acceptance_report, latest_deep_acceptance_report, run_deep_acceptance, run_live_acceptance
from .catalog import get_catalog from .catalog import get_catalog
from .config import settings from .config import settings
from .coverage import get_coverage_report from .coverage import get_coverage_report
@@ -81,6 +81,16 @@ def api_acceptance_smoke(base_url: str = "http://127.0.0.1:8010") -> dict:
return run_live_acceptance(base_url) return run_live_acceptance(base_url)
@app.get("/api/acceptance/deep/latest")
def api_deep_acceptance_latest() -> dict:
return latest_deep_acceptance_report()
@app.post("/api/acceptance/deep")
def api_deep_acceptance() -> dict:
return run_deep_acceptance()
@app.get("/api/datasets") @app.get("/api/datasets")
def api_datasets() -> list[dict]: def api_datasets() -> list[dict]:
return list_uploaded_datasets() return list_uploaded_datasets()

View File

@@ -109,6 +109,14 @@ type AcceptancePayload = {
}; };
}; };
type DeepAcceptancePayload = {
available?: boolean;
passed?: boolean;
run_id?: string;
created_at?: string;
checks?: Array<{ name: string; passed: boolean }>;
};
type GpuPayload = { type GpuPayload = {
available: boolean; available: boolean;
gpus: Array<{ gpus: Array<{
@@ -191,11 +199,12 @@ function useData() {
const [datasets, setDatasets] = useState<UploadedDataset[]>([]); const [datasets, setDatasets] = useState<UploadedDataset[]>([]);
const [coverage, setCoverage] = useState<CoveragePayload | null>(null); const [coverage, setCoverage] = useState<CoveragePayload | null>(null);
const [acceptance, setAcceptance] = useState<AcceptancePayload | null>(null); const [acceptance, setAcceptance] = useState<AcceptancePayload | null>(null);
const [deepAcceptance, setDeepAcceptance] = useState<DeepAcceptancePayload | null>(null);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
async function refresh() { async function refresh() {
try { try {
const [catalogNext, gpusNext, jobsNext, resultsNext, curvesNext, datasetsNext, coverageNext, acceptanceNext] = await Promise.all([ const [catalogNext, gpusNext, jobsNext, resultsNext, curvesNext, datasetsNext, coverageNext, acceptanceNext, deepAcceptanceNext] = await Promise.all([
api<Catalog>("/api/catalog"), api<Catalog>("/api/catalog"),
api<GpuPayload>("/api/system/gpus"), api<GpuPayload>("/api/system/gpus"),
api<Job[]>("/api/jobs"), api<Job[]>("/api/jobs"),
@@ -203,7 +212,8 @@ function useData() {
api<TrainingCurve[]>("/api/results/curves"), api<TrainingCurve[]>("/api/results/curves"),
api<UploadedDataset[]>("/api/datasets"), api<UploadedDataset[]>("/api/datasets"),
api<CoveragePayload>("/api/coverage"), api<CoveragePayload>("/api/coverage"),
api<AcceptancePayload>("/api/acceptance/latest") api<AcceptancePayload>("/api/acceptance/latest"),
api<DeepAcceptancePayload>("/api/acceptance/deep/latest")
]); ]);
setCatalog(catalogNext); setCatalog(catalogNext);
setGpus(gpusNext); setGpus(gpusNext);
@@ -213,6 +223,7 @@ function useData() {
setDatasets(datasetsNext); setDatasets(datasetsNext);
setCoverage(coverageNext); setCoverage(coverageNext);
setAcceptance(acceptanceNext); setAcceptance(acceptanceNext);
setDeepAcceptance(deepAcceptanceNext);
setError(""); setError("");
} catch (err) { } catch (err) {
setError(String(err)); setError(String(err));
@@ -225,7 +236,7 @@ function useData() {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, []); }, []);
return { catalog, gpus, jobs, results, curves, datasets, coverage, acceptance, error, refresh }; return { catalog, gpus, jobs, results, curves, datasets, coverage, acceptance, deepAcceptance, error, refresh };
} }
function StatusPill({ status }: { status: string }) { function StatusPill({ status }: { status: string }) {
@@ -233,7 +244,7 @@ function StatusPill({ status }: { status: string }) {
} }
function App() { function App() {
const { catalog, gpus, jobs, results, curves, datasets, coverage, acceptance, error, refresh } = useData(); const { catalog, gpus, jobs, results, curves, datasets, coverage, acceptance, deepAcceptance, error, refresh } = useData();
const [taskType, setTaskType] = useState("mock.echo"); const [taskType, setTaskType] = useState("mock.echo");
const [params, setParams] = useState(JSON.stringify(defaultParams["mock.echo"], null, 2)); const [params, setParams] = useState(JSON.stringify(defaultParams["mock.echo"], null, 2));
const [selectedJob, setSelectedJob] = useState<Job | null>(null); const [selectedJob, setSelectedJob] = useState<Job | null>(null);
@@ -333,6 +344,16 @@ function App() {
} }
} }
async function runDeepAcceptance() {
setBusy(true);
try {
await api("/api/acceptance/deep", { method: "POST" });
await refresh();
} finally {
setBusy(false);
}
}
async function createDataset() { async function createDataset() {
setBusy(true); setBusy(true);
try { try {
@@ -591,9 +612,14 @@ function App() {
<p className="eyebrow">Coverage</p> <p className="eyebrow">Coverage</p>
<h2>Seg </h2> <h2>Seg </h2>
</div> </div>
<button className="iconButton" disabled={busy} onClick={runAcceptanceSmoke} title="运行轻量验收"> <div className="buttonRow compactButtons">
<ClipboardCheck size={18} /> <button className="iconButton" disabled={busy} onClick={runAcceptanceSmoke} title="运行轻量验收">
</button> <ClipboardCheck size={18} />
</button>
<button className="iconButton" disabled={busy} onClick={runDeepAcceptance} title="运行深度训练验收">
<Activity size={18} />
</button>
</div>
</div> </div>
<div className="coverageGrid"> <div className="coverageGrid">
<div> <div>
@@ -616,12 +642,17 @@ function App() {
<span></span> <span></span>
<strong>{acceptance?.model_family_readiness?.passed ? "OK" : "Check"}</strong> <strong>{acceptance?.model_family_readiness?.passed ? "OK" : "Check"}</strong>
</div> </div>
<div>
<span></span>
<strong>{deepAcceptance?.available === false ? "New" : deepAcceptance?.passed ? "OK" : "Check"}</strong>
</div>
</div> </div>
<div className="coverageStatus"> <div className="coverageStatus">
{(coverage?.unmapped_user_scripts.length ?? 0) === 0 ? ( {(coverage?.unmapped_user_scripts.length ?? 0) === 0 ? (
<> <>
<span></span> <span></span>
<span>{acceptance?.created_at ?? "尚未运行"} {acceptance?.run_id ? `#${acceptance.run_id}` : ""}</span> <span>{acceptance?.created_at ?? "尚未运行"} {acceptance?.run_id ? `#${acceptance.run_id}` : ""}</span>
<span>{deepAcceptance?.created_at ?? "尚未运行"} {deepAcceptance?.run_id ? `#${deepAcceptance.run_id}` : ""} {deepAcceptance?.checks?.filter((item) => item.passed).length ?? 0}/{deepAcceptance?.checks?.length ?? 0}</span>
<span> readiness{acceptance?.model_family_readiness?.checks?.filter((item) => item.passed).length ?? 0}/{acceptance?.model_family_readiness?.checks?.length ?? 0}warnings {acceptance?.model_family_readiness?.warnings?.length ?? 0}</span> <span> readiness{acceptance?.model_family_readiness?.checks?.filter((item) => item.passed).length ?? 0}/{acceptance?.model_family_readiness?.checks?.length ?? 0}warnings {acceptance?.model_family_readiness?.warnings?.length ?? 0}</span>
</> </>
) : ( ) : (

View File

@@ -358,6 +358,10 @@ textarea {
gap: 10px; gap: 10px;
} }
.buttonRow.compactButtons {
gap: 8px;
}
.opGrid { .opGrid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -399,7 +403,7 @@ textarea {
.coverageGrid { .coverageGrid {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px; gap: 10px;
margin-bottom: 14px; margin-bottom: 14px;
} }