diff --git a/README.md b/README.md index 51c732d..9bb92a5 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ checks YOLO txt labels and mask dimensions, and can generate a `dataset.yaml` for the `yolo.train_custom` task. The selected upload dataset also exposes direct YOLO custom train, predict, and heatmap actions; custom outputs are written under `var/custom_yolo_runs` and are scanned by the results dashboard. +When a dataset is selected, the dataset panel shows its custom YOLO `best.pt`, +prediction previews, heatmap previews, and detected training curves. Segmentation previews, YOLO heatmaps, and loss/metric artifacts are grouped on the results dashboard, and YOLO-style `results.csv` files are parsed into lightweight training curves. diff --git a/backend/app/agents/evaluation_agent.py b/backend/app/agents/evaluation_agent.py index 6776dac..47193de 100644 --- a/backend/app/agents/evaluation_agent.py +++ b/backend/app/agents/evaluation_agent.py @@ -44,6 +44,7 @@ def evaluate_project() -> dict: "upload_ui": "uploadDatasetFiles" in frontend_text and "labels" in frontend_text and "masks" in frontend_text, "dataset_quality_ui": "DatasetQuality" in frontend_text and "generateSelectedYoloYaml" in frontend_text, "uploaded_yolo_workflow_ui": "startSelectedYoloTrain" in frontend_text and "startSelectedYoloPredict" in frontend_text and "startSelectedYoloHeatmap" in frontend_text, + "dataset_yolo_outputs_ui": "DatasetYoloOutputs" in frontend_text and "selectedYoloOutputs" in frontend_text and "BEST.PT READY" in frontend_text, "loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower() and "CurvePanel" in frontend_text, "job_progress_ui": "JobProgressBar" in frontend_text and "progressTrack" in frontend_text, "runtime_readiness_ui": "runtimeReadiness" in frontend_text and "环境就绪" in frontend_text, diff --git a/backend/app/modules/system/service.py b/backend/app/modules/system/service.py index 5c6e9ea..28c46a7 100644 --- a/backend/app/modules/system/service.py +++ b/backend/app/modules/system/service.py @@ -14,6 +14,7 @@ import fcntl from ...config import settings READINESS_CACHE_SECONDS = 300 +READINESS_FAILURE_CACHE_SECONDS = 30 _readiness_cache: tuple[float, dict[str, Any]] | None = None _readiness_thread_lock = threading.Lock() @@ -215,18 +216,22 @@ def inspect_conda_env(env_name: str, required_imports: list[dict[str, str]], tim def get_runtime_readiness(force: bool = False) -> dict[str, Any]: global _readiness_cache now = time.time() - if not force and _readiness_cache and now - _readiness_cache[0] < READINESS_CACHE_SECONDS: - cached = dict(_readiness_cache[1]) - cached["cached"] = True - return cached - - with readiness_probe_lock(): - now = time.time() - if not force and _readiness_cache and now - _readiness_cache[0] < READINESS_CACHE_SECONDS: + if not force and _readiness_cache: + cache_ttl = READINESS_CACHE_SECONDS if _readiness_cache[1].get("passed") else READINESS_FAILURE_CACHE_SECONDS + if now - _readiness_cache[0] < cache_ttl: cached = dict(_readiness_cache[1]) cached["cached"] = True return cached + with readiness_probe_lock(): + now = time.time() + if not force and _readiness_cache: + cache_ttl = READINESS_CACHE_SECONDS if _readiness_cache[1].get("passed") else READINESS_FAILURE_CACHE_SECONDS + if now - _readiness_cache[0] < cache_ttl: + cached = dict(_readiness_cache[1]) + cached["cached"] = True + return cached + conda = get_conda_envs() env_paths = {item["name"]: item["path"] for item in conda.get("envs", [])} envs: list[dict[str, Any]] = [] @@ -254,6 +259,7 @@ def get_runtime_readiness(force: bool = False) -> dict[str, Any]: "passed": bool(conda.get("available")) and all(item["passed"] for item in envs), "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)), "cache_seconds": READINESS_CACHE_SECONDS, + "failure_cache_seconds": READINESS_FAILURE_CACHE_SECONDS, "cached": False, "envs": envs, "specs": { diff --git a/backend/tests/test_system_service.py b/backend/tests/test_system_service.py index 984ccb5..1a5c22c 100644 --- a/backend/tests/test_system_service.py +++ b/backend/tests/test_system_service.py @@ -71,3 +71,15 @@ def test_runtime_readiness_aggregates_probe_results(monkeypatch): assert readiness["passed"] is True assert readiness["envs"][0]["path"] == "/envs/seg_smp" assert readiness["specs"]["env_files"] == ["envs/seg_smp.yml", "envs/seg_mmcv.yml"] + + +def test_runtime_readiness_refreshes_stale_failure_cache(monkeypatch): + monkeypatch.setattr(service, "_readiness_cache", (100.0, {"passed": False, "envs": [], "available": True})) + monkeypatch.setattr(service.time, "time", lambda: 100.0 + service.READINESS_FAILURE_CACHE_SECONDS + 1) + monkeypatch.setattr(service, "runtime_environment_specs", lambda: []) + monkeypatch.setattr(service, "get_conda_envs", lambda: {"available": True, "envs": []}) + + readiness = get_runtime_readiness(force=False) + + assert readiness["cached"] is False + assert readiness["passed"] is True diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 952bfad..17cbc50 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -115,6 +115,14 @@ type TrainingCurve = { series: CurveSeries[]; }; +type DatasetYoloOutputsPayload = { + bestWeight?: ResultItem; + artifacts: ResultItem[]; + curves: TrainingCurve[]; + predictions: ResultItem[]; + heatmaps: ResultItem[]; +}; + type CoveragePayload = { scripts_total: number; user_scripts_total: number; @@ -352,7 +360,7 @@ function useData() { setRuntimeReadiness(readinessNext); setCapabilities(capabilitiesNext); setJobs(jobsNext); - setResults(resultsNext.slice(0, 80)); + setResults(resultsNext.slice(0, 240)); setCurves(curvesNext.slice(0, 12)); setDatasets(datasetsNext); const validationEntries: Array<[string, DatasetValidation]> = []; @@ -444,6 +452,26 @@ function App() { ); const selectedValidation = selectedDataset ? datasetValidations[selectedDataset.name] : undefined; const selectedCurve = curves.find((curve) => curve.relative_path === selectedCurvePath) ?? curves[0]; + const selectedYoloOutputs = useMemo(() => { + if (!selectedDataset) { + return { artifacts: [], curves: [], predictions: [], heatmaps: [] }; + } + const prefixes = [ + `var/custom_yolo_runs/${selectedDataset.name}`, + `var/custom_yolo_runs/${selectedDataset.name}_predict`, + `var/custom_yolo_runs/${selectedDataset.name}_heatmap` + ]; + const matches = (relativePath: string) => prefixes.some((prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`)); + const artifacts = results.filter((item) => matches(item.relative_path)); + return { + bestWeight: artifacts.find((item) => item.relative_path.endsWith("/weights/best.pt")), + artifacts, + curves: curves.filter((curve) => matches(curve.relative_path)), + predictions: artifacts.filter((item) => item.role === "segmentation" && item.previewable), + heatmaps: artifacts.filter((item) => item.role === "heatmap" && item.previewable) + }; + }, [curves, results, selectedDataset]); + const selectedYoloWeightReady = Boolean(selectedYoloOutputs.bestWeight); function pickTask(next: string) { setTaskType(next); @@ -562,7 +590,7 @@ function App() { function customYoloWeightPath(dataset: UploadedDataset) { const expected = `var/custom_yolo_runs/${dataset.name}/weights/best.pt`; - return results.find((item) => item.relative_path === expected || item.relative_path.endsWith(`/custom_yolo_runs/${dataset.name}/weights/best.pt`))?.relative_path ?? expected; + return selectedYoloOutputs.bestWeight?.relative_path ?? results.find((item) => item.relative_path === expected || item.relative_path.endsWith(`/custom_yolo_runs/${dataset.name}/weights/best.pt`))?.relative_path ?? expected; } async function createSelectedYoloYaml() { @@ -879,10 +907,10 @@ function App() { - - @@ -929,6 +957,7 @@ function App() { ))} {selectedValidation && } + {selectedDataset && } @@ -1258,6 +1287,48 @@ function DatasetQuality({ validation }: { validation: DatasetValidation }) { ); } +function DatasetYoloOutputs({ dataset, outputs }: { dataset: UploadedDataset; outputs: DatasetYoloOutputsPayload }) { + const previewItems = [...outputs.heatmaps.slice(0, 3), ...outputs.predictions.slice(0, 3)].slice(0, 6); + return ( +
+
+ {dataset.name} · YOLO + {outputs.bestWeight ? "BEST.PT READY" : "BEST.PT MISSING"} +
+
+
Weights{outputs.bestWeight ? 1 : 0}
+
Predict{outputs.predictions.length}
+
Heatmap{outputs.heatmaps.length}
+
Curves{outputs.curves.length}
+
+
+ {outputs.bestWeight && ( + + best.pt + {formatBytes(outputs.bestWeight.size)} + + )} + {outputs.curves.slice(0, 2).map((curve) => ( + + {curve.name} + {curve.row_count} epochs + + ))} +
+ {!!previewItems.length && ( +
+ {previewItems.map((item) => ( + + {item.name} + {item.role} + + ))} +
+ )} +
+ ); +} + function CurvePanel({ curves, selected, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ba524ef..39b6440 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -761,6 +761,16 @@ textarea { background: #0b0d0b; } +.datasetOutputBox { + display: grid; + gap: 10px; + margin-top: 12px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 7px; + background: #0b0d0b; +} + .qualityHead { display: flex; justify-content: space-between; @@ -817,6 +827,65 @@ textarea { border-color: rgba(240, 113, 103, 0.55); } +.datasetOutputLinks { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.datasetOutputLinks a { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + padding: 8px; + border-radius: 6px; + border: 1px solid var(--line); + background: #101310; + color: var(--ink); + text-decoration: none; +} + +.datasetOutputLinks span, +.datasetOutputLinks small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.datasetOutputPreview { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.datasetOutputPreview a { + min-width: 0; + overflow: hidden; + border-radius: 6px; + border: 1px solid var(--line); + background: #101310; + color: var(--muted); + text-decoration: none; +} + +.datasetOutputPreview img { + display: block; + width: 100%; + aspect-ratio: 1.35 / 1; + object-fit: cover; + background: #060806; +} + +.datasetOutputPreview span { + display: block; + padding: 6px; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .jobList, .resultList { display: grid; gap: 8px; @@ -1201,6 +1270,8 @@ meter { .opGrid, .sampleStrip, + .datasetOutputLinks, + .datasetOutputPreview, .taskCheckList, .agentCheckList, .qualityStats,