From b1c0c982b8abc41c62c8c9da21d8da8050ff83d6 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 1 Jul 2026 11:43:30 +0800 Subject: [PATCH] Redesign dataset workbench management --- backend/app/main.py | 33 ++- backend/app/modules/dataset/service.py | 75 ++++- backend/app/schemas.py | 5 + backend/tests/test_dataset_service.py | 29 +- frontend/src/main.tsx | 380 +++++++++++++++++++++---- frontend/src/styles.css | 293 +++++++++++++++++++ 6 files changed, 740 insertions(+), 75 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 1317ac2..8a99058 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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}") diff --git a/backend/app/modules/dataset/service.py b/backend/app/modules/dataset/service.py index a05b26a..c342961 100644 --- a/backend/app/modules/dataset/service.py +++ b/backend/app/modules/dataset/service.py @@ -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: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index cd62c1f..9c56332 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/tests/test_dataset_service.py b/backend/tests/test_dataset_service.py index d968321..c377f70 100644 --- a/backend/tests/test_dataset_service.py +++ b/backend/tests/test_dataset_service.py @@ -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): diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ae734b3..555633e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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>; + samples: Record; + files?: Record; }; 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(); + for (const mask of masks) { + if (mask.pair_stem && !masksByStem.has(mask.pair_stem)) { + masksByStem.set(mask.pair_stem, mask); + } + } + const usedMasks = new Set(); + 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> = { "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(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 = { ...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(`/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("/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() { - {error &&
{error}
} + {(error || actionError) &&
{actionError || error}
}
@@ -1226,12 +1389,12 @@ function App() {
-
-
+
+

Dataset Bench

-

数据集与 Masks 上传

+

上传与管理

@@ -1244,6 +1407,7 @@ function App() { 说明 setDatasetDescription(event.target.value)} /> + {datasetNameExists && 已存在同名数据集,只能上传追加,不能重复创建。}
{(["images", "masks"] as const).map((kind) => (
- +
-
- {datasetOps.map((task) => ( -
+ +
+ +
+ {datasets.map((dataset) => ( + + ))} +
+ + {copyNameExists && 复制目标名称已存在。} +
+ + +
+
+ +
+
+ 图片预处理 + 使用手册 +
+
+ + + + + +
+
+ {manualDatasetOps.map((task) => ( + @@ -1272,11 +1497,11 @@ function App() {
-
+

Files

-

数据集浏览

+

{selectedDataset?.name ?? "选择一个数据集"}

-
-
- {datasets.map((dataset) => ( -
-
{ - setDatasetName(dataset.name); - setSelectedDatasetName(dataset.name); - }} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - setDatasetName(dataset.name); - setSelectedDatasetName(dataset.name); - } - }} - > -
- {dataset.name} - {dataset.counts.images} images · {dataset.counts.masks} masks -
-
- - Mask {datasetValidations[dataset.name]?.pairs.image_mask ?? 0} pair -
-
- {(["images", "masks"] as const).flatMap((kind) => - (dataset.samples[kind] ?? []).slice(0, 4).map((sample) => ( - - {sample.previewable && {sample.name}} - {kind} - {sample.name} - - )) - )} -
-
-
- ))} -
- {selectedValidation && } - {selectedDataset && } + {selectedDataset ? ( + <> + + {selectedValidation && } + + + ) : ( +

左侧创建或选择一个数据集后,这里会显示图片、masks、配对和异常项。

+ )}
@@ -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 ( +
+
+
images / video{dataset.counts.images}
+
masks{dataset.counts.masks}
+
paired{validation?.pairs.image_mask ?? rows.filter((row) => row.status === "paired").length}
+
status{ready ? "MASK READY" : "CHECK"}
+
+ +
+ {[...images.slice(0, 4), ...masks.slice(0, 4)].slice(0, 8).map((item) => ( + + {item.previewable ? {item.name} : } + {item.name} + + ))} +
+ +
+
+ 配对 + images / video + 大小 + masks + 大小 + 状态 +
+
+ {rows.length ? rows.slice(0, 500).map((row) => ( +
+ {row.stem} + + {row.image?.name ?? "-"} + + {row.image ? formatBytes(row.image.size) : "-"} + + {row.mask?.name ?? "-"} + + {row.mask ? formatBytes(row.mask.size) : "-"} + {row.status === "paired" ? "已配对" : row.status === "missing_mask" ? "缺少 mask" : "多余 mask"} +
+ )) : ( +

暂无文件。

+ )} +
+
+ +
+
+ 多余或无法匹配 + {problemRows.length ? problemRows.slice(0, 80).map((row) => ( + {row.status === "missing_mask" ? row.image?.name : row.mask?.name} + )) : 当前没有未配对文件。} +
+
+ 尺寸问题 + {shapeErrors.length ? shapeErrors.slice(0, 20).map((item, index) => ( + {JSON.stringify(item)} + )) : 当前抽样未发现 image/mask 尺寸不一致。} +
+
+
+ ); +} + function DatasetQuality({ validation }: { validation: DatasetValidation }) { return (
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 55c0cd9..40730e6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }