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 .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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user