From 32eb02ccb6a53bf7bbfaf289d47c36b2624746a6 Mon Sep 17 00:00:00 2001 From: jlightner Date: Thu, 26 Mar 2026 04:45:52 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20Implemented=205=20preset=20configs=20(si?= =?UTF-8?q?gn,=20patch,=20stencil,=20detailed,=20cust=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "engine/presets/sign.json" - "engine/presets/patch.json" - "engine/presets/stencil.json" - "engine/presets/detailed.json" - "engine/presets/custom.json" - "engine/presets/loader.py" - "engine/api/routes.py" - "engine/tests/test_presets.py" GSD-Task: S03/T01 --- engine/api/routes.py | 62 ++++++-- engine/presets/__init__.py | 1 + engine/presets/custom.json | 11 ++ engine/presets/detailed.json | 39 +++++ engine/presets/loader.py | 117 ++++++++++++++ engine/presets/patch.json | 39 +++++ engine/presets/sign.json | 39 +++++ engine/presets/stencil.json | 39 +++++ engine/tests/test_api.py | 6 +- engine/tests/test_presets.py | 290 +++++++++++++++++++++++++++++++++++ 10 files changed, 626 insertions(+), 17 deletions(-) create mode 100644 engine/presets/__init__.py create mode 100644 engine/presets/custom.json create mode 100644 engine/presets/detailed.json create mode 100644 engine/presets/loader.py create mode 100644 engine/presets/patch.json create mode 100644 engine/presets/sign.json create mode 100644 engine/presets/stencil.json create mode 100644 engine/tests/test_presets.py diff --git a/engine/api/routes.py b/engine/api/routes.py index e642fcd..3a48445 100644 --- a/engine/api/routes.py +++ b/engine/api/routes.py @@ -10,6 +10,7 @@ from output import generate_dxf, generate_json, generate_svg from pipeline.postprocess import postprocess_svg from pipeline.preprocessing import preprocess from pipeline.vectorize import potrace_trace, vtracer_trace +from presets.loader import all_presets, preset_names, resolve_params router = APIRouter() @@ -62,24 +63,26 @@ def _format_response( } +@router.get("/engine/presets") +async def list_presets(): + """Return all available presets and their parameter values.""" + return {"presets": all_presets()} + + @router.post("/engine/trace") async def trace( file: UploadFile = File(...), - mode: str = Form("potrace"), + mode: str = Form(None), output_format: str = Form("svg"), - preset: str = Form("default"), + preset: str = Form("sign"), params: str = Form("{}"), ): """Convert a raster image to vector via the preprocessing + vectorization + post-processing pipeline. Supports three output formats: svg (default), dxf, json. + A preset provides default parameters for each pipeline stage. + User params override preset defaults. """ - if mode not in VALID_MODES: - raise HTTPException( - status_code=422, - detail=f"Invalid mode '{mode}'. Must be one of: {', '.join(sorted(VALID_MODES))}", - ) - if output_format not in VALID_OUTPUT_FORMATS: raise HTTPException( status_code=422, @@ -91,6 +94,28 @@ async def trace( except json.JSONDecodeError as exc: raise HTTPException(status_code=422, detail=f"Invalid params JSON: {exc}") + # If mode is explicitly provided, inject it into user_params for resolve_params + if mode is not None: + user_params.setdefault("mode", mode) + + # Validate the preset name + valid_presets = preset_names() + if preset not in valid_presets: + raise HTTPException( + status_code=422, + detail=f"Unknown preset '{preset}'. Must be one of: {', '.join(valid_presets)}", + ) + + # Resolve effective parameters: preset defaults + user overrides + resolved = resolve_params(preset, user_params) + effective_mode = resolved["vectorization_mode"] + + if effective_mode not in VALID_MODES: + raise HTTPException( + status_code=422, + detail=f"Invalid mode '{effective_mode}'. Must be one of: {', '.join(sorted(VALID_MODES))}", + ) + raw_bytes = await file.read() if not raw_bytes: raise HTTPException(status_code=422, detail="Uploaded file is empty") @@ -99,19 +124,20 @@ async def trace( start = time.perf_counter() try: - preprocessed = preprocess(raw_bytes, params=user_params) + preprocessed = preprocess(raw_bytes, params=resolved["preprocessing"]) except ValueError as exc: raise HTTPException(status_code=422, detail=f"Preprocessing failed: {exc}") try: - if mode == "potrace": + vec_params = resolved["vectorizer_params"] + if effective_mode == "potrace": svg_output = potrace_trace(preprocessed, **{ - k: v for k, v in user_params.items() + k: v for k, v in vec_params.items() if k in ("turdsize", "alphamax", "opticurve", "opttolerance") }) else: svg_output = vtracer_trace(preprocessed, **{ - k: v for k, v in user_params.items() + k: v for k, v in vec_params.items() if k in ( "colormode", "hierarchical", "filter_speckle", "color_precision", "layer_difference", "corner_threshold", "length_threshold", @@ -122,9 +148,17 @@ async def trace( raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}") # Post-processing: RDP simplification, island detection, open path analysis - epsilon = float(user_params.get("epsilon", 1.0)) + post = resolved["postprocessing"] + epsilon = float(post.get("epsilon", 1.0)) + close_tolerance = float(post.get("close_tolerance", 1.0)) + auto_close = bool(post.get("auto_close", False)) try: - result = postprocess_svg(svg_output, epsilon=epsilon) + result = postprocess_svg( + svg_output, + epsilon=epsilon, + close_tolerance=close_tolerance, + auto_close=auto_close, + ) except Exception as exc: raise HTTPException(status_code=500, detail=f"Post-processing failed: {exc}") diff --git a/engine/presets/__init__.py b/engine/presets/__init__.py new file mode 100644 index 0000000..1e82253 --- /dev/null +++ b/engine/presets/__init__.py @@ -0,0 +1 @@ +"""Kerf Engine presets package.""" diff --git a/engine/presets/custom.json b/engine/presets/custom.json new file mode 100644 index 0000000..c03477c --- /dev/null +++ b/engine/presets/custom.json @@ -0,0 +1,11 @@ +{ + "name": "custom", + "description": "All params exposed, no defaults applied", + "preprocessing": {}, + "vectorization": { + "mode": "potrace", + "potrace": {}, + "vtracer": {} + }, + "postprocessing": {} +} diff --git a/engine/presets/detailed.json b/engine/presets/detailed.json new file mode 100644 index 0000000..a29451d --- /dev/null +++ b/engine/presets/detailed.json @@ -0,0 +1,39 @@ +{ + "name": "detailed", + "description": "High-fidelity illustration work", + "preprocessing": { + "denoise_d": 5, + "denoise_sigma_color": 50.0, + "denoise_sigma_space": 50.0, + "clahe_clip_limit": 1.5, + "clahe_tile_grid_size": [4, 4], + "threshold_manual": null, + "edge_detect": false, + "morph_kernel_size": 3, + "morph_dilate_iterations": 1, + "morph_erode_iterations": 1 + }, + "vectorization": { + "mode": "potrace", + "potrace": { + "turdsize": 1, + "alphamax": 1.3333, + "opticurve": true, + "opttolerance": 0.1 + }, + "vtracer": { + "colormode": "binary", + "hierarchical": "stacked", + "filter_speckle": 2, + "corner_threshold": 30, + "length_threshold": 2.0, + "splice_threshold": 30, + "mode": "spline" + } + }, + "postprocessing": { + "epsilon": 0.5, + "close_tolerance": 0.5, + "auto_close": false + } +} diff --git a/engine/presets/loader.py b/engine/presets/loader.py new file mode 100644 index 0000000..3c77fd4 --- /dev/null +++ b/engine/presets/loader.py @@ -0,0 +1,117 @@ +"""Preset loader — reads JSON config files from the presets directory.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_PRESETS_DIR = Path(__file__).parent +_VALID_SECTIONS = {"preprocessing", "vectorization", "postprocessing"} + +# In-memory cache — loaded once per process, cleared on reload(). +_cache: dict[str, dict[str, Any]] = {} + + +def _load_all() -> dict[str, dict[str, Any]]: + """Scan the presets directory and load every .json file.""" + presets: dict[str, dict[str, Any]] = {} + for p in sorted(_PRESETS_DIR.glob("*.json")): + try: + data = json.loads(p.read_text()) + name = data.get("name", p.stem) + presets[name] = data + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Skipping invalid preset file %s: %s", p, exc) + return presets + + +def all_presets() -> dict[str, dict[str, Any]]: + """Return all presets, loading from disk on first call. + + Returns: + Dict mapping preset name → full preset config dict. + """ + if not _cache: + _cache.update(_load_all()) + return dict(_cache) + + +def get_preset(name: str) -> dict[str, Any] | None: + """Return a single preset by name, or None if not found.""" + presets = all_presets() + return presets.get(name) + + +def preset_names() -> list[str]: + """Return sorted list of available preset names.""" + return sorted(all_presets().keys()) + + +def resolve_params( + preset_name: str, + user_params: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Resolve effective parameters by merging preset defaults with user overrides. + + The preset provides three sections: preprocessing, vectorization, postprocessing. + User params are applied as a flat overlay — keys in user_params override matching + keys from the preset at any level. + + Special keys: + - ``mode``: if present in user_params, overrides ``vectorization.mode``. + - ``epsilon``: if present in user_params, overrides ``postprocessing.epsilon``. + + Returns: + Merged param dict with top-level keys for preprocessing, vectorization mode, + vectorizer-specific params, and postprocessing epsilon. + """ + user = user_params or {} + preset = get_preset(preset_name) + + if preset is None: + # No preset found — fall back to raw user params. + return { + "preprocessing": {}, + "vectorization_mode": user.get("mode", "potrace"), + "vectorizer_params": {k: v for k, v in user.items() if k != "mode"}, + "postprocessing": {"epsilon": float(user.get("epsilon", 1.0))}, + } + + # -- Preprocessing: preset defaults + user overrides -- + pre = dict(preset.get("preprocessing", {})) + for key in list(pre.keys()): + if key in user: + pre[key] = user[key] + + # -- Vectorization mode -- + vec_cfg = preset.get("vectorization", {}) + mode = user.get("mode", vec_cfg.get("mode", "potrace")) + + # -- Vectorizer-specific params: preset defaults + user overrides -- + vec_params = dict(vec_cfg.get(mode, vec_cfg.get("potrace", {}))) + # Apply any user overrides that match vectorizer param names. + for key in list(vec_params.keys()): + if key in user: + vec_params[key] = user[key] + + # -- Postprocessing -- + post = dict(preset.get("postprocessing", {})) + if "epsilon" in user: + post["epsilon"] = float(user["epsilon"]) + + return { + "preprocessing": pre, + "vectorization_mode": mode, + "vectorizer_params": vec_params, + "postprocessing": post, + } + + +def reload() -> None: + """Clear the preset cache and reload from disk.""" + _cache.clear() + _cache.update(_load_all()) diff --git a/engine/presets/patch.json b/engine/presets/patch.json new file mode 100644 index 0000000..3649934 --- /dev/null +++ b/engine/presets/patch.json @@ -0,0 +1,39 @@ +{ + "name": "patch", + "description": "Embroidered patches, fabric cutting", + "preprocessing": { + "denoise_d": 9, + "denoise_sigma_color": 75.0, + "denoise_sigma_space": 75.0, + "clahe_clip_limit": 2.0, + "clahe_tile_grid_size": [8, 8], + "threshold_manual": null, + "edge_detect": false, + "morph_kernel_size": 3, + "morph_dilate_iterations": 1, + "morph_erode_iterations": 1 + }, + "vectorization": { + "mode": "potrace", + "potrace": { + "turdsize": 4, + "alphamax": 1.3, + "opticurve": true, + "opttolerance": 0.15 + }, + "vtracer": { + "colormode": "binary", + "hierarchical": "stacked", + "filter_speckle": 8, + "corner_threshold": 45, + "length_threshold": 4.0, + "splice_threshold": 45, + "mode": "spline" + } + }, + "postprocessing": { + "epsilon": 1.0, + "close_tolerance": 1.5, + "auto_close": true + } +} diff --git a/engine/presets/sign.json b/engine/presets/sign.json new file mode 100644 index 0000000..570b707 --- /dev/null +++ b/engine/presets/sign.json @@ -0,0 +1,39 @@ +{ + "name": "sign", + "description": "Metal signage, bold text cutouts", + "preprocessing": { + "denoise_d": 9, + "denoise_sigma_color": 90.0, + "denoise_sigma_space": 90.0, + "clahe_clip_limit": 3.0, + "clahe_tile_grid_size": [8, 8], + "threshold_manual": null, + "edge_detect": false, + "morph_kernel_size": 5, + "morph_dilate_iterations": 2, + "morph_erode_iterations": 2 + }, + "vectorization": { + "mode": "potrace", + "potrace": { + "turdsize": 10, + "alphamax": 1.0, + "opticurve": true, + "opttolerance": 0.2 + }, + "vtracer": { + "colormode": "binary", + "hierarchical": "stacked", + "filter_speckle": 20, + "corner_threshold": 60, + "length_threshold": 6.0, + "splice_threshold": 45, + "mode": "spline" + } + }, + "postprocessing": { + "epsilon": 2.5, + "close_tolerance": 2.0, + "auto_close": true + } +} diff --git a/engine/presets/stencil.json b/engine/presets/stencil.json new file mode 100644 index 0000000..77cf5a1 --- /dev/null +++ b/engine/presets/stencil.json @@ -0,0 +1,39 @@ +{ + "name": "stencil", + "description": "Physical stencil cutting", + "preprocessing": { + "denoise_d": 11, + "denoise_sigma_color": 100.0, + "denoise_sigma_space": 100.0, + "clahe_clip_limit": 2.5, + "clahe_tile_grid_size": [8, 8], + "threshold_manual": 128, + "edge_detect": false, + "morph_kernel_size": 5, + "morph_dilate_iterations": 2, + "morph_erode_iterations": 1 + }, + "vectorization": { + "mode": "potrace", + "potrace": { + "turdsize": 15, + "alphamax": 0.8, + "opticurve": true, + "opttolerance": 0.3 + }, + "vtracer": { + "colormode": "binary", + "hierarchical": "stacked", + "filter_speckle": 25, + "corner_threshold": 75, + "length_threshold": 8.0, + "splice_threshold": 60, + "mode": "polygon" + } + }, + "postprocessing": { + "epsilon": 3.0, + "close_tolerance": 2.0, + "auto_close": true + } +} diff --git a/engine/tests/test_api.py b/engine/tests/test_api.py index 58f22ba..957e9e7 100644 --- a/engine/tests/test_api.py +++ b/engine/tests/test_api.py @@ -293,14 +293,14 @@ class TestTraceEndpointValidation: ) assert resp.status_code == 422 - def test_preset_ignored(self, test_png): - """Preset param is accepted but ignored for now.""" + def test_unknown_preset_rejected(self, test_png): + """Unknown preset names are rejected with 422.""" resp = client.post( "/engine/trace", files={"file": ("test.png", test_png, "image/png")}, data={"mode": "potrace", "preset": "logo"}, ) - assert resp.status_code == 200 + assert resp.status_code == 422 # ----------------------------------------------------------------------- diff --git a/engine/tests/test_presets.py b/engine/tests/test_presets.py new file mode 100644 index 0000000..1cba35a --- /dev/null +++ b/engine/tests/test_presets.py @@ -0,0 +1,290 @@ +"""Tests for the preset system — loader, GET /engine/presets, and preset-driven trace.""" + +import json + +import cv2 +import numpy as np +import pytest +from fastapi.testclient import TestClient + +from main import app +from presets.loader import all_presets, get_preset, preset_names, reload, resolve_params + +client = TestClient(app) + + +def _make_test_png(width: int = 100, height: int = 100) -> bytes: + """Create a simple test PNG with a white rectangle on black background.""" + img = np.zeros((height, width, 3), dtype=np.uint8) + cv2.rectangle(img, (20, 20), (80, 80), (255, 255, 255), -1) + ok, buf = cv2.imencode(".png", img) + assert ok + return buf.tobytes() + + +@pytest.fixture +def test_png() -> bytes: + return _make_test_png() + + +# ----------------------------------------------------------------------- +# Preset Loader Unit Tests +# ----------------------------------------------------------------------- + +class TestPresetLoader: + """Tests for the preset loading and resolution logic.""" + + def test_all_presets_loads_five(self): + reload() + presets = all_presets() + assert len(presets) == 5 + assert set(presets.keys()) == {"sign", "patch", "stencil", "detailed", "custom"} + + def test_preset_names_sorted(self): + reload() + names = preset_names() + assert names == sorted(names) + assert "sign" in names + assert "custom" in names + + def test_get_preset_returns_config(self): + reload() + sign = get_preset("sign") + assert sign is not None + assert sign["name"] == "sign" + assert "preprocessing" in sign + assert "vectorization" in sign + assert "postprocessing" in sign + + def test_get_preset_unknown_returns_none(self): + reload() + assert get_preset("nonexistent") is None + + def test_sign_preset_has_aggressive_simplification(self): + reload() + sign = get_preset("sign") + assert sign["postprocessing"]["epsilon"] > 1.0 + assert sign["preprocessing"]["morph_kernel_size"] >= 5 + + def test_detailed_preset_has_low_simplification(self): + reload() + detailed = get_preset("detailed") + assert detailed["postprocessing"]["epsilon"] < 1.0 + assert detailed["vectorization"]["potrace"]["turdsize"] <= 2 + + def test_stencil_preset_has_manual_threshold(self): + reload() + stencil = get_preset("stencil") + assert stencil["preprocessing"]["threshold_manual"] is not None + + def test_custom_preset_has_empty_params(self): + reload() + custom = get_preset("custom") + assert custom["preprocessing"] == {} + assert custom["vectorization"]["potrace"] == {} + + def test_each_preset_has_description(self): + reload() + for name, config in all_presets().items(): + assert "description" in config, f"Preset {name} missing description" + assert len(config["description"]) > 0 + + +# ----------------------------------------------------------------------- +# Preset Resolution Tests +# ----------------------------------------------------------------------- + +class TestPresetResolution: + """Tests for resolve_params merging logic.""" + + def test_resolve_uses_preset_defaults(self): + reload() + resolved = resolve_params("sign") + assert resolved["vectorization_mode"] == "potrace" + assert resolved["postprocessing"]["epsilon"] == 2.5 + assert resolved["preprocessing"]["morph_kernel_size"] == 5 + + def test_resolve_user_override_epsilon(self): + reload() + resolved = resolve_params("sign", {"epsilon": 0.5}) + assert resolved["postprocessing"]["epsilon"] == 0.5 + + def test_resolve_user_override_mode(self): + reload() + resolved = resolve_params("sign", {"mode": "vtracer"}) + assert resolved["vectorization_mode"] == "vtracer" + + def test_resolve_user_override_vectorizer_param(self): + reload() + resolved = resolve_params("sign", {"turdsize": 99}) + assert resolved["vectorizer_params"]["turdsize"] == 99 + + def test_resolve_custom_preset_falls_through(self): + reload() + resolved = resolve_params("custom", {"epsilon": 3.0, "turdsize": 5}) + assert resolved["postprocessing"]["epsilon"] == 3.0 + + def test_resolve_unknown_preset_uses_user_params(self): + resolved = resolve_params("nonexistent", {"mode": "vtracer", "epsilon": 2.0}) + assert resolved["vectorization_mode"] == "vtracer" + assert resolved["postprocessing"]["epsilon"] == 2.0 + + +# ----------------------------------------------------------------------- +# GET /engine/presets Endpoint Tests +# ----------------------------------------------------------------------- + +class TestPresetsEndpoint: + """Tests for the GET /engine/presets endpoint.""" + + def test_get_presets_returns_all(self): + resp = client.get("/engine/presets") + assert resp.status_code == 200 + body = resp.json() + assert "presets" in body + presets = body["presets"] + assert len(presets) == 5 + assert "sign" in presets + assert "patch" in presets + assert "stencil" in presets + assert "detailed" in presets + assert "custom" in presets + + def test_preset_structure(self): + resp = client.get("/engine/presets") + body = resp.json() + for name, config in body["presets"].items(): + assert "name" in config + assert "description" in config + assert "preprocessing" in config + assert "vectorization" in config + assert "postprocessing" in config + + +# ----------------------------------------------------------------------- +# Preset-Driven Trace Endpoint Tests +# ----------------------------------------------------------------------- + +class TestPresetTrace: + """Tests for /engine/trace with preset selection.""" + + def test_trace_with_sign_preset(self, test_png): + resp = client.post( + "/engine/trace", + files={"file": ("test.png", test_png, "image/png")}, + data={"preset": "sign"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["format"] == "svg" + assert "