1024 * 1024:
size_str = f"{svg_bytes / 1024 / 1024:.1f} MB"
else:
size_str = f"{svg_bytes / 1024:.1f} KB"
stats = f"**{path_count}** paths · **{size_str}** · **{len(colors)}** colors · **{img.size[0]}×{img.size[1]}** px"
# Save SVG file
svg_filename = os.path.join(TEMP_DIR, "output.svg")
with open(svg_filename, "w") as f:
f.write(svg_str)
# Build preview HTML
preview_html = build_preview_html(svg_str, img.size[0], img.size[1])
progress(1.0, desc="Done!")
return preview_html, svg_filename, stats, build_color_editor_html(colors), json.dumps(colors)
def build_preview_html(svg_str: str, w: int, h: int) -> str:
"""Build an HTML preview with the SVG rendered in a styled container."""
return f''''''
def build_color_editor_html(colors: list) -> str:
"""Build HTML color editor with interactive pickers."""
if not colors:
return 'No colors detected in SVG
'
pickers_html = ""
for i, color in enumerate(colors[:30]): # Limit to 30 colors
pickers_html += f'''
{color}
'''
return f'''
{pickers_html}
'''
# ─── Apply Color Edits ──────────────────────────────────────────────────────
def apply_color_edits(current_svg_path, color_map_json):
"""Apply color replacements to the SVG file."""
if not current_svg_path or not color_map_json:
return None, None
try:
color_map = json.loads(color_map_json)
except:
return None, None
with open(current_svg_path, 'r') as f:
svg_str = f.read()
for old_color, new_color in color_map.items():
svg_str = replace_svg_color(svg_str, old_color, new_color)
svg_filename = os.path.join(TEMP_DIR, "output_edited.svg")
with open(svg_filename, "w") as f:
f.write(svg_str)
w_match = re.search(r'width="(\d+)"', svg_str)
h_match = re.search(r'height="(\d+)"', svg_str)
w = int(w_match.group(1)) if w_match else 800
h = int(h_match.group(1)) if h_match else 600
return build_preview_html(svg_str, w, h), svg_filename
# ─── CSS ─────────────────────────────────────────────────────────────────────
CUSTOM_CSS = """
/* Global dark theme */
.gradio-container {
background: linear-gradient(160deg, #0a0a1a 0%, #1a1040 40%, #0d1530 100%) !important;
min-height: 100vh;
}
/* Main title */
.app-title {
text-align: center;
margin-bottom: 8px;
}
.app-title h1 {
font-size: 2.4em;
background: linear-gradient(135deg, #a78bfa, #60a5fa, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 800;
margin: 0;
letter-spacing: -0.02em;
}
.app-subtitle {
text-align: center;
color: #8892b0;
font-size: 1.05em;
margin-bottom: 24px;
font-weight: 400;
}
/* Panels */
.panel {
background: rgba(255,255,255,0.03) !important;
border: 1px solid rgba(255,255,255,0.08) !important;
border-radius: 16px !important;
backdrop-filter: blur(12px);
}
/* Mode selector pills */
.mode-selector .gr-radio {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 8px 16px;
}
/* Primary button */
.convert-btn {
background: linear-gradient(135deg, #7c3aed, #2563eb) !important;
border: none !important;
border-radius: 12px !important;
font-size: 1.1em !important;
font-weight: 700 !important;
padding: 14px 32px !important;
box-shadow: 0 8px 32px rgba(124, 58, 237, 0.35) !important;
transition: all 0.3s ease !important;
letter-spacing: 0.02em !important;
}
.convert-btn:hover {
transform: translateY(-2px) !important;
box-shadow: 0 12px 40px rgba(124, 58, 237, 0.5) !important;
}
/* Stats banner */
.stats-bar {
background: rgba(124,58,237,0.1);
border: 1px solid rgba(124,58,237,0.2);
border-radius: 10px;
padding: 10px 16px;
}
/* Accordion styling */
.gr-accordion {
border: 1px solid rgba(255,255,255,0.06) !important;
border-radius: 12px !important;
background: rgba(255,255,255,0.02) !important;
}
/* Slider accent */
input[type="range"] {
accent-color: #7c3aed !important;
}
/* File download */
.download-section .gr-file {
border: 2px dashed rgba(124,58,237,0.3) !important;
border-radius: 12px !important;
background: rgba(124,58,237,0.05) !important;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(0,0,0,0.2);
}
::-webkit-scrollbar-thumb {
background: rgba(124,58,237,0.4);
border-radius: 3px;
}
/* Footer */
.footer {
text-align: center;
color: #4a5568;
font-size: 0.85em;
margin-top: 32px;
padding: 16px;
}
/* Hide unnecessary borders */
.gr-block.gr-box {
border: none !important;
}
/* Tab styling */
.gr-tab-nav button {
font-weight: 600 !important;
}
.gr-tab-nav button.selected {
border-bottom: 3px solid #7c3aed !important;
color: #a78bfa !important;
}
"""
# ─── Build Gradio UI ────────────────────────────────────────────────────────
def create_app():
with gr.Blocks(title="VectorForge — Image to SVG") as demo:
# State
current_colors = gr.State("[]")
# ── Header ──
gr.HTML('''
⚡ VectorForge
Professional Image → SVG Converter · Edge Detection · Color Quantization · Live Editing
''')
with gr.Row(equal_height=False):
# ══════ LEFT COLUMN: Input & Controls ══════
with gr.Column(scale=4):
# Image Upload
image_input = gr.Image(
label="📸 Upload Image",
type="pil",
sources=["upload", "clipboard"],
height=300,
)
# Conversion Mode
conversion_mode = gr.Radio(
["🖤 Black & White", "🎨 Color", "✏️ Edge Art", "🌈 Smart Color (Edge + Fill)", "🔲 Posterize"],
value="🎨 Color",
label="Conversion Mode",
elem_classes=["mode-selector"]
)
# ── Image Enhancement ──
with gr.Accordion("🔧 Image Enhancement", open=False):
with gr.Row():
contrast = gr.Slider(0.5, 3.0, value=1.0, step=0.1, label="Contrast")
brightness = gr.Slider(0.5, 3.0, value=1.0, step=0.1, label="Brightness")
with gr.Row():
sharpness = gr.Slider(0.5, 3.0, value=1.0, step=0.1, label="Sharpness")
denoise = gr.Checkbox(label="Denoise", value=False)
# ── Edge Detection Settings ──
with gr.Accordion("🔍 Edge Detection", open=False) as edge_accordion:
edge_method = gr.Dropdown(
["Canny", "Sobel", "Laplacian", "Scharr", "Adaptive Threshold", "Sketch (Pencil)"],
value="Canny", label="Detection Method"
)
with gr.Row():
edge_low = gr.Slider(10, 200, value=50, step=5, label="Low Threshold")
edge_high = gr.Slider(50, 300, value=150, step=5, label="High Threshold")
edge_aperture = gr.Slider(3, 7, value=3, step=2, label="Aperture Size")
# ── Tracing Parameters ──
with gr.Accordion("⚙️ Tracing Settings", open=False):
with gr.Row():
hierarchical = gr.Radio(
["Stacked", "Cutout"], value="Stacked",
label="Layering Mode",
info="Stacked: overlapping fills (photos) · Cutout: no overlap (icons)"
)
curve_mode = gr.Radio(
["Spline", "Polygon", "None"], value="Spline",
label="Curve Fitting",
info="Spline: smooth curves · Polygon: straight segments"
)
with gr.Row():
filter_speckle = gr.Slider(0, 20, value=4, step=1, label="Speckle Filter",
info="Remove small noise (higher = cleaner)")
corner_threshold = gr.Slider(0, 180, value=60, step=5, label="Corner Sharpness",
info="Lower = sharper corners")
with gr.Row():
length_threshold = gr.Slider(2.0, 10.0, value=4.0, step=0.5, label="Path Detail",
info="Lower = more detail, larger file")
splice_threshold = gr.Slider(0, 180, value=45, step=5, label="Splice Threshold")
with gr.Row():
max_iterations = gr.Slider(1, 30, value=10, step=1, label="Fitting Iterations")
path_precision = gr.Slider(1, 10, value=5, step=1, label="Path Precision",
info="Decimal places in SVG coordinates")
# ── Color Settings ──
with gr.Accordion("🎨 Color Settings", open=False) as color_accordion:
with gr.Row():
num_colors = gr.Slider(2, 64, value=16, step=1, label="Number of Colors",
info="For color quantization")
color_precision = gr.Slider(1, 8, value=6, step=1, label="Color Precision",
info="Bits for color detection (higher = more colors)")
with gr.Row():
layer_difference = gr.Slider(1, 64, value=16, step=1, label="Layer Difference",
info="Threshold for separating color layers")
pre_quantize = gr.Checkbox(label="Pre-quantize Colors", value=False,
info="Apply KMeans clustering before tracing")
# ── Output Options ──
with gr.Accordion("📦 Output Options", open=False):
with gr.Row():
optimize = gr.Checkbox(label="Optimize SVG (scour)", value=True,
info="Reduce file size ~30-70%")
stroke_width = gr.Slider(0.1, 5.0, value=1.0, step=0.1,
label="Edge Stroke Width",
info="For Smart Color mode edges")
# Convert Button
convert_btn = gr.Button(
"⚡ Convert to SVG",
variant="primary",
elem_classes=["convert-btn"],
size="lg"
)
# ══════ RIGHT COLUMN: Output ══════
with gr.Column(scale=6):
# Stats
stats_display = gr.Markdown(
value="*Upload an image and click Convert to begin*",
elem_classes=["stats-bar"]
)
# SVG Preview
svg_preview = gr.HTML(
value='''
🎨
SVG preview will appear here
''',
label="SVG Preview"
)
# Download
svg_file = gr.File(
label="⬇️ Download SVG",
file_types=[".svg"],
elem_classes=["download-section"]
)
# Color Editor
with gr.Accordion("🎨 Live Color Editor", open=False) as color_editor_accordion:
color_editor = gr.HTML(
value='Convert an image first to edit colors
'
)
# ── Footer ──
gr.HTML('''
''')
# ── Event Handlers ──
convert_btn.click(
fn=process_image,
inputs=[
image_input, conversion_mode,
contrast, brightness, sharpness, denoise,
edge_method, edge_low, edge_high, edge_aperture,
hierarchical, curve_mode, filter_speckle, color_precision,
layer_difference, corner_threshold, length_threshold,
max_iterations, splice_threshold, path_precision,
num_colors, pre_quantize,
optimize, stroke_width
],
outputs=[svg_preview, svg_file, stats_display, color_editor, current_colors]
)
# Auto-open relevant accordions based on mode
def update_visibility(mode):
is_edge = mode in ["✏️ Edge Art", "🌈 Smart Color (Edge + Fill)"]
is_color = mode in ["🎨 Color", "🌈 Smart Color (Edge + Fill)", "🔲 Posterize"]
return (
gr.Accordion(open=is_edge),
gr.Accordion(open=is_color)
)
conversion_mode.change(
fn=update_visibility,
inputs=[conversion_mode],
outputs=[edge_accordion, color_accordion]
)
return demo
# ─── Launch ──────────────────────────────────────────────────────────────────
if __name__ == "__main__":
demo = create_app()
demo.launch(
server_name="0.0.0.0",
server_port=7860,
share=False,
css=CUSTOM_CSS,
)