diff --git a/app/src/App.css b/app/src/App.css index 747f858..d244157 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -190,7 +190,7 @@ .parameter-sliders { display: flex; flex-direction: column; - gap: 12px; + gap: 4px; } .parameter-heading { @@ -202,6 +202,25 @@ letter-spacing: 0.5px; } +.slider-group { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 8px; +} + +.slider-group-label { + font-size: 11px; + font-weight: 600; + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.8px; + margin: 0; + padding-bottom: 2px; + border-bottom: 1px solid var(--border); + opacity: 0.6; +} + .slider-row { display: grid; grid-template-columns: 120px 1fr 48px; @@ -209,6 +228,19 @@ gap: 8px; } +.slider-row--toggle { + display: flex; + justify-content: space-between; + align-items: center; +} + +.slider-row--select { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + .slider-label { font-size: 13px; color: var(--text-h); @@ -230,6 +262,81 @@ font-variant-numeric: tabular-nums; } +.toggle-input { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.select-input { + padding: 4px 8px; + font-size: 13px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text-h); + cursor: pointer; +} + +/* Mode selector tabs */ +.mode-selector { + display: flex; + flex-direction: column; + gap: 8px; +} + +.mode-tabs { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.mode-tab { + flex: 1; + padding: 8px 12px; + font-size: 13px; + font-weight: 500; + border: none; + background: var(--bg); + color: var(--text); + cursor: pointer; + transition: background-color 0.12s, color 0.12s; +} + +.mode-tab:not(:last-child) { + border-right: 1px solid var(--border); +} + +.mode-tab:hover { + background: var(--accent-bg); +} + +.mode-tab--active { + background: var(--accent); + color: white; +} + +.mode-tab--active:hover { + background: var(--accent); +} + +/* Invert toggle row */ +.invert-toggle { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; +} + +.invert-label { + font-size: 13px; + font-weight: 500; + color: var(--text-h); +} + /* SVG Preview */ .svg-preview { display: flex; diff --git a/app/src/components/ParameterSliders.tsx b/app/src/components/ParameterSliders.tsx index a4d0ee7..a1c3b07 100644 --- a/app/src/components/ParameterSliders.tsx +++ b/app/src/components/ParameterSliders.tsx @@ -1,9 +1,10 @@ import { useCallback } from 'react'; -import type { PresetConfig } from '../types/engine'; +import type { ConversionMode, PresetConfig } from '../types/engine'; interface ParameterSlidersProps { presetConfig: PresetConfig | null; params: Record; + conversionMode: ConversionMode; onChange: (params: Record) => void; } @@ -14,119 +15,521 @@ interface SliderDef { max: number; step: number; defaultValue: number; - /** Only shown when this vectorization mode is active */ - modeFilter?: 'potrace' | 'vtracer'; + group: 'preprocessing' | 'vectorization' | 'postprocessing'; } -function getSliderDefs(config: PresetConfig): SliderDef[] { - const mode = config.vectorization.mode; +interface ToggleDef { + key: string; + label: string; + defaultValue: boolean; + group: 'preprocessing' | 'vectorization'; +} + +interface SelectDef { + key: string; + label: string; + options: { value: string; label: string }[]; + defaultValue: string; + group: 'vectorization'; +} + +type ControlDef = + | (SliderDef & { type: 'slider' }) + | (ToggleDef & { type: 'toggle' }) + | (SelectDef & { type: 'select' }); + +/** + * Build the full set of controls based on conversion mode and vectorizer mode. + */ +function getControlDefs(config: PresetConfig, conversionMode: ConversionMode): ControlDef[] { + const vecMode = config.vectorization.mode; const potrace = config.vectorization.potrace; const vtracer = config.vectorization.vtracer; const post = config.postprocessing; - const sliders: SliderDef[] = [ - { - key: 'epsilon', - label: 'Detail Level', - min: 0.5, - max: 10, - step: 0.5, - defaultValue: post.epsilon ?? 2.5, - }, - ]; + const controls: ControlDef[] = []; - if (mode === 'potrace') { - sliders.push( - { + // ── Postprocessing (all modes) ── + controls.push({ + type: 'slider', + key: 'epsilon', + label: 'Path Simplification', + min: 0.1, + max: 20, + step: 0.1, + defaultValue: post.epsilon ?? 2.5, + group: 'postprocessing', + }); + + // ── B&W-specific preprocessing controls ── + if (conversionMode === 'bw') { + controls.push({ + type: 'slider', + key: 'threshold_manual', + label: 'Threshold', + min: 0, + max: 255, + step: 1, + defaultValue: 128, + group: 'preprocessing', + }); + + controls.push({ + type: 'slider', + key: 'morph_dilate_iterations', + label: 'Expand (Dilate)', + min: 0, + max: 10, + step: 1, + defaultValue: 1, + group: 'preprocessing', + }); + + controls.push({ + type: 'slider', + key: 'morph_erode_iterations', + label: 'Shrink (Erode)', + min: 0, + max: 10, + step: 1, + defaultValue: 1, + group: 'preprocessing', + }); + + controls.push({ + type: 'slider', + key: 'morph_kernel_size', + label: 'Morph Brush Size', + min: 1, + max: 15, + step: 2, + defaultValue: 3, + group: 'preprocessing', + }); + + controls.push({ + type: 'toggle', + key: 'edge_detect', + label: 'Edge Detection', + defaultValue: false, + group: 'preprocessing', + }); + + controls.push({ + type: 'slider', + key: 'edge_low', + label: 'Edge Sensitivity (Low)', + min: 0, + max: 255, + step: 1, + defaultValue: 50, + group: 'preprocessing', + }); + + controls.push({ + type: 'slider', + key: 'edge_high', + label: 'Edge Sensitivity (High)', + min: 0, + max: 255, + step: 1, + defaultValue: 150, + group: 'preprocessing', + }); + + // B&W Potrace controls + if (vecMode === 'potrace') { + controls.push({ + type: 'slider', key: 'turdsize', label: 'Noise Filter', min: 0, - max: 50, + max: 200, step: 1, defaultValue: potrace?.turdsize ?? 10, - modeFilter: 'potrace', - }, - { + group: 'vectorization', + }); + + controls.push({ + type: 'slider', key: 'alphamax', - label: 'Smooth Curves', + label: 'Curve Smoothing', min: 0, max: 1.334, - step: 0.1, + step: 0.01, defaultValue: potrace?.alphamax ?? 1.0, - modeFilter: 'potrace', - }, - ); - } else { - sliders.push( - { + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'opttolerance', + label: 'Curve Optimize', + min: 0, + max: 2.0, + step: 0.05, + defaultValue: potrace?.opttolerance ?? 0.2, + group: 'vectorization', + }); + + controls.push({ + type: 'select', + key: 'turnpolicy', + label: 'Turn Policy', + options: [ + { value: 'minority', label: 'Minority' }, + { value: 'majority', label: 'Majority' }, + { value: 'black', label: 'Black' }, + { value: 'white', label: 'White' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, + ], + defaultValue: 'minority', + group: 'vectorization', + }); + } + + // B&W VTracer controls + if (vecMode === 'vtracer') { + controls.push({ + type: 'slider', key: 'filter_speckle', label: 'Noise Filter', min: 0, - max: 50, + max: 200, step: 1, defaultValue: vtracer?.filter_speckle ?? 20, - modeFilter: 'vtracer', - }, - { + group: 'vectorization', + }); + + controls.push({ + type: 'slider', key: 'corner_threshold', label: 'Corner Threshold', min: 0, max: 180, step: 1, defaultValue: vtracer?.corner_threshold ?? 60, - modeFilter: 'vtracer', - }, - ); + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'length_threshold', + label: 'Min Segment Length', + min: 0, + max: 20, + step: 0.5, + defaultValue: vtracer?.length_threshold ?? 4.0, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'splice_threshold', + label: 'Splice Angle', + min: 0, + max: 180, + step: 1, + defaultValue: vtracer?.splice_threshold ?? 45, + group: 'vectorization', + }); + } } - return sliders; + // ── Grayscale mode (VTracer only) ── + if (conversionMode === 'grayscale') { + controls.push({ + type: 'slider', + key: 'color_precision', + label: 'Gray Levels', + min: 1, + max: 8, + step: 1, + defaultValue: vtracer?.color_precision ?? 4, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'layer_difference', + label: 'Layer Separation', + min: 0, + max: 128, + step: 1, + defaultValue: vtracer?.layer_difference ?? 16, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'filter_speckle', + label: 'Noise Filter', + min: 0, + max: 200, + step: 1, + defaultValue: vtracer?.filter_speckle ?? 20, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'corner_threshold', + label: 'Corner Threshold', + min: 0, + max: 180, + step: 1, + defaultValue: vtracer?.corner_threshold ?? 60, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'length_threshold', + label: 'Min Segment Length', + min: 0, + max: 20, + step: 0.5, + defaultValue: vtracer?.length_threshold ?? 4.0, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'splice_threshold', + label: 'Splice Angle', + min: 0, + max: 180, + step: 1, + defaultValue: vtracer?.splice_threshold ?? 45, + group: 'vectorization', + }); + } + + // ── Color mode (VTracer only) ── + if (conversionMode === 'color') { + controls.push({ + type: 'slider', + key: 'color_precision', + label: 'Color Depth', + min: 1, + max: 8, + step: 1, + defaultValue: vtracer?.color_precision ?? 6, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'layer_difference', + label: 'Layer Separation', + min: 0, + max: 128, + step: 1, + defaultValue: vtracer?.layer_difference ?? 16, + group: 'vectorization', + }); + + controls.push({ + type: 'select', + key: 'hierarchical', + label: 'Layer Mode', + options: [ + { value: 'stacked', label: 'Stacked' }, + { value: 'cutout', label: 'Cutout' }, + ], + defaultValue: vtracer?.hierarchical ?? 'stacked', + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'filter_speckle', + label: 'Noise Filter', + min: 0, + max: 200, + step: 1, + defaultValue: vtracer?.filter_speckle ?? 20, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'corner_threshold', + label: 'Corner Threshold', + min: 0, + max: 180, + step: 1, + defaultValue: vtracer?.corner_threshold ?? 60, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'length_threshold', + label: 'Min Segment Length', + min: 0, + max: 20, + step: 0.5, + defaultValue: vtracer?.length_threshold ?? 4.0, + group: 'vectorization', + }); + + controls.push({ + type: 'slider', + key: 'splice_threshold', + label: 'Splice Angle', + min: 0, + max: 180, + step: 1, + defaultValue: vtracer?.splice_threshold ?? 45, + group: 'vectorization', + }); + } + + return controls; } +const GROUP_LABELS: Record = { + preprocessing: 'Preprocessing', + vectorization: 'Vectorization', + postprocessing: 'Output', +}; + +const GROUP_ORDER = ['preprocessing', 'vectorization', 'postprocessing']; + export default function ParameterSliders({ presetConfig, params, + conversionMode, onChange, }: ParameterSlidersProps) { - const handleChange = useCallback( + const handleSliderChange = useCallback( (key: string, value: number) => { onChange({ ...params, [key]: value }); }, [params, onChange], ); + const handleToggleChange = useCallback( + (key: string, value: boolean) => { + onChange({ ...params, [key]: value }); + }, + [params, onChange], + ); + + const handleSelectChange = useCallback( + (key: string, value: string) => { + onChange({ ...params, [key]: value }); + }, + [params, onChange], + ); + if (!presetConfig) { return null; } - const sliders = getSliderDefs(presetConfig); + const controls = getControlDefs(presetConfig, conversionMode); + + // Group controls by section + const grouped: Record = {}; + for (const c of controls) { + if (!grouped[c.group]) grouped[c.group] = []; + grouped[c.group].push(c); + } + + // Hide edge sliders when edge_detect is off + const edgeDetectOn = params.edge_detect === true; return (
-

Parameters

- {sliders.map((slider) => { - const value = - typeof params[slider.key] === 'number' - ? (params[slider.key] as number) - : slider.defaultValue; + {GROUP_ORDER.map((groupKey) => { + const groupControls = grouped[groupKey]; + if (!groupControls || groupControls.length === 0) return null; return ( -
- - - handleChange(slider.key, parseFloat(e.target.value)) +
+

{GROUP_LABELS[groupKey]}

+ {groupControls.map((control) => { + // Hide edge low/high when edge detection is off + if ((control.key === 'edge_low' || control.key === 'edge_high') && !edgeDetectOn) { + return null; } - /> - {value} + + if (control.type === 'toggle') { + const checked = + typeof params[control.key] === 'boolean' + ? (params[control.key] as boolean) + : control.defaultValue; + return ( +
+ + handleToggleChange(control.key, e.target.checked)} + /> +
+ ); + } + + if (control.type === 'select') { + const value = + typeof params[control.key] === 'string' + ? (params[control.key] as string) + : control.defaultValue; + return ( +
+ + +
+ ); + } + + // Slider + const value = + typeof params[control.key] === 'number' + ? (params[control.key] as number) + : control.defaultValue; + + return ( +
+ + + handleSliderChange(control.key, parseFloat(e.target.value)) + } + /> + {value} +
+ ); + })}
); })} diff --git a/app/src/types/engine.ts b/app/src/types/engine.ts index 9cafea9..6f227b3 100644 --- a/app/src/types/engine.ts +++ b/app/src/types/engine.ts @@ -1,5 +1,9 @@ /** TypeScript interfaces matching the Kerf Engine API shapes. */ +// -- Conversion mode -- + +export type ConversionMode = 'bw' | 'grayscale' | 'color'; + // -- Preset configuration (mirrors engine/presets/*.json) -- export interface PreprocessingConfig { @@ -10,6 +14,8 @@ export interface PreprocessingConfig { clahe_tile_grid_size?: [number, number]; threshold_manual?: number | null; edge_detect?: boolean; + edge_low?: number; + edge_high?: number; morph_kernel_size?: number; morph_dilate_iterations?: number; morph_erode_iterations?: number; @@ -20,6 +26,7 @@ export interface PotraceConfig { alphamax?: number; opticurve?: boolean; opttolerance?: number; + turnpolicy?: string; } export interface VtracerConfig { diff --git a/app/src/views/ImportConvert.module.css b/app/src/views/ImportConvert.module.css index 7b828ac..9e0bb69 100644 --- a/app/src/views/ImportConvert.module.css +++ b/app/src/views/ImportConvert.module.css @@ -8,14 +8,16 @@ box-sizing: border-box; } -.leftPanel { +.sidebar { flex: 0 0 340px; display: flex; flex-direction: column; gap: 20px; + max-height: calc(100svh - 80px); + overflow-y: auto; } -.rightPanel { +.main { flex: 1; display: flex; flex-direction: column; @@ -24,24 +26,17 @@ border: 1px solid var(--border); border-radius: 8px; min-height: 400px; - background: var(--code-bg); + background: #ffffff; padding: 16px; } -.previewPlaceholder { - color: var(--text); - font-size: 15px; - text-align: center; - opacity: 0.7; -} - @media (max-width: 768px) { .container { flex-direction: column; padding: 16px; } - .leftPanel { + .sidebar { flex: none; width: 100%; } diff --git a/app/src/views/ImportConvert.tsx b/app/src/views/ImportConvert.tsx index 121a7bf..97bc18a 100644 --- a/app/src/views/ImportConvert.tsx +++ b/app/src/views/ImportConvert.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import type { PresetConfig, TraceMetadata } from '../types/engine'; +import type { ConversionMode, PresetConfig, TraceMetadata } from '../types/engine'; import FileUpload from '../components/FileUpload'; import PresetSelector from '../components/PresetSelector'; import ParameterSliders from '../components/ParameterSliders'; @@ -12,21 +12,50 @@ interface ImportConvertProps { onUseThis: (svgOutput: string, metadata: TraceMetadata) => void; } +const CONVERSION_MODES: { value: ConversionMode; label: string; desc: string }[] = [ + { value: 'bw', label: 'B&W', desc: 'Black & white — classic vector conversion' }, + { value: 'grayscale', label: 'Grayscale', desc: 'Preserve gray tonal layers' }, + { value: 'color', label: 'Color', desc: 'Full color vector conversion' }, +]; + /** - * Extract default slider parameters from a preset config. + * Extract default slider parameters from a preset config and conversion mode. */ -function defaultParamsFromPreset(config: PresetConfig): Record { - const mode = config.vectorization.mode; +function defaultParamsFromPreset(config: PresetConfig, mode: ConversionMode): Record { + const vecMode = config.vectorization.mode; const params: Record = { epsilon: config.postprocessing.epsilon ?? 2.5, }; - if (mode === 'potrace') { - params.turdsize = config.vectorization.potrace?.turdsize ?? 10; - params.alphamax = config.vectorization.potrace?.alphamax ?? 1.0; + if (mode === 'bw') { + if (vecMode === 'potrace') { + params.turdsize = config.vectorization.potrace?.turdsize ?? 10; + params.alphamax = config.vectorization.potrace?.alphamax ?? 1.0; + params.opttolerance = config.vectorization.potrace?.opttolerance ?? 0.2; + params.turnpolicy = 'minority'; + } else { + params.filter_speckle = config.vectorization.vtracer?.filter_speckle ?? 20; + params.corner_threshold = config.vectorization.vtracer?.corner_threshold ?? 60; + params.length_threshold = config.vectorization.vtracer?.length_threshold ?? 4.0; + params.splice_threshold = config.vectorization.vtracer?.splice_threshold ?? 45; + } + params.morph_dilate_iterations = 1; + params.morph_erode_iterations = 1; + params.morph_kernel_size = 3; + params.edge_detect = false; + params.edge_low = 50; + params.edge_high = 150; } else { + // Grayscale and Color: VTracer params + params.color_precision = config.vectorization.vtracer?.color_precision ?? (mode === 'grayscale' ? 4 : 6); + params.layer_difference = config.vectorization.vtracer?.layer_difference ?? 16; params.filter_speckle = config.vectorization.vtracer?.filter_speckle ?? 20; params.corner_threshold = config.vectorization.vtracer?.corner_threshold ?? 60; + params.length_threshold = config.vectorization.vtracer?.length_threshold ?? 4.0; + params.splice_threshold = config.vectorization.vtracer?.splice_threshold ?? 45; + if (mode === 'color') { + params.hierarchical = config.vectorization.vtracer?.hierarchical ?? 'stacked'; + } } return params; @@ -37,18 +66,26 @@ export default function ImportConvert({ onUseThis }: ImportConvertProps) { const [_isSvgMode, setIsSvgMode] = useState(false); const [selectedPreset, setSelectedPreset] = useState('sign'); const [presetConfig, setPresetConfig] = useState(null); + const [conversionMode, setConversionMode] = useState('bw'); + const [invert, setInvert] = useState(false); const [currentParams, setCurrentParams] = useState>({ epsilon: 2.5, turdsize: 10, alphamax: 1.0, }); - // Hook handles params stabilization via JSON.stringify internally + // Merge conversion_mode and invert into the params sent to the engine + const traceParams = { + ...currentParams, + conversion_mode: conversionMode, + invert: invert && conversionMode === 'bw', + }; + const { svgOutput, metadata, isLoading, error } = useDebouncedTrace( selectedFile, selectedPreset, - currentParams, - 300, + traceParams, + 100, // Reduced from 300ms for snappier response ); const handleFileSelect = useCallback( @@ -63,9 +100,23 @@ export default function ImportConvert({ onUseThis }: ImportConvertProps) { (name: string, config: PresetConfig) => { setSelectedPreset(name); setPresetConfig(config); - setCurrentParams(defaultParamsFromPreset(config)); + setCurrentParams(defaultParamsFromPreset(config, conversionMode)); }, - [], + [conversionMode], + ); + + const handleModeChange = useCallback( + (mode: ConversionMode) => { + setConversionMode(mode); + if (mode !== 'bw') { + setInvert(false); + } + // Reset sliders for new mode + if (presetConfig) { + setCurrentParams(defaultParamsFromPreset(presetConfig, mode)); + } + }, + [presetConfig], ); const handleParamsChange = useCallback( @@ -83,27 +134,52 @@ export default function ImportConvert({ onUseThis }: ImportConvertProps) { return (
-
+
- + + + {/* Conversion Mode Selector */} +
+

Conversion Mode

+
+ {CONVERSION_MODES.map((m) => ( + + ))} +
+
+ + {/* Invert Toggle (B&W only) */} + {conversionMode === 'bw' && ( +
+ + setInvert(e.target.checked)} + /> +
+ )} + -
-
+ +
+ {svgOutput && !isLoading && ( + + )}
); diff --git a/engine/api/routes.py b/engine/api/routes.py index b98eefc..0181ffa 100644 --- a/engine/api/routes.py +++ b/engine/api/routes.py @@ -8,7 +8,7 @@ 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.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 @@ -21,7 +21,7 @@ async def health(): return {"status": "ok"} -VALID_MODES = {"potrace", "vtracer"} +VALID_VECTORIZER_MODES = {"potrace", "vtracer"} VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"} @@ -101,6 +101,15 @@ async def trace( 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( @@ -125,14 +134,29 @@ async def trace( 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"] - if effective_mode not in VALID_MODES: + # 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_MODES))}", + detail=f"Invalid mode '{effective_mode}'. Must be one of: {', '.join(sorted(VALID_VECTORIZER_MODES))}", ) raw_bytes = await file.read() @@ -140,10 +164,24 @@ async def trace( 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=resolved["preprocessing"]) + preprocessed = preprocess(raw_bytes, params=pre_params) except ValueError as exc: raise HTTPException(status_code=422, detail=f"Preprocessing failed: {exc}") @@ -152,17 +190,26 @@ async def trace( 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") + if k in ("turdsize", "alphamax", "opticurve", "opttolerance", "turnpolicy") }) else: - svg_output = vtracer_trace(preprocessed, **{ + # 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}") diff --git a/engine/pipeline/preprocessing.py b/engine/pipeline/preprocessing.py index fddc037..c3bcf0f 100644 --- a/engine/pipeline/preprocessing.py +++ b/engine/pipeline/preprocessing.py @@ -1,8 +1,17 @@ -"""OpenCV preprocessing pipeline for raster-to-vector conversion.""" +"""OpenCV preprocessing pipeline for raster-to-vector conversion. + +Supports three conversion modes: +- **bw** (default): Full pipeline → binary image for potrace/vtracer. +- **grayscale**: Decode → grayscale → denoise → contrast → 8-bit output for vtracer. +- **color**: Decode → denoise (bilateral on BGR) → full-color output for vtracer. +""" import cv2 import numpy as np +# Valid conversion modes +VALID_MODES = {"bw", "grayscale", "color"} + def decode_image(raw_bytes: bytes) -> np.ndarray: """Decode raw image bytes into a BGR numpy array.""" @@ -35,7 +44,7 @@ def enhance_contrast( clip_limit: float = 2.0, tile_grid_size: tuple[int, int] = (8, 8), ) -> np.ndarray: - """Apply CLAHE contrast enhancement.""" + """Apply CLAHE contrast enhancement (grayscale only).""" clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size) return clahe.apply(img) @@ -74,16 +83,52 @@ def morphological_ops( return result +def apply_mask(img: np.ndarray, mask_regions: list[dict]) -> np.ndarray: + """Zero out rectangular regions of the image. + + Each region is a dict with keys: x, y, width, height (in pixel coordinates). + Masked areas become white (255) for grayscale/bw or white (255,255,255) for color, + effectively removing them from vectorization. + """ + for region in mask_regions: + x = int(region.get("x", 0)) + y = int(region.get("y", 0)) + w = int(region.get("width", 0)) + h = int(region.get("height", 0)) + if w <= 0 or h <= 0: + continue + if img.ndim == 3: + img[y:y + h, x:x + w] = 255 + else: + img[y:y + h, x:x + w] = 255 + return img + + def preprocess( raw_bytes: bytes, params: dict | None = None, ) -> np.ndarray: - """Run the full preprocessing pipeline on raw image bytes. + """Run the preprocessing pipeline on raw image bytes. - Stages: decode → grayscale → denoise → contrast → threshold → morphological ops. - Edge detection is optional (enabled via params["edge_detect"] = True). + The pipeline varies by ``conversion_mode``: - All stage parameters are tunable via the params dict. Keys: + **bw** (default): + decode → grayscale → denoise → contrast → threshold → [invert] → + [edge detect] → morphological ops → binary output + + **grayscale**: + decode → grayscale → denoise → contrast → 8-bit grayscale output + + **color**: + decode → denoise (bilateral on BGR) → full-color BGR output + + Mask regions (if provided) are applied after decoding, before any + processing — masked pixels are set to white. + + Params dict keys: + conversion_mode: 'bw' | 'grayscale' | 'color' + invert: bool (B&W mode only) + mask_regions: list of {x, y, width, height} dicts denoise_d, denoise_sigma_color, denoise_sigma_space, clahe_clip_limit, clahe_tile_grid_size, threshold_manual, @@ -91,8 +136,30 @@ def preprocess( morph_kernel_size, morph_dilate_iterations, morph_erode_iterations """ p = params or {} + mode = p.get("conversion_mode", "bw") + if mode not in VALID_MODES: + raise ValueError( + f"Invalid conversion_mode '{mode}'. Must be one of: {', '.join(sorted(VALID_MODES))}" + ) img = decode_image(raw_bytes) + + # Apply mask regions early — before any color conversion + mask_regions = p.get("mask_regions") + if mask_regions: + img = apply_mask(img, mask_regions) + + # ── Color mode: denoise BGR, return as-is ── + if mode == "color": + img = denoise( + img, + d=p.get("denoise_d", 9), + sigma_color=p.get("denoise_sigma_color", 75.0), + sigma_space=p.get("denoise_sigma_space", 75.0), + ) + return img + + # ── Grayscale and B&W both start with grayscale conversion ── img = to_grayscale(img) img = denoise( @@ -108,11 +175,19 @@ def preprocess( tile_grid_size=p.get("clahe_tile_grid_size", (8, 8)), ) + # ── Grayscale mode: return 8-bit grayscale (no threshold) ── + if mode == "grayscale": + return img + + # ── B&W mode: threshold → invert → edge detect → morphological ── img = threshold( img, manual_thresh=p.get("threshold_manual"), ) + if p.get("invert", False): + img = cv2.bitwise_not(img) + if p.get("edge_detect", False): img = edge_detect( img, diff --git a/engine/pipeline/vectorize.py b/engine/pipeline/vectorize.py index 04c3c40..270e9ae 100644 --- a/engine/pipeline/vectorize.py +++ b/engine/pipeline/vectorize.py @@ -6,12 +6,23 @@ import potrace import vtracer +_TURNPOLICY_MAP = { + "black": potrace.TURNPOLICY_BLACK, + "white": potrace.TURNPOLICY_WHITE, + "left": potrace.TURNPOLICY_LEFT, + "right": potrace.TURNPOLICY_RIGHT, + "minority": potrace.TURNPOLICY_MINORITY, + "majority": potrace.TURNPOLICY_MAJORITY, +} + + def potrace_trace( binary_img: np.ndarray, turdsize: int = 2, alphamax: float = 1.0, opticurve: bool = True, opttolerance: float = 0.2, + turnpolicy: str = "minority", ) -> str: """Trace a binary image using Potrace and return an SVG string. @@ -21,6 +32,8 @@ def potrace_trace( alphamax: Corner detection threshold (0.0 = polygon, 1.3333 = no corners). opticurve: Whether to optimize curves by reducing Bezier segments. opttolerance: Tolerance for curve optimization. + turnpolicy: How to resolve ambiguities — 'minority', 'majority', 'black', + 'white', 'left', 'right'. Returns: Well-formed SVG string. @@ -34,12 +47,15 @@ def potrace_trace( # Convert to uint32 — pypotrace needs values that fit in a C int. data = (binary_img > 0).astype(np.uint32) + tp = _TURNPOLICY_MAP.get(turnpolicy, potrace.TURNPOLICY_MINORITY) + bmp = potrace.Bitmap(data) path = bmp.trace( turdsize=turdsize, alphamax=alphamax, opticurve=int(opticurve), opttolerance=opttolerance, + turnpolicy=tp, ) return _path_to_svg(path, w, h) diff --git a/engine/presets/detailed.json b/engine/presets/detailed.json index a29451d..5e3f798 100644 --- a/engine/presets/detailed.json +++ b/engine/presets/detailed.json @@ -6,7 +6,10 @@ "denoise_sigma_color": 50.0, "denoise_sigma_space": 50.0, "clahe_clip_limit": 1.5, - "clahe_tile_grid_size": [4, 4], + "clahe_tile_grid_size": [ + 4, + 4 + ], "threshold_manual": null, "edge_detect": false, "morph_kernel_size": 3, @@ -19,7 +22,8 @@ "turdsize": 1, "alphamax": 1.3333, "opticurve": true, - "opttolerance": 0.1 + "opttolerance": 0.1, + "turnpolicy": "minority" }, "vtracer": { "colormode": "binary", @@ -28,7 +32,10 @@ "corner_threshold": 30, "length_threshold": 2.0, "splice_threshold": 30, - "mode": "spline" + "mode": "spline", + "color_precision": 8, + "layer_difference": 8, + "max_iterations": 15 } }, "postprocessing": { diff --git a/engine/presets/patch.json b/engine/presets/patch.json index 3649934..cdfe414 100644 --- a/engine/presets/patch.json +++ b/engine/presets/patch.json @@ -6,7 +6,10 @@ "denoise_sigma_color": 75.0, "denoise_sigma_space": 75.0, "clahe_clip_limit": 2.0, - "clahe_tile_grid_size": [8, 8], + "clahe_tile_grid_size": [ + 8, + 8 + ], "threshold_manual": null, "edge_detect": false, "morph_kernel_size": 3, @@ -19,7 +22,8 @@ "turdsize": 4, "alphamax": 1.3, "opticurve": true, - "opttolerance": 0.15 + "opttolerance": 0.15, + "turnpolicy": "minority" }, "vtracer": { "colormode": "binary", @@ -28,7 +32,10 @@ "corner_threshold": 45, "length_threshold": 4.0, "splice_threshold": 45, - "mode": "spline" + "mode": "spline", + "color_precision": 6, + "layer_difference": 16, + "max_iterations": 10 } }, "postprocessing": { diff --git a/engine/presets/sign.json b/engine/presets/sign.json index 570b707..9ced581 100644 --- a/engine/presets/sign.json +++ b/engine/presets/sign.json @@ -6,7 +6,10 @@ "denoise_sigma_color": 90.0, "denoise_sigma_space": 90.0, "clahe_clip_limit": 3.0, - "clahe_tile_grid_size": [8, 8], + "clahe_tile_grid_size": [ + 8, + 8 + ], "threshold_manual": null, "edge_detect": false, "morph_kernel_size": 5, @@ -19,7 +22,8 @@ "turdsize": 10, "alphamax": 1.0, "opticurve": true, - "opttolerance": 0.2 + "opttolerance": 0.2, + "turnpolicy": "minority" }, "vtracer": { "colormode": "binary", @@ -28,7 +32,10 @@ "corner_threshold": 60, "length_threshold": 6.0, "splice_threshold": 45, - "mode": "spline" + "mode": "spline", + "color_precision": 6, + "layer_difference": 16, + "max_iterations": 10 } }, "postprocessing": { diff --git a/engine/presets/stencil.json b/engine/presets/stencil.json index 77cf5a1..c027da2 100644 --- a/engine/presets/stencil.json +++ b/engine/presets/stencil.json @@ -6,7 +6,10 @@ "denoise_sigma_color": 100.0, "denoise_sigma_space": 100.0, "clahe_clip_limit": 2.5, - "clahe_tile_grid_size": [8, 8], + "clahe_tile_grid_size": [ + 8, + 8 + ], "threshold_manual": 128, "edge_detect": false, "morph_kernel_size": 5, @@ -19,7 +22,8 @@ "turdsize": 15, "alphamax": 0.8, "opticurve": true, - "opttolerance": 0.3 + "opttolerance": 0.3, + "turnpolicy": "majority" }, "vtracer": { "colormode": "binary", @@ -28,7 +32,10 @@ "corner_threshold": 75, "length_threshold": 8.0, "splice_threshold": 60, - "mode": "polygon" + "mode": "polygon", + "color_precision": 4, + "layer_difference": 24, + "max_iterations": 10 } }, "postprocessing": { diff --git a/engine/tests/test_modes.py b/engine/tests/test_modes.py new file mode 100644 index 0000000..0773f6d --- /dev/null +++ b/engine/tests/test_modes.py @@ -0,0 +1,289 @@ +"""Tests for conversion modes, invert, expanded params, and mask regions.""" + +import numpy as np +import cv2 +import pytest + +from pipeline.preprocessing import preprocess, decode_image, apply_mask, VALID_MODES +from pipeline.vectorize import potrace_trace, vtracer_trace + + +def _make_test_image(width=100, height=100, color=True): + """Create a test image with a colored rectangle on white background.""" + if color: + img = np.full((height, width, 3), 255, dtype=np.uint8) + # Red rectangle top-left + img[10:50, 10:50] = [0, 0, 200] + # Blue rectangle bottom-right + img[50:90, 50:90] = [200, 0, 0] + # Green strip + img[20:40, 60:90] = [0, 180, 0] + else: + img = np.full((height, width, 3), 255, dtype=np.uint8) + img[10:50, 10:50] = [0, 0, 0] + _, buf = cv2.imencode(".png", img) + return buf.tobytes() + + +def _make_grayscale_image(width=100, height=100): + """Create a test image with distinct gray levels.""" + img = np.full((height, width, 3), 255, dtype=np.uint8) + # Dark gray + img[10:40, 10:40] = [60, 60, 60] + # Medium gray + img[40:70, 40:70] = [128, 128, 128] + # Light gray + img[60:90, 60:90] = [200, 200, 200] + _, buf = cv2.imencode(".png", img) + return buf.tobytes() + + +# ── Conversion mode tests ── + + +class TestConversionModes: + def test_bw_mode_returns_2d_binary(self): + raw = _make_test_image(color=False) + result = preprocess(raw, {"conversion_mode": "bw"}) + assert result.ndim == 2 + unique = set(np.unique(result)) + assert unique <= {0, 255}, f"Expected binary, got values: {unique}" + + def test_grayscale_mode_returns_2d_non_binary(self): + raw = _make_grayscale_image() + result = preprocess(raw, {"conversion_mode": "grayscale"}) + assert result.ndim == 2 + # Should have more than 2 unique values (not just 0 and 255) + unique_count = len(np.unique(result)) + assert unique_count > 2, f"Expected multiple gray levels, got {unique_count}" + + def test_color_mode_returns_3d_bgr(self): + raw = _make_test_image(color=True) + result = preprocess(raw, {"conversion_mode": "color"}) + assert result.ndim == 3 + assert result.shape[2] == 3 # BGR channels + + def test_default_mode_is_bw(self): + raw = _make_test_image(color=False) + result = preprocess(raw, {}) + assert result.ndim == 2 + unique = set(np.unique(result)) + assert unique <= {0, 255} + + def test_invalid_mode_raises(self): + raw = _make_test_image() + with pytest.raises(ValueError, match="Invalid conversion_mode"): + preprocess(raw, {"conversion_mode": "invalid"}) + + def test_valid_modes_constant(self): + assert VALID_MODES == {"bw", "grayscale", "color"} + + +# ── Invert tests ── + + +class TestInvert: + def test_invert_flips_bw_output(self): + raw = _make_test_image(color=False) + normal = preprocess(raw, {"conversion_mode": "bw", "invert": False}) + inverted = preprocess(raw, {"conversion_mode": "bw", "invert": True}) + + # Foreground and background should be swapped + assert not np.array_equal(normal, inverted) + # Pixels that were 0 should now be 255 and vice versa + np.testing.assert_array_equal(normal, cv2.bitwise_not(inverted)) + + def test_invert_false_by_default(self): + raw = _make_test_image(color=False) + default_result = preprocess(raw, {"conversion_mode": "bw"}) + explicit_false = preprocess(raw, {"conversion_mode": "bw", "invert": False}) + np.testing.assert_array_equal(default_result, explicit_false) + + +# ── Mask region tests ── + + +class TestMaskRegions: + def test_mask_sets_region_to_white_2d(self): + img = np.zeros((100, 100), dtype=np.uint8) + result = apply_mask(img, [{"x": 10, "y": 10, "width": 20, "height": 20}]) + assert np.all(result[10:30, 10:30] == 255) + # Outside mask should still be 0 + assert result[0, 0] == 0 + + def test_mask_sets_region_to_white_3d(self): + img = np.zeros((100, 100, 3), dtype=np.uint8) + result = apply_mask(img, [{"x": 10, "y": 10, "width": 20, "height": 20}]) + assert np.all(result[10:30, 10:30] == 255) + assert np.all(result[0, 0] == 0) + + def test_multiple_masks(self): + img = np.zeros((100, 100), dtype=np.uint8) + masks = [ + {"x": 0, "y": 0, "width": 10, "height": 10}, + {"x": 50, "y": 50, "width": 10, "height": 10}, + ] + result = apply_mask(img, masks) + assert np.all(result[0:10, 0:10] == 255) + assert np.all(result[50:60, 50:60] == 255) + assert result[30, 30] == 0 + + def test_mask_in_preprocessing_pipeline(self): + raw = _make_test_image(color=False) + # Mask the entire dark rectangle area + result = preprocess(raw, { + "conversion_mode": "bw", + "mask_regions": [{"x": 5, "y": 5, "width": 50, "height": 50}], + }) + # The masked area should be white (255) in the output + # since we masked the dark rectangle + assert result.ndim == 2 + + def test_zero_size_mask_ignored(self): + img = np.zeros((100, 100), dtype=np.uint8) + result = apply_mask(img, [{"x": 10, "y": 10, "width": 0, "height": 20}]) + assert np.all(result == 0) + + +# ── Preprocessing parameter tests ── + + +class TestPreprocessingParams: + def test_threshold_manual_overrides_otsu(self): + raw = _make_grayscale_image() + # With THRESH_BINARY: pixels > threshold → 255 (white), else → 0 (black). + # Disable morphological ops to isolate threshold effect. + base = {"conversion_mode": "bw", "morph_dilate_iterations": 0, "morph_erode_iterations": 0} + # Low threshold: most pixels are above 30 → lots of white + low = preprocess(raw, {**base, "threshold_manual": 30}) + # High threshold: only very bright pixels above 240 → less white + high = preprocess(raw, {**base, "threshold_manual": 240}) + # Low threshold produces more white pixels than high threshold + assert np.sum(low == 255) > np.sum(high == 255) + + def test_morph_dilate_expands_shapes(self): + raw = _make_test_image(color=False) + no_dilate = preprocess(raw, { + "conversion_mode": "bw", + "morph_dilate_iterations": 0, + "morph_erode_iterations": 0, + }) + heavy_dilate = preprocess(raw, { + "conversion_mode": "bw", + "morph_dilate_iterations": 5, + "morph_erode_iterations": 0, + }) + # More dilation = more foreground pixels + assert np.sum(heavy_dilate > 0) >= np.sum(no_dilate > 0) + + def test_morph_erode_shrinks_shapes(self): + raw = _make_test_image(color=False) + no_erode = preprocess(raw, { + "conversion_mode": "bw", + "morph_dilate_iterations": 0, + "morph_erode_iterations": 0, + }) + heavy_erode = preprocess(raw, { + "conversion_mode": "bw", + "morph_dilate_iterations": 0, + "morph_erode_iterations": 5, + }) + # More erosion = fewer foreground pixels + assert np.sum(heavy_erode > 0) <= np.sum(no_erode > 0) + + def test_edge_detect_produces_edges(self): + raw = _make_test_image(color=False) + normal = preprocess(raw, {"conversion_mode": "bw", "edge_detect": False}) + edges = preprocess(raw, {"conversion_mode": "bw", "edge_detect": True}) + # Edge detection produces a visually different image + assert not np.array_equal(normal, edges) + + +# ── Vectorization with different modes ── + + +class TestVectorizationModes: + def test_bw_potrace_produces_svg(self): + raw = _make_test_image(color=False) + binary = preprocess(raw, {"conversion_mode": "bw"}) + svg = potrace_trace(binary) + assert "