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:
jlightner 2026-03-26 08:41:30 +00:00
parent 87fa4eff91
commit 31f78727e0
13 changed files with 1174 additions and 126 deletions

View file

@ -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;

View file

@ -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>
);
} }

View file

@ -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 {

View file

@ -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%;
} }

View file

@ -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>
); );

View file

@ -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}")

View file

@ -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,

View file

@ -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)

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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
View 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()