kerf-engine/engine/api/routes.py
jlightner 6c8c31e13b feat: Extended generate_dxf() with units/scale_factor/layer_map params…
- "engine/output/dxf.py"
- "engine/api/routes.py"
- "engine/tests/test_output.py"

GSD-Task: S01/T01
2026-03-26 06:17:06 +00:00

245 lines
8.2 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 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_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.
"""
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)}",
)
# 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")
warnings: list[str] = []
start = time.perf_counter()
try:
preprocessed = preprocess(raw_bytes, params=resolved["preprocessing"])
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")
})
else:
svg_output = vtracer_trace(preprocessed, **{
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",
)
})
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,
)