- "engine/output/dxf.py" - "engine/api/routes.py" - "engine/tests/test_output.py" GSD-Task: S01/T01
245 lines
8.2 KiB
Python
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,
|
|
)
|