Add result curve discovery dashboard
This commit is contained in:
@@ -39,8 +39,9 @@ def evaluate_project() -> dict:
|
||||
expectations = {
|
||||
"left_nav_dataset": "数据集" in frontend_text and "#datasets" in frontend_text,
|
||||
"upload_ui": "uploadDatasetFiles" in frontend_text and "labels" in frontend_text and "masks" in frontend_text,
|
||||
"loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower(),
|
||||
"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,
|
||||
"curve_api": "/api/results/curves" in backend_text,
|
||||
"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"],
|
||||
"yolo_dataset_tools": "dataset.yolo_txt_sort" in catalog["task_types"] and "dataset.yolo_resize" in catalog["task_types"],
|
||||
|
||||
@@ -12,6 +12,7 @@ from ..acceptance import run_live_acceptance
|
||||
from ..catalog import get_catalog
|
||||
from ..config import settings
|
||||
from ..coverage import get_coverage_report
|
||||
from ..modules.results.service import scan_training_curves
|
||||
from ..modules.system.service import get_conda_envs, get_gpus
|
||||
from ..modules.weights.service import load_manifest
|
||||
|
||||
@@ -53,6 +54,8 @@ def validate_project(run_build: bool = False) -> dict:
|
||||
checks.append({"name": "task_buildability", "passed": coverage["task_build_passed"], "detail": coverage["task_build_checks"]})
|
||||
checks.append({"name": "script_coverage_user_facing", "passed": not coverage["unmapped_user_scripts"], "detail": coverage})
|
||||
checks.append({"name": "weights_manifest_present", "passed": manifest.get("count", 0) >= 1})
|
||||
curves = scan_training_curves()
|
||||
checks.append({"name": "training_curves_detected", "passed": len(curves) >= 1, "detail": {"count": len(curves), "examples": [item["relative_path"] for item in curves[:5]]}})
|
||||
checks.append({"name": "gpus_query", "passed": bool(get_gpus().get("available"))})
|
||||
env_names = [item["name"] for item in get_conda_envs().get("envs", [])]
|
||||
checks.append({"name": "task_env_exists", "passed": settings.task_conda_env in env_names, "detail": {"env": settings.task_conda_env}})
|
||||
@@ -95,10 +98,12 @@ def validate_project(run_build: bool = False) -> dict:
|
||||
health = _fetch(f"{backend_url}/api/health")
|
||||
datasets = _fetch(f"{backend_url}/api/datasets")
|
||||
live_coverage = _fetch(f"{backend_url}/api/coverage")
|
||||
live_curves = _fetch(f"{backend_url}/api/results/curves")
|
||||
frontend = _fetch(frontend_url)
|
||||
checks.append({"name": "live_backend_health", "passed": health["passed"] and '"ok":true' in health.get("body", "").replace(" ", ""), "detail": health})
|
||||
checks.append({"name": "live_dataset_api", "passed": datasets["passed"] and datasets.get("body", "").lstrip().startswith("["), "detail": datasets})
|
||||
checks.append({"name": "live_coverage_api", "passed": live_coverage["passed"] and '"task_build_passed":true' in live_coverage.get("body", "").replace(" ", ""), "detail": live_coverage})
|
||||
checks.append({"name": "live_training_curves_api", "passed": live_curves["passed"] and live_curves.get("body", "").lstrip().startswith("["), "detail": live_curves})
|
||||
checks.append({"name": "live_frontend_index", "passed": frontend["passed"] and "Seg Data Server" in frontend.get("body", ""), "detail": frontend})
|
||||
if os.getenv("SEG_VALIDATE_ACCEPTANCE", "1") == "1":
|
||||
acceptance = run_live_acceptance(backend_url)
|
||||
|
||||
@@ -14,7 +14,8 @@ from .catalog import get_catalog
|
||||
from .config import settings
|
||||
from .coverage import get_coverage_report
|
||||
from .jobs import cancel_job, create_job
|
||||
from .modules.system.service import disk_usage, get_conda_envs, get_gpus, scan_results
|
||||
from .modules.results.service import scan_results, scan_training_curves
|
||||
from .modules.system.service import disk_usage, get_conda_envs, get_gpus
|
||||
from .modules.dataset.service import create_dataset, list_uploaded_datasets, save_upload
|
||||
from .modules.weights.service import load_manifest, sync_weights, verify_weights
|
||||
from .agents.evaluation_agent import evaluate_project
|
||||
@@ -170,6 +171,11 @@ def api_results() -> list[dict]:
|
||||
return scan_results()
|
||||
|
||||
|
||||
@app.get("/api/results/curves")
|
||||
def api_result_curves() -> list[dict]:
|
||||
return scan_training_curves()
|
||||
|
||||
|
||||
@app.get("/api/artifacts/{artifact_path:path}")
|
||||
def api_artifact(artifact_path: str):
|
||||
candidate = Path(artifact_path)
|
||||
|
||||
@@ -80,6 +80,7 @@ def describe_dataset(name: str) -> dict:
|
||||
safe_name = slugify(name)
|
||||
root = dataset_dir(safe_name)
|
||||
meta = _load_meta(safe_name)
|
||||
absolute_layout = {kind: str((root / kind).resolve()) for kind in DATASET_KINDS}
|
||||
counts = {}
|
||||
samples = {}
|
||||
for kind in sorted(DATASET_KINDS):
|
||||
@@ -95,7 +96,7 @@ def describe_dataset(name: str) -> dict:
|
||||
}
|
||||
for path in files[:80]
|
||||
]
|
||||
return {**meta, "counts": counts, "samples": samples}
|
||||
return {**meta, "absolute_layout": absolute_layout, "counts": counts, "samples": samples}
|
||||
|
||||
|
||||
def list_uploaded_datasets() -> list[dict]:
|
||||
|
||||
1
backend/app/modules/results/__init__.py
Normal file
1
backend/app/modules/results/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Result artifact discovery and lightweight metric parsing."""
|
||||
208
backend/app/modules/results/service.py
Normal file
208
backend/app/modules/results/service.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from ...config import settings
|
||||
|
||||
RESULT_EXTS = {".csv", ".png", ".jpg", ".jpeg", ".svg", ".log", ".pth", ".pt", ".mp4", ".avi"}
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".svg"}
|
||||
CURVE_NAME_HINTS = ("results.csv", "loss", "metric", "miou", "iou")
|
||||
SERIES_HINTS = ("loss", "miou", "iou", "map", "precision", "recall", "acc", "dice", "f1")
|
||||
SKIP_SERIES_HINTS = ("time", "lr/")
|
||||
|
||||
|
||||
def result_roots() -> list[Path]:
|
||||
source = settings.source_root
|
||||
project = settings.project_root
|
||||
roots = [
|
||||
source / "DataSet_Public_outputs",
|
||||
source / "BestMode_Predict_Results_DataSet_Public",
|
||||
source / "Hardisk",
|
||||
source / "Seg_All_In_One_Analysis",
|
||||
source / "Seg_Predict_YoloModel",
|
||||
source / "Seg_Predict_SegModel",
|
||||
source / "Seg_Predict_Own_Video_V2",
|
||||
source / "Tool-可视化" / "runs",
|
||||
source / "Tool-可视化" / "Data" / "result",
|
||||
source / "Tool-图片堆叠" / "result_0.3透明度",
|
||||
]
|
||||
upload_root = project / "var" / "uploads" / "datasets"
|
||||
if upload_root.exists():
|
||||
roots.extend(path for path in upload_root.glob("*/results") if path.is_dir())
|
||||
return roots
|
||||
|
||||
|
||||
def _safe_relative(path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
for root in (settings.source_root, settings.project_root):
|
||||
try:
|
||||
return str(resolved.relative_to(root))
|
||||
except ValueError:
|
||||
continue
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def _family_for_path(path: Path) -> str:
|
||||
lower = str(path).lower()
|
||||
if "yolo" in lower:
|
||||
return "yolo"
|
||||
if "mmseg" in lower or "dataset_public_outputs" in lower or "bestmode_predict" in lower:
|
||||
return "mmseg"
|
||||
if "segmodel" in lower:
|
||||
return "segmodel"
|
||||
if "analysis" in lower:
|
||||
return "analysis"
|
||||
if "tool-" in lower or "tool_" in lower:
|
||||
return "tool"
|
||||
return "artifact"
|
||||
|
||||
|
||||
def _role_for_path(path: Path) -> str:
|
||||
lower = str(path).lower()
|
||||
if any(key in lower for key in ("heat", "cam", "grad")):
|
||||
return "heatmap"
|
||||
if any(key in lower for key in ("predict", "pred", "mask", "comparison", "overlay", "result_")) and path.suffix.lower() in IMAGE_EXTS:
|
||||
return "segmentation"
|
||||
if any(key in lower for key in ("loss", "metric", "miou", "iou", "curve", "results.csv")):
|
||||
return "curve"
|
||||
if path.suffix.lower() in {".pt", ".pth"}:
|
||||
return "weight"
|
||||
if path.suffix.lower() in {".mp4", ".avi"}:
|
||||
return "video"
|
||||
return "artifact"
|
||||
|
||||
|
||||
def _iter_result_files() -> Iterable[Path]:
|
||||
seen: set[Path] = set()
|
||||
for root in result_roots():
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file() or path.suffix.lower() not in RESULT_EXTS:
|
||||
continue
|
||||
resolved = path.resolve()
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
yield resolved
|
||||
|
||||
|
||||
def scan_results(limit: int = 1000) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
for path in _iter_result_files():
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"path": str(path),
|
||||
"relative_path": _safe_relative(path),
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"kind": path.suffix.lower().lstrip("."),
|
||||
"family": _family_for_path(path),
|
||||
"role": _role_for_path(path),
|
||||
"previewable": path.suffix.lower() in IMAGE_EXTS,
|
||||
}
|
||||
)
|
||||
results.sort(key=lambda item: item["modified"], reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
def _is_curve_candidate(path: Path) -> bool:
|
||||
lower = path.name.lower()
|
||||
whole = str(path).lower()
|
||||
return path.suffix.lower() == ".csv" and (lower in CURVE_NAME_HINTS or any(hint in whole for hint in CURVE_NAME_HINTS))
|
||||
|
||||
|
||||
def _as_float(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
number = float(value.strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(number):
|
||||
return None
|
||||
return number
|
||||
|
||||
|
||||
def _series_candidates(headers: list[str]) -> list[str]:
|
||||
candidates = []
|
||||
for header in headers:
|
||||
key = header.strip()
|
||||
lower = key.lower()
|
||||
if any(skip in lower for skip in SKIP_SERIES_HINTS):
|
||||
continue
|
||||
if any(hint in lower for hint in SERIES_HINTS):
|
||||
candidates.append(key)
|
||||
return candidates[:10]
|
||||
|
||||
|
||||
def parse_training_curve(path: Path, max_points: int = 300) -> dict | None:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
if not reader.fieldnames:
|
||||
return None
|
||||
headers = [field.strip() for field in reader.fieldnames]
|
||||
x_key = "epoch" if "epoch" in headers else "iter" if "iter" in headers else "step" if "step" in headers else headers[0]
|
||||
series_names = _series_candidates(headers)
|
||||
if not series_names:
|
||||
return None
|
||||
rows = []
|
||||
for raw in reader:
|
||||
normalized = {key.strip(): value for key, value in raw.items() if key is not None}
|
||||
x = _as_float(normalized.get(x_key))
|
||||
if x is None:
|
||||
continue
|
||||
rows.append((x, normalized))
|
||||
except (OSError, UnicodeDecodeError, csv.Error):
|
||||
return None
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
stride = max(1, math.ceil(len(rows) / max_points))
|
||||
sampled = rows[::stride]
|
||||
series = []
|
||||
for name in series_names:
|
||||
points = []
|
||||
for x, row in sampled:
|
||||
y = _as_float(row.get(name))
|
||||
if y is not None:
|
||||
points.append({"x": x, "y": y})
|
||||
if len(points) >= 2:
|
||||
values = [point["y"] for point in points]
|
||||
series.append({"name": name, "points": points, "last": values[-1], "min": min(values), "max": max(values)})
|
||||
if not series:
|
||||
return None
|
||||
stat = path.stat()
|
||||
return {
|
||||
"name": path.parent.name if path.name.lower() == "results.csv" else path.stem,
|
||||
"file_name": path.name,
|
||||
"path": str(path.resolve()),
|
||||
"relative_path": _safe_relative(path),
|
||||
"modified": stat.st_mtime,
|
||||
"size": stat.st_size,
|
||||
"family": _family_for_path(path),
|
||||
"x_key": x_key,
|
||||
"row_count": len(rows),
|
||||
"series": series,
|
||||
}
|
||||
|
||||
|
||||
def scan_training_curves(limit: int = 20) -> list[dict]:
|
||||
curves = []
|
||||
for path in _iter_result_files():
|
||||
if not _is_curve_candidate(path):
|
||||
continue
|
||||
curve = parse_training_curve(path)
|
||||
if curve:
|
||||
curves.append(curve)
|
||||
curves.sort(key=lambda item: item["modified"], reverse=True)
|
||||
return curves[:limit]
|
||||
27
backend/tests/test_results_service.py
Normal file
27
backend/tests/test_results_service.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.modules.results.service import parse_training_curve
|
||||
|
||||
|
||||
def test_parse_yolo_results_curve(tmp_path: Path):
|
||||
csv_path = tmp_path / "results.csv"
|
||||
csv_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"epoch,time,train/box_loss,train/seg_loss,metrics/mAP50(M),val/seg_loss,lr/pg0",
|
||||
"1,1.0,1.5,3.0,0.12,2.8,0.001",
|
||||
"2,2.0,1.2,2.5,0.18,2.1,0.001",
|
||||
"3,3.0,0.9,2.0,0.25,1.9,0.001",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
curve = parse_training_curve(csv_path)
|
||||
|
||||
assert curve is not None
|
||||
assert curve["x_key"] == "epoch"
|
||||
names = [item["name"] for item in curve["series"]]
|
||||
assert "train/seg_loss" in names
|
||||
assert "metrics/mAP50(M)" in names
|
||||
assert all("lr/" not in name for name in names)
|
||||
Reference in New Issue
Block a user