Add dataset bench and validation agents

This commit is contained in:
2026-06-30 12:38:25 +08:00
parent 69f9a8e29b
commit dd7b7384ec
16 changed files with 853 additions and 24 deletions

View File

@@ -3,7 +3,7 @@ SEG_DATA_SERVER_ROOT=.
SEG_BACKEND_DB=var/seg_data_server.sqlite3
SEG_BACKEND_LOG_DIR=var/job_logs
SEG_TASK_CONDA_ENV=seg_smp
SEG_BACKEND_CONDA_ENV=seg_server
SEG_BACKEND_CONDA_ENV=seg_smp
SEG_WEIGHT_MODE=copy
SEG_ENABLE_SHELL_TASKS=1
VITE_API_BASE=http://localhost:8000
VITE_API_BASE=http://localhost:8010

View File

@@ -27,8 +27,9 @@ Seg_Data_Server_Net/
cd Seg_Data_Server_Net
cp .env.example .env
# Backend. The existing machine already has a seg_server env with FastAPI.
conda run -n seg_server uvicorn app.main:app --app-dir backend --host 0.0.0.0 --port 8000
# Backend. The deployment env is seg_smp so the API and task wrappers share
# the same segmentation dependency stack.
conda run -n seg_smp uvicorn app.main:app --app-dir backend --host 0.0.0.0 --port 8010
# Frontend.
cd frontend
@@ -37,7 +38,13 @@ npm run dev -- --host 0.0.0.0
```
Open the Vite URL shown in the terminal. The frontend expects the backend at
`http://localhost:8000` by default.
`http://localhost:8010` by default.
The web UI includes a dataset bench for creating upload workspaces, uploading
images/labels/masks, and jumping into the existing rename, PNG conversion,
resize, pair-check, label rebuild, transparent overlay, stitch, and video-frame
jobs. Segmentation previews, YOLO heatmaps, and loss/metric artifacts are
grouped on the results dashboard.
## Weight Sync
@@ -77,3 +84,15 @@ The backend exposes all current Seg capabilities as job types. Examples:
Use `GET /api/catalog` to inspect supported models, algorithms, datasets, and
task types discovered from the existing `Seg/` workspace.
## Agents
Run the local evaluation and validation agents before publishing changes:
```bash
PYTHONPATH=backend conda run -n seg_smp python scripts/run_agents.py --build
```
The validation agent checks catalog coverage, the new `seg_smp` env, GPU
visibility, no-weight Git safety, backend tests, frontend build, and live
backend/frontend endpoints when the services are running.

View File

