feat: Added B&W/grayscale/color conversion modes, invert toggle, 10+ mode-aware sliders, mask regions, turnpolicy, and white preview background
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.
This commit is contained in:
parent
87fa4eff91
commit
31f78727e0
13 changed files with 1174 additions and 126 deletions
109
app/src/App.css
109
app/src/App.css
|
|
@ -190,7 +190,7 @@
|
||||||
.parameter-sliders {
|
.parameter-sliders {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-heading {
|
.parameter-heading {
|
||||||
|
|
@ -202,6 +202,25 @@
|
||||||
letter-spacing: 0.5px;
|
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 {
|
.slider-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 120px 1fr 48px;
|
grid-template-columns: 120px 1fr 48px;
|
||||||
|
|
@ -209,6 +228,19 @@
|
||||||
gap: 8px;
|
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 {
|
.slider-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
|
|
@ -230,6 +262,81 @@
|
||||||
font-variant-numeric: tabular-nums;
|
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 */
|
||||||
.svg-preview {
|
.svg-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { PresetConfig } from '../types/engine';
|
import type { ConversionMode, PresetConfig } from '../types/engine';
|
||||||
|
|
||||||
interface ParameterSlidersProps {
|
interface ParameterSlidersProps {
|
||||||
presetConfig: PresetConfig | null;
|
presetConfig: PresetConfig | null;
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
|
conversionMode: ConversionMode;
|
||||||
onChange: (params: Record<string, unknown>) => void;
|
onChange: (params: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -14,116 +15,515 @@ interface SliderDef {
|
||||||
max: number;
|
max: number;
|
||||||
step: number;
|
step: number;
|
||||||
defaultValue: number;
|
defaultValue: number;
|
||||||
/** Only shown when this vectorization mode is active */
|
group: 'preprocessing' | 'vectorization' | 'postprocessing';
|
||||||
modeFilter?: 'potrace' | 'vtracer';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSliderDefs(config: PresetConfig): SliderDef[] {
|
interface ToggleDef {
|
||||||
const mode = config.vectorization.mode;
|
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 potrace = config.vectorization.potrace;
|
||||||
const vtracer = config.vectorization.vtracer;
|
const vtracer = config.vectorization.vtracer;
|
||||||
const post = config.postprocessing;
|
const post = config.postprocessing;
|
||||||
|
|
||||||
const sliders: SliderDef[] = [
|
const controls: ControlDef[] = [];
|
||||||
{
|
|
||||||
key: 'epsilon',
|
|
||||||
label: 'Detail Level',
|
|
||||||
min: 0.5,
|
|
||||||
max: 10,
|
|
||||||
step: 0.5,
|
|
||||||
defaultValue: post.epsilon ?? 2.5,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (mode === 'potrace') {
|
// ── Postprocessing (all modes) ──
|
||||||
sliders.push(
|
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',
|
key: 'turdsize',
|
||||||
label: 'Noise Filter',
|
label: 'Noise Filter',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 50,
|
max: 200,
|
||||||
step: 1,
|
step: 1,
|
||||||
defaultValue: potrace?.turdsize ?? 10,
|
defaultValue: potrace?.turdsize ?? 10,
|
||||||
modeFilter: 'potrace',
|
group: 'vectorization',
|
||||||
},
|
});
|
||||||
{
|
|
||||||
|
controls.push({
|
||||||
|
type: 'slider',
|
||||||
key: 'alphamax',
|
key: 'alphamax',
|
||||||
label: 'Smooth Curves',
|
label: 'Curve Smoothing',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1.334,
|
max: 1.334,
|
||||||
step: 0.1,
|
step: 0.01,
|
||||||
defaultValue: potrace?.alphamax ?? 1.0,
|
defaultValue: potrace?.alphamax ?? 1.0,
|
||||||
modeFilter: 'potrace',
|
group: 'vectorization',
|
||||||
},
|
});
|
||||||
);
|
|
||||||
} else {
|
controls.push({
|
||||||
sliders.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',
|
key: 'filter_speckle',
|
||||||
label: 'Noise Filter',
|
label: 'Noise Filter',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 50,
|
max: 200,
|
||||||
step: 1,
|
step: 1,
|
||||||
defaultValue: vtracer?.filter_speckle ?? 20,
|
defaultValue: vtracer?.filter_speckle ?? 20,
|
||||||
modeFilter: 'vtracer',
|
group: 'vectorization',
|
||||||
},
|
});
|
||||||
{
|
|
||||||
|
controls.push({
|
||||||
|
type: 'slider',
|
||||||
key: 'corner_threshold',
|
key: 'corner_threshold',
|
||||||
label: 'Corner Threshold',
|
label: 'Corner Threshold',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 180,
|
max: 180,
|
||||||
step: 1,
|
step: 1,
|
||||||
defaultValue: vtracer?.corner_threshold ?? 60,
|
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<string, string> = {
|
||||||
|
preprocessing: 'Preprocessing',
|
||||||
|
vectorization: 'Vectorization',
|
||||||
|
postprocessing: 'Output',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP_ORDER = ['preprocessing', 'vectorization', 'postprocessing'];
|
||||||
|
|
||||||
export default function ParameterSliders({
|
export default function ParameterSliders({
|
||||||
presetConfig,
|
presetConfig,
|
||||||
params,
|
params,
|
||||||
|
conversionMode,
|
||||||
onChange,
|
onChange,
|
||||||
}: ParameterSlidersProps) {
|
}: ParameterSlidersProps) {
|
||||||
const handleChange = useCallback(
|
const handleSliderChange = useCallback(
|
||||||
(key: string, value: number) => {
|
(key: string, value: number) => {
|
||||||
onChange({ ...params, [key]: value });
|
onChange({ ...params, [key]: value });
|
||||||
},
|
},
|
||||||
[params, onChange],
|
[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) {
|
if (!presetConfig) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sliders = getSliderDefs(presetConfig);
|
const controls = getControlDefs(presetConfig, conversionMode);
|
||||||
|
|
||||||
|
// Group controls by section
|
||||||
|
const grouped: Record<string, ControlDef[]> = {};
|
||||||
|
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 (
|
return (
|
||||||
<div className="parameter-sliders">
|
<div className="parameter-sliders">
|
||||||
<h3 className="parameter-heading">Parameters</h3>
|
{GROUP_ORDER.map((groupKey) => {
|
||||||
{sliders.map((slider) => {
|
const groupControls = grouped[groupKey];
|
||||||
const value =
|
if (!groupControls || groupControls.length === 0) return null;
|
||||||
typeof params[slider.key] === 'number'
|
|
||||||
? (params[slider.key] as number)
|
|
||||||
: slider.defaultValue;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={slider.key} className="slider-row">
|
<div key={groupKey} className="slider-group">
|
||||||
<label className="slider-label" htmlFor={`slider-${slider.key}`}>
|
<h4 className="slider-group-label">{GROUP_LABELS[groupKey]}</h4>
|
||||||
{slider.label}
|
{groupControls.map((control) => {
|
||||||
|
// Hide edge low/high when edge detection is off
|
||||||
|
if ((control.key === 'edge_low' || control.key === 'edge_high') && !edgeDetectOn) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'toggle') {
|
||||||
|
const checked =
|
||||||
|
typeof params[control.key] === 'boolean'
|
||||||
|
? (params[control.key] as boolean)
|
||||||
|
: control.defaultValue;
|
||||||
|
return (
|
||||||
|
<div key={control.key} className="slider-row slider-row--toggle">
|
||||||
|
<label className="slider-label" htmlFor={`toggle-${control.key}`}>
|
||||||
|
{control.label}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={`slider-${slider.key}`}
|
id={`toggle-${control.key}`}
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle-input"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => handleToggleChange(control.key, e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.type === 'select') {
|
||||||
|
const value =
|
||||||
|
typeof params[control.key] === 'string'
|
||||||
|
? (params[control.key] as string)
|
||||||
|
: control.defaultValue;
|
||||||
|
return (
|
||||||
|
<div key={control.key} className="slider-row slider-row--select">
|
||||||
|
<label className="slider-label" htmlFor={`select-${control.key}`}>
|
||||||
|
{control.label}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`select-${control.key}`}
|
||||||
|
className="select-input"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleSelectChange(control.key, e.target.value)}
|
||||||
|
>
|
||||||
|
{control.options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slider
|
||||||
|
const value =
|
||||||
|
typeof params[control.key] === 'number'
|
||||||
|
? (params[control.key] as number)
|
||||||
|
: control.defaultValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={control.key} className="slider-row">
|
||||||
|
<label className="slider-label" htmlFor={`slider-${control.key}`}>
|
||||||
|
{control.label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`slider-${control.key}`}
|
||||||
type="range"
|
type="range"
|
||||||
className="slider-input"
|
className="slider-input"
|
||||||
min={slider.min}
|
min={control.min}
|
||||||
max={slider.max}
|
max={control.max}
|
||||||
step={slider.step}
|
step={control.step}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleChange(slider.key, parseFloat(e.target.value))
|
handleSliderChange(control.key, parseFloat(e.target.value))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="slider-value">{value}</span>
|
<span className="slider-value">{value}</span>
|
||||||
|
|
@ -132,4 +532,7 @@ export default function ParameterSliders({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
/** TypeScript interfaces matching the Kerf Engine API shapes. */
|
/** TypeScript interfaces matching the Kerf Engine API shapes. */
|
||||||
|
|
||||||
|
// -- Conversion mode --
|
||||||
|
|
||||||
|
export type ConversionMode = 'bw' | 'grayscale' | 'color';
|
||||||
|
|
||||||
// -- Preset configuration (mirrors engine/presets/*.json) --
|
// -- Preset configuration (mirrors engine/presets/*.json) --
|
||||||
|
|
||||||
export interface PreprocessingConfig {
|
export interface PreprocessingConfig {
|
||||||
|
|
@ -10,6 +14,8 @@ export interface PreprocessingConfig {
|
||||||
clahe_tile_grid_size?: [number, number];
|
clahe_tile_grid_size?: [number, number];
|
||||||
threshold_manual?: number | null;
|
threshold_manual?: number | null;
|
||||||
edge_detect?: boolean;
|
edge_detect?: boolean;
|
||||||
|
edge_low?: number;
|
||||||
|
edge_high?: number;
|
||||||
morph_kernel_size?: number;
|
morph_kernel_size?: number;
|
||||||
morph_dilate_iterations?: number;
|
morph_dilate_iterations?: number;
|
||||||
morph_erode_iterations?: number;
|
morph_erode_iterations?: number;
|
||||||
|
|
@ -20,6 +26,7 @@ export interface PotraceConfig {
|
||||||
alphamax?: number;
|
alphamax?: number;
|
||||||
opticurve?: boolean;
|
opticurve?: boolean;
|
||||||
opttolerance?: number;
|
opttolerance?: number;
|
||||||
|
turnpolicy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VtracerConfig {
|
export interface VtracerConfig {
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,16 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leftPanel {
|
.sidebar {
|
||||||
flex: 0 0 340px;
|
flex: 0 0 340px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
max-height: calc(100svh - 80px);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rightPanel {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -24,24 +26,17 @@
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
background: var(--code-bg);
|
background: #ffffff;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewPlaceholder {
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 15px;
|
|
||||||
text-align: center;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leftPanel {
|
.sidebar {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useState } from 'react';
|
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 FileUpload from '../components/FileUpload';
|
||||||
import PresetSelector from '../components/PresetSelector';
|
import PresetSelector from '../components/PresetSelector';
|
||||||
import ParameterSliders from '../components/ParameterSliders';
|
import ParameterSliders from '../components/ParameterSliders';
|
||||||
|
|
@ -12,21 +12,50 @@ interface ImportConvertProps {
|
||||||
onUseThis: (svgOutput: string, metadata: TraceMetadata) => void;
|
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<string, unknown> {
|
function defaultParamsFromPreset(config: PresetConfig, mode: ConversionMode): Record<string, unknown> {
|
||||||
const mode = config.vectorization.mode;
|
const vecMode = config.vectorization.mode;
|
||||||
const params: Record<string, unknown> = {
|
const params: Record<string, unknown> = {
|
||||||
epsilon: config.postprocessing.epsilon ?? 2.5,
|
epsilon: config.postprocessing.epsilon ?? 2.5,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'potrace') {
|
if (mode === 'bw') {
|
||||||
|
if (vecMode === 'potrace') {
|
||||||
params.turdsize = config.vectorization.potrace?.turdsize ?? 10;
|
params.turdsize = config.vectorization.potrace?.turdsize ?? 10;
|
||||||
params.alphamax = config.vectorization.potrace?.alphamax ?? 1.0;
|
params.alphamax = config.vectorization.potrace?.alphamax ?? 1.0;
|
||||||
|
params.opttolerance = config.vectorization.potrace?.opttolerance ?? 0.2;
|
||||||
|
params.turnpolicy = 'minority';
|
||||||
} else {
|
} else {
|
||||||
params.filter_speckle = config.vectorization.vtracer?.filter_speckle ?? 20;
|
params.filter_speckle = config.vectorization.vtracer?.filter_speckle ?? 20;
|
||||||
params.corner_threshold = config.vectorization.vtracer?.corner_threshold ?? 60;
|
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;
|
return params;
|
||||||
|
|
@ -37,18 +66,26 @@ export default function ImportConvert({ onUseThis }: ImportConvertProps) {
|
||||||
const [_isSvgMode, setIsSvgMode] = useState(false);
|
const [_isSvgMode, setIsSvgMode] = useState(false);
|
||||||
const [selectedPreset, setSelectedPreset] = useState('sign');
|
const [selectedPreset, setSelectedPreset] = useState('sign');
|
||||||
const [presetConfig, setPresetConfig] = useState<PresetConfig | null>(null);
|
const [presetConfig, setPresetConfig] = useState<PresetConfig | null>(null);
|
||||||
|
const [conversionMode, setConversionMode] = useState<ConversionMode>('bw');
|
||||||
|
const [invert, setInvert] = useState(false);
|
||||||
const [currentParams, setCurrentParams] = useState<Record<string, unknown>>({
|
const [currentParams, setCurrentParams] = useState<Record<string, unknown>>({
|
||||||
epsilon: 2.5,
|
epsilon: 2.5,
|
||||||
turdsize: 10,
|
turdsize: 10,
|
||||||
alphamax: 1.0,
|
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(
|
const { svgOutput, metadata, isLoading, error } = useDebouncedTrace(
|
||||||
selectedFile,
|
selectedFile,
|
||||||
selectedPreset,
|
selectedPreset,
|
||||||
currentParams,
|
traceParams,
|
||||||
300,
|
100, // Reduced from 300ms for snappier response
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileSelect = useCallback(
|
const handleFileSelect = useCallback(
|
||||||
|
|
@ -63,9 +100,23 @@ export default function ImportConvert({ onUseThis }: ImportConvertProps) {
|
||||||
(name: string, config: PresetConfig) => {
|
(name: string, config: PresetConfig) => {
|
||||||
setSelectedPreset(name);
|
setSelectedPreset(name);
|
||||||
setPresetConfig(config);
|
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(
|
const handleParamsChange = useCallback(
|
||||||
|
|
@ -83,27 +134,52 @@ export default function ImportConvert({ onUseThis }: ImportConvertProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.leftPanel}>
|
<div className={styles.sidebar}>
|
||||||
<FileUpload onFileSelect={handleFileSelect} selectedFile={selectedFile} />
|
<FileUpload onFileSelect={handleFileSelect} selectedFile={selectedFile} />
|
||||||
<PresetSelector
|
<PresetSelector onPresetSelect={handlePresetSelect} selectedPreset={selectedPreset} />
|
||||||
selectedPreset={selectedPreset}
|
|
||||||
onPresetSelect={handlePresetSelect}
|
{/* Conversion Mode Selector */}
|
||||||
|
<div className="mode-selector">
|
||||||
|
<h3 className="parameter-heading">Conversion Mode</h3>
|
||||||
|
<div className="mode-tabs">
|
||||||
|
{CONVERSION_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
className={`mode-tab ${conversionMode === m.value ? 'mode-tab--active' : ''}`}
|
||||||
|
onClick={() => handleModeChange(m.value)}
|
||||||
|
title={m.desc}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invert Toggle (B&W only) */}
|
||||||
|
{conversionMode === 'bw' && (
|
||||||
|
<div className="invert-toggle">
|
||||||
|
<label className="invert-label" htmlFor="invert-toggle">
|
||||||
|
Invert
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="invert-toggle"
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle-input"
|
||||||
|
checked={invert}
|
||||||
|
onChange={(e) => setInvert(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ParameterSliders
|
<ParameterSliders
|
||||||
presetConfig={presetConfig}
|
presetConfig={presetConfig}
|
||||||
params={currentParams}
|
params={currentParams}
|
||||||
|
conversionMode={conversionMode}
|
||||||
onChange={handleParamsChange}
|
onChange={handleParamsChange}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="use-this-btn"
|
|
||||||
disabled={!svgOutput || isLoading}
|
|
||||||
onClick={handleUseThis}
|
|
||||||
>
|
|
||||||
Use This →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.rightPanel}>
|
|
||||||
|
<div className={styles.main}>
|
||||||
<SvgPreview
|
<SvgPreview
|
||||||
svgOutput={svgOutput}
|
svgOutput={svgOutput}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
@ -111,6 +187,11 @@ export default function ImportConvert({ onUseThis }: ImportConvertProps) {
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
/>
|
/>
|
||||||
<OutputInfoBar metadata={metadata} />
|
<OutputInfoBar metadata={metadata} />
|
||||||
|
{svgOutput && !isLoading && (
|
||||||
|
<button className="use-this-btn" onClick={handleUseThis}>
|
||||||
|
Use This →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from fastapi.responses import Response
|
||||||
|
|
||||||
from output import generate_dxf, generate_json, generate_svg
|
from output import generate_dxf, generate_json, generate_svg
|
||||||
from pipeline.postprocess import postprocess_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 pipeline.vectorize import potrace_trace, vtracer_trace
|
||||||
from presets.loader import all_presets, preset_names, resolve_params
|
from presets.loader import all_presets, preset_names, resolve_params
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
VALID_MODES = {"potrace", "vtracer"}
|
VALID_VECTORIZER_MODES = {"potrace", "vtracer"}
|
||||||
VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"}
|
VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,6 +101,15 @@ async def trace(
|
||||||
Supports three output formats: svg (default), dxf, json.
|
Supports three output formats: svg (default), dxf, json.
|
||||||
A preset provides default parameters for each pipeline stage.
|
A preset provides default parameters for each pipeline stage.
|
||||||
User params override preset defaults.
|
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:
|
if output_format not in VALID_OUTPUT_FORMATS:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -125,14 +134,29 @@ async def trace(
|
||||||
detail=f"Unknown preset '{preset}'. Must be one of: {', '.join(valid_presets)}",
|
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
|
# Resolve effective parameters: preset defaults + user overrides
|
||||||
resolved = resolve_params(preset, user_params)
|
resolved = resolve_params(preset, user_params)
|
||||||
effective_mode = resolved["vectorization_mode"]
|
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(
|
raise HTTPException(
|
||||||
status_code=422,
|
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()
|
raw_bytes = await file.read()
|
||||||
|
|
@ -140,10 +164,24 @@ async def trace(
|
||||||
raise HTTPException(status_code=422, detail="Uploaded file is empty")
|
raise HTTPException(status_code=422, detail="Uploaded file is empty")
|
||||||
|
|
||||||
warnings: list[str] = []
|
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()
|
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:
|
try:
|
||||||
preprocessed = preprocess(raw_bytes, params=resolved["preprocessing"])
|
preprocessed = preprocess(raw_bytes, params=pre_params)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"Preprocessing failed: {exc}")
|
raise HTTPException(status_code=422, detail=f"Preprocessing failed: {exc}")
|
||||||
|
|
||||||
|
|
@ -152,17 +190,26 @@ async def trace(
|
||||||
if effective_mode == "potrace":
|
if effective_mode == "potrace":
|
||||||
svg_output = potrace_trace(preprocessed, **{
|
svg_output = potrace_trace(preprocessed, **{
|
||||||
k: v for k, v in vec_params.items()
|
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:
|
else:
|
||||||
svg_output = vtracer_trace(preprocessed, **{
|
# Set VTracer colormode based on conversion_mode
|
||||||
|
vtracer_kwargs = {
|
||||||
k: v for k, v in vec_params.items()
|
k: v for k, v in vec_params.items()
|
||||||
if k in (
|
if k in (
|
||||||
"colormode", "hierarchical", "filter_speckle", "color_precision",
|
"colormode", "hierarchical", "filter_speckle", "color_precision",
|
||||||
"layer_difference", "corner_threshold", "length_threshold",
|
"layer_difference", "corner_threshold", "length_threshold",
|
||||||
"splice_threshold", "mode", "path_precision", "max_iterations",
|
"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:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}")
|
raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
# Valid conversion modes
|
||||||
|
VALID_MODES = {"bw", "grayscale", "color"}
|
||||||
|
|
||||||
|
|
||||||
def decode_image(raw_bytes: bytes) -> np.ndarray:
|
def decode_image(raw_bytes: bytes) -> np.ndarray:
|
||||||
"""Decode raw image bytes into a BGR numpy array."""
|
"""Decode raw image bytes into a BGR numpy array."""
|
||||||
|
|
@ -35,7 +44,7 @@ def enhance_contrast(
|
||||||
clip_limit: float = 2.0,
|
clip_limit: float = 2.0,
|
||||||
tile_grid_size: tuple[int, int] = (8, 8),
|
tile_grid_size: tuple[int, int] = (8, 8),
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Apply CLAHE contrast enhancement."""
|
"""Apply CLAHE contrast enhancement (grayscale only)."""
|
||||||
clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)
|
clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)
|
||||||
return clahe.apply(img)
|
return clahe.apply(img)
|
||||||
|
|
||||||
|
|
@ -74,16 +83,52 @@ def morphological_ops(
|
||||||
return result
|
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(
|
def preprocess(
|
||||||
raw_bytes: bytes,
|
raw_bytes: bytes,
|
||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
) -> np.ndarray:
|
) -> 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.
|
The pipeline varies by ``conversion_mode``:
|
||||||
Edge detection is optional (enabled via params["edge_detect"] = True).
|
|
||||||
|
|
||||||
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,
|
denoise_d, denoise_sigma_color, denoise_sigma_space,
|
||||||
clahe_clip_limit, clahe_tile_grid_size,
|
clahe_clip_limit, clahe_tile_grid_size,
|
||||||
threshold_manual,
|
threshold_manual,
|
||||||
|
|
@ -91,8 +136,30 @@ def preprocess(
|
||||||
morph_kernel_size, morph_dilate_iterations, morph_erode_iterations
|
morph_kernel_size, morph_dilate_iterations, morph_erode_iterations
|
||||||
"""
|
"""
|
||||||
p = params or {}
|
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)
|
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 = to_grayscale(img)
|
||||||
|
|
||||||
img = denoise(
|
img = denoise(
|
||||||
|
|
@ -108,11 +175,19 @@ def preprocess(
|
||||||
tile_grid_size=p.get("clahe_tile_grid_size", (8, 8)),
|
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 = threshold(
|
||||||
img,
|
img,
|
||||||
manual_thresh=p.get("threshold_manual"),
|
manual_thresh=p.get("threshold_manual"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if p.get("invert", False):
|
||||||
|
img = cv2.bitwise_not(img)
|
||||||
|
|
||||||
if p.get("edge_detect", False):
|
if p.get("edge_detect", False):
|
||||||
img = edge_detect(
|
img = edge_detect(
|
||||||
img,
|
img,
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,23 @@ import potrace
|
||||||
import vtracer
|
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(
|
def potrace_trace(
|
||||||
binary_img: np.ndarray,
|
binary_img: np.ndarray,
|
||||||
turdsize: int = 2,
|
turdsize: int = 2,
|
||||||
alphamax: float = 1.0,
|
alphamax: float = 1.0,
|
||||||
opticurve: bool = True,
|
opticurve: bool = True,
|
||||||
opttolerance: float = 0.2,
|
opttolerance: float = 0.2,
|
||||||
|
turnpolicy: str = "minority",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Trace a binary image using Potrace and return an SVG string.
|
"""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).
|
alphamax: Corner detection threshold (0.0 = polygon, 1.3333 = no corners).
|
||||||
opticurve: Whether to optimize curves by reducing Bezier segments.
|
opticurve: Whether to optimize curves by reducing Bezier segments.
|
||||||
opttolerance: Tolerance for curve optimization.
|
opttolerance: Tolerance for curve optimization.
|
||||||
|
turnpolicy: How to resolve ambiguities — 'minority', 'majority', 'black',
|
||||||
|
'white', 'left', 'right'.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Well-formed SVG string.
|
Well-formed SVG string.
|
||||||
|
|
@ -34,12 +47,15 @@ def potrace_trace(
|
||||||
# Convert to uint32 — pypotrace needs values that fit in a C int.
|
# Convert to uint32 — pypotrace needs values that fit in a C int.
|
||||||
data = (binary_img > 0).astype(np.uint32)
|
data = (binary_img > 0).astype(np.uint32)
|
||||||
|
|
||||||
|
tp = _TURNPOLICY_MAP.get(turnpolicy, potrace.TURNPOLICY_MINORITY)
|
||||||
|
|
||||||
bmp = potrace.Bitmap(data)
|
bmp = potrace.Bitmap(data)
|
||||||
path = bmp.trace(
|
path = bmp.trace(
|
||||||
turdsize=turdsize,
|
turdsize=turdsize,
|
||||||
alphamax=alphamax,
|
alphamax=alphamax,
|
||||||
opticurve=int(opticurve),
|
opticurve=int(opticurve),
|
||||||
opttolerance=opttolerance,
|
opttolerance=opttolerance,
|
||||||
|
turnpolicy=tp,
|
||||||
)
|
)
|
||||||
|
|
||||||
return _path_to_svg(path, w, h)
|
return _path_to_svg(path, w, h)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
"denoise_sigma_color": 50.0,
|
"denoise_sigma_color": 50.0,
|
||||||
"denoise_sigma_space": 50.0,
|
"denoise_sigma_space": 50.0,
|
||||||
"clahe_clip_limit": 1.5,
|
"clahe_clip_limit": 1.5,
|
||||||
"clahe_tile_grid_size": [4, 4],
|
"clahe_tile_grid_size": [
|
||||||
|
4,
|
||||||
|
4
|
||||||
|
],
|
||||||
"threshold_manual": null,
|
"threshold_manual": null,
|
||||||
"edge_detect": false,
|
"edge_detect": false,
|
||||||
"morph_kernel_size": 3,
|
"morph_kernel_size": 3,
|
||||||
|
|
@ -19,7 +22,8 @@
|
||||||
"turdsize": 1,
|
"turdsize": 1,
|
||||||
"alphamax": 1.3333,
|
"alphamax": 1.3333,
|
||||||
"opticurve": true,
|
"opticurve": true,
|
||||||
"opttolerance": 0.1
|
"opttolerance": 0.1,
|
||||||
|
"turnpolicy": "minority"
|
||||||
},
|
},
|
||||||
"vtracer": {
|
"vtracer": {
|
||||||
"colormode": "binary",
|
"colormode": "binary",
|
||||||
|
|
@ -28,7 +32,10 @@
|
||||||
"corner_threshold": 30,
|
"corner_threshold": 30,
|
||||||
"length_threshold": 2.0,
|
"length_threshold": 2.0,
|
||||||
"splice_threshold": 30,
|
"splice_threshold": 30,
|
||||||
"mode": "spline"
|
"mode": "spline",
|
||||||
|
"color_precision": 8,
|
||||||
|
"layer_difference": 8,
|
||||||
|
"max_iterations": 15
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"postprocessing": {
|
"postprocessing": {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
"denoise_sigma_color": 75.0,
|
"denoise_sigma_color": 75.0,
|
||||||
"denoise_sigma_space": 75.0,
|
"denoise_sigma_space": 75.0,
|
||||||
"clahe_clip_limit": 2.0,
|
"clahe_clip_limit": 2.0,
|
||||||
"clahe_tile_grid_size": [8, 8],
|
"clahe_tile_grid_size": [
|
||||||
|
8,
|
||||||
|
8
|
||||||
|
],
|
||||||
"threshold_manual": null,
|
"threshold_manual": null,
|
||||||
"edge_detect": false,
|
"edge_detect": false,
|
||||||
"morph_kernel_size": 3,
|
"morph_kernel_size": 3,
|
||||||
|
|
@ -19,7 +22,8 @@
|
||||||
"turdsize": 4,
|
"turdsize": 4,
|
||||||
"alphamax": 1.3,
|
"alphamax": 1.3,
|
||||||
"opticurve": true,
|
"opticurve": true,
|
||||||
"opttolerance": 0.15
|
"opttolerance": 0.15,
|
||||||
|
"turnpolicy": "minority"
|
||||||
},
|
},
|
||||||
"vtracer": {
|
"vtracer": {
|
||||||
"colormode": "binary",
|
"colormode": "binary",
|
||||||
|
|
@ -28,7 +32,10 @@
|
||||||
"corner_threshold": 45,
|
"corner_threshold": 45,
|
||||||
"length_threshold": 4.0,
|
"length_threshold": 4.0,
|
||||||
"splice_threshold": 45,
|
"splice_threshold": 45,
|
||||||
"mode": "spline"
|
"mode": "spline",
|
||||||
|
"color_precision": 6,
|
||||||
|
"layer_difference": 16,
|
||||||
|
"max_iterations": 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"postprocessing": {
|
"postprocessing": {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
"denoise_sigma_color": 90.0,
|
"denoise_sigma_color": 90.0,
|
||||||
"denoise_sigma_space": 90.0,
|
"denoise_sigma_space": 90.0,
|
||||||
"clahe_clip_limit": 3.0,
|
"clahe_clip_limit": 3.0,
|
||||||
"clahe_tile_grid_size": [8, 8],
|
"clahe_tile_grid_size": [
|
||||||
|
8,
|
||||||
|
8
|
||||||
|
],
|
||||||
"threshold_manual": null,
|
"threshold_manual": null,
|
||||||
"edge_detect": false,
|
"edge_detect": false,
|
||||||
"morph_kernel_size": 5,
|
"morph_kernel_size": 5,
|
||||||
|
|
@ -19,7 +22,8 @@
|
||||||
"turdsize": 10,
|
"turdsize": 10,
|
||||||
"alphamax": 1.0,
|
"alphamax": 1.0,
|
||||||
"opticurve": true,
|
"opticurve": true,
|
||||||
"opttolerance": 0.2
|
"opttolerance": 0.2,
|
||||||
|
"turnpolicy": "minority"
|
||||||
},
|
},
|
||||||
"vtracer": {
|
"vtracer": {
|
||||||
"colormode": "binary",
|
"colormode": "binary",
|
||||||
|
|
@ -28,7 +32,10 @@
|
||||||
"corner_threshold": 60,
|
"corner_threshold": 60,
|
||||||
"length_threshold": 6.0,
|
"length_threshold": 6.0,
|
||||||
"splice_threshold": 45,
|
"splice_threshold": 45,
|
||||||
"mode": "spline"
|
"mode": "spline",
|
||||||
|
"color_precision": 6,
|
||||||
|
"layer_difference": 16,
|
||||||
|
"max_iterations": 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"postprocessing": {
|
"postprocessing": {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
"denoise_sigma_color": 100.0,
|
"denoise_sigma_color": 100.0,
|
||||||
"denoise_sigma_space": 100.0,
|
"denoise_sigma_space": 100.0,
|
||||||
"clahe_clip_limit": 2.5,
|
"clahe_clip_limit": 2.5,
|
||||||
"clahe_tile_grid_size": [8, 8],
|
"clahe_tile_grid_size": [
|
||||||
|
8,
|
||||||
|
8
|
||||||
|
],
|
||||||
"threshold_manual": 128,
|
"threshold_manual": 128,
|
||||||
"edge_detect": false,
|
"edge_detect": false,
|
||||||
"morph_kernel_size": 5,
|
"morph_kernel_size": 5,
|
||||||
|
|
@ -19,7 +22,8 @@
|
||||||
"turdsize": 15,
|
"turdsize": 15,
|
||||||
"alphamax": 0.8,
|
"alphamax": 0.8,
|
||||||
"opticurve": true,
|
"opticurve": true,
|
||||||
"opttolerance": 0.3
|
"opttolerance": 0.3,
|
||||||
|
"turnpolicy": "majority"
|
||||||
},
|
},
|
||||||
"vtracer": {
|
"vtracer": {
|
||||||
"colormode": "binary",
|
"colormode": "binary",
|
||||||
|
|
@ -28,7 +32,10 @@
|
||||||
"corner_threshold": 75,
|
"corner_threshold": 75,
|
||||||
"length_threshold": 8.0,
|
"length_threshold": 8.0,
|
||||||
"splice_threshold": 60,
|
"splice_threshold": 60,
|
||||||
"mode": "polygon"
|
"mode": "polygon",
|
||||||
|
"color_precision": 4,
|
||||||
|
"layer_difference": 24,
|
||||||
|
"max_iterations": 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"postprocessing": {
|
"postprocessing": {
|
||||||
|
|
|
||||||
289
engine/tests/test_modes.py
Normal file
289
engine/tests/test_modes.py
Normal file
|
|
@ -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 "<svg" in svg
|
||||||
|
assert "path" in svg
|
||||||
|
|
||||||
|
def test_bw_vtracer_produces_svg(self):
|
||||||
|
raw = _make_test_image(color=False)
|
||||||
|
binary = preprocess(raw, {"conversion_mode": "bw"})
|
||||||
|
svg = vtracer_trace(binary, colormode="binary")
|
||||||
|
assert "<svg" in svg.lower()
|
||||||
|
|
||||||
|
def test_grayscale_vtracer_produces_multi_path_svg(self):
|
||||||
|
raw = _make_grayscale_image()
|
||||||
|
gray = preprocess(raw, {"conversion_mode": "grayscale"})
|
||||||
|
svg = vtracer_trace(gray, colormode="color")
|
||||||
|
assert "<svg" in svg.lower()
|
||||||
|
# Color mode on grayscale should produce multiple paths for different gray levels
|
||||||
|
assert "<path" in svg.lower() or "<rect" in svg.lower()
|
||||||
|
|
||||||
|
def test_color_vtracer_produces_color_svg(self):
|
||||||
|
raw = _make_test_image(color=True)
|
||||||
|
color_img = preprocess(raw, {"conversion_mode": "color"})
|
||||||
|
svg = vtracer_trace(color_img, colormode="color")
|
||||||
|
assert "<svg" in svg.lower()
|
||||||
|
# Color SVG should contain fill colors (not just black)
|
||||||
|
# VTracer color mode produces rgb() or hex fills
|
||||||
|
svg_lower = svg.lower()
|
||||||
|
has_color = ("fill=" in svg_lower) or ("style=" in svg_lower)
|
||||||
|
assert has_color, "Color SVG should contain fill attributes"
|
||||||
|
|
||||||
|
def test_color_precision_affects_output(self):
|
||||||
|
raw = _make_test_image(color=True)
|
||||||
|
color_img = preprocess(raw, {"conversion_mode": "color"})
|
||||||
|
# Fewer bits = fewer color layers = fewer paths
|
||||||
|
svg_low = vtracer_trace(color_img, colormode="color", color_precision=1)
|
||||||
|
svg_high = vtracer_trace(color_img, colormode="color", color_precision=8)
|
||||||
|
# Low precision should generally produce fewer paths
|
||||||
|
low_count = svg_low.lower().count("<path")
|
||||||
|
high_count = svg_high.lower().count("<path")
|
||||||
|
# At minimum, both should produce valid SVGs
|
||||||
|
assert "<svg" in svg_low.lower()
|
||||||
|
assert "<svg" in svg_high.lower()
|
||||||
|
|
||||||
|
def test_turnpolicy_accepted(self):
|
||||||
|
raw = _make_test_image(color=False)
|
||||||
|
binary = preprocess(raw, {"conversion_mode": "bw"})
|
||||||
|
for policy in ("minority", "majority", "black", "white", "left", "right"):
|
||||||
|
svg = potrace_trace(binary, turnpolicy=policy)
|
||||||
|
assert "<svg" in svg, f"turnpolicy={policy} should produce valid SVG"
|
||||||
|
|
||||||
|
def test_opttolerance_affects_output(self):
|
||||||
|
raw = _make_test_image(color=False)
|
||||||
|
binary = preprocess(raw, {"conversion_mode": "bw"})
|
||||||
|
svg_tight = potrace_trace(binary, opttolerance=0.0)
|
||||||
|
svg_loose = potrace_trace(binary, opttolerance=2.0)
|
||||||
|
# Different tolerances should produce different path data
|
||||||
|
# (at least different path lengths, though both valid)
|
||||||
|
assert "<svg" in svg_tight
|
||||||
|
assert "<svg" in svg_loose
|
||||||
|
|
||||||
|
def test_vtracer_hierarchical_modes(self):
|
||||||
|
raw = _make_test_image(color=True)
|
||||||
|
color_img = preprocess(raw, {"conversion_mode": "color"})
|
||||||
|
for mode in ("stacked", "cutout"):
|
||||||
|
svg = vtracer_trace(color_img, colormode="color", hierarchical=mode)
|
||||||
|
assert "<svg" in svg.lower(), f"hierarchical={mode} should produce valid SVG"
|
||||||
|
|
||||||
|
def test_vtracer_length_threshold(self):
|
||||||
|
raw = _make_test_image(color=False)
|
||||||
|
binary = preprocess(raw, {"conversion_mode": "bw"})
|
||||||
|
svg = vtracer_trace(binary, length_threshold=0.5)
|
||||||
|
assert "<svg" in svg.lower()
|
||||||
|
svg2 = vtracer_trace(binary, length_threshold=20.0)
|
||||||
|
assert "<svg" in svg2.lower()
|
||||||
|
|
||||||
|
def test_vtracer_splice_threshold(self):
|
||||||
|
raw = _make_test_image(color=False)
|
||||||
|
binary = preprocess(raw, {"conversion_mode": "bw"})
|
||||||
|
svg = vtracer_trace(binary, splice_threshold=10)
|
||||||
|
assert "<svg" in svg.lower()
|
||||||
|
svg2 = vtracer_trace(binary, splice_threshold=170)
|
||||||
|
assert "<svg" in svg2.lower()
|
||||||
Loading…
Add table
Reference in a new issue