kerf-engine/engine/api/routes.py
jlightner 31f78727e0 feat: Added B&W/grayscale/color conversion modes, invert toggle, 10+ mode-aware sliders, mask regions, turnpolicy, and white preview background
Engine:
- preprocess() accepts conversion_mode (bw/grayscale/color), invert, mask_regions
- B&W: full pipeline → binary; Grayscale: skip threshold → 8-bit; Color: skip grayscale → BGR
- routes.py forces VTracer for non-binary modes, sets colormode appropriately
- potrace_trace() accepts turnpolicy param mapped to potrace constants
- 27 new tests in test_modes.py (modes, invert, masks, params, vectorization)

App:
- Mode selector tabs (B&W | Grayscale | Color) in ImportConvert
- Invert toggle (B&W only)
- ParameterSliders rewritten: grouped sections, 10+ mode-aware controls
- Debounce reduced from 300ms to 100ms
- Preview background changed to white
- Preset JSONs updated with turnpolicy, color_precision, layer_difference defaults

Tests: 126 app + 234 engine = 360 total, all pass. Zero TypeScript errors.
2026-03-26 08:41:30 +00:00

292 lines
10 KiB
Python

"""API routes for the Kerf Engine trace and simplify endpoints."""
import json
import time
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from output import generate_dxf, generate_json, generate_svg
from pipeline.postprocess import postprocess_svg
from pipeline.preprocessing import VALID_MODES as VALID_CONVERSION_MODES, preprocess
from pipeline.vectorize import potrace_trace, vtracer_trace
from presets.loader import all_presets, preset_names, resolve_params
router = APIRouter()
@router.get("/engine/health")
async def health():
"""Healthcheck endpoint for container orchestration."""
return {"status": "ok"}
VALID_VECTORIZER_MODES = {"potrace", "vtracer"}
VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"}
def _format_response(
result,
output_format: str,
warnings: list[str],
processing_ms: float,
*,
units: str | None = None,
scale_factor: float = 1.0,
):
"""Build a standardized response from a PostProcessResult and output format.
SVG and JSON return a JSON envelope with output + metadata.
DXF returns raw bytes with application/dxf content type.
For DXF output, *units* and *scale_factor* are forwarded to
:func:`generate_dxf` so the resulting file contains correct unit
headers and real-world coordinate values.
"""
metadata = {
"format": output_format,
"path_count": len(result.paths),
"node_count_total": result.total_nodes,
"open_paths": result.open_path_count,
"island_count": result.island_count,
"warnings": warnings,
"processing_ms": processing_ms,
}
if output_format == "dxf":
dxf_kwargs: dict = {}
if units is not None:
dxf_kwargs["units"] = units
if scale_factor != 1.0:
dxf_kwargs["scale_factor"] = scale_factor
dxf_bytes = generate_dxf(result, **dxf_kwargs)
return Response(
content=dxf_bytes,
media_type="application/dxf",
headers={
"Content-Disposition": "attachment; filename=output.dxf",
"X-Kerf-Metadata": json.dumps(metadata),
},
)
elif output_format == "json":
return {
"output": json.loads(generate_json(result)),
"format": "json",
"metadata": metadata,
}
else:
return {
"output": generate_svg(result),
"format": "svg",
"metadata": metadata,
}
@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(None),
output_format: str = Form("svg"),
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.
The ``params`` JSON may include:
- ``conversion_mode``: 'bw' (default), 'grayscale', or 'color'
- ``invert``: bool (B&W mode only)
- ``mask_regions``: list of {x, y, width, height} dicts for exclusion zones
- Any preprocessing, vectorization, or postprocessing parameter
In grayscale/color modes, vectorization is forced to vtracer regardless
of the preset or mode param (potrace only handles binary images).
"""
if output_format not in VALID_OUTPUT_FORMATS:
raise HTTPException(
status_code=422,
detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}",
)
try:
user_params = json.loads(params)
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)}",
)
# Extract conversion_mode before resolve_params (it's a pipeline-level concern)
conversion_mode = user_params.pop("conversion_mode", "bw")
if conversion_mode not in VALID_CONVERSION_MODES:
raise HTTPException(
status_code=422,
detail=f"Invalid conversion_mode '{conversion_mode}'. Must be one of: {', '.join(sorted(VALID_CONVERSION_MODES))}",
)
invert = bool(user_params.pop("invert", False))
mask_regions = user_params.pop("mask_regions", None)
# Resolve effective parameters: preset defaults + user overrides
resolved = resolve_params(preset, user_params)
effective_mode = resolved["vectorization_mode"]
# Force vtracer for non-binary conversion modes (potrace can't handle color/gray)
if conversion_mode in ("color", "grayscale") and effective_mode == "potrace":
effective_mode = "vtracer"
if effective_mode not in VALID_VECTORIZER_MODES:
raise HTTPException(
status_code=422,
detail=f"Invalid mode '{effective_mode}'. Must be one of: {', '.join(sorted(VALID_VECTORIZER_MODES))}",
)
raw_bytes = await file.read()
if not raw_bytes:
raise HTTPException(status_code=422, detail="Uploaded file is empty")
warnings: list[str] = []
# Warn if potrace was requested but overridden
if conversion_mode in ("color", "grayscale") and resolved["vectorization_mode"] == "potrace":
warnings.append(
f"Potrace does not support {conversion_mode} mode — switched to vtracer"
)
start = time.perf_counter()
# Inject conversion_mode, invert, and mask_regions into preprocessing params
pre_params = dict(resolved["preprocessing"])
pre_params["conversion_mode"] = conversion_mode
pre_params["invert"] = invert
if mask_regions:
pre_params["mask_regions"] = mask_regions
try:
preprocessed = preprocess(raw_bytes, params=pre_params)
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Preprocessing failed: {exc}")
try:
vec_params = resolved["vectorizer_params"]
if effective_mode == "potrace":
svg_output = potrace_trace(preprocessed, **{
k: v for k, v in vec_params.items()
if k in ("turdsize", "alphamax", "opticurve", "opttolerance", "turnpolicy")
})
else:
# Set VTracer colormode based on conversion_mode
vtracer_kwargs = {
k: v for k, v in vec_params.items()
if k in (
"colormode", "hierarchical", "filter_speckle", "color_precision",
"layer_difference", "corner_threshold", "length_threshold",
"splice_threshold", "mode", "path_precision", "max_iterations",
)
}
if conversion_mode == "color":
vtracer_kwargs["colormode"] = "color"
elif conversion_mode == "grayscale":
# VTracer binary mode on grayscale input quantizes to tonal layers
vtracer_kwargs["colormode"] = "color"
# bw mode keeps whatever colormode was in preset (usually "binary")
svg_output = vtracer_trace(preprocessed, **vtracer_kwargs)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}")
# Post-processing: RDP simplification, island detection, open path analysis
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,
close_tolerance=close_tolerance,
auto_close=auto_close,
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Post-processing failed: {exc}")
processing_ms = round((time.perf_counter() - start) * 1000, 2)
return _format_response(result, output_format, warnings, processing_ms)
VALID_UNITS = {"inches", "mm"}
@router.post("/engine/simplify")
async def simplify(
file: UploadFile = File(...),
epsilon: float = Form(1.0),
output_format: str = Form("svg"),
units: str | None = Form(None),
scale_factor: float = Form(1.0),
):
"""Simplify an existing SVG using RDP path simplification.
Accepts an SVG file upload, applies Ramer-Douglas-Peucker simplification
with the given epsilon, and returns the result in the requested format.
For DXF output, *units* (``'inches'`` or ``'mm'``) and *scale_factor*
control the unit metadata headers and coordinate scaling in the DXF file.
"""
if output_format not in VALID_OUTPUT_FORMATS:
raise HTTPException(
status_code=422,
detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}",
)
if units is not None and units not in VALID_UNITS:
raise HTTPException(
status_code=422,
detail=f"Unsupported units '{units}'. Must be one of: {', '.join(sorted(VALID_UNITS))}",
)
raw_bytes = await file.read()
if not raw_bytes:
raise HTTPException(status_code=422, detail="Uploaded file is empty")
try:
svg_str = raw_bytes.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(status_code=422, detail="File is not valid UTF-8 text")
if "<svg" not in svg_str.lower():
raise HTTPException(status_code=422, detail="File does not appear to be a valid SVG")
warnings: list[str] = []
start = time.perf_counter()
try:
result = postprocess_svg(svg_str, epsilon=epsilon)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Simplification failed: {exc}")
processing_ms = round((time.perf_counter() - start) * 1000, 2)
return _format_response(
result, output_format, warnings, processing_ms,
units=units, scale_factor=scale_factor,
)