@@ -0,0 +1,2 @@
"""Local deterministic agents for app evaluation and validation."""

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from pathlib import Path
from ..catalog import get_catalog
from ..config import settings
REQUIRED_TASKS = {
"dataset.upload": "covered_by_api",
"dataset.video_frames": "job",
"segmodel.train": "job",
"segmodel.predict": "job",
"yolo.heatmap": "job",
"mmseg.flops_fps": "job",
"analysis.all": "job",
}
def evaluate_project() -> dict:
"""Return product/implementation suggestions for the current web platform."""
frontend = settings.project_root / "frontend" / "src" / "main.tsx"
backend = settings.project_root / "backend" / "app" / "main.py"
readme = settings.project_root / "README.md"
catalog = get_catalog()
checks = []
suggestions = []
frontend_text = frontend.read_text(encoding="utf-8") if frontend.exists() else ""
backend_text = backend.read_text(encoding="utf-8") if backend.exists() else ""
readme_text = readme.read_text(encoding="utf-8") if readme.exists() else ""
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(),
"dataset_api": "/api/datasets" in backend_text and "api_upload_dataset_files" in backend_text,
"no_weight_to_gitea": "Do not push" in readme_text and "check_no_weight_git" in readme_text,
"all_core_tasks": all(task in catalog["task_types"] for task in REQUIRED_TASKS if REQUIRED_TASKS[task] == "job"),
}
for name, passed in expectations.items():
checks.append({"name": name, "passed": bool(passed)})
if not passed:
suggestions.append(f"Improve missing capability: {name}")
if len(catalog["mmseg_algorithms"]) < 31:
suggestions.append("MMSeg algorithm catalog should expose all 31 algorithm generators.")
if len(catalog["segmodel_architectures"]) < 12:
suggestions.append("SegModel catalog should expose all 12 supported architectures.")
if not suggestions:
suggestions.append("Current platform covers the requested control-plane features; next focus is real dataset/training acceptance tests.")
score = sum(1 for item in checks if item["passed"]) / max(len(checks), 1)
return {
"agent": "evaluation_suggestion_agent",
"score": round(score, 3),
"checks": checks,
"suggestions": suggestions,
}

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
import json
import os
import subprocess
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from ..catalog import get_catalog
from ..config import settings
from ..modules.system.service import get_conda_envs, get_gpus
from ..modules.weights.service import load_manifest
def _run(command: list[str], cwd: Path | None = None, timeout: int = 60) -> dict:
result = subprocess.run(command, cwd=cwd, capture_output=True, text=True, timeout=timeout)
return {
"command": command,
"returncode": result.returncode,
"stdout": result.stdout[-4000:],
"stderr": result.stderr[-4000:],
"passed": result.returncode == 0,
}
def _fetch(url: str, timeout: int = 5) -> dict:
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
body = response.read(20000).decode("utf-8", errors="replace")
return {"url": url, "status": response.status, "body": body, "passed": 200 <= response.status < 300}
except urllib.error.HTTPError as exc:
body = exc.read(20000).decode("utf-8", errors="replace")
return {"url": url, "status": exc.code, "body": body, "passed": False}
except Exception as exc:
return {"url": url, "error": str(exc), "passed": False}
def validate_project(run_build: bool = False) -> dict:
"""Validate current runtime readiness without launching heavy training."""
checks = []
catalog = get_catalog()
manifest = load_manifest()
checks.append({"name": "catalog_has_yolo_heatmap", "passed": "yolo.heatmap" in catalog["task_types"]})
checks.append({"name": "catalog_has_mmseg_31_algs", "passed": len(catalog["mmseg_algorithms"]) >= 31})
checks.append({"name": "weights_manifest_present", "passed": manifest.get("count", 0) >= 1})
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": "seg_smp_env_exists", "passed": "seg_smp" in env_names})
smoke = _run(
[
"conda",
"run",
"-n",
"seg_smp",
"python",
"-c",
"import fastapi, uvicorn, torch, cv2, segmentation_models_pytorch, ultralytics, albumentations, mmengine, mmseg, mmcv; print(torch.__version__, torch.cuda.is_available())",
],
cwd=settings.project_root,
)
checks.append({"name": "seg_smp_backend_smoke", "passed": smoke["passed"], "detail": smoke})
no_weight = _run(["bash", "scripts/check_no_weight_git.sh"], cwd=settings.project_root)
checks.append({"name": "no_weight_in_git", "passed": no_weight["passed"], "detail": no_weight})
if os.getenv("SEG_VALIDATE_LIVE", "1") == "1":
backend_url = os.getenv("SEG_VALIDATE_BACKEND_URL", "http://127.0.0.1:8010")
frontend_url = os.getenv("SEG_VALIDATE_FRONTEND_URL", "http://127.0.0.1:5173")
health = _fetch(f"{backend_url}/api/health")
datasets = _fetch(f"{backend_url}/api/datasets")
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_frontend_index", "passed": frontend["passed"] and "Seg Data Server" in frontend.get("body", ""), "detail": frontend})
if run_build:
tests = _run(["conda", "run", "-n", settings.backend_conda_env, "python", "-m", "pytest", "-q"], cwd=settings.project_root, timeout=120)
checks.append({"name": "backend_tests", "passed": tests["passed"], "detail": tests})
build = _run(["npm", "run", "build"], cwd=settings.project_root / "frontend", timeout=120)
checks.append({"name": "frontend_build", "passed": build["passed"], "detail": build})
passed = all(item["passed"] for item in checks)
return {
"agent": "validation_agent",
"passed": passed,
"checks": checks,
}
def write_validation_report(path: Path, run_build: bool = False) -> dict:
result = validate_project(run_build=run_build)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result

