Show dataset YOLO output readiness
This commit is contained in:
@@ -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
|
for the `yolo.train_custom` task. The selected upload dataset also exposes
|
||||||
direct YOLO custom train, predict, and heatmap actions; custom outputs are
|
direct YOLO custom train, predict, and heatmap actions; custom outputs are
|
||||||
written under `var/custom_yolo_runs` and are scanned by the results dashboard.
|
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
|
Segmentation previews, YOLO heatmaps, and loss/metric artifacts are grouped on
|
||||||
the results dashboard, and YOLO-style `results.csv` files are parsed into
|
the results dashboard, and YOLO-style `results.csv` files are parsed into
|
||||||
lightweight training curves.
|
lightweight training curves.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ def evaluate_project() -> dict:
|
|||||||
"upload_ui": "uploadDatasetFiles" in frontend_text and "labels" in frontend_text and "masks" in frontend_text,
|
"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,
|
"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,
|
"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,
|
"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,
|
"job_progress_ui": "JobProgressBar" in frontend_text and "progressTrack" in frontend_text,
|
||||||
"runtime_readiness_ui": "runtimeReadiness" in frontend_text and "环境就绪" in frontend_text,
|
"runtime_readiness_ui": "runtimeReadiness" in frontend_text and "环境就绪" in frontend_text,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import fcntl
|
|||||||
from ...config import settings
|
from ...config import settings
|
||||||
|
|
||||||
READINESS_CACHE_SECONDS = 300
|
READINESS_CACHE_SECONDS = 300
|
||||||
|
READINESS_FAILURE_CACHE_SECONDS = 30
|
||||||
_readiness_cache: tuple[float, dict[str, Any]] | None = None
|
_readiness_cache: tuple[float, dict[str, Any]] | None = None
|
||||||
_readiness_thread_lock = threading.Lock()
|
_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]:
|
def get_runtime_readiness(force: bool = False) -> dict[str, Any]:
|
||||||
global _readiness_cache
|
global _readiness_cache
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if not force and _readiness_cache and now - _readiness_cache[0] < READINESS_CACHE_SECONDS:
|
if not force and _readiness_cache:
|
||||||
cached = dict(_readiness_cache[1])
|
cache_ttl = READINESS_CACHE_SECONDS if _readiness_cache[1].get("passed") else READINESS_FAILURE_CACHE_SECONDS
|
||||||
cached["cached"] = True
|
if now - _readiness_cache[0] < cache_ttl:
|
||||||
return cached
|
|
||||||
|
|
||||||
with readiness_probe_lock():
|
|
||||||
now = time.time()
|
|
||||||
if not force and _readiness_cache and now - _readiness_cache[0] < READINESS_CACHE_SECONDS:
|
|
||||||
cached = dict(_readiness_cache[1])
|
cached = dict(_readiness_cache[1])
|
||||||
cached["cached"] = True
|
cached["cached"] = True
|
||||||
return cached
|
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()
|
conda = get_conda_envs()
|
||||||
env_paths = {item["name"]: item["path"] for item in conda.get("envs", [])}
|
env_paths = {item["name"]: item["path"] for item in conda.get("envs", [])}
|
||||||
envs: list[dict[str, Any]] = []
|
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),
|
"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)),
|
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
|
||||||
"cache_seconds": READINESS_CACHE_SECONDS,
|
"cache_seconds": READINESS_CACHE_SECONDS,
|
||||||
|
"failure_cache_seconds": READINESS_FAILURE_CACHE_SECONDS,
|
||||||
"cached": False,
|
"cached": False,
|
||||||
"envs": envs,
|
"envs": envs,
|
||||||
"specs": {
|
"specs": {
|
||||||
|
|||||||
@@ -71,3 +71,15 @@ def test_runtime_readiness_aggregates_probe_results(monkeypatch):
|
|||||||
assert readiness["passed"] is True
|
assert readiness["passed"] is True
|
||||||
assert readiness["envs"][0]["path"] == "/envs/seg_smp"
|
assert readiness["envs"][0]["path"] == "/envs/seg_smp"
|
||||||
assert readiness["specs"]["env_files"] == ["envs/seg_smp.yml", "envs/seg_mmcv.yml"]
|
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
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ type TrainingCurve = {
|
|||||||
series: CurveSeries[];
|
series: CurveSeries[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DatasetYoloOutputsPayload = {
|
||||||
|
bestWeight?: ResultItem;
|
||||||
|
artifacts: ResultItem[];
|
||||||
|
curves: TrainingCurve[];
|
||||||
|
predictions: ResultItem[];
|
||||||
|
heatmaps: ResultItem[];
|
||||||
|
};
|
||||||
|
|
||||||
type CoveragePayload = {
|
type CoveragePayload = {
|
||||||
scripts_total: number;
|
scripts_total: number;
|
||||||
user_scripts_total: number;
|
user_scripts_total: number;
|
||||||
@@ -352,7 +360,7 @@ function useData() {
|
|||||||
setRuntimeReadiness(readinessNext);
|
setRuntimeReadiness(readinessNext);
|
||||||
setCapabilities(capabilitiesNext);
|
setCapabilities(capabilitiesNext);
|
||||||
setJobs(jobsNext);
|
setJobs(jobsNext);
|
||||||
setResults(resultsNext.slice(0, 80));
|
setResults(resultsNext.slice(0, 240));
|
||||||
setCurves(curvesNext.slice(0, 12));
|
setCurves(curvesNext.slice(0, 12));
|
||||||
setDatasets(datasetsNext);
|
setDatasets(datasetsNext);
|
||||||
const validationEntries: Array<[string, DatasetValidation]> = [];
|
const validationEntries: Array<[string, DatasetValidation]> = [];
|
||||||
@@ -444,6 +452,26 @@ function App() {
|
|||||||
);
|
);
|
||||||
const selectedValidation = selectedDataset ? datasetValidations[selectedDataset.name] : undefined;
|
const selectedValidation = selectedDataset ? datasetValidations[selectedDataset.name] : undefined;
|
||||||
const selectedCurve = curves.find((curve) => curve.relative_path === selectedCurvePath) ?? curves[0];
|
const selectedCurve = curves.find((curve) => curve.relative_path === selectedCurvePath) ?? curves[0];
|
||||||
|
const selectedYoloOutputs = useMemo<DatasetYoloOutputsPayload>(() => {
|
||||||
|
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) {
|
function pickTask(next: string) {
|
||||||
setTaskType(next);
|
setTaskType(next);
|
||||||
@@ -562,7 +590,7 @@ function App() {
|
|||||||
|
|
||||||
function customYoloWeightPath(dataset: UploadedDataset) {
|
function customYoloWeightPath(dataset: UploadedDataset) {
|
||||||
const expected = `var/custom_yolo_runs/${dataset.name}/weights/best.pt`;
|
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() {
|
async function createSelectedYoloYaml() {
|
||||||
@@ -879,10 +907,10 @@ function App() {
|
|||||||
<button className="iconButton" disabled={busy || !selectedValidation?.ready.yolo} onClick={startSelectedYoloTrain} title="启动自定义 YOLO 训练">
|
<button className="iconButton" disabled={busy || !selectedValidation?.ready.yolo} onClick={startSelectedYoloTrain} title="启动自定义 YOLO 训练">
|
||||||
<Play size={18} />
|
<Play size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button className="iconButton" disabled={busy || !selectedDataset?.absolute_layout} onClick={startSelectedYoloPredict} title="使用自定义 best.pt 预测">
|
<button className="iconButton" disabled={busy || !selectedDataset?.absolute_layout || !selectedYoloWeightReady} onClick={startSelectedYoloPredict} title={selectedYoloWeightReady ? "使用自定义 best.pt 预测" : "best.pt 尚未生成"}>
|
||||||
<FileImage size={18} />
|
<FileImage size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button className="iconButton" disabled={busy || !selectedDataset?.absolute_layout} onClick={startSelectedYoloHeatmap} title="使用自定义 best.pt 生成热度图">
|
<button className="iconButton" disabled={busy || !selectedDataset?.absolute_layout || !selectedYoloWeightReady} onClick={startSelectedYoloHeatmap} title={selectedYoloWeightReady ? "使用自定义 best.pt 生成热度图" : "best.pt 尚未生成"}>
|
||||||
<Zap size={18} />
|
<Zap size={18} />
|
||||||
</button>
|
</button>
|
||||||
<FileImage size={22} />
|
<FileImage size={22} />
|
||||||
@@ -929,6 +957,7 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{selectedValidation && <DatasetQuality validation={selectedValidation} />}
|
{selectedValidation && <DatasetQuality validation={selectedValidation} />}
|
||||||
|
{selectedDataset && <DatasetYoloOutputs dataset={selectedDataset} outputs={selectedYoloOutputs} />}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -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 (
|
||||||
|
<div className="datasetOutputBox">
|
||||||
|
<div className="qualityHead">
|
||||||
|
<strong>{dataset.name} · YOLO</strong>
|
||||||
|
<span>{outputs.bestWeight ? "BEST.PT READY" : "BEST.PT MISSING"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="qualityStats">
|
||||||
|
<div><span>Weights</span><strong>{outputs.bestWeight ? 1 : 0}</strong></div>
|
||||||
|
<div><span>Predict</span><strong>{outputs.predictions.length}</strong></div>
|
||||||
|
<div><span>Heatmap</span><strong>{outputs.heatmaps.length}</strong></div>
|
||||||
|
<div><span>Curves</span><strong>{outputs.curves.length}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div className="datasetOutputLinks">
|
||||||
|
{outputs.bestWeight && (
|
||||||
|
<a href={`${API_BASE}/api/artifacts/${outputs.bestWeight.relative_path}`} target="_blank" rel="noreferrer">
|
||||||
|
<span>best.pt</span>
|
||||||
|
<small>{formatBytes(outputs.bestWeight.size)}</small>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{outputs.curves.slice(0, 2).map((curve) => (
|
||||||
|
<a key={curve.relative_path} href={`${API_BASE}/api/artifacts/${curve.relative_path}`} target="_blank" rel="noreferrer">
|
||||||
|
<span>{curve.name}</span>
|
||||||
|
<small>{curve.row_count} epochs</small>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!!previewItems.length && (
|
||||||
|
<div className="datasetOutputPreview">
|
||||||
|
{previewItems.map((item) => (
|
||||||
|
<a key={item.relative_path} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer">
|
||||||
|
<img src={`${API_BASE}/api/artifacts/${item.relative_path}`} alt={item.name} />
|
||||||
|
<span>{item.role}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CurvePanel({
|
function CurvePanel({
|
||||||
curves,
|
curves,
|
||||||
selected,
|
selected,
|
||||||
|
|||||||
@@ -761,6 +761,16 @@ textarea {
|
|||||||
background: #0b0d0b;
|
background: #0b0d0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.datasetOutputBox {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #0b0d0b;
|
||||||
|
}
|
||||||
|
|
||||||
.qualityHead {
|
.qualityHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -817,6 +827,65 @@ textarea {
|
|||||||
border-color: rgba(240, 113, 103, 0.55);
|
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 {
|
.jobList, .resultList {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1201,6 +1270,8 @@ meter {
|
|||||||
|
|
||||||
.opGrid,
|
.opGrid,
|
||||||
.sampleStrip,
|
.sampleStrip,
|
||||||
|
.datasetOutputLinks,
|
||||||
|
.datasetOutputPreview,
|
||||||
.taskCheckList,
|
.taskCheckList,
|
||||||
.agentCheckList,
|
.agentCheckList,
|
||||||
.qualityStats,
|
.qualityStats,
|
||||||
|
|||||||
Reference in New Issue
Block a user