kerf-engine/engine/api/routes.py
jlightner 2d8efb15dd feat: Created multi-stage Dockerfile.engine with healthcheck endpoint;…
- "docker/Dockerfile.engine"
- "engine/api/routes.py"
- ".dockerignore"

GSD-Task: S03/T02
2026-03-26 04:49:38 +00:00

216 lines
7.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,
):
"""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.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)
@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)