View File

@@ -103,6 +103,11 @@ def discover_datasets() -> list[dict[str, Any]]:
if item.name == "All_Data_Record.json" or not data:
continue
candidates.append({"name": item.stem, "path": rel(item, root), "source": "mmseg_parameter"})
uploaded_root = settings.project_root / "var" / "uploads" / "datasets"
if uploaded_root.exists():
for item in sorted(uploaded_root.iterdir()):
if item.is_dir():
candidates.append({"name": item.name, "path": rel(item, settings.project_root), "source": "uploaded"})
return candidates
@@ -137,4 +142,3 @@ def get_catalog() -> dict[str, Any]:
"datasets": discover_datasets(),
"weights": discover_weights_summary(),
}

View File

@@ -46,7 +46,7 @@ def get_settings() -> Settings:
log_dir=log_dir,
weights_root=weights_root,
task_conda_env=os.getenv("SEG_TASK_CONDA_ENV", "seg_smp"),
backend_conda_env=os.getenv("SEG_BACKEND_CONDA_ENV", "seg_server"),
backend_conda_env=os.getenv("SEG_BACKEND_CONDA_ENV", "seg_smp"),
weight_mode=os.getenv("SEG_WEIGHT_MODE", "copy"),
enable_shell_tasks=os.getenv("SEG_ENABLE_SHELL_TASKS", "1") == "1",
)

View File

@@ -4,7 +4,7 @@ import asyncio
import json
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
@@ -13,9 +13,12 @@ from .catalog import get_catalog
from .config import settings
from .jobs import cancel_job, create_job
from .modules.system.service import disk_usage, get_conda_envs, get_gpus, scan_results
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
from .agents.validation_agent import validate_project
from .paths import ensure_inside
from .schemas import JobCreate, ProfileCreate, WeightSyncRequest
from .schemas import DatasetCreate, JobCreate, ProfileCreate, WeightSyncRequest
app = FastAPI(title="Seg Data Server Net", version="0.1.0")
@@ -60,6 +63,24 @@ def api_catalog() -> dict:
return get_catalog()
@app.get("/api/datasets")
def api_datasets() -> list[dict]:
return list_uploaded_datasets()
@app.post("/api/datasets")
def api_create_dataset(dataset: DatasetCreate) -> dict:
return create_dataset(dataset.name, dataset.description)
@app.post("/api/datasets/{dataset_name}/upload/{kind}")
async def api_upload_dataset_files(dataset_name: str, kind: str, files: list[UploadFile] = File(...)) -> dict:
try:
return await save_upload(dataset_name, kind, files)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/profiles")
def api_profiles(kind: str | None = None) -> list[dict]:
return db.list_profiles(kind)
@@ -170,3 +191,12 @@ def api_weight_sync(request: WeightSyncRequest) -> dict:
def api_weight_verify() -> dict:
return verify_weights()
@app.get("/api/agents/evaluate")
def api_agent_evaluate() -> dict:
return evaluate_project()
@app.get("/api/agents/validate")
def api_agent_validate(run_build: bool = False) -> dict:
return validate_project(run_build=run_build)

