Redesign dataset workbench management

This commit is contained in:
2026-07-01 11:43:30 +08:00
parent 9d66f65212
commit b1c0c982b8
6 changed files with 740 additions and 75 deletions

View File

@@ -26,14 +26,14 @@ from .coverage import get_coverage_report
from .jobs import cancel_job, create_job
from .modules.results.service import scan_results, scan_training_curves
from .modules.system.service import disk_usage, get_conda_envs, get_gpus, get_runtime_readiness
from .modules.dataset.service import create_dataset, generate_yolo_dataset_yaml, list_uploaded_datasets, save_upload, validate_dataset
from .modules.dataset.service import copy_dataset, create_dataset, delete_dataset, describe_dataset, generate_yolo_dataset_yaml, list_uploaded_datasets, save_upload, validate_dataset
from .modules.weights.service import load_manifest, sync_weights, verify_weights
from .agents.evaluation_agent import evaluate_project
from .agents.user_agent import latest_user_agent_report, run_user_agent
from .agents.validation_agent import validate_project
from .paths import ensure_inside
from .progress import progress_from_log_path
from .schemas import DatasetCreate, DatasetYoloYamlRequest, JobCreate, ProfileCreate, WeightSyncRequest
from .schemas import DatasetCopyRequest, DatasetCreate, DatasetYoloYamlRequest, JobCreate, ProfileCreate, WeightSyncRequest
app = FastAPI(title="Seg Data Server Net", version="0.1.0")
@@ -149,9 +149,36 @@ def api_datasets() -> list[dict]:
return list_uploaded_datasets()
@app.get("/api/datasets/{dataset_name}")
def api_dataset(dataset_name: str) -> dict:
try:
return describe_dataset(dataset_name)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/datasets")
def api_create_dataset(dataset: DatasetCreate) -> dict:
return create_dataset(dataset.name, dataset.description)
try:
return create_dataset(dataset.name, dataset.description)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/datasets/{dataset_name}/copy")
def api_copy_dataset(dataset_name: str, request: DatasetCopyRequest) -> dict:
try:
return copy_dataset(dataset_name, request.name, request.description)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.delete("/api/datasets/{dataset_name}")
def api_delete_dataset(dataset_name: str) -> dict:
try:
return delete_dataset(dataset_name)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/datasets/{dataset_name}/upload/{kind}")

View File

