kerf-engine/engine/api/routes.py
jlightner 9540f37f70 test: Wire post-processing into /engine/trace, add output_format routin…
- "engine/api/routes.py"
- "engine/tests/test_api.py"

GSD-Task: S02/T03
2026-03-26 04:39:52 +00:00

175 lines
5.8 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
router = APIRouter()
VALID_MODES = {"potrace", "vtracer"}
VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"}
def _format_response(
result,
output_format: str,
warnings: list[str],
processing_ms: float,
):
"""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.
"""
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_bytes = generate_dxf(result)
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.post("/engine/trace")
async def trace(
file: UploadFile = File(...),
mode: str = Form("potrace"),
output_format: str = Form("svg"),
preset: str = Form("default"),
params: str = Form("{}"),
):
"""Convert a raster image to vector via the preprocessing + vectorization + post-processing pipeline.
Supports three output formats: svg (default), dxf, json.
"""
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,
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}")
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=user_params)
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Preprocessing failed: {exc}")
try:
if mode == "potrace":
svg_output = potrace_trace(preprocessed, **{
k: v for k, v in user_params.items()
if k in ("turdsize", "alphamax", "opticurve", "opttolerance")
})
else:
svg_output = vtracer_trace(preprocessed, **{
k: v for k, v in user_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
epsilon = float(user_params.get("epsilon", 1.0))
try:
result = postprocess_svg(svg_output, epsilon=epsilon)
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)
@router.post("/engine/simplify")
async def simplify(
file: UploadFile = File(...),
epsilon: float = Form(1.0),
output_format: str = Form("svg"),
):
"""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.
"""
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))}",
)
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)