View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import json
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
from fastapi import UploadFile
from ...config import settings
DATASET_KINDS = ("images", "labels", "masks")
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
def uploads_root() -> Path:
root = settings.project_root / "var" / "uploads" / "datasets"
root.mkdir(parents=True, exist_ok=True)
return root
def slugify(value: str) -> str:
text = re.sub(r"[^A-Za-z0-9_.\-\u4e00-\u9fff]+", "_", value.strip())
return text.strip("._") or "dataset"
def safe_filename(value: str | None) -> str:
original = Path(value or "upload.bin").name
suffix = Path(original).suffix.lower()
stem = slugify(Path(original).stem or "upload")
if suffix and re.fullmatch(r"\.[A-Za-z0-9]{1,12}", suffix):
return f"{stem}{suffix}"
return stem
def dataset_dir(name: str) -> Path:
return uploads_root() / slugify(name)
def metadata_path(name: str) -> Path:
return dataset_dir(name) / "dataset.json"
def create_dataset(name: str, description: str = "") -> dict:
safe_name = slugify(name)
root = dataset_dir(safe_name)
for kind in DATASET_KINDS:
(root / kind).mkdir(parents=True, exist_ok=True)
meta = {
"name": safe_name,
"description": description,
"created_at": datetime.now(timezone.utc).isoformat(),
"root": str(root.relative_to(settings.project_root)),
"layout": {
"images": str((root / "images").relative_to(settings.project_root)),
"labels": str((root / "labels").relative_to(settings.project_root)),
"masks": str((root / "masks").relative_to(settings.project_root)),
},
}
metadata_path(safe_name).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return describe_dataset(safe_name)
def _load_meta(name: str) -> dict:
path = metadata_path(name)
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
return {"name": slugify(name), "description": "", "root": str(dataset_dir(name).relative_to(settings.project_root))}
def _iter_files(root: Path) -> Iterable[Path]:
if not root.exists():
return []
return sorted(path for path in root.rglob("*") if path.is_file())
def describe_dataset(name: str) -> dict:
safe_name = slugify(name)
root = dataset_dir(safe_name)
meta = _load_meta(safe_name)
counts = {}
samples = {}
for kind in sorted(DATASET_KINDS):
files = list(_iter_files(root / kind))
counts[kind] = len(files)
samples[kind] = [
{
"name": path.name,
"path": str(path.resolve()),
"relative_path": str(path.resolve().relative_to(settings.project_root)),
"size": path.stat().st_size,
"previewable": path.suffix.lower() in IMAGE_EXTS,
}
for path in files[:80]
]
return {**meta, "counts": counts, "samples": samples}
def list_uploaded_datasets() -> list[dict]:
root = uploads_root()
datasets = []
for item in sorted(root.iterdir()):
if item.is_dir():
datasets.append(describe_dataset(item.name))
return datasets
async def save_upload(dataset: str, kind: str, files: list[UploadFile]) -> dict:
if kind not in DATASET_KINDS:
raise ValueError(f"unsupported dataset file kind: {kind}")
safe_name = slugify(dataset)
if not metadata_path(safe_name).exists():
create_dataset(safe_name)
target = dataset_dir(safe_name) / kind
target.mkdir(parents=True, exist_ok=True)
saved = []
for upload in files:
filename = safe_filename(upload.filename)
dst = target / filename
if dst.exists():
stem = dst.stem
suffix = dst.suffix
counter = 1
while dst.exists():
dst = target / f"{stem}_{counter}{suffix}"
counter += 1
with dst.open("wb") as handle:
shutil.copyfileobj(upload.file, handle)
saved.append({"name": dst.name, "relative_path": str(dst.relative_to(settings.project_root)), "size": dst.stat().st_size})
return {"dataset": describe_dataset(safe_name), "saved": saved}

View File

@@ -43,3 +43,7 @@ class WeightSyncRequest(BaseModel):
hash_files: bool = True
skip_existing: bool = True
class DatasetCreate(BaseModel):
name: str
description: str = ""

View File

@@ -0,0 +1,14 @@
from app.agents.evaluation_agent import evaluate_project
from app.agents.validation_agent import validate_project
def test_evaluation_agent_returns_checks():
result = evaluate_project()
assert result["agent"] == "evaluation_suggestion_agent"
assert result["checks"]
def test_validation_agent_lightweight():
result = validate_project(run_build=False)
assert result["agent"] == "validation_agent"
assert any(item["name"] == "catalog_has_yolo_heatmap" for item in result["checks"])

View File

@@ -0,0 +1,13 @@
from app.modules.dataset.service import create_dataset, describe_dataset
def test_create_dataset_layout(tmp_path, monkeypatch):
from types import SimpleNamespace
from app.modules.dataset import service
monkeypatch.setattr(service, "settings", SimpleNamespace(project_root=tmp_path))
created = create_dataset("case 01", "demo")
assert created["name"] == "case_01"
assert created["counts"] == {"images": 0, "labels": 0, "masks": 0}
described = describe_dataset("case_01")
assert described["layout"]["images"].endswith("images")

View File

@@ -3,8 +3,10 @@ import { createRoot } from "react-dom/client";
import {
Activity,
BarChart3,
Boxes,
Cpu,
Database,
FileImage,
FileSearch,
Gauge,
HardDrive,
@@ -15,11 +17,12 @@ import {
Square,
Terminal,
UploadCloud,
Wand2,
Zap
} from "lucide-react";
import "./styles.css";
const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8000";
const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8010";
type Job = {
id: string;
@@ -42,6 +45,22 @@ type Catalog = {
weights: { count: number; total_bytes: number; updated_at?: string };
};
type UploadedDataset = {
name: string;
description?: string;
counts: { images: number; labels: number; masks: number };
samples: Record<string, Array<{ name: string; relative_path: string; size: number; previewable: boolean }>>;
};
type ResultItem = {
name: string;
path: string;
relative_path: string;
size: number;
modified: number;
kind: string;
};
type GpuPayload = {
available: boolean;
gpus: Array<{
@@ -66,6 +85,13 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
const defaultParams: Record<string, Record<string, unknown>> = {
"mock.echo": { message: "hello from Seg Data Server" },
"dataset.rename": { input_dir: "../DataSet_Own", prefix: "image" },
"dataset.to_png": { input_dir: "../DataSet_Own", output_dir: "../DataSet_Own_png" },
"dataset.resize": { input_dir: "../DataSet_Own", output_dir: "../DataSet_Own_resize", size: "512x512" },
"dataset.pair": { image_dir: "../DataSet_Own/images", label_dir: "../DataSet_Own/labels" },
"dataset.rebuild_labels": { label_dir: "../DataSet_Own/labels", output_dir: "../DataSet_Own/rebuilt_labels" },
"dataset.stack": { image_dir: "../DataSet_Own/images", mask_dir: "../DataSet_Own/masks", output_dir: "../DataSet_Own/stacked" },
"dataset.stitch": { input_dir: "../DataSet_Own/stacked", output_dir: "../DataSet_Own/stitch" },
"dataset.video_frames": { video: "../Seg_Predict_Own_Video_V2/LC_Video_1.mp4", interval: 0.5, resize: "1920x1080" },
"segmodel.train": { architecture: "Unet" },
"segmodel.predict": { architecture: "Unet", run_choice: 1 },
@@ -79,6 +105,17 @@ const defaultParams: Record<string, Record<string, unknown>> = {
"analysis.all": { input_dir: "../BestMode_Predict_Results_DataSet_Public", output_dir: "./", dataset_choice: 1 }
};
const taskLabels: Record<string, string> = {
"dataset.rename": "重命名",
"dataset.to_png": "转 PNG",
"dataset.resize": "Resize",
"dataset.pair": "图片/Label 配对",
"dataset.rebuild_labels": "重建 Label",
"dataset.stack": "透明叠加",
"dataset.stitch": "拼接检查",
"dataset.video_frames": "视频抽帧"
};
function formatBytes(value?: number) {
if (!value) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -95,21 +132,24 @@ function useData() {
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [gpus, setGpus] = useState<GpuPayload | null>(null);
const [jobs, setJobs] = useState<Job[]>([]);
const [results, setResults] = useState<Array<Record<string, unknown>>>([]);
const [results, setResults] = useState<ResultItem[]>([]);
const [datasets, setDatasets] = useState<UploadedDataset[]>([]);
const [error, setError] = useState<string>("");
async function refresh() {
try {
const [catalogNext, gpusNext, jobsNext, resultsNext] = await Promise.all([
const [catalogNext, gpusNext, jobsNext, resultsNext, datasetsNext] = await Promise.all([
api<Catalog>("/api/catalog"),
api<GpuPayload>("/api/system/gpus"),
api<Job[]>("/api/jobs"),
api<Array<Record<string, unknown>>>("/api/results")
api<ResultItem[]>("/api/results"),
api<UploadedDataset[]>("/api/datasets")
]);
setCatalog(catalogNext);
setGpus(gpusNext);
setJobs(jobsNext);
setResults(resultsNext.slice(0, 80));
setDatasets(datasetsNext);
setError("");
} catch (err) {
setError(String(err));
@@ -122,7 +162,7 @@ function useData() {
return () => window.clearInterval(timer);
}, []);
return { catalog, gpus, jobs, results, error, refresh };
return { catalog, gpus, jobs, results, datasets, error, refresh };
}
function StatusPill({ status }: { status: string }) {
@@ -130,12 +170,16 @@ function StatusPill({ status }: { status: string }) {
}
function App() {
const { catalog, gpus, jobs, results, error, refresh } = useData();
const { catalog, gpus, jobs, results, datasets, error, refresh } = useData();
const [taskType, setTaskType] = useState("mock.echo");
const [params, setParams] = useState(JSON.stringify(defaultParams["mock.echo"], null, 2));
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [log, setLog] = useState("");
const [busy, setBusy] = useState(false);
const [datasetName, setDatasetName] = useState("demo_dataset");
const [datasetDescription, setDatasetDescription] = useState("");
const [uploadKind, setUploadKind] = useState<"images" | "labels" | "masks">("images");
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null);
const runningCount = jobs.filter((job) => job.status === "running").length;
const successCount = jobs.filter((job) => job.status === "success").length;
@@ -152,11 +196,18 @@ function App() {
};
}, [catalog]);
const datasetOps = taskGroups.dataset.filter((task) => task in taskLabels);
function pickTask(next: string) {
setTaskType(next);
setParams(JSON.stringify(defaultParams[next] ?? {}, null, 2));
}
function pickDatasetTask(next: string) {
pickTask(next);
window.location.hash = "jobs";
}
async function createJob() {
setBusy(true);
try {
@@ -183,6 +234,36 @@ function App() {
}
}
async function createDataset() {
setBusy(true);
try {
await api("/api/datasets", {
method: "POST",
body: JSON.stringify({ name: datasetName, description: datasetDescription })
});
await refresh();
} finally {
setBusy(false);
}
}
async function uploadDatasetFiles() {
if (!uploadFiles || uploadFiles.length === 0) return;
setBusy(true);
try {
const body = new FormData();
Array.from(uploadFiles).forEach((file) => body.append("files", file));
const res = await fetch(`${API_BASE}/api/datasets/${encodeURIComponent(datasetName)}/upload/${uploadKind}`, {
method: "POST",
body
});
if (!res.ok) throw new Error(await res.text());
await refresh();
} finally {
setBusy(false);
}
}
async function inspectJob(job: Job) {
const detail = await api<Job>(`/api/jobs/${job.id}`);
setSelectedJob(detail);
@@ -214,6 +295,7 @@ function App() {
</div>
<nav>
<a href="#jobs"><Terminal size={18} /></a>
<a href="#datasets"><Boxes size={18} /></a>
<a href="#gpu"><Cpu size={18} />GPU</a>
<a href="#weights"><HardDrive size={18} /></a>
<a href="#results"><BarChart3 size={18} /></a>
@@ -251,8 +333,8 @@ function App() {
</div>
<div className="metric">
<Database size={20} />
<span></span>
<strong>{catalog?.datasets.length ?? 0}</strong>
<span></span>
<strong>{datasets.length}</strong>
</div>
</section>
@@ -305,6 +387,82 @@ function App() {
</div>
</section>
<section className="grid two" id="datasets">
<div className="panel">
<div className="panelHead">
<div>
<p className="eyebrow">Dataset Bench</p>
<h2>LabelMask </h2>
</div>
<Database size={22} />
</div>
<div className="datasetForm">
<label className="field compact">
<span></span>
<input value={datasetName} onChange={(event) => setDatasetName(event.target.value)} />
</label>
<label className="field compact">
<span></span>
<input value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
</label>
<div className="segmented">
{(["images", "labels", "masks"] as const).map((kind) => (
<button key={kind} className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
{kind}
</button>
))}
</div>
<label className="drop">
<UploadCloud size={24} />
<span>{uploadFiles?.length ? `${uploadFiles.length} files selected` : "选择图片、label 或 mask 文件"}</span>
<input multiple type="file" accept="image/*,.txt,.json,.yaml,.yml" onChange={(event) => setUploadFiles(event.target.files)} />
</label>
<div className="buttonRow">
<button className="primary" disabled={busy} onClick={createDataset}><Boxes size={17} /></button>
<button className="primary secondary" disabled={busy || !uploadFiles?.length} onClick={uploadDatasetFiles}><UploadCloud size={17} /></button>
</div>
<div className="opGrid">
{datasetOps.map((task) => (
<button key={task} type="button" onClick={() => pickDatasetTask(task)}>
<Wand2 size={16} />
<span>{taskLabels[task]}</span>
</button>
))}
</div>
</div>
</div>
<div className="panel">
<div className="panelHead">
<div>
<p className="eyebrow">Files</p>
<h2></h2>
</div>
<FileImage size={22} />
</div>
<div className="datasetList">
{datasets.map((dataset) => (
<div className="datasetCard" key={dataset.name}>
<div className="datasetCardHead">
<strong>{dataset.name}</strong>
<span>{dataset.counts.images} image · {dataset.counts.labels} label · {dataset.counts.masks} mask</span>
</div>
<div className="sampleStrip">
{["images", "labels", "masks"].flatMap((kind) =>
(dataset.samples[kind] ?? []).slice(0, 4).map((sample) => (
<a key={`${kind}-${sample.relative_path}`} href={`${API_BASE}/api/artifacts/${sample.relative_path}`} target="_blank" rel="noreferrer">
<span>{kind}</span>
<small>{sample.name}</small>
</a>
))
)}
</div>
</div>
))}
</div>
</div>
</section>
<section className="grid three">
<div className="panel" id="gpu">
<div className="panelHead">
@@ -356,6 +514,46 @@ function App() {
</div>
</section>
<section className="grid three">
<div className="panel insight">
<div className="panelHead">
<div>
<p className="eyebrow">Segmentation</p>
<h2></h2>
</div>
<Wand2 size={22} />
</div>
<ResultPreview results={results.filter((item) => /predict|mask|comparison|prediction/i.test(item.relative_path) && ["png", "jpg", "jpeg"].includes(item.kind)).slice(0, 6)} />
</div>
<div className="panel insight">
<div className="panelHead">
<div>
<p className="eyebrow">Heatmap</p>
<h2>YOLO </h2>
</div>
<Zap size={22} />
</div>
<ResultPreview results={results.filter((item) => /heat|cam|grad/i.test(item.relative_path) && ["png", "jpg", "jpeg"].includes(item.kind)).slice(0, 6)} />
</div>
<div className="panel insight">
<div className="panelHead">
<div>
<p className="eyebrow">Curves</p>
<h2>Loss / </h2>
</div>
<BarChart3 size={22} />
</div>
<div className="resultList tight">
{results.filter((item) => /loss|metric|miou|iou|csv|curve/i.test(item.relative_path)).slice(0, 10).map((item) => (
<a key={item.path} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer">
<span>{item.name}</span>
<small>{formatBytes(item.size)}</small>
</a>
))}
</div>
</div>
</section>
<section className="grid two">
<div className="panel logPanel">
<div className="panelHead">
@@ -380,9 +578,9 @@ function App() {
</div>
<div className="resultList">
{results.map((item) => (
<a key={String(item.path)} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer">
<span>{String(item.name)}</span>
<small>{formatBytes(Number(item.size))}</small>
<a key={item.path} href={`${API_BASE}/api/artifacts/${item.relative_path}`} target="_blank" rel="noreferrer">
<span>{item.name}</span>
<small>{formatBytes(item.size)}</small>
</a>
))}
</div>
@@ -393,6 +591,22 @@ function App() {
);
}
function ResultPreview({ results }: { results: ResultItem[] }) {
if (!results.length) {
return <p className="muted"></p>;
}
return (
<div className="previewGrid">
{results.map((item) => (
<a key={item.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.name}</span>
</a>
))}
</div>
);
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />

View File

@@ -32,6 +32,11 @@ button, textarea, select {
font: inherit;
}
input {
font: inherit;
color: var(--ink);
}
button {
border: 0;
color: inherit;
@@ -154,6 +159,10 @@ h2 {
font-weight: 700;
}
.primary.secondary {
background: var(--cyan);
}
.primary:disabled, .iconButton:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -283,6 +292,159 @@ textarea {
font-size: 13px;
}
.field.compact {
margin-top: 0;
}
.field input {
height: 38px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--field);
}
.datasetForm {
display: grid;
gap: 12px;
}
.segmented {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 4px;
border: 1px solid var(--line);
background: #101310;
border-radius: 7px;
}
.segmented button {
height: 34px;
border-radius: 5px;
background: transparent;
color: var(--muted);
}
.segmented button.active {
background: var(--green);
color: #0b0d0b;
font-weight: 760;
}
.drop {
min-height: 118px;
display: grid;
place-items: center;
gap: 8px;
position: relative;
border: 1px dashed #526052;
border-radius: 8px;
background: rgba(13, 16, 13, 0.8);
color: var(--muted);
text-align: center;
overflow: hidden;
}
.drop input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.buttonRow {
display: flex;
gap: 10px;
}
.opGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.opGrid button {
min-width: 0;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid var(--line);
background: #101310;
color: var(--muted);
font-size: 12px;
}
.opGrid button:hover {
color: var(--ink);
border-color: var(--green);
}
.opGrid span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasetList {
display: grid;
gap: 10px;
max-height: 420px;
overflow: auto;
}
.datasetCard {
padding: 12px;
border: 1px solid var(--line);
border-radius: 7px;
background: #101310;
}
.datasetCardHead {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.datasetCardHead span {
color: var(--muted);
font-size: 12px;
}
.sampleStrip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.sampleStrip a {
min-width: 0;
padding: 8px;
border-radius: 5px;
border: 1px solid var(--line);
text-decoration: none;
color: var(--ink);
background: #0b0d0b;
}
.sampleStrip span,
.sampleStrip small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sampleStrip span {
color: var(--green);
font-size: 11px;
}
.jobList, .resultList {
display: grid;
gap: 8px;
@@ -382,11 +544,53 @@ meter {
border-color: var(--green);
}
.resultList.tight {
max-height: 290px;
}
.insight {
min-height: 350px;
}
.previewGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.previewGrid a {
min-width: 0;
border: 1px solid var(--line);
border-radius: 7px;
overflow: hidden;
color: var(--ink);
text-decoration: none;
background: #0b0d0b;
}
.previewGrid img {
display: block;
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
background: #060806;
}
.previewGrid span {
display: block;
padding: 8px;
color: var(--muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 1180px) {
body { min-width: 960px; }
.shell { grid-template-columns: 220px 1fr; }
.taskColumns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.opGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid.three { grid-template-columns: 1fr; }
.grid.two { grid-template-columns: 1fr; }
}

35
scripts/run_agents.py Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "backend"))
from app.agents.evaluation_agent import evaluate_project # noqa: E402
from app.agents.validation_agent import validate_project # noqa: E402
def main() -> None:
parser = argparse.ArgumentParser(description="Run local evaluation and validation agents.")
parser.add_argument("--build", action="store_true", help="also run pytest and frontend build")
parser.add_argument("--out", default="var/agent_reports/latest.json")
args = parser.parse_args()
report = {
"evaluation": evaluate_project(),
"validation": validate_project(run_build=args.build),
}
out = ROOT / args.out
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
if not report["validation"]["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()

View File

@@ -2,10 +2,9 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BACKEND_ENV="${SEG_BACKEND_CONDA_ENV:-seg_server}"
BACKEND_ENV="${SEG_BACKEND_CONDA_ENV:-seg_smp}"
HOST="${SEG_BACKEND_HOST:-0.0.0.0}"
PORT="${SEG_BACKEND_PORT:-8000}"
PORT="${SEG_BACKEND_PORT:-8010}"
cd "${ROOT_DIR}"
exec conda run -n "${BACKEND_ENV}" uvicorn app.main:app --app-dir backend --host "${HOST}" --port "${PORT}" --reload