Add result curve discovery dashboard

This commit is contained in:
2026-06-30 13:35:07 +08:00
parent 2d7d54ba13
commit 7d6e1692b1
10 changed files with 554 additions and 22 deletions

View File

@@ -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"],

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]:

View File

@@ -0,0 +1 @@
"""Result artifact discovery and lightweight metric parsing."""

View 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]