@@ -16,6 +16,7 @@ from ...config import settings
DATASET_KINDS = ("images", "labels", "masks")
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
LABEL_EXTS = {".txt", ".json", ".yaml", ".yml"}
VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm", ".m4v"}
ARCHIVE_SUFFIXES = (".zip", ".tar", ".tar.gz", ".tgz")
ARCHIVE_ALIASES = {
"images": {"image", "images", "img", "imgs", "ori", "original", "originals"},
@@ -47,7 +48,7 @@ def safe_filename(value: str | None) -> str:
def upload_exts_for_kind(kind: str) -> set[str]:
if kind == "images":
return IMAGE_EXTS
return IMAGE_EXTS | VIDEO_EXTS
if kind == "masks":
return IMAGE_EXTS | LABEL_EXTS
if kind == "labels":
@@ -138,6 +139,8 @@ def metadata_path(name: str) -> Path:
def create_dataset(name: str, description: str = "") -> dict:
safe_name = slugify(name)
root = dataset_dir(safe_name)
if root.exists():
raise ValueError(f"dataset already exists: {safe_name}")
for kind in DATASET_KINDS:
(root / kind).mkdir(parents=True, exist_ok=True)
meta = {
@@ -155,6 +158,44 @@ def create_dataset(name: str, description: str = "") -> dict:
return describe_dataset(safe_name)
def delete_dataset(name: str) -> dict:
safe_name = slugify(name)
root = dataset_dir(safe_name)
if not root.exists() or not root.is_dir():
raise ValueError(f"dataset not found: {safe_name}")
shutil.rmtree(root)
return {"deleted": True, "name": safe_name}
def copy_dataset(source_name: str, target_name: str, description: str | None = None) -> dict:
source_safe = slugify(source_name)
target_safe = slugify(target_name)
source = dataset_dir(source_safe)
target = dataset_dir(target_safe)
if not source.exists() or not source.is_dir():
raise ValueError(f"dataset not found: {source_safe}")
if target.exists():
raise ValueError(f"dataset already exists: {target_safe}")
shutil.copytree(source, target)
meta = _load_meta(source_safe)
meta.update(
{
"name": target_safe,
"description": description if description is not None else f"Copy of {source_safe}",
"created_at": datetime.now(timezone.utc).isoformat(),
"copied_from": source_safe,
"root": str(target.relative_to(settings.project_root)),
"layout": {
"images": str((target / "images").relative_to(settings.project_root)),
"labels": str((target / "labels").relative_to(settings.project_root)),
"masks": str((target / "masks").relative_to(settings.project_root)),
},
}
)
metadata_path(target_safe).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return describe_dataset(target_safe)
def _load_meta(name: str) -> dict:
path = metadata_path(name)
if path.exists():
@@ -192,6 +233,22 @@ def _image_shape(path: Path) -> dict | None:
return None
def _file_info(path: Path, kind: str) -> dict:
suffix = path.suffix.lower()
stem = path.stem
return {
"name": path.name,
"stem": stem,
"pair_stem": normalize_mask_stem(stem) if kind == "masks" else stem,
"path": str(path.resolve()),
"relative_path": str(path.resolve().relative_to(settings.project_root)),
"size": path.stat().st_size,
"ext": suffix,
"previewable": suffix in IMAGE_EXTS,
"media_type": "image" if suffix in IMAGE_EXTS else "video" if suffix in VIDEO_EXTS else "file",
}
def _validate_yolo_txt(path: Path) -> dict:
errors = []
classes: set[int] = set()
@@ -232,20 +289,14 @@ def describe_dataset(name: str) -> dict:
absolute_layout = {kind: str((root / kind).resolve()) for kind in DATASET_KINDS}
counts = {}
samples = {}
files_by_kind = {}
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, "absolute_layout": absolute_layout, "counts": counts, "samples": samples}
file_items = [_file_info(path, kind) for path in files[:2000]]
files_by_kind[kind] = file_items
samples[kind] = file_items[:80]
return {**meta, "absolute_layout": absolute_layout, "counts": counts, "samples": samples, "files": files_by_kind}
def validate_dataset(name: str) -> dict:

View File

@@ -49,5 +49,10 @@ class DatasetCreate(BaseModel):
description: str = ""
class DatasetCopyRequest(BaseModel):
name: str
description: str | None = None
class DatasetYoloYamlRequest(BaseModel):
class_names: list[str] | None = None

View File

@@ -4,9 +4,10 @@ import zipfile
import cv2
import numpy as np
import pytest
from fastapi import UploadFile
from app.modules.dataset.service import create_dataset, describe_dataset, generate_yolo_dataset_yaml, save_upload, validate_dataset
from app.modules.dataset.service import copy_dataset, create_dataset, delete_dataset, describe_dataset, generate_yolo_dataset_yaml, save_upload, validate_dataset
def test_create_dataset_layout(tmp_path, monkeypatch):
@@ -19,6 +20,32 @@ def test_create_dataset_layout(tmp_path, monkeypatch):
assert created["counts"] == {"images": 0, "labels": 0, "masks": 0}
described = describe_dataset("case_01")
assert described["layout"]["images"].endswith("images")
assert described["files"]["images"] == []
with pytest.raises(ValueError, match="already exists"):
create_dataset("case 01", "duplicate")
def test_copy_and_delete_dataset(tmp_path, monkeypatch):
from types import SimpleNamespace
from app.modules.dataset import service
monkeypatch.setattr(service, "settings", SimpleNamespace(project_root=tmp_path))
create_dataset("case base", "demo")
root = service.dataset_dir("case_base")
(root / "images" / "sample.png").write_bytes(b"image")
copied = copy_dataset("case_base", "case copy", "copy")
assert copied["name"] == "case_copy"
assert copied["description"] == "copy"
assert copied["counts"]["images"] == 1
with pytest.raises(ValueError, match="already exists"):
copy_dataset("case_base", "case_copy")
deleted = delete_dataset("case_copy")
assert deleted == {"deleted": True, "name": "case_copy"}
assert not service.dataset_dir("case_copy").exists()
def test_validate_dataset_and_generate_yolo_yaml(tmp_path, monkeypatch):

View File

@@ -6,6 +6,7 @@ import {
Bot,
Boxes,
ClipboardCheck,
Copy,
Cpu,
Database,
FileImage,
@@ -18,6 +19,7 @@ import {
ShieldCheck,
Square,
Terminal,
Trash2,
UploadCloud,
PackageOpen,
Wand2,
@@ -69,13 +71,26 @@ type Catalog = {
weights: { count: number; total_bytes: number; updated_at?: string };
};
type DatasetFileItem = {
name: string;
stem?: string;
pair_stem?: string;
path?: string;
relative_path: string;
size: number;
ext?: string;
previewable: boolean;
media_type?: string;
};
type UploadedDataset = {
name: string;
description?: string;
absolute_layout?: Record<"images" | "labels" | "masks", string>;
layout?: Record<"images" | "labels" | "masks", string>;
counts: { images: number; labels: number; masks: number };
samples: Record<string, Array<{ name: string; relative_path: string; size: number; previewable: boolean }>>;
samples: Record<string, DatasetFileItem[]>;
files?: Record<string, DatasetFileItem[]>;
};
type DatasetValidation = {
@@ -90,6 +105,7 @@ type DatasetValidation = {
masks_without_images: string[];
};
classes: number[];
sample_shapes?: Array<{ name: string; width: number; height: number; channels: number }>;
checks: Array<{ name: string; passed: boolean; count?: number; labels?: number; masks?: number; errors?: unknown[] }>;
ready: { yolo: boolean; mask: boolean; any: boolean };
};
@@ -378,6 +394,55 @@ function artifactUrl(path: string) {
return `${API_BASE}/api/artifacts/${path.split("/").map(encodeURIComponent).join("/")}`;
}
const MASK_STEM_SUFFIXES = ["-mask", "_mask", "-label", "_label", "-gt", "_gt", "-seg", "_seg"];
function slugifyName(value: string) {
const text = value.trim().replace(/[^A-Za-z0-9_.\-\u4e00-\u9fff]+/g, "_").replace(/^[._]+|[._]+$/g, "");
return text || "dataset";
}
function stemFromName(name: string) {
const last = name.split("/").pop() ?? name;
const dot = last.lastIndexOf(".");
return dot > 0 ? last.slice(0, dot) : last;
}
function normalizeMaskStem(stem: string) {
const lower = stem.toLowerCase();
const suffix = MASK_STEM_SUFFIXES.find((item) => lower.endsWith(item) && stem.length > item.length);
return suffix ? stem.slice(0, -suffix.length) : stem;
}
function datasetFiles(dataset: UploadedDataset | undefined, kind: "images" | "masks") {
if (!dataset) return [];
return (dataset.files?.[kind] ?? dataset.samples?.[kind] ?? []).map((item) => ({
...item,
stem: item.stem ?? stemFromName(item.name),
pair_stem: item.pair_stem ?? (kind === "masks" ? normalizeMaskStem(stemFromName(item.name)) : stemFromName(item.name))
}));
}
function datasetPairRows(dataset: UploadedDataset | undefined) {
const images = datasetFiles(dataset, "images");
const masks = datasetFiles(dataset, "masks");
const masksByStem = new Map<string, DatasetFileItem>();
for (const mask of masks) {
if (mask.pair_stem && !masksByStem.has(mask.pair_stem)) {
masksByStem.set(mask.pair_stem, mask);
}
}
const usedMasks = new Set<string>();
const imageRows = images.map((image) => {
const mask = image.stem ? masksByStem.get(image.stem) : undefined;
if (mask) usedMasks.add(mask.relative_path);
return { key: `image-${image.relative_path}`, stem: image.stem ?? image.name, image, mask, status: mask ? "paired" : "missing_mask" };
});
const extraMaskRows = masks
.filter((mask) => !usedMasks.has(mask.relative_path))
.map((mask) => ({ key: `mask-${mask.relative_path}`, stem: mask.pair_stem ?? mask.name, image: undefined, mask, status: "extra_mask" }));
return [...imageRows, ...extraMaskRows];
}
const defaultParams: Record<string, Record<string, unknown>> = {
"mock.echo": { message: "hello from Seg Data Server" },
"dataset.rename": { image_dir: "../DataSet_Own/ORI", label_dir: "../DataSet_Own/Label" },
@@ -550,9 +615,16 @@ function App() {
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [log, setLog] = useState("");
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState("");
const [datasetName, setDatasetName] = useState("demo_dataset");
const [datasetDescription, setDatasetDescription] = useState("");
const [copyDatasetName, setCopyDatasetName] = useState("");
const [selectedDatasetName, setSelectedDatasetName] = useState("");
const [preprocessWidth, setPreprocessWidth] = useState(1920);
const [preprocessHeight, setPreprocessHeight] = useState(1080);
const [preprocessPrefix, setPreprocessPrefix] = useState("");
const [preprocessSuffix, setPreprocessSuffix] = useState("-mask");
const [preprocessAlpha, setPreprocessAlpha] = useState(0.3);
const [selectedCurvePath, setSelectedCurvePath] = useState("");
const [resultFamilyFilter, setResultFamilyFilter] = useState("all");
const [resultRoleFilter, setResultRoleFilter] = useState("all");
@@ -597,6 +669,8 @@ function App() {
}, [catalog]);
const datasetOps = taskGroups.dataset.filter((task) => task in taskLabels);
const manualDatasetOps = ["dataset.rename", "dataset.to_png", "dataset.resize", "dataset.pair", "dataset.rebuild_labels", "dataset.stack", "dataset.stitch"]
.filter((task) => datasetOps.includes(task));
const selectedDataset = useMemo(
() => datasets.find((dataset) => dataset.name === selectedDatasetName) ?? datasets.find((dataset) => dataset.name === datasetName),
[datasetName, datasets, selectedDatasetName]
@@ -623,6 +697,8 @@ function App() {
};
}, [curves, results, selectedDataset]);
const selectedYoloWeightReady = Boolean(selectedYoloOutputs.bestWeight);
const datasetNameExists = datasets.some((item) => item.name === slugifyName(datasetName));
const copyNameExists = datasets.some((item) => item.name === slugifyName(copyDatasetName));
const availableGpus = gpus?.gpus ?? [];
const condaEnvOptions = useMemo(() => ["auto", ...Array.from(new Set((condaEnvs?.envs ?? []).map((item) => item.name))).sort()], [condaEnvs]);
const selectedGpuDevice = selectedGpuIds.length ? selectedGpuIds.join(",") : "cpu";
@@ -678,7 +754,7 @@ function App() {
const activePage = pages.find((item) => item.id === page) ?? pages[0];
const inferenceSource = inferenceSourcePath.trim() || selectedDataset?.absolute_layout?.images || "";
const uploadAccept = uploadKind === "images"
? "image/*,.zip,.tar,.tar.gz,.tgz"
? "image/*,video/*,.zip,.tar,.tar.gz,.tgz"
: "image/*,.txt,.json,.yaml,.yml,.zip,.tar,.tar.gz,.tgz";
const inferenceOutputs = useMemo(() => {
const predictions = results.filter((item) => item.role === "segmentation" && item.previewable).slice(0, 8);
@@ -698,6 +774,12 @@ function App() {
}
}, [datasets, selectedDatasetName]);
useEffect(() => {
if (selectedDataset) {
setCopyDatasetName(`${selectedDataset.name}_copy`);
}
}, [selectedDataset?.name]);
function navigate(next: PageId) {
setPage(next);
window.location.hash = next;
@@ -748,7 +830,19 @@ function App() {
const maskDir = layout.masks || layout.labels;
const resultDir = `${layout.images.replace(/\/images$/, "")}/results/${next.replace(".", "_")}`;
if (["dataset.pair", "dataset.resize", "dataset.stack", "dataset.stitch", "dataset.yolo_check_pairs", "dataset.yolo_stack"].includes(next)) {
return { ...base, image_dir: layout.images, label_dir: maskDir, result_dir: resultDir };
const nextParams: Record<string, unknown> = { ...base, image_dir: layout.images, label_dir: maskDir, result_dir: resultDir };
if (next === "dataset.resize") {
nextParams.width = preprocessWidth;
nextParams.height = preprocessHeight;
}
if (["dataset.pair", "dataset.stack", "dataset.stitch", "dataset.yolo_check_pairs", "dataset.yolo_stack"].includes(next)) {
nextParams.prefix = preprocessPrefix;
nextParams.suffix = preprocessSuffix;
}
if (["dataset.stack", "dataset.yolo_stack"].includes(next)) {
nextParams.alpha = preprocessAlpha;
}
return nextParams;
}
if (["dataset.rebuild_labels", "dataset.yolo_rebuild_labels", "dataset.yolo_txt_sort"].includes(next)) {
return { ...base, label_dir: maskDir, folder: maskDir };
@@ -858,14 +952,21 @@ function App() {
}
async function createDataset() {
if (datasetNameExists) {
setActionError(`数据集已存在:${slugifyName(datasetName)}`);
return;
}
setBusy(true);
try {
setActionError("");
await api("/api/datasets", {
method: "POST",
body: JSON.stringify({ name: datasetName, description: datasetDescription })
});
setSelectedDatasetName(datasetName);
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
@@ -875,6 +976,7 @@ function App() {
if (!uploadFiles || uploadFiles.length === 0) return;
setBusy(true);
try {
setActionError("");
const body = new FormData();
Array.from(uploadFiles).forEach((file) => body.append("files", file));
let res: Response;
@@ -888,6 +990,67 @@ function App() {
}
if (!res.ok) throw new Error(await res.text());
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
}
async function copySelectedDataset() {
if (!selectedDataset || !copyDatasetName.trim()) return;
if (copyNameExists) {
setActionError(`数据集已存在:${slugifyName(copyDatasetName)}`);
return;
}
setBusy(true);
try {
setActionError("");
const copied = await api<UploadedDataset>(`/api/datasets/${encodeURIComponent(selectedDataset.name)}/copy`, {
method: "POST",
body: JSON.stringify({ name: copyDatasetName, description: `Copy of ${selectedDataset.name}` })
});
setSelectedDatasetName(copied.name);
setDatasetName(copied.name);
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
}
async function deleteSelectedDataset() {
if (!selectedDataset) return;
if (!window.confirm(`删除数据集 ${selectedDataset.name}?此操作会删除上传目录。`)) return;
setBusy(true);
try {
setActionError("");
await api(`/api/datasets/${encodeURIComponent(selectedDataset.name)}`, { method: "DELETE" });
setSelectedDatasetName("");
setDatasetName("demo_dataset");
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
}
async function startDatasetPreprocessTask(next: string) {
if (!selectedDataset?.absolute_layout) return;
setBusy(true);
try {
setActionError("");
const job = await api<Job>("/api/jobs", {
method: "POST",
body: JSON.stringify(jobPayload(next, datasetParamsForTask(next)))
});
await inspectJob(job);
navigate("training");
await refresh();
} catch (err) {
setActionError(String(err));
} finally {
setBusy(false);
}
@@ -1071,7 +1234,7 @@ function App() {
</button>
</header>
{error && <div className="alert">{error}</div>}
{(error || actionError) && <div className="alert">{actionError || error}</div>}
<section className="metrics" data-page-section="overview">
<div className="metric">
@@ -1226,12 +1389,12 @@ function App() {
</div>
</section>
<section className="grid two" id="datasets" data-page-section="datasets">
<div className="panel">
<section className="datasetWorkbench" id="datasets" data-page-section="datasets">
<div className="panel datasetSidePanel">
<div className="panelHead">
<div>
<p className="eyebrow">Dataset Bench</p>
<h2> Masks </h2>
<h2></h2>
</div>
<Database size={22} />
</div>
@@ -1244,6 +1407,7 @@ function App() {
<span></span>
<input value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
</label>
{datasetNameExists && <small className="formWarn"></small>}
<div className="segmented">
{(["images", "masks"] as const).map((kind) => (
<button key={kind} className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
@@ -1253,17 +1417,78 @@ function App() {
</div>
<label className="drop">
{uploadFiles?.length ? <PackageOpen size={24} /> : <UploadCloud size={24} />}
<span>{uploadFiles?.length ? `${uploadFiles.length} 个文件待上传` : `选择 ${uploadKind} 图片或压缩包`}</span>
<small>.png/.jpg/.tif/.zip/.tar.gz </small>
<span>{uploadFiles?.length ? `${uploadFiles.length} 个文件待上传` : `选择 ${uploadKind} 图片、视频或压缩包`}</span>
<small>.png/.jpg/.tif/.mp4/.zip/.tar.gz </small>
<input multiple type="file" accept={uploadAccept} onChange={(event) => setUploadFiles(event.target.files)} />
</label>
<div className="buttonRow">
<button className="primary" disabled={busy} onClick={createDataset}><Boxes size={17} /></button>
<button className="primary" disabled={busy || datasetNameExists} 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)}>
</div>
<div className="datasetManager">
<label className="field compact">
<span></span>
<select
value={selectedDataset?.name ?? ""}
onChange={(event) => {
setSelectedDatasetName(event.target.value);
setDatasetName(event.target.value || "demo_dataset");
}}
aria-label="uploaded datasets"
>
{datasets.length ? datasets.map((dataset) => (
<option key={dataset.name} value={dataset.name}>
{dataset.name} · {dataset.counts.images}/{dataset.counts.masks}
</option>
)) : (
<option value=""></option>
)}
</select>
</label>
<div className="datasetMiniList">
{datasets.map((dataset) => (
<button
key={dataset.name}
type="button"
className={selectedDataset?.name === dataset.name ? "selected" : ""}
onClick={() => {
setSelectedDatasetName(dataset.name);
setDatasetName(dataset.name);
}}
>
<span>{dataset.name}</span>
<small>{datasetValidations[dataset.name]?.pairs.image_mask ?? 0} pair · {dataset.counts.images} images · {dataset.counts.masks} masks</small>
</button>
))}
</div>
<label className="field compact">
<span></span>
<input value={copyDatasetName} onChange={(event) => setCopyDatasetName(event.target.value)} />
</label>
{copyNameExists && <small className="formWarn"></small>}
<div className="buttonRow">
<button className="primary secondary" disabled={busy || !selectedDataset || !copyDatasetName.trim() || copyNameExists} onClick={copySelectedDataset}><Copy size={17} /></button>
<button className="primary danger" disabled={busy || !selectedDataset} onClick={deleteSelectedDataset}><Trash2 size={17} /></button>
</div>
</div>
<div className="preprocessBox">
<div className="qualityHead">
<strong></strong>
<span>使</span>
</div>
<div className="preprocessControls">
<label><span></span><input type="number" min={1} value={preprocessWidth} onChange={(event) => setPreprocessWidth(Number(event.target.value) || 1)} /></label>
<label><span></span><input type="number" min={1} value={preprocessHeight} onChange={(event) => setPreprocessHeight(Number(event.target.value) || 1)} /></label>
<label><span></span><input value={preprocessPrefix} onChange={(event) => setPreprocessPrefix(event.target.value)} /></label>
<label><span></span><input value={preprocessSuffix} onChange={(event) => setPreprocessSuffix(event.target.value)} /></label>
<label><span></span><input type="number" min={0} max={1} step={0.05} value={preprocessAlpha} onChange={(event) => setPreprocessAlpha(Number(event.target.value) || 0)} /></label>
</div>
<div className="opGrid compactOps">
{manualDatasetOps.map((task) => (
<button key={task} type="button" disabled={busy || !selectedDataset?.absolute_layout} onClick={() => startDatasetPreprocessTask(task)}>
<Wand2 size={16} />
<span>{taskLabels[task]}</span>
</button>
@@ -1272,11 +1497,11 @@ function App() {
</div>
</div>
<div className="panel">
<div className="panel datasetDetailPanel">
<div className="panelHead">
<div>
<p className="eyebrow">Files</p>
<h2></h2>
<h2>{selectedDataset?.name ?? "选择一个数据集"}</h2>
</div>
<div className="buttonRow compactButtons">
<button className="iconButton" disabled={busy || !selectedValidation?.ready.yolo} onClick={generateSelectedYoloYaml} title="生成 YOLO dataset.yaml">
@@ -1291,52 +1516,17 @@ function App() {
<button className="iconButton" disabled={busy || !selectedDataset?.absolute_layout || !selectedYoloWeightReady} onClick={startSelectedYoloHeatmap} title={selectedYoloWeightReady ? "使用自定义 best.pt 生成热度图" : "best.pt 尚未生成"}>
<Zap size={18} />
</button>
<FileImage size={22} />
</div>
</div>
<div className="datasetList">
{datasets.map((dataset) => (
<div key={dataset.name}>
<div
className={`datasetCard ${selectedDataset?.name === dataset.name ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => {
setDatasetName(dataset.name);
setSelectedDatasetName(dataset.name);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
setDatasetName(dataset.name);
setSelectedDatasetName(dataset.name);
}
}}
>
<div className="datasetCardHead">
<strong>{dataset.name}</strong>
<span>{dataset.counts.images} images · {dataset.counts.masks} masks</span>
</div>
<div className="readinessLine">
<StatusPill status={datasetValidations[dataset.name]?.ready.mask ? "success" : "queued"} />
<small>Mask {datasetValidations[dataset.name]?.pairs.image_mask ?? 0} pair</small>
</div>
<div className="sampleStrip">
{(["images", "masks"] as const).flatMap((kind) =>
(dataset.samples[kind] ?? []).slice(0, 4).map((sample) => (
<a key={`${kind}-${sample.relative_path}`} href={artifactUrl(sample.relative_path)} target="_blank" rel="noreferrer">
{sample.previewable && <img src={artifactUrl(sample.relative_path)} alt={sample.name} />}
<span>{kind}</span>
<small>{sample.name}</small>
</a>
))
)}
</div>
</div>
</div>
))}
</div>
{selectedValidation && <DatasetQuality validation={selectedValidation} />}
{selectedDataset && <DatasetYoloOutputs dataset={selectedDataset} outputs={selectedYoloOutputs} />}
{selectedDataset ? (
<>
<DatasetDetail dataset={selectedDataset} validation={selectedValidation} />
{selectedValidation && <DatasetQuality validation={selectedValidation} />}
<DatasetYoloOutputs dataset={selectedDataset} outputs={selectedYoloOutputs} />
</>
) : (
<p className="muted">masks</p>
)}
</div>
</section>
@@ -1967,6 +2157,78 @@ function AcceptanceArtifactLinks({ artifacts }: { artifacts?: AcceptancePayload[
);
}
function DatasetDetail({ dataset, validation }: { dataset: UploadedDataset; validation?: DatasetValidation }) {
const rows = datasetPairRows(dataset);
const images = datasetFiles(dataset, "images");
const masks = datasetFiles(dataset, "masks");
const problemRows = rows.filter((row) => row.status !== "paired");
const shapeErrors = validation?.checks.find((check) => check.name === "mask_shapes_match")?.errors ?? [];
const ready = validation?.ready.mask;
return (
<div className="datasetDetailStack">
<div className="datasetSummaryStrip">
<div><span>images / video</span><strong>{dataset.counts.images}</strong></div>
<div><span>masks</span><strong>{dataset.counts.masks}</strong></div>
<div><span>paired</span><strong>{validation?.pairs.image_mask ?? rows.filter((row) => row.status === "paired").length}</strong></div>
<div><span>status</span><strong>{ready ? "MASK READY" : "CHECK"}</strong></div>
</div>
<div className="datasetPreviewBand">
{[...images.slice(0, 4), ...masks.slice(0, 4)].slice(0, 8).map((item) => (
<a key={item.relative_path} href={artifactUrl(item.relative_path)} target="_blank" rel="noreferrer">
{item.previewable ? <img src={artifactUrl(item.relative_path)} alt={item.name} /> : <FileImage size={22} />}
<span>{item.name}</span>
</a>
))}
</div>
<div className="fileTable">
<div className="fileTableHead">
<span></span>
<span>images / video</span>
<span></span>
<span>masks</span>
<span></span>
<span></span>
</div>
<div className="fileTableBody">
{rows.length ? rows.slice(0, 500).map((row) => (
<div key={row.key} className={`fileTableRow ${row.status}`}>
<span>{row.stem}</span>
<a href={row.image ? artifactUrl(row.image.relative_path) : undefined} target="_blank" rel="noreferrer">
{row.image?.name ?? "-"}
</a>
<span>{row.image ? formatBytes(row.image.size) : "-"}</span>
<a href={row.mask ? artifactUrl(row.mask.relative_path) : undefined} target="_blank" rel="noreferrer">
{row.mask?.name ?? "-"}
</a>
<span>{row.mask ? formatBytes(row.mask.size) : "-"}</span>
<strong>{row.status === "paired" ? "已配对" : row.status === "missing_mask" ? "缺少 mask" : "多余 mask"}</strong>
</div>
)) : (
<p className="muted"></p>
)}
</div>
</div>
<div className="issueGrid">
<div>
<strong></strong>
{problemRows.length ? problemRows.slice(0, 80).map((row) => (
<small key={row.key}>{row.status === "missing_mask" ? row.image?.name : row.mask?.name}</small>
)) : <small></small>}
</div>
<div>
<strong></strong>
{shapeErrors.length ? shapeErrors.slice(0, 20).map((item, index) => (
<small key={index}>{JSON.stringify(item)}</small>
)) : <small> image/mask </small>}
</div>
</div>
</div>
);
}
function DatasetQuality({ validation }: { validation: DatasetValidation }) {
return (
<div className="qualityBox">

View File

@@ -198,6 +198,10 @@ h2 {
background: var(--cyan);
}
.primary.danger {
background: var(--red);
}
.primary:disabled, .iconButton:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -718,6 +722,43 @@ textarea {
gap: 12px;
}
.datasetWorkbench {
display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.datasetSidePanel,
.datasetDetailPanel {
min-height: 720px;
}
.datasetSidePanel {
display: grid;
align-content: start;
gap: 16px;
}
.datasetManager,
.preprocessBox,
.datasetDetailStack {
display: grid;
gap: 12px;
}
.datasetManager,
.preprocessBox {
padding-top: 14px;
border-top: 1px solid var(--line);
}
.formWarn {
display: block;
color: var(--amber);
line-height: 1.35;
}
.segmented {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -779,6 +820,10 @@ textarea {
gap: 8px;
}
.buttonRow .primary {
flex: 0 0 auto;
}
.opGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -811,6 +856,243 @@ textarea {
white-space: nowrap;
}
.opGrid.compactOps {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.datasetMiniList {
display: grid;
gap: 7px;
max-height: 240px;
overflow: auto;
}
.datasetMiniList button {
min-width: 0;
display: grid;
gap: 3px;
padding: 9px;
text-align: left;
border: 1px solid var(--line);
border-radius: 6px;
background: #101310;
color: var(--ink);
}
.datasetMiniList button.selected {
border-color: var(--green);
background: rgba(157, 226, 111, 0.08);
}
.datasetMiniList span,
.datasetMiniList small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasetMiniList small {
color: var(--muted);
}
.preprocessControls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.preprocessControls label {
min-width: 0;
display: grid;
gap: 5px;
}
.preprocessControls label:last-child {
grid-column: 1 / -1;
}
.preprocessControls span {
color: var(--muted);
font-size: 11px;
}
.preprocessControls input {
width: 100%;
height: 34px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--field);
color: var(--ink);
}
.datasetSummaryStrip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.datasetSummaryStrip div {
min-width: 0;
padding: 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: #101310;
}
.datasetSummaryStrip span,
.datasetSummaryStrip strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasetSummaryStrip span {
color: var(--muted);
font-size: 11px;
}
.datasetSummaryStrip strong {
margin-top: 3px;
font-size: 20px;
}
.datasetPreviewBand {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 8px;
}
.datasetPreviewBand a {
min-width: 0;
min-height: 92px;
display: grid;
align-content: start;
gap: 6px;
padding: 7px;
border: 1px solid var(--line);
border-radius: 6px;
background: #0b0d0b;
color: var(--ink);
text-decoration: none;
}
.datasetPreviewBand img {
width: 100%;
aspect-ratio: 1.25 / 1;
object-fit: cover;
border-radius: 4px;
background: #060806;
}
.datasetPreviewBand svg {
width: 100%;
height: 54px;
color: var(--muted);
}
.datasetPreviewBand span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 11px;
}
.fileTable {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 7px;
background: #0b0d0b;
}
.fileTableHead,
.fileTableRow {
display: grid;
grid-template-columns: minmax(86px, 0.8fr) minmax(140px, 1.5fr) 82px minmax(140px, 1.5fr) 82px 88px;
gap: 8px;
align-items: center;
}
.fileTableHead {
padding: 9px 10px;
color: var(--muted);
font-size: 11px;
border-bottom: 1px solid var(--line);
background: #101310;
}
.fileTableBody {
max-height: 420px;
overflow: auto;
}
.fileTableRow {
min-height: 38px;
padding: 8px 10px;
border-bottom: 1px solid rgba(51, 59, 50, 0.7);
color: var(--muted);
font-size: 12px;
}
.fileTableRow:last-child {
border-bottom: 0;
}
.fileTableRow a,
.fileTableRow span,
.fileTableRow strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileTableRow a {
color: var(--ink);
text-decoration: none;
}
.fileTableRow.paired strong {
color: var(--green);
}
.fileTableRow.missing_mask strong,
.fileTableRow.extra_mask strong {
color: var(--amber);
}
.issueGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.issueGrid div {
min-width: 0;
display: grid;
gap: 6px;
align-content: start;
max-height: 180px;
overflow: auto;
padding: 10px;
border-radius: 7px;
border: 1px solid var(--line);
background: #101310;
}
.issueGrid strong,
.issueGrid small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datasetList {
display: grid;
gap: 10px;
@@ -1879,6 +2161,7 @@ meter {
.metrics,
.capSummary,
.capabilityGrid,
.datasetWorkbench,
.grid.two,
.grid.three,
.grid.four,
@@ -1904,9 +2187,17 @@ meter {
.pipelineSteps,
.pipelineStats,
.inferencePreview,
.datasetSummaryStrip,
.datasetPreviewBand,
.issueGrid,
.userAgentDataset {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fileTableHead,
.fileTableRow {
grid-template-columns: minmax(80px, 0.8fr) minmax(120px, 1.3fr) 74px minmax(120px, 1.3fr) 74px 82px;
}
}
.resultList a:hover, .jobRow:hover {
@@ -1961,6 +2252,8 @@ meter {
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.taskColumns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.opGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.datasetWorkbench { grid-template-columns: 1fr; }
.datasetPreviewBand { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.coverageGrid,
.taskCheckList { grid-template-columns: 1fr; }
.grid.three { grid-template-columns: 1fr; }