Redesign dataset workbench management
This commit is contained in:
@@ -26,14 +26,14 @@ from .coverage import get_coverage_report
|
|||||||
from .jobs import cancel_job, create_job
|
from .jobs import cancel_job, create_job
|
||||||
from .modules.results.service import scan_results, scan_training_curves
|
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.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 .modules.weights.service import load_manifest, sync_weights, verify_weights
|
||||||
from .agents.evaluation_agent import evaluate_project
|
from .agents.evaluation_agent import evaluate_project
|
||||||
from .agents.user_agent import latest_user_agent_report, run_user_agent
|
from .agents.user_agent import latest_user_agent_report, run_user_agent
|
||||||
from .agents.validation_agent import validate_project
|
from .agents.validation_agent import validate_project
|
||||||
from .paths import ensure_inside
|
from .paths import ensure_inside
|
||||||
from .progress import progress_from_log_path
|
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")
|
app = FastAPI(title="Seg Data Server Net", version="0.1.0")
|
||||||
|
|
||||||
@@ -149,9 +149,36 @@ def api_datasets() -> list[dict]:
|
|||||||
return list_uploaded_datasets()
|
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")
|
@app.post("/api/datasets")
|
||||||
def api_create_dataset(dataset: DatasetCreate) -> dict:
|
def api_create_dataset(dataset: DatasetCreate) -> dict:
|
||||||
|
try:
|
||||||
return create_dataset(dataset.name, dataset.description)
|
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}")
|
@app.post("/api/datasets/{dataset_name}/upload/{kind}")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ...config import settings
|
|||||||
DATASET_KINDS = ("images", "labels", "masks")
|
DATASET_KINDS = ("images", "labels", "masks")
|
||||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
|
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
|
||||||
LABEL_EXTS = {".txt", ".json", ".yaml", ".yml"}
|
LABEL_EXTS = {".txt", ".json", ".yaml", ".yml"}
|
||||||
|
VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm", ".m4v"}
|
||||||
ARCHIVE_SUFFIXES = (".zip", ".tar", ".tar.gz", ".tgz")
|
ARCHIVE_SUFFIXES = (".zip", ".tar", ".tar.gz", ".tgz")
|
||||||
ARCHIVE_ALIASES = {
|
ARCHIVE_ALIASES = {
|
||||||
"images": {"image", "images", "img", "imgs", "ori", "original", "originals"},
|
"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]:
|
def upload_exts_for_kind(kind: str) -> set[str]:
|
||||||
if kind == "images":
|
if kind == "images":
|
||||||
return IMAGE_EXTS
|
return IMAGE_EXTS | VIDEO_EXTS
|
||||||
if kind == "masks":
|
if kind == "masks":
|
||||||
return IMAGE_EXTS | LABEL_EXTS
|
return IMAGE_EXTS | LABEL_EXTS
|
||||||
if kind == "labels":
|
if kind == "labels":
|
||||||
@@ -138,6 +139,8 @@ def metadata_path(name: str) -> Path:
|
|||||||
def create_dataset(name: str, description: str = "") -> dict:
|
def create_dataset(name: str, description: str = "") -> dict:
|
||||||
safe_name = slugify(name)
|
safe_name = slugify(name)
|
||||||
root = dataset_dir(safe_name)
|
root = dataset_dir(safe_name)
|
||||||
|
if root.exists():
|
||||||
|
raise ValueError(f"dataset already exists: {safe_name}")
|
||||||
for kind in DATASET_KINDS:
|
for kind in DATASET_KINDS:
|
||||||
(root / kind).mkdir(parents=True, exist_ok=True)
|
(root / kind).mkdir(parents=True, exist_ok=True)
|
||||||
meta = {
|
meta = {
|
||||||
@@ -155,6 +158,44 @@ def create_dataset(name: str, description: str = "") -> dict:
|
|||||||
return describe_dataset(safe_name)
|
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:
|
def _load_meta(name: str) -> dict:
|
||||||
path = metadata_path(name)
|
path = metadata_path(name)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
@@ -192,6 +233,22 @@ def _image_shape(path: Path) -> dict | None:
|
|||||||
return 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:
|
def _validate_yolo_txt(path: Path) -> dict:
|
||||||
errors = []
|
errors = []
|
||||||
classes: set[int] = set()
|
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}
|
absolute_layout = {kind: str((root / kind).resolve()) for kind in DATASET_KINDS}
|
||||||
counts = {}
|
counts = {}
|
||||||
samples = {}
|
samples = {}
|
||||||
|
files_by_kind = {}
|
||||||
for kind in sorted(DATASET_KINDS):
|
for kind in sorted(DATASET_KINDS):
|
||||||
files = list(_iter_files(root / kind))
|
files = list(_iter_files(root / kind))
|
||||||
counts[kind] = len(files)
|
counts[kind] = len(files)
|
||||||
samples[kind] = [
|
file_items = [_file_info(path, kind) for path in files[:2000]]
|
||||||
{
|
files_by_kind[kind] = file_items
|
||||||
"name": path.name,
|
samples[kind] = file_items[:80]
|
||||||
"path": str(path.resolve()),
|
return {**meta, "absolute_layout": absolute_layout, "counts": counts, "samples": samples, "files": files_by_kind}
|
||||||
"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}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_dataset(name: str) -> dict:
|
def validate_dataset(name: str) -> dict:
|
||||||
|
|||||||
@@ -49,5 +49,10 @@ class DatasetCreate(BaseModel):
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetCopyRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class DatasetYoloYamlRequest(BaseModel):
|
class DatasetYoloYamlRequest(BaseModel):
|
||||||
class_names: list[str] | None = None
|
class_names: list[str] | None = None
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import zipfile
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pytest
|
||||||
from fastapi import UploadFile
|
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):
|
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}
|
assert created["counts"] == {"images": 0, "labels": 0, "masks": 0}
|
||||||
described = describe_dataset("case_01")
|
described = describe_dataset("case_01")
|
||||||
assert described["layout"]["images"].endswith("images")
|
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):
|
def test_validate_dataset_and_generate_yolo_yaml(tmp_path, monkeypatch):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
Copy,
|
||||||
Cpu,
|
Cpu,
|
||||||
Database,
|
Database,
|
||||||
FileImage,
|
FileImage,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Square,
|
Square,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
Trash2,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
Wand2,
|
Wand2,
|
||||||
@@ -69,13 +71,26 @@ type Catalog = {
|
|||||||
weights: { count: number; total_bytes: number; updated_at?: string };
|
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 = {
|
type UploadedDataset = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
absolute_layout?: Record<"images" | "labels" | "masks", string>;
|
absolute_layout?: Record<"images" | "labels" | "masks", string>;
|
||||||
layout?: Record<"images" | "labels" | "masks", string>;
|
layout?: Record<"images" | "labels" | "masks", string>;
|
||||||
counts: { images: number; labels: number; masks: number };
|
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 = {
|
type DatasetValidation = {
|
||||||
@@ -90,6 +105,7 @@ type DatasetValidation = {
|
|||||||
masks_without_images: string[];
|
masks_without_images: string[];
|
||||||
};
|
};
|
||||||
classes: number[];
|
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[] }>;
|
checks: Array<{ name: string; passed: boolean; count?: number; labels?: number; masks?: number; errors?: unknown[] }>;
|
||||||
ready: { yolo: boolean; mask: boolean; any: boolean };
|
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("/")}`;
|
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>> = {
|
const defaultParams: Record<string, Record<string, unknown>> = {
|
||||||
"mock.echo": { message: "hello from Seg Data Server" },
|
"mock.echo": { message: "hello from Seg Data Server" },
|
||||||
"dataset.rename": { image_dir: "../DataSet_Own/ORI", label_dir: "../DataSet_Own/Label" },
|
"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 [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||||
const [log, setLog] = useState("");
|
const [log, setLog] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
const [datasetName, setDatasetName] = useState("demo_dataset");
|
const [datasetName, setDatasetName] = useState("demo_dataset");
|
||||||
const [datasetDescription, setDatasetDescription] = useState("");
|
const [datasetDescription, setDatasetDescription] = useState("");
|
||||||
|
const [copyDatasetName, setCopyDatasetName] = useState("");
|
||||||
const [selectedDatasetName, setSelectedDatasetName] = 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 [selectedCurvePath, setSelectedCurvePath] = useState("");
|
||||||
const [resultFamilyFilter, setResultFamilyFilter] = useState("all");
|
const [resultFamilyFilter, setResultFamilyFilter] = useState("all");
|
||||||
const [resultRoleFilter, setResultRoleFilter] = useState("all");
|
const [resultRoleFilter, setResultRoleFilter] = useState("all");
|
||||||
@@ -597,6 +669,8 @@ function App() {
|
|||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
const datasetOps = taskGroups.dataset.filter((task) => task in taskLabels);
|
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(
|
const selectedDataset = useMemo(
|
||||||
() => datasets.find((dataset) => dataset.name === selectedDatasetName) ?? datasets.find((dataset) => dataset.name === datasetName),
|
() => datasets.find((dataset) => dataset.name === selectedDatasetName) ?? datasets.find((dataset) => dataset.name === datasetName),
|
||||||
[datasetName, datasets, selectedDatasetName]
|
[datasetName, datasets, selectedDatasetName]
|
||||||
@@ -623,6 +697,8 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [curves, results, selectedDataset]);
|
}, [curves, results, selectedDataset]);
|
||||||
const selectedYoloWeightReady = Boolean(selectedYoloOutputs.bestWeight);
|
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 availableGpus = gpus?.gpus ?? [];
|
||||||
const condaEnvOptions = useMemo(() => ["auto", ...Array.from(new Set((condaEnvs?.envs ?? []).map((item) => item.name))).sort()], [condaEnvs]);
|
const condaEnvOptions = useMemo(() => ["auto", ...Array.from(new Set((condaEnvs?.envs ?? []).map((item) => item.name))).sort()], [condaEnvs]);
|
||||||
const selectedGpuDevice = selectedGpuIds.length ? selectedGpuIds.join(",") : "cpu";
|
const selectedGpuDevice = selectedGpuIds.length ? selectedGpuIds.join(",") : "cpu";
|
||||||
@@ -678,7 +754,7 @@ function App() {
|
|||||||
const activePage = pages.find((item) => item.id === page) ?? pages[0];
|
const activePage = pages.find((item) => item.id === page) ?? pages[0];
|
||||||
const inferenceSource = inferenceSourcePath.trim() || selectedDataset?.absolute_layout?.images || "";
|
const inferenceSource = inferenceSourcePath.trim() || selectedDataset?.absolute_layout?.images || "";
|
||||||
const uploadAccept = uploadKind === "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";
|
: "image/*,.txt,.json,.yaml,.yml,.zip,.tar,.tar.gz,.tgz";
|
||||||
const inferenceOutputs = useMemo(() => {
|
const inferenceOutputs = useMemo(() => {
|
||||||
const predictions = results.filter((item) => item.role === "segmentation" && item.previewable).slice(0, 8);
|
const predictions = results.filter((item) => item.role === "segmentation" && item.previewable).slice(0, 8);
|
||||||
@@ -698,6 +774,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [datasets, selectedDatasetName]);
|
}, [datasets, selectedDatasetName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDataset) {
|
||||||
|
setCopyDatasetName(`${selectedDataset.name}_copy`);
|
||||||
|
}
|
||||||
|
}, [selectedDataset?.name]);
|
||||||
|
|
||||||
function navigate(next: PageId) {
|
function navigate(next: PageId) {
|
||||||
setPage(next);
|
setPage(next);
|
||||||
window.location.hash = next;
|
window.location.hash = next;
|
||||||
@@ -748,7 +830,19 @@ function App() {
|
|||||||
const maskDir = layout.masks || layout.labels;
|
const maskDir = layout.masks || layout.labels;
|
||||||
const resultDir = `${layout.images.replace(/\/images$/, "")}/results/${next.replace(".", "_")}`;
|
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)) {
|
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)) {
|
if (["dataset.rebuild_labels", "dataset.yolo_rebuild_labels", "dataset.yolo_txt_sort"].includes(next)) {
|
||||||
return { ...base, label_dir: maskDir, folder: maskDir };
|
return { ...base, label_dir: maskDir, folder: maskDir };
|
||||||
@@ -858,14 +952,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createDataset() {
|
async function createDataset() {
|
||||||
|
if (datasetNameExists) {
|
||||||
|
setActionError(`数据集已存在:${slugifyName(datasetName)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
setActionError("");
|
||||||
await api("/api/datasets", {
|
await api("/api/datasets", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name: datasetName, description: datasetDescription })
|
body: JSON.stringify({ name: datasetName, description: datasetDescription })
|
||||||
});
|
});
|
||||||
setSelectedDatasetName(datasetName);
|
setSelectedDatasetName(datasetName);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -875,6 +976,7 @@ function App() {
|
|||||||
if (!uploadFiles || uploadFiles.length === 0) return;
|
if (!uploadFiles || uploadFiles.length === 0) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
setActionError("");
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
Array.from(uploadFiles).forEach((file) => body.append("files", file));
|
Array.from(uploadFiles).forEach((file) => body.append("files", file));
|
||||||
let res: Response;
|
let res: Response;
|
||||||
@@ -888,6 +990,67 @@ function App() {
|
|||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
await refresh();
|
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 {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -1071,7 +1234,7 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <div className="alert">{error}</div>}
|
{(error || actionError) && <div className="alert">{actionError || error}</div>}
|
||||||
|
|
||||||
<section className="metrics" data-page-section="overview">
|
<section className="metrics" data-page-section="overview">
|
||||||
<div className="metric">
|
<div className="metric">
|
||||||
@@ -1226,12 +1389,12 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid two" id="datasets" data-page-section="datasets">
|
<section className="datasetWorkbench" id="datasets" data-page-section="datasets">
|
||||||
<div className="panel">
|
<div className="panel datasetSidePanel">
|
||||||
<div className="panelHead">
|
<div className="panelHead">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Dataset Bench</p>
|
<p className="eyebrow">Dataset Bench</p>
|
||||||
<h2>数据集与 Masks 上传</h2>
|
<h2>上传与管理</h2>
|
||||||
</div>
|
</div>
|
||||||
<Database size={22} />
|
<Database size={22} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1244,6 +1407,7 @@ function App() {
|
|||||||
<span>说明</span>
|
<span>说明</span>
|
||||||
<input value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
|
<input value={datasetDescription} onChange={(event) => setDatasetDescription(event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
{datasetNameExists && <small className="formWarn">已存在同名数据集,只能上传追加,不能重复创建。</small>}
|
||||||
<div className="segmented">
|
<div className="segmented">
|
||||||
{(["images", "masks"] as const).map((kind) => (
|
{(["images", "masks"] as const).map((kind) => (
|
||||||
<button key={kind} className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
|
<button key={kind} className={uploadKind === kind ? "active" : ""} onClick={() => setUploadKind(kind)}>
|
||||||
@@ -1253,17 +1417,78 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<label className="drop">
|
<label className="drop">
|
||||||
{uploadFiles?.length ? <PackageOpen size={24} /> : <UploadCloud size={24} />}
|
{uploadFiles?.length ? <PackageOpen size={24} /> : <UploadCloud size={24} />}
|
||||||
<span>{uploadFiles?.length ? `${uploadFiles.length} 个文件待上传` : `选择 ${uploadKind} 图片或压缩包`}</span>
|
<span>{uploadFiles?.length ? `${uploadFiles.length} 个文件待上传` : `选择 ${uploadKind} 图片、视频或压缩包`}</span>
|
||||||
<small>.png/.jpg/.tif/.zip/.tar.gz 均可上传</small>
|
<small>.png/.jpg/.tif/.mp4/.zip/.tar.gz 均可上传</small>
|
||||||
<input multiple type="file" accept={uploadAccept} onChange={(event) => setUploadFiles(event.target.files)} />
|
<input multiple type="file" accept={uploadAccept} onChange={(event) => setUploadFiles(event.target.files)} />
|
||||||
</label>
|
</label>
|
||||||
<div className="buttonRow">
|
<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>
|
<button className="primary secondary" disabled={busy || !uploadFiles?.length} onClick={uploadDatasetFiles}><UploadCloud size={17} />上传</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="opGrid">
|
</div>
|
||||||
{datasetOps.map((task) => (
|
|
||||||
<button key={task} type="button" onClick={() => pickDatasetTask(task)}>
|
<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} />
|
<Wand2 size={16} />
|
||||||
<span>{taskLabels[task]}</span>
|
<span>{taskLabels[task]}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1272,11 +1497,11 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel">
|
<div className="panel datasetDetailPanel">
|
||||||
<div className="panelHead">
|
<div className="panelHead">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Files</p>
|
<p className="eyebrow">Files</p>
|
||||||
<h2>数据集浏览</h2>
|
<h2>{selectedDataset?.name ?? "选择一个数据集"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="buttonRow compactButtons">
|
<div className="buttonRow compactButtons">
|
||||||
<button className="iconButton" disabled={busy || !selectedValidation?.ready.yolo} onClick={generateSelectedYoloYaml} title="生成 YOLO dataset.yaml">
|
<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 尚未生成"}>
|
<button className="iconButton" disabled={busy || !selectedDataset?.absolute_layout || !selectedYoloWeightReady} onClick={startSelectedYoloHeatmap} title={selectedYoloWeightReady ? "使用自定义 best.pt 生成热度图" : "best.pt 尚未生成"}>
|
||||||
<Zap size={18} />
|
<Zap size={18} />
|
||||||
</button>
|
</button>
|
||||||
<FileImage size={22} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="datasetList">
|
{selectedDataset ? (
|
||||||
{datasets.map((dataset) => (
|
<>
|
||||||
<div key={dataset.name}>
|
<DatasetDetail dataset={selectedDataset} validation={selectedValidation} />
|
||||||
<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} />}
|
{selectedValidation && <DatasetQuality validation={selectedValidation} />}
|
||||||
{selectedDataset && <DatasetYoloOutputs dataset={selectedDataset} outputs={selectedYoloOutputs} />}
|
<DatasetYoloOutputs dataset={selectedDataset} outputs={selectedYoloOutputs} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="muted">左侧创建或选择一个数据集后,这里会显示图片、masks、配对和异常项。</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 }) {
|
function DatasetQuality({ validation }: { validation: DatasetValidation }) {
|
||||||
return (
|
return (
|
||||||
<div className="qualityBox">
|
<div className="qualityBox">
|
||||||
|
|||||||
@@ -198,6 +198,10 @@ h2 {
|
|||||||
background: var(--cyan);
|
background: var(--cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.primary.danger {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
.primary:disabled, .iconButton:disabled {
|
.primary:disabled, .iconButton:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -718,6 +722,43 @@ textarea {
|
|||||||
gap: 12px;
|
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 {
|
.segmented {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
@@ -779,6 +820,10 @@ textarea {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttonRow .primary {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.opGrid {
|
.opGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
@@ -811,6 +856,243 @@ textarea {
|
|||||||
white-space: nowrap;
|
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 {
|
.datasetList {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1879,6 +2161,7 @@ meter {
|
|||||||
.metrics,
|
.metrics,
|
||||||
.capSummary,
|
.capSummary,
|
||||||
.capabilityGrid,
|
.capabilityGrid,
|
||||||
|
.datasetWorkbench,
|
||||||
.grid.two,
|
.grid.two,
|
||||||
.grid.three,
|
.grid.three,
|
||||||
.grid.four,
|
.grid.four,
|
||||||
@@ -1904,9 +2187,17 @@ meter {
|
|||||||
.pipelineSteps,
|
.pipelineSteps,
|
||||||
.pipelineStats,
|
.pipelineStats,
|
||||||
.inferencePreview,
|
.inferencePreview,
|
||||||
|
.datasetSummaryStrip,
|
||||||
|
.datasetPreviewBand,
|
||||||
|
.issueGrid,
|
||||||
.userAgentDataset {
|
.userAgentDataset {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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 {
|
.resultList a:hover, .jobRow:hover {
|
||||||
@@ -1961,6 +2252,8 @@ meter {
|
|||||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.taskColumns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.taskColumns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
.opGrid { grid-template-columns: repeat(2, 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,
|
.coverageGrid,
|
||||||
.taskCheckList { grid-template-columns: 1fr; }
|
.taskCheckList { grid-template-columns: 1fr; }
|
||||||
.grid.three { grid-template-columns: 1fr; }
|
.grid.three { grid-template-columns: 1fr; }
|
||||||
|
|||||||
Reference in New Issue
Block a user