fix: Implemented 5 preset configs (sign, patch, stencil, detailed, cust…
- "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
This commit is contained in:
parent
0c197f5497
commit
32eb02ccb6
10 changed files with 626 additions and 17 deletions
|
|
@ -10,6 +10,7 @@ from output import generate_dxf, generate_json, generate_svg
|
||||||
from pipeline.postprocess import postprocess_svg
|
from pipeline.postprocess import postprocess_svg
|
||||||
from pipeline.preprocessing import preprocess
|
from pipeline.preprocessing import preprocess
|
||||||
from pipeline.vectorize import potrace_trace, vtracer_trace
|
from pipeline.vectorize import potrace_trace, vtracer_trace
|
||||||
|
from presets.loader import all_presets, preset_names, resolve_params
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.post("/engine/trace")
|
||||||
async def trace(
|
async def trace(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
mode: str = Form("potrace"),
|
mode: str = Form(None),
|
||||||
output_format: str = Form("svg"),
|
output_format: str = Form("svg"),
|
||||||
preset: str = Form("default"),
|
preset: str = Form("sign"),
|
||||||
params: str = Form("{}"),
|
params: str = Form("{}"),
|
||||||
):
|
):
|
||||||
"""Convert a raster image to vector via the preprocessing + vectorization + post-processing pipeline.
|
"""Convert a raster image to vector via the preprocessing + vectorization + post-processing pipeline.
|
||||||
|
|
||||||
Supports three output formats: svg (default), dxf, json.
|
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:
|
if output_format not in VALID_OUTPUT_FORMATS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
|
|
@ -91,6 +94,28 @@ async def trace(
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"Invalid params JSON: {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()
|
raw_bytes = await file.read()
|
||||||
if not raw_bytes:
|
if not raw_bytes:
|
||||||
raise HTTPException(status_code=422, detail="Uploaded file is empty")
|
raise HTTPException(status_code=422, detail="Uploaded file is empty")
|
||||||
|
|
@ -99,19 +124,20 @@ async def trace(
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
preprocessed = preprocess(raw_bytes, params=user_params)
|
preprocessed = preprocess(raw_bytes, params=resolved["preprocessing"])
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"Preprocessing failed: {exc}")
|
raise HTTPException(status_code=422, detail=f"Preprocessing failed: {exc}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if mode == "potrace":
|
vec_params = resolved["vectorizer_params"]
|
||||||
|
if effective_mode == "potrace":
|
||||||
svg_output = potrace_trace(preprocessed, **{
|
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")
|
if k in ("turdsize", "alphamax", "opticurve", "opttolerance")
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
svg_output = vtracer_trace(preprocessed, **{
|
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 (
|
if k in (
|
||||||
"colormode", "hierarchical", "filter_speckle", "color_precision",
|
"colormode", "hierarchical", "filter_speckle", "color_precision",
|
||||||
"layer_difference", "corner_threshold", "length_threshold",
|
"layer_difference", "corner_threshold", "length_threshold",
|
||||||
|
|
@ -122,9 +148,17 @@ async def trace(
|
||||||
raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}")
|
raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}")
|
||||||
|
|
||||||
# Post-processing: RDP simplification, island detection, open path analysis
|
# 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:
|
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:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Post-processing failed: {exc}")
|
raise HTTPException(status_code=500, detail=f"Post-processing failed: {exc}")
|
||||||
|
|
||||||
|
|
|
||||||
1
engine/presets/__init__.py
Normal file
1
engine/presets/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Kerf Engine presets package."""
|
||||||
11
engine/presets/custom.json
Normal file
11
engine/presets/custom.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"name": "custom",
|
||||||
|
"description": "All params exposed, no defaults applied",
|
||||||
|
"preprocessing": {},
|
||||||
|
"vectorization": {
|
||||||
|
"mode": "potrace",
|
||||||
|
"potrace": {},
|
||||||
|
"vtracer": {}
|
||||||
|
},
|
||||||
|
"postprocessing": {}
|
||||||
|
}
|
||||||
39
engine/presets/detailed.json
Normal file
39
engine/presets/detailed.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
117
engine/presets/loader.py
Normal file
117
engine/presets/loader.py
Normal file
|
|
@ -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())
|
||||||
39
engine/presets/patch.json
Normal file
39
engine/presets/patch.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
39
engine/presets/sign.json
Normal file
39
engine/presets/sign.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
39
engine/presets/stencil.json
Normal file
39
engine/presets/stencil.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -293,14 +293,14 @@ class TestTraceEndpointValidation:
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
def test_preset_ignored(self, test_png):
|
def test_unknown_preset_rejected(self, test_png):
|
||||||
"""Preset param is accepted but ignored for now."""
|
"""Unknown preset names are rejected with 422."""
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/engine/trace",
|
"/engine/trace",
|
||||||
files={"file": ("test.png", test_png, "image/png")},
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
data={"mode": "potrace", "preset": "logo"},
|
data={"mode": "potrace", "preset": "logo"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
290
engine/tests/test_presets.py
Normal file
290
engine/tests/test_presets.py
Normal file
|
|
@ -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 "<svg" in body["output"]
|
||||||
|
|
||||||
|
def test_trace_with_detailed_preset(self, test_png):
|
||||||
|
resp = client.post(
|
||||||
|
"/engine/trace",
|
||||||
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
|
data={"preset": "detailed"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["format"] == "svg"
|
||||||
|
|
||||||
|
def test_trace_with_stencil_preset(self, test_png):
|
||||||
|
resp = client.post(
|
||||||
|
"/engine/trace",
|
||||||
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
|
data={"preset": "stencil"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_trace_with_patch_preset(self, test_png):
|
||||||
|
resp = client.post(
|
||||||
|
"/engine/trace",
|
||||||
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
|
data={"preset": "patch"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_trace_with_custom_preset(self, test_png):
|
||||||
|
resp = client.post(
|
||||||
|
"/engine/trace",
|
||||||
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
|
data={"preset": "custom"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_trace_unknown_preset_rejected(self, test_png):
|
||||||
|
resp = client.post(
|
||||||
|
"/engine/trace",
|
||||||
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
|
data={"preset": "nonexistent"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "nonexistent" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_preset_with_mode_override(self, test_png):
|
||||||
|
resp = client.post(
|
||||||
|
"/engine/trace",
|
||||||
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
|
data={"preset": "sign", "mode": "vtracer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_preset_with_params_override(self, test_png):
|
||||||
|
resp = client.post(
|
||||||
|
"/engine/trace",
|
||||||
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
|
data={
|
||||||
|
"preset": "sign",
|
||||||
|
"params": json.dumps({"epsilon": 0.1, "turdsize": 1}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_presets_produce_different_node_counts(self, test_png):
|
||||||
|
"""Different presets should produce different outputs from the same input.
|
||||||
|
|
||||||
|
Sign uses aggressive simplification (epsilon=2.5) while
|
||||||
|
detailed uses minimal simplification (epsilon=0.5). With a complex
|
||||||
|
enough image, the node counts should differ.
|
||||||
|
"""
|
||||||
|
# Build a complex test image with circles, lines, and noise —
|
||||||
|
# simple rectangles have too few nodes for RDP differences to show.
|
||||||
|
img = np.zeros((200, 200, 3), dtype=np.uint8)
|
||||||
|
cv2.circle(img, (100, 100), 80, (255, 255, 255), 2)
|
||||||
|
cv2.circle(img, (100, 100), 40, (255, 255, 255), 2)
|
||||||
|
cv2.line(img, (20, 20), (180, 180), (255, 255, 255), 1)
|
||||||
|
cv2.line(img, (180, 20), (20, 180), (255, 255, 255), 1)
|
||||||
|
cv2.ellipse(img, (100, 100), (90, 50), 45, 0, 360, (255, 255, 255), 1)
|
||||||
|
# Add some speckle noise
|
||||||
|
for x in range(10, 190, 15):
|
||||||
|
for y in range(10, 190, 15):
|
||||||
|
cv2.circle(img, (x, y), 1, (255, 255, 255), -1)
|
||||||
|
ok, buf = cv2.imencode(".png", img)
|
||||||
|
assert ok
|
||||||
|
complex_png = buf.tobytes()
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for preset_name in ("sign", "detailed", "stencil"):
|
||||||
|
resp = client.post(
|
||||||
|
"/engine/trace",
|
||||||
|
files={"file": ("test.png", complex_png, "image/png")},
|
||||||
|
data={"preset": preset_name},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
results[preset_name] = body["metadata"]["node_count_total"]
|
||||||
|
|
||||||
|
# Not all three can be equal — at least one pair must differ
|
||||||
|
values = list(results.values())
|
||||||
|
assert not (values[0] == values[1] == values[2]), (
|
||||||
|
f"All presets produced identical node counts: {results}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_preset_is_sign(self, test_png):
|
||||||
|
"""When no preset is specified, 'sign' is used."""
|
||||||
|
resp = client.post(
|
||||||
|
"/engine/trace",
|
||||||
|
files={"file": ("test.png", test_png, "image/png")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
Loading…
Add table
Reference in a new issue