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.
292 lines
10 KiB
Python
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,
|
|
)
|