diff --git a/studio/backend/core/inference/diffusion_families.py b/studio/backend/core/inference/diffusion_families.py index ee8222d2f7..f49843ed3d 100644 --- a/studio/backend/core/inference/diffusion_families.py +++ b/studio/backend/core/inference/diffusion_families.py @@ -320,6 +320,7 @@ def trainable_family_names() -> tuple[str, ...]: """Names of families Studio can train a LoRA on, in registry order.""" return tuple(fam.name for fam in _FAMILIES if fam.trainable) + # Editing / inpaint checkpoints share an arch keyword but need a different # pipeline and an input image, which this text-to-image backend doesn't drive. # "layered" rejects Qwen-Image-Layered: its transformer sets additional_t_cond=True diff --git a/studio/backend/core/training/diffusion_train_common.py b/studio/backend/core/training/diffusion_train_common.py index f89b05ff80..9458c4e5c9 100644 --- a/studio/backend/core/training/diffusion_train_common.py +++ b/studio/backend/core/training/diffusion_train_common.py @@ -95,9 +95,7 @@ def resolve_trainable_family(base_model: str, model_family: Optional[str] = None known = ", ".join(supported_family_names()) raise ValueError(f"Unknown model_family {model_family!r}. Known families: {known}.") if not fam.trainable: - raise ValueError( - f"'{fam.name}' models can't be trained yet. {_trainable_hint()}" - ) + raise ValueError(f"'{fam.name}' models can't be trained yet. {_trainable_hint()}") return fam.name fam = detect_family_for_pick(base_model) diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py index b36661cfef..2479571357 100644 --- a/studio/backend/models/training.py +++ b/studio/backend/models/training.py @@ -802,3 +802,72 @@ class DiffusionDatasetUploadResponse(BaseModel): image_count: int caption_count: int uploaded: int + + +class DiffusionDatasetImageRecord(BaseModel): + """One image in a training dataset folder, with its resolved caption. ``caption`` is + null when no caption exists from any source; ``caption_source`` records where the + shown caption came from (``metadata`` beats a per-image ``sidecar``; ``none`` when + uncaptioned) so the labeling UI can highlight images that still need a caption.""" + + filename: str + caption: Optional[str] = None + caption_source: Literal["sidecar", "metadata", "none"] = "none" + width: int + height: int + size_bytes: int + + +class DiffusionDatasetImagesResponse(BaseModel): + """Every image in a dataset folder (including uncaptioned ones), for the labeling grid.""" + + name: str + path: str + images: List[DiffusionDatasetImageRecord] + + +class DiffusionCaptionUpdateRequest(BaseModel): + """Write (or, when blank, clear) the per-image ``.txt`` caption sidecar.""" + + caption: str = "" + + +class DiffusionDatasetExample(BaseModel): + """A curated, one-click-importable example image dataset. ``image_cap`` bounds how many + images are materialized; ``license`` is shown verbatim so users see the terms before + importing; ``suggested_trigger`` seeds the trigger prompt for uncaptioned subject sets.""" + + id: str + label: str + repo: str + description: str + license: str + image_cap: int + suggested_trigger: Optional[str] = None + + +class DiffusionDatasetExamplesResponse(BaseModel): + """The curated example-dataset registry the Train tab offers for one-click import.""" + + examples: List[DiffusionDatasetExample] + + +class DiffusionDatasetImportRequest(BaseModel): + """Import a curated example (``id``) into a dataset folder (``name``; defaults to the + example id).""" + + id: str + name: Optional[str] = None + + +class DiffusionDatasetImportResponse(BaseModel): + """Result of a one-click example import: folder counts plus provenance so the UI can + show what was fetched and under what license.""" + + name: str + path: str + image_count: int + caption_count: int + imported: int + license: str + source_repo: str diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py index 0eb7e00561..a5b4c3b06e 100644 --- a/studio/backend/routes/training.py +++ b/studio/backend/routes/training.py @@ -58,6 +58,13 @@ from models import ( TrainingProgress, ) from models.training import ( + DiffusionCaptionUpdateRequest, + DiffusionDatasetExample, + DiffusionDatasetExamplesResponse, + DiffusionDatasetImageRecord, + DiffusionDatasetImagesResponse, + DiffusionDatasetImportRequest, + DiffusionDatasetImportResponse, DiffusionDatasetSummary, DiffusionDatasetUploadResponse, DiffusionMetricHistory, @@ -1401,3 +1408,509 @@ async def upload_diffusion_dataset( caption_count = summary.caption_count, uploaded = uploaded, ) + + +# ── Dataset labeling (per-image caption editing) + one-click example imports ── +# Thumbnails live in a hidden subdir so they never appear in dataset listings or the +# trainer's own image discovery (both scan only top-level files). +_THUMBS_DIRNAME = ".thumbs" +_MAX_CAPTION_CHARS = 2000 + + +def _resolve_dataset_folder(name: str, *, must_exist: bool = True) -> Path: + """Validate ``name`` (single component, no traversal) and resolve it under the Studio + datasets root. 404 when a read target is missing.""" + from utils.paths import datasets_root + + cleaned = _clean_diffusion_dataset_name(name) + folder = datasets_root() / cleaned + if must_exist and not folder.is_dir(): + raise HTTPException(status_code = 404, detail = f"Dataset '{cleaned}' not found.") + return folder + + +def _safe_dataset_image_path(folder: Path, filename: str) -> Path: + """Resolve ``filename`` to an image path strictly inside ``folder``. Rejects any path + separators / traversal / null bytes and non-image extensions.""" + raw = filename or "" + if "/" in raw or "\\" in raw or ".." in raw or "\x00" in raw or raw != Path(raw).name: + raise HTTPException(status_code = 400, detail = "Invalid image filename.") + if Path(raw).suffix.lower() not in _DIFFUSION_DATASET_IMAGE_EXTS: + exts = ", ".join(sorted(_DIFFUSION_DATASET_IMAGE_EXTS)) + raise HTTPException(status_code = 400, detail = f"Not an image file. Allowed: {exts}") + path = folder / raw + # Defense in depth: the real path must stay under the dataset folder. + try: + path.resolve().relative_to(folder.resolve()) + except ValueError: + raise HTTPException(status_code = 400, detail = "Invalid image filename.") + return path + + +def _load_metadata_captions(folder: Path) -> dict[str, str]: + """Read metadata.jsonl / captions.jsonl into {file_name: caption}, mirroring the + trainer's discovery (keys file_name/image/file; caption in the ``text`` column).""" + import json + + out: dict[str, str] = {} + for meta_name in ("metadata.jsonl", "captions.jsonl"): + meta_path = folder / meta_name + if not meta_path.is_file(): + continue + try: + lines = meta_path.read_text(encoding = "utf-8").splitlines() + except OSError: + continue + for line in lines: + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + key = row.get("file_name") or row.get("image") or row.get("file") + if key and "text" in row: + out[str(key)] = str(row["text"]) + return out + + +def _image_record( + folder: Path, image_path: Path, meta_captions: dict[str, str] +) -> DiffusionDatasetImageRecord: + """Build one image record, resolving its caption with metadata > sidecar precedence + (the same order the trainer uses).""" + caption: Optional[str] = meta_captions.get(image_path.name) + source = "metadata" if caption is not None else "none" + if caption is None: + for ext in (".txt", ".caption"): + sidecar = image_path.with_suffix(ext) + if sidecar.is_file(): + try: + caption = sidecar.read_text(encoding = "utf-8").strip() + source = "sidecar" + except OSError: + caption = None + break + try: + size_bytes = image_path.stat().st_size + except OSError: + size_bytes = 0 + width = height = 0 + try: + from PIL import Image + with Image.open(image_path) as im: + width, height = im.size + except Exception: # noqa: BLE001 -- an unreadable image still lists (0x0) rather than 500 + pass + return DiffusionDatasetImageRecord( + filename = image_path.name, + caption = caption, + caption_source = source, # type: ignore[arg-type] + width = width, + height = height, + size_bytes = size_bytes, + ) + + +@router.get("/diffusion/dataset/{name}/images", response_model = DiffusionDatasetImagesResponse) +async def list_diffusion_dataset_images( + name: str, current_subject: str = Depends(get_current_subject) +): + """List every image in a dataset folder with its resolved caption (including + uncaptioned images), for the labeling grid.""" + folder = _resolve_dataset_folder(name) + + def scan() -> DiffusionDatasetImagesResponse: + meta = _load_metadata_captions(folder) + records: list[DiffusionDatasetImageRecord] = [] + for p in sorted(folder.iterdir()): + if p.is_file() and p.suffix.lower() in _DIFFUSION_DATASET_IMAGE_EXTS: + records.append(_image_record(folder, p, meta)) + return DiffusionDatasetImagesResponse(name = folder.name, path = str(folder), images = records) + + return await asyncio.to_thread(scan) + + +@router.get("/diffusion/dataset/{name}/image/{filename}") +async def get_diffusion_dataset_image( + name: str, + filename: str, + thumb: Optional[int] = None, + current_subject: str = Depends(get_current_subject), +): + """Serve a dataset image. ``?thumb=`` returns a cached downscaled JPEG (regenerated + when the source is newer), used by the labeling grid to stay light.""" + from fastapi.responses import FileResponse + + folder = _resolve_dataset_folder(name) + image_path = _safe_dataset_image_path(folder, filename) + if not image_path.is_file(): + raise HTTPException(status_code = 404, detail = "Image not found.") + if not thumb: + return FileResponse(str(image_path)) + + size = max(32, min(1024, int(thumb))) + + def make_thumb() -> Path: + from PIL import Image + + thumbs_dir = folder / _THUMBS_DIRNAME + thumbs_dir.mkdir(exist_ok = True) + thumb_path = thumbs_dir / f"{image_path.stem}_{size}.jpg" + src_mtime = image_path.stat().st_mtime + if thumb_path.is_file() and thumb_path.stat().st_mtime >= src_mtime: + return thumb_path + with Image.open(image_path) as im: + im = im.convert("RGB") + im.thumbnail((size, size), Image.LANCZOS) + im.save(thumb_path, format = "JPEG", quality = 85) + return thumb_path + + try: + thumb_path = await asyncio.to_thread(make_thumb) + except Exception as e: # noqa: BLE001 -- fall back to the original on any decode failure + logger.warning("Thumbnail generation failed for %s: %s", image_path, e) + return FileResponse(str(image_path)) + return FileResponse(str(thumb_path), media_type = "image/jpeg") + + +@router.put( + "/diffusion/dataset/{name}/caption/{filename}", + response_model = DiffusionDatasetImageRecord, +) +async def set_diffusion_dataset_caption( + name: str, + filename: str, + body: DiffusionCaptionUpdateRequest, + current_subject: str = Depends(get_current_subject), +): + """Write (or, when blank, clear) an image's ``.txt`` caption sidecar. Returns the + updated image record.""" + folder = _resolve_dataset_folder(name) + image_path = _safe_dataset_image_path(folder, filename) + if not image_path.is_file(): + raise HTTPException(status_code = 404, detail = "Image not found.") + caption = (body.caption or "").strip() + if len(caption) > _MAX_CAPTION_CHARS: + raise HTTPException( + status_code = 400, + detail = f"Caption too long (max {_MAX_CAPTION_CHARS} characters).", + ) + + def write() -> DiffusionDatasetImageRecord: + sidecar = image_path.with_suffix(".txt") + if caption: + sidecar.write_text(caption, encoding = "utf-8") + else: + # Blank clears the sidecar; also drop a stale .caption so the image reads as + # uncaptioned afterwards. + sidecar.unlink(missing_ok = True) + image_path.with_suffix(".caption").unlink(missing_ok = True) + return _image_record(folder, image_path, _load_metadata_captions(folder)) + + return await asyncio.to_thread(write) + + +@router.delete("/diffusion/dataset/{name}/image/{filename}") +async def delete_diffusion_dataset_image( + name: str, + filename: str, + current_subject: str = Depends(get_current_subject), +): + """Remove an image, its caption sidecars, and any cached thumbnails.""" + folder = _resolve_dataset_folder(name) + image_path = _safe_dataset_image_path(folder, filename) + if not image_path.is_file(): + raise HTTPException(status_code = 404, detail = "Image not found.") + + def remove() -> dict: + image_path.unlink(missing_ok = True) + for ext in (".txt", ".caption"): + image_path.with_suffix(ext).unlink(missing_ok = True) + thumbs_dir = folder / _THUMBS_DIRNAME + if thumbs_dir.is_dir(): + for t in thumbs_dir.glob(f"{image_path.stem}_*.jpg"): + t.unlink(missing_ok = True) + return {"deleted": image_path.name} + + return await asyncio.to_thread(remove) + + +# Curated, license-labelled example datasets for one-click import. ``loader`` picks the +# materialization strategy: "hf_dataset" streams rows from datasets.load_dataset (image + +# optional caption column); "imagefolder_jsonl" snapshot-downloads a dataset repo whose +# captions live in a *.jsonl (file_name/text) rather than a standard metadata.jsonl. +_DATASET_EXAMPLES: list[dict] = [ + { + "id": "dreambooth-dog", + "label": "Dog (DreamBooth subject)", + "repo": "diffusers/dog-example", + "description": ( + "5 photos of one dog. The classic DreamBooth subject set: teach the model a " + "specific subject, then summon it with the trigger prompt." + ), + "license": "Released by Google for DreamBooth research/demos", + "image_cap": 10, + "suggested_trigger": "a photo of sks dog", + "loader": "hf_dataset", + "caption_column": None, + "no_checks": False, + }, + { + "id": "tuxemon", + "label": "Tuxemon (captioned style set)", + "repo": "linoyts/Tuxemon", + "description": ( + "Captioned cartoon monster art. A good style set: each image ships a caption, " + "so the adapter learns the look without a trigger word." + ), + "license": "cc-by-sa-3.0", + "image_cap": 60, + "suggested_trigger": None, + "loader": "hf_dataset", + "caption_column": "prompt", + "no_checks": True, + }, + { + "id": "tarot-1920", + "label": "1920 Tarot (public domain style set)", + "repo": "multimodalart/1920-raider-waite-tarot-public-domain", + "description": ( + "Public-domain 1920 Raider-Waite tarot art with captions. A permissive style " + "set for demoing captioned LoRA training." + ), + "license": "public domain", + "image_cap": 60, + "suggested_trigger": None, + "loader": "imagefolder_jsonl", + "caption_column": "text", + "no_checks": True, + }, + { + "id": "smithsonian-butterflies", + "label": "Smithsonian Butterflies", + "repo": "huggan/smithsonian_butterflies_subset", + "description": ( + "100 butterfly specimen photos. The classic diffusers-docs training set. No " + "captions, so pair it with the trigger prompt to teach a butterfly subject." + ), + "license": "CC0 (Smithsonian Open Access)", + "image_cap": 100, + # The metadata columns are species names / boilerplate alt-text, not text-to-image + # captions, so train it as a subject set with the trigger prompt instead. + "suggested_trigger": "a photo of a sks butterfly", + "loader": "hf_dataset", + "caption_column": None, + "no_checks": False, + }, + { + "id": "pixel-nouns", + "label": "Nouns (pixel avatars)", + "repo": "m1guelpf/nouns", + "description": ( + "100 captioned Nouns pixel-art avatars. A captioned style set: each image ships " + "a caption, so the adapter learns the pixel look without a trigger word." + ), + "license": "cc0-1.0", + "image_cap": 100, + "suggested_trigger": None, + "loader": "hf_dataset", + "caption_column": "text", + "no_checks": False, + }, +] + + +def _example_by_id(example_id: str) -> dict: + for entry in _DATASET_EXAMPLES: + if entry["id"] == example_id: + return entry + raise HTTPException(status_code = 404, detail = f"Unknown example dataset '{example_id}'.") + + +@router.get("/diffusion/dataset-examples", response_model = DiffusionDatasetExamplesResponse) +async def list_diffusion_dataset_examples(current_subject: str = Depends(get_current_subject)): + """List the curated example datasets available for one-click import.""" + return DiffusionDatasetExamplesResponse( + examples = [ + DiffusionDatasetExample( + id = e["id"], + label = e["label"], + repo = e["repo"], + description = e["description"], + license = e["license"], + image_cap = e["image_cap"], + suggested_trigger = e["suggested_trigger"], + ) + for e in _DATASET_EXAMPLES + ] + ) + + +def _detect_image_column(features) -> Optional[str]: + """Return the first datasets Image-feature column name, else None.""" + try: + from datasets import Image as HFImage + except Exception: # noqa: BLE001 + HFImage = None # type: ignore[assignment] + for col, feat in features.items(): + if HFImage is not None and isinstance(feat, HFImage): + return col + if type(feat).__name__ == "Image": + return col + return None + + +def _detect_caption_column(entry: dict, columns: list[str]) -> Optional[str]: + """Pick the caption column: the entry's declared one if present, else a common name.""" + declared = entry.get("caption_column") + if declared and declared in columns: + return declared + for cand in ("text", "prompt", "caption", "captions"): + if cand in columns: + return cand + return None + + +def _materialize_hf_dataset(entry: dict, dest: Path, cap: int) -> int: + """Stream rows from datasets.load_dataset into ``dest`` as numbered images + optional + .txt sidecars. Returns the number of images written.""" + from datasets import load_dataset + + kwargs = {"split": "train"} + if entry.get("no_checks"): + kwargs["verification_mode"] = "no_checks" + ds = load_dataset(entry["repo"], **kwargs) + image_col = _detect_image_column(ds.features) + if image_col is None: + raise HTTPException( + status_code = 502, + detail = f"'{entry['repo']}' has no image column to import.", + ) + caption_col = _detect_caption_column(entry, list(ds.features.keys())) + written = 0 + for row in ds: + if written >= cap: + break + img = row[image_col] + if img is None: + continue + img = img.convert("RGB") + stem = f"img_{written:04d}" + img.save(dest / f"{stem}.png", format = "PNG") + if caption_col: + cap_text = row.get(caption_col) + if cap_text: + (dest / f"{stem}.txt").write_text(str(cap_text).strip(), encoding = "utf-8") + written += 1 + return written + + +def _materialize_imagefolder_jsonl(entry: dict, dest: Path, cap: int) -> int: + """Snapshot-download a dataset repo whose captions live in *.jsonl (file_name/text), + then copy referenced images + write .txt sidecars. Returns images written.""" + import json + import shutil + + from huggingface_hub import snapshot_download + + caption_col = entry.get("caption_column") or "text" + snap = Path( + snapshot_download( + entry["repo"], + repo_type = "dataset", + allow_patterns = [ + "*.jsonl", + "*.jpg", + "*.jpeg", + "*.png", + "*.webp", + "*.bmp", + "**/*.jpg", + "**/*.jpeg", + "**/*.png", + "**/*.webp", + "**/*.bmp", + ], + ) + ) + # Map basename -> caption from every jsonl carrying file_name + caption column. + captions: dict[str, str] = {} + for jf in snap.rglob("*.jsonl"): + for line in jf.read_text(encoding = "utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + fn = row.get("file_name") or row.get("image") or row.get("file") + if fn and caption_col in row: + captions[Path(str(fn)).name] = str(row[caption_col]) + # Copy images (those with a caption first, so a cap keeps captioned pairs). + images = sorted( + p + for p in snap.rglob("*") + if p.is_file() and p.suffix.lower() in _DIFFUSION_DATASET_IMAGE_EXTS + ) + images.sort(key = lambda p: (p.name not in captions, p.name)) + written = 0 + for src in images: + if written >= cap: + break + stem = f"img_{written:04d}" + shutil.copyfile(src, dest / f"{stem}{src.suffix.lower()}") + cap_text = captions.get(src.name) + if cap_text: + (dest / f"{stem}.txt").write_text(cap_text.strip(), encoding = "utf-8") + written += 1 + return written + + +@router.post("/diffusion/dataset/import-example", response_model = DiffusionDatasetImportResponse) +async def import_diffusion_dataset_example( + body: DiffusionDatasetImportRequest, current_subject: str = Depends(get_current_subject) +): + """Materialize a curated example dataset into a Studio dataset folder (images + .txt + captions), ready to train. Idempotent: a folder that already holds images is returned + as-is rather than re-downloaded.""" + entry = _example_by_id(body.id) + folder = _resolve_dataset_folder(body.name or entry["id"], must_exist = False) + + def do_import() -> DiffusionDatasetImportResponse: + folder.mkdir(parents = True, exist_ok = True) + existing = _diffusion_dataset_summary(folder) + imported = 0 + if existing.image_count == 0: + cap = int(entry["image_cap"]) + try: + if entry["loader"] == "imagefolder_jsonl": + imported = _materialize_imagefolder_jsonl(entry, folder, cap) + else: + imported = _materialize_hf_dataset(entry, folder, cap) + except HTTPException: + raise + except Exception as e: # noqa: BLE001 -- surface a readable fetch/parse failure + raise HTTPException( + status_code = 502, + detail = f"Could not import '{entry['repo']}': {e}", + ) + if imported == 0: + raise HTTPException( + status_code = 502, + detail = f"No images found in '{entry['repo']}'.", + ) + summary = _diffusion_dataset_summary(folder) + return DiffusionDatasetImportResponse( + name = folder.name, + path = str(folder), + image_count = summary.image_count, + caption_count = summary.caption_count, + imported = imported, + license = entry["license"], + source_repo = entry["repo"], + ) + + return await asyncio.to_thread(do_import) diff --git a/studio/backend/tests/test_diffusion_dataset_api.py b/studio/backend/tests/test_diffusion_dataset_api.py new file mode 100644 index 0000000000..b7a895281a --- /dev/null +++ b/studio/backend/tests/test_diffusion_dataset_api.py @@ -0,0 +1,320 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for the diffusion dataset labeling + example-import routes. + +The routes are hit with the FastAPI TestClient; the datasets root is redirected to a +tmp_path so nothing touches a real Studio home. The example importer is exercised with a +mocked datasets.load_dataset so no network / GPU is needed. +""" + +from __future__ import annotations + +import io +import json + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from PIL import Image + +from auth.authentication import get_current_subject +from routes.training import router as training_router + + +def _png_bytes(color = (200, 100, 50), size = (8, 8)) -> bytes: + buf = io.BytesIO() + Image.new("RGB", size, color).save(buf, format = "PNG") + return buf.getvalue() + + +def _write_png( + path, + color = (200, 100, 50), + size = (8, 8), +) -> None: + Image.new("RGB", size, color).save(path, format = "PNG") + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(training_router, prefix = "/api/train") + app.dependency_overrides[get_current_subject] = lambda: "test-user" + return TestClient(app) + + +@pytest.fixture +def ds_root(monkeypatch, tmp_path): + import utils.paths as up + + root = tmp_path / "assets" / "datasets" + root.mkdir(parents = True) + monkeypatch.setattr(up, "datasets_root", lambda: root) + return root + + +# ── listing + caption precedence ───────────────────────────────────────────── +def test_list_images_caption_precedence(client, ds_root): + folder = ds_root / "styleset" + folder.mkdir() + _write_png(folder / "a.png") + _write_png(folder / "b.png") + _write_png(folder / "c.png") + # a.png -> metadata (beats a stray sidecar), b.png -> sidecar, c.png -> none. + (folder / "metadata.jsonl").write_text( + json.dumps({"file_name": "a.png", "text": "from metadata"}) + "\n", encoding = "utf-8" + ) + (folder / "a.txt").write_text("stray sidecar", encoding = "utf-8") + (folder / "b.txt").write_text("from sidecar", encoding = "utf-8") + + r = client.get("/api/train/diffusion/dataset/styleset/images") + assert r.status_code == 200, r.text + body = r.json() + assert body["name"] == "styleset" + recs = {rec["filename"]: rec for rec in body["images"]} + assert set(recs) == {"a.png", "b.png", "c.png"} + assert recs["a.png"]["caption"] == "from metadata" + assert recs["a.png"]["caption_source"] == "metadata" + assert recs["b.png"]["caption"] == "from sidecar" + assert recs["b.png"]["caption_source"] == "sidecar" + assert recs["c.png"]["caption"] is None + assert recs["c.png"]["caption_source"] == "none" + assert recs["a.png"]["width"] == 8 and recs["a.png"]["height"] == 8 + + +def test_list_images_missing_dataset_404(client, ds_root): + assert client.get("/api/train/diffusion/dataset/nope/images").status_code == 404 + + +# ── image serving + thumbnails ─────────────────────────────────────────────── +def test_get_image_and_thumbnail_excluded_from_listing(client, ds_root): + folder = ds_root / "pics" + folder.mkdir() + _write_png(folder / "one.png", size = (64, 48)) + + full = client.get("/api/train/diffusion/dataset/pics/image/one.png") + assert full.status_code == 200, full.text + + thumb = client.get("/api/train/diffusion/dataset/pics/image/one.png?thumb=32") + assert thumb.status_code == 200 + assert thumb.headers["content-type"] == "image/jpeg" + assert (folder / ".thumbs").is_dir() + + # The .thumbs cache dir must not surface as a dataset image. + listing = client.get("/api/train/diffusion/dataset/pics/images").json() + assert [rec["filename"] for rec in listing["images"]] == ["one.png"] + + +def test_get_image_missing_404(client, ds_root): + (ds_root / "pics").mkdir() + assert client.get("/api/train/diffusion/dataset/pics/image/ghost.png").status_code == 404 + + +# ── caption write / clear ──────────────────────────────────────────────────── +def test_put_caption_roundtrip_and_clear(client, ds_root): + folder = ds_root / "cap" + folder.mkdir() + _write_png(folder / "x.png") + + r = client.put( + "/api/train/diffusion/dataset/cap/caption/x.png", json = {"caption": "a red apple"} + ) + assert r.status_code == 200, r.text + assert r.json()["caption"] == "a red apple" + assert r.json()["caption_source"] == "sidecar" + assert (folder / "x.txt").read_text(encoding = "utf-8") == "a red apple" + + # Blank clears the sidecar. + r = client.put("/api/train/diffusion/dataset/cap/caption/x.png", json = {"caption": " "}) + assert r.status_code == 200 + assert r.json()["caption"] is None + assert r.json()["caption_source"] == "none" + assert not (folder / "x.txt").exists() + + +def test_put_caption_missing_image_404(client, ds_root): + (ds_root / "cap").mkdir() + r = client.put("/api/train/diffusion/dataset/cap/caption/ghost.png", json = {"caption": "hi"}) + assert r.status_code == 404 + + +def test_put_caption_too_long_400(client, ds_root): + folder = ds_root / "cap" + folder.mkdir() + _write_png(folder / "x.png") + r = client.put("/api/train/diffusion/dataset/cap/caption/x.png", json = {"caption": "z" * 2001}) + assert r.status_code == 400 + + +# ── delete ─────────────────────────────────────────────────────────────────── +def test_delete_image_cleans_sidecar_and_thumb(client, ds_root): + folder = ds_root / "d" + folder.mkdir() + _write_png(folder / "x.png") + (folder / "x.txt").write_text("cap", encoding = "utf-8") + # Generate a thumbnail so we can assert it is cleaned up too. + client.get("/api/train/diffusion/dataset/d/image/x.png?thumb=32") + assert list((folder / ".thumbs").glob("x_*.jpg")) + + r = client.delete("/api/train/diffusion/dataset/d/image/x.png") + assert r.status_code == 200, r.text + assert not (folder / "x.png").exists() + assert not (folder / "x.txt").exists() + assert not list((folder / ".thumbs").glob("x_*.jpg")) + + +# ── traversal / validation ─────────────────────────────────────────────────── +def test_dataset_name_traversal_rejected_over_http(client, ds_root): + # A name that fails the folder-name validator returns 400, never touches disk. + assert client.get("/api/train/diffusion/dataset/bad name!/images").status_code == 400 + + +def test_image_filename_validation_rejects_traversal(): + from pathlib import Path + + from routes.training import _safe_dataset_image_path + + folder = Path("/tmp/some-dataset") + for bad in ("../../etc/passwd", "/etc/passwd", "..", "sub/dir.png", "notimage.txt"): + with pytest.raises(HTTPException) as exc: + _safe_dataset_image_path(folder, bad) + assert exc.value.status_code == 400 + # A plain image name resolves inside the folder. + assert _safe_dataset_image_path(folder, "ok.png") == folder / "ok.png" + + +def test_clean_dataset_name_rejects_dotdot(): + from routes.training import _clean_diffusion_dataset_name + for bad in ("../x", "a/b", "..", " "): + with pytest.raises(HTTPException) as exc: + _clean_diffusion_dataset_name(bad) + assert exc.value.status_code == 400 + + +# ── examples registry + import ─────────────────────────────────────────────── +def test_list_dataset_examples(client, ds_root): + r = client.get("/api/train/diffusion/dataset-examples") + assert r.status_code == 200, r.text + ids = {e["id"] for e in r.json()["examples"]} + assert { + "dreambooth-dog", + "tuxemon", + "tarot-1920", + "smithsonian-butterflies", + "pixel-nouns", + } <= ids + dog = next(e for e in r.json()["examples"] if e["id"] == "dreambooth-dog") + assert dog["suggested_trigger"] == "a photo of sks dog" + assert dog["license"] + + +def test_list_dataset_examples_large_sets(client, ds_root): + # The two ~100-image sets: butterflies is a subject set (trigger, no caption column), + # nouns is a captioned style set (caption column, no trigger). Both cap at 100. + r = client.get("/api/train/diffusion/dataset-examples") + examples = {e["id"]: e for e in r.json()["examples"]} + butterflies = examples["smithsonian-butterflies"] + assert butterflies["image_cap"] == 100 + assert butterflies["suggested_trigger"] == "a photo of a sks butterfly" + assert "CC0" in butterflies["license"] + nouns = examples["pixel-nouns"] + assert nouns["image_cap"] == 100 + assert nouns["suggested_trigger"] is None + assert nouns["license"] == "cc0-1.0" + + +class _FakeImageFeature: + # Mimics datasets.Image so _detect_image_column matches by class name. + pass + + +_FakeImageFeature.__name__ = "Image" + + +class _FakeDS: + def __init__(self, rows, features): + self._rows = rows + self.features = features + + def __iter__(self): + return iter(self._rows) + + +def _install_fake_load_dataset(monkeypatch, n_rows): + calls = {"count": 0} + rows = [ + {"image": Image.new("RGB", (8, 8), (i * 30 % 255, 60, 90)), "prompt": f"caption {i}"} + for i in range(n_rows) + ] + features = {"image": _FakeImageFeature(), "prompt": object()} + + def fake_load(repo, **kwargs): + calls["count"] += 1 + assert kwargs.get("split") == "train" + return _FakeDS(rows, features) + + import datasets + + monkeypatch.setattr(datasets, "load_dataset", fake_load) + return calls + + +def test_import_example_writes_images_and_captions(client, ds_root, monkeypatch): + calls = _install_fake_load_dataset(monkeypatch, n_rows = 3) + r = client.post( + "/api/train/diffusion/dataset/import-example", + json = {"id": "tuxemon", "name": "my-tux"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["name"] == "my-tux" + assert body["imported"] == 3 + assert body["image_count"] == 3 + assert body["caption_count"] == 3 + assert body["license"] == "cc-by-sa-3.0" + assert body["source_repo"] == "linoyts/Tuxemon" + folder = ds_root / "my-tux" + assert sorted(p.name for p in folder.glob("*.png")) == [f"img_{i:04d}.png" for i in range(3)] + assert (folder / "img_0000.txt").read_text(encoding = "utf-8") == "caption 0" + + # Idempotent: a second call does not reload or duplicate. + r2 = client.post( + "/api/train/diffusion/dataset/import-example", + json = {"id": "tuxemon", "name": "my-tux"}, + ) + assert r2.status_code == 200 + assert r2.json()["imported"] == 0 + assert r2.json()["image_count"] == 3 + assert calls["count"] == 1 + + +def test_import_example_respects_cap(client, ds_root, monkeypatch): + _install_fake_load_dataset(monkeypatch, n_rows = 5) + entry = next( + e + for e in __import__("routes.training", fromlist = ["_DATASET_EXAMPLES"])._DATASET_EXAMPLES + if e["id"] == "tuxemon" + ) + monkeypatch.setitem(entry, "image_cap", 2) + r = client.post("/api/train/diffusion/dataset/import-example", json = {"id": "tuxemon"}) + assert r.status_code == 200, r.text + assert r.json()["imported"] == 2 + assert r.json()["image_count"] == 2 + + +def test_import_example_unknown_id_404(client, ds_root): + r = client.post("/api/train/diffusion/dataset/import-example", json = {"id": "does-not-exist"}) + assert r.status_code == 404 + + +def test_import_example_load_failure_maps_to_502(client, ds_root, monkeypatch): + import datasets + + def boom(repo, **kwargs): + raise RuntimeError("network down") + + monkeypatch.setattr(datasets, "load_dataset", boom) + r = client.post("/api/train/diffusion/dataset/import-example", json = {"id": "tuxemon"}) + assert r.status_code == 502 + assert "Could not import" in r.json()["detail"] diff --git a/studio/backend/tests/test_diffusion_lora_trainer.py b/studio/backend/tests/test_diffusion_lora_trainer.py index 15eb70f3a2..ff8f3ec89e 100644 --- a/studio/backend/tests/test_diffusion_lora_trainer.py +++ b/studio/backend/tests/test_diffusion_lora_trainer.py @@ -236,14 +236,12 @@ def test_config_accepts_sdxl_and_unknown_base_models(): # ── trainer registry + family resolution + metadata sidecar (PR A platform) ── def test_get_trainer_resolves_sdxl(): from core.training.diffusion_lora_trainer import get_trainer, run_diffusion_lora_training - assert get_trainer("sdxl") is run_diffusion_lora_training assert get_trainer("SDXL") is run_diffusion_lora_training # case-insensitive def test_get_trainer_unknown_family_raises(): from core.training.diffusion_lora_trainer import get_trainer - with pytest.raises(ValueError, match = "No trainer"): get_trainer("flux.2-dev") # a real family with no registered trainer @@ -260,7 +258,9 @@ def test_normalized_sets_resolved_family(): base_model = "stabilityai/stable-diffusion-xl-base-1.0", data_dir = "d", output_dir = "o" ).normalized() assert cfg.resolved_family == "sdxl" - cfg2 = DiffusionLoraConfig(base_model = "my-custom-thing", data_dir = "d", output_dir = "o").normalized() + cfg2 = DiffusionLoraConfig( + base_model = "my-custom-thing", data_dir = "d", output_dir = "o" + ).normalized() assert cfg2.resolved_family == "sdxl" # unknown -> default SDXL trainer diff --git a/studio/backend/tests/test_diffusion_training.py b/studio/backend/tests/test_diffusion_training.py index 6931418cde..886a3e5d4f 100644 --- a/studio/backend/tests/test_diffusion_training.py +++ b/studio/backend/tests/test_diffusion_training.py @@ -484,8 +484,15 @@ def test_route_start_refuses_non_sdxl_base_without_freeing_gpu(client, monkeypat def test_apply_event_records_metric_history_and_perf(): svc = DiffusionTrainingService(ctx = _FakeCtx(), target = _happy_target) svc._apply_event( - {"type": "progress", "step": 1, "total_steps": 10, "loss": 0.5, - "learning_rate": 1e-4, "samples_per_second": 3.2, "peak_memory_gb": 7.1} + { + "type": "progress", + "step": 1, + "total_steps": 10, + "loss": 0.5, + "learning_rate": 1e-4, + "samples_per_second": 3.2, + "peak_memory_gb": 7.1, + } ) svc._apply_event( {"type": "progress", "step": 2, "total_steps": 10, "loss": 0.4, "learning_rate": 9e-5} @@ -531,8 +538,14 @@ def test_metric_history_decimates_at_cap(): def test_complete_event_records_family_and_catalog(): svc = DiffusionTrainingService(ctx = _FakeCtx(), target = _happy_target) svc._apply_event( - {"type": "complete", "output_dir": "/o", "lora_path": "/o/w.safetensors", - "catalog_path": "/loras/w.safetensors", "family": "sdxl", "base_model": "b"} + { + "type": "complete", + "output_dir": "/o", + "lora_path": "/o/w.safetensors", + "catalog_path": "/loras/w.safetensors", + "family": "sdxl", + "base_model": "b", + } ) st = svc.status() assert st["status"] == "completed" @@ -544,8 +557,12 @@ def test_complete_event_records_family_and_catalog(): def test_status_route_nests_metric_history(client): # The status route folds the service's flat arrays into a nested metric_history object. client._fake.status_extra = { - "metric_steps": [1, 2], "metric_loss": [0.5, 0.4], "metric_lr": [1e-4, 9e-5], - "family": "sdxl", "samples_per_second": 2.0, "peak_memory_gb": 6.0, + "metric_steps": [1, 2], + "metric_loss": [0.5, 0.4], + "metric_lr": [1e-4, 9e-5], + "family": "sdxl", + "samples_per_second": 2.0, + "peak_memory_gb": 6.0, } r = client.get("/api/train/diffusion/status") assert r.status_code == 200, r.text