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