Spaces:
Sleeping
Sleeping
| """ | |
| VectorForge — Professional Image-to-SVG Converter | |
| A modern web app that converts raster images to high-quality SVG vectors | |
| with edge detection, color quantization, smart tracing, and live color editing. | |
| """ | |
| import io | |
| import re | |
| import os | |
| import tempfile | |
| import numpy as np | |
| import cv2 | |
| from PIL import Image, ImageFilter, ImageEnhance | |
| import vtracer | |
| from scour import scour | |
| from sklearn.cluster import MiniBatchKMeans | |
| import gradio as gr | |
| import json | |
| # ─── Constants ─────────────────────────────────────────────────────────────── | |
| MAX_DIMENSION = 2048 | |
| TEMP_DIR = tempfile.mkdtemp() | |
| # ─── Image Preprocessing ──────────────────────────────────────────────────── | |
| def resize_image(img: Image.Image, max_dim: int = MAX_DIMENSION) -> Image.Image: | |
| """Resize image if it exceeds max dimension, preserving aspect ratio.""" | |
| w, h = img.size | |
| if max(w, h) <= max_dim: | |
| return img | |
| scale = max_dim / max(w, h) | |
| new_size = (int(w * scale), int(h * scale)) | |
| return img.resize(new_size, Image.LANCZOS) | |
| def enhance_image(img: Image.Image, contrast: float = 1.0, brightness: float = 1.0, | |
| sharpness: float = 1.0, denoise: bool = False) -> Image.Image: | |
| """Apply image enhancements before conversion.""" | |
| if contrast != 1.0: | |
| img = ImageEnhance.Contrast(img).enhance(contrast) | |
| if brightness != 1.0: | |
| img = ImageEnhance.Brightness(img).enhance(brightness) | |
| if sharpness != 1.0: | |
| img = ImageEnhance.Sharpness(img).enhance(sharpness) | |
| if denoise: | |
| arr = np.array(img) | |
| arr = cv2.fastNlMeansDenoisingColored(arr, None, 10, 10, 7, 21) | |
| img = Image.fromarray(arr) | |
| return img | |
| # ─── Edge Detection Engine ────────────────────────────────────────────────── | |
| def apply_edge_detection(img: Image.Image, method: str = "canny", | |
| low_thresh: int = 50, high_thresh: int = 150, | |
| aperture: int = 3, invert: bool = True) -> Image.Image: | |
| """Apply edge detection to produce a line-art image for tracing.""" | |
| gray = np.array(img.convert("L")) | |
| if method == "Canny": | |
| blurred = cv2.GaussianBlur(gray, (5, 5), 1.4) | |
| edges = cv2.Canny(blurred, low_thresh, high_thresh, | |
| apertureSize=aperture, L2gradient=True) | |
| elif method == "Sobel": | |
| sobelx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=aperture) | |
| sobely = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=aperture) | |
| edges = np.uint8(np.clip(np.sqrt(sobelx**2 + sobely**2), 0, 255)) | |
| elif method == "Laplacian": | |
| blurred = cv2.GaussianBlur(gray, (3, 3), 0) | |
| lap = cv2.Laplacian(blurred, cv2.CV_64F, ksize=aperture) | |
| edges = np.uint8(np.clip(np.abs(lap) * 2, 0, 255)) | |
| elif method == "Scharr": | |
| sx = cv2.Scharr(gray, cv2.CV_64F, 1, 0) | |
| sy = cv2.Scharr(gray, cv2.CV_64F, 0, 1) | |
| edges = np.uint8(np.clip(np.sqrt(sx**2 + sy**2), 0, 255)) | |
| elif method == "Adaptive Threshold": | |
| blurred = cv2.GaussianBlur(gray, (5, 5), 0) | |
| edges = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, | |
| cv2.THRESH_BINARY, 11, 2) | |
| invert = not invert # already inverted | |
| elif method == "Sketch (Pencil)": | |
| blurred = cv2.GaussianBlur(gray, (21, 21), 0) | |
| divided = cv2.divide(gray, blurred, scale=256) | |
| edges = divided | |
| invert = False | |
| else: | |
| edges = gray | |
| if invert and method not in ["Sketch (Pencil)"]: | |
| edges = 255 - edges | |
| return Image.fromarray(edges) | |
| # ─── Color Quantization ───────────────────────────────────────────────────── | |
| def quantize_colors(img: Image.Image, n_colors: int = 8) -> Image.Image: | |
| """Reduce image to N colors using MiniBatch KMeans clustering.""" | |
| arr = np.array(img.convert("RGB")) | |
| h, w = arr.shape[:2] | |
| pixels = arr.reshape(-1, 3).astype(np.float32) | |
| km = MiniBatchKMeans(n_clusters=n_colors, n_init=3, random_state=42, batch_size=1024) | |
| labels = km.fit_predict(pixels) | |
| palette = km.cluster_centers_.astype(np.uint8) | |
| quantized = palette[labels].reshape(h, w, 3) | |
| return Image.fromarray(quantized) | |
| # ─── SVG Conversion Engine ────────────────────────────────────────────────── | |
| def convert_to_svg(img: Image.Image, colormode: str = "color", | |
| hierarchical: str = "stacked", mode: str = "spline", | |
| filter_speckle: int = 4, color_precision: int = 6, | |
| layer_difference: int = 16, corner_threshold: int = 60, | |
| length_threshold: float = 4.0, max_iterations: int = 10, | |
| splice_threshold: int = 45, path_precision: int = 5) -> str: | |
| """Convert PIL Image to SVG string using vtracer.""" | |
| img_byte_array = io.BytesIO() | |
| img.save(img_byte_array, format='PNG') | |
| img_bytes = img_byte_array.getvalue() | |
| svg_str = vtracer.convert_raw_image_to_svg( | |
| img_bytes, | |
| img_format='png', | |
| colormode=colormode, | |
| hierarchical=hierarchical, | |
| mode=mode, | |
| filter_speckle=int(filter_speckle), | |
| color_precision=int(color_precision), | |
| layer_difference=int(layer_difference), | |
| corner_threshold=int(corner_threshold), | |
| length_threshold=float(length_threshold), | |
| max_iterations=int(max_iterations), | |
| splice_threshold=int(splice_threshold), | |
| path_precision=int(path_precision) | |
| ) | |
| return svg_str | |
| def optimize_svg(svg_str: str) -> str: | |
| """Optimize SVG with scour for smaller file size.""" | |
| try: | |
| options = scour.parse_args([ | |
| '--enable-viewboxing', | |
| '--enable-id-stripping', | |
| '--enable-comment-stripping', | |
| '--shorten-ids', | |
| '--remove-metadata', | |
| '--set-precision=3', | |
| ]) | |
| options.infilename = None | |
| options.outfilename = None | |
| return scour.scourString(svg_str, options) | |
| except Exception: | |
| return svg_str | |
| def extract_svg_colors(svg_str: str) -> list: | |
| """Extract unique fill/stroke colors from SVG.""" | |
| colors = re.findall(r'(?:fill|stroke)="(#[0-9a-fA-F]{3,8})"', svg_str) | |
| # Normalize to 6-digit hex | |
| normalized = [] | |
| for c in colors: | |
| if len(c) == 4: # #rgb | |
| c = f"#{c[1]*2}{c[2]*2}{c[3]*2}" | |
| normalized.append(c.upper()) | |
| return list(dict.fromkeys(normalized)) # preserve order, deduplicate | |
| def replace_svg_color(svg_str: str, old_color: str, new_color: str) -> str: | |
| """Replace a color in SVG string (case-insensitive).""" | |
| pattern = re.compile(re.escape(old_color), re.IGNORECASE) | |
| return pattern.sub(new_color, svg_str) | |
| # ─── Main Pipeline ────────────────────────────────────────────────────────── | |
| def process_image( | |
| image, conversion_mode, | |
| # Enhancement | |
| contrast, brightness, sharpness, denoise, | |
| # Edge Detection | |
| edge_method, edge_low, edge_high, edge_aperture, | |
| # Tracing | |
| hierarchical, curve_mode, filter_speckle, color_precision, | |
| layer_difference, corner_threshold, length_threshold, | |
| max_iterations, splice_threshold, path_precision, | |
| # Color | |
| num_colors, pre_quantize, | |
| # Output | |
| optimize, stroke_width, | |
| progress=gr.Progress() | |
| ): | |
| """Main conversion pipeline.""" | |
| if image is None: | |
| gr.Warning("Please upload an image first!") | |
| return None, None, None, None, "[]" | |
| progress(0.1, desc="Preparing image...") | |
| img = resize_image(image) | |
| original = img.copy() | |
| # Apply enhancements | |
| progress(0.2, desc="Enhancing image...") | |
| img = enhance_image(img, contrast, brightness, sharpness, denoise) | |
| if conversion_mode == "🖤 Black & White": | |
| progress(0.4, desc="Converting to B&W SVG...") | |
| colormode = "binary" | |
| svg_str = convert_to_svg( | |
| img, colormode="binary", hierarchical=hierarchical.lower(), | |
| mode=curve_mode.lower(), filter_speckle=filter_speckle, | |
| color_precision=color_precision, layer_difference=layer_difference, | |
| corner_threshold=corner_threshold, length_threshold=length_threshold, | |
| max_iterations=max_iterations, splice_threshold=splice_threshold, | |
| path_precision=path_precision | |
| ) | |
| elif conversion_mode == "🎨 Color": | |
| progress(0.3, desc="Quantizing colors..." if pre_quantize else "Tracing colors...") | |
| if pre_quantize: | |
| img = quantize_colors(img, n_colors=num_colors) | |
| progress(0.5, desc="Converting to color SVG...") | |
| svg_str = convert_to_svg( | |
| img, colormode="color", hierarchical=hierarchical.lower(), | |
| mode=curve_mode.lower(), filter_speckle=filter_speckle, | |
| color_precision=color_precision, layer_difference=layer_difference, | |
| corner_threshold=corner_threshold, length_threshold=length_threshold, | |
| max_iterations=max_iterations, splice_threshold=splice_threshold, | |
| path_precision=path_precision | |
| ) | |
| elif conversion_mode == "✏️ Edge Art": | |
| progress(0.3, desc=f"Detecting edges ({edge_method})...") | |
| edge_img = apply_edge_detection( | |
| img, method=edge_method, | |
| low_thresh=edge_low, high_thresh=edge_high, | |
| aperture=edge_aperture, invert=True | |
| ) | |
| progress(0.5, desc="Tracing edges to SVG...") | |
| svg_str = convert_to_svg( | |
| edge_img, colormode="binary", hierarchical="stacked", | |
| mode=curve_mode.lower(), filter_speckle=filter_speckle, | |
| color_precision=1, layer_difference=layer_difference, | |
| corner_threshold=corner_threshold, length_threshold=length_threshold, | |
| max_iterations=max_iterations, splice_threshold=splice_threshold, | |
| path_precision=path_precision | |
| ) | |
| elif conversion_mode == "🌈 Smart Color (Edge + Fill)": | |
| progress(0.2, desc="Detecting edges...") | |
| edge_img = apply_edge_detection( | |
| img, method=edge_method, | |
| low_thresh=edge_low, high_thresh=edge_high, | |
| aperture=edge_aperture, invert=True | |
| ) | |
| progress(0.35, desc="Tracing edge outlines...") | |
| edge_svg = convert_to_svg( | |
| edge_img, colormode="binary", hierarchical="stacked", | |
| mode=curve_mode.lower(), filter_speckle=filter_speckle, | |
| color_precision=1, layer_difference=layer_difference, | |
| corner_threshold=corner_threshold, length_threshold=length_threshold, | |
| max_iterations=max_iterations, splice_threshold=splice_threshold, | |
| path_precision=path_precision | |
| ) | |
| progress(0.55, desc="Quantizing & tracing color fills...") | |
| if pre_quantize: | |
| color_img = quantize_colors(img, n_colors=num_colors) | |
| else: | |
| color_img = img | |
| color_svg = convert_to_svg( | |
| color_img, colormode="color", hierarchical="stacked", | |
| mode=curve_mode.lower(), filter_speckle=max(filter_speckle, 6), | |
| color_precision=color_precision, layer_difference=layer_difference, | |
| corner_threshold=corner_threshold, length_threshold=max(length_threshold, 5.0), | |
| max_iterations=max_iterations, splice_threshold=splice_threshold, | |
| path_precision=path_precision | |
| ) | |
| # Merge: color fills as background + edge lines on top | |
| progress(0.7, desc="Merging layers...") | |
| w, h = img.size | |
| # Extract inner content from both SVGs | |
| color_inner = re.search(r'<svg[^>]*>(.*)</svg>', color_svg, re.DOTALL) | |
| edge_inner = re.search(r'<svg[^>]*>(.*)</svg>', edge_svg, re.DOTALL) | |
| color_content = color_inner.group(1) if color_inner else "" | |
| edge_content = edge_inner.group(1) if edge_inner else "" | |
| # Adjust edge strokes | |
| sw = stroke_width | |
| edge_content = edge_content.replace('fill="black"', f'fill="none" stroke="#222" stroke-width="{sw}"') | |
| edge_content = edge_content.replace('fill="#000000"', f'fill="none" stroke="#222" stroke-width="{sw}"') | |
| svg_str = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}"> | |
| <g id="color-fills">{color_content}</g> | |
| <g id="edge-lines" opacity="0.7">{edge_content}</g> | |
| </svg>''' | |
| elif conversion_mode == "🔲 Posterize": | |
| progress(0.3, desc="Posterizing...") | |
| # Reduce to N levels per channel | |
| arr = np.array(img.convert("RGB")).astype(np.float32) | |
| levels = max(2, num_colors) | |
| arr = np.floor(arr / 256 * levels) / levels * 255 | |
| posterized = Image.fromarray(arr.astype(np.uint8)) | |
| progress(0.5, desc="Tracing posterized image...") | |
| svg_str = convert_to_svg( | |
| posterized, colormode="color", hierarchical=hierarchical.lower(), | |
| mode=curve_mode.lower(), filter_speckle=filter_speckle, | |
| color_precision=color_precision, layer_difference=layer_difference, | |
| corner_threshold=corner_threshold, length_threshold=length_threshold, | |
| max_iterations=max_iterations, splice_threshold=splice_threshold, | |
| path_precision=path_precision | |
| ) | |
| else: # Default to color | |
| svg_str = convert_to_svg(img, colormode="color") | |
| # Optimize SVG | |
| progress(0.8, desc="Optimizing SVG...") | |
| if optimize: | |
| svg_str = optimize_svg(svg_str) | |
| # Extract colors for editor | |
| progress(0.9, desc="Extracting colors...") | |
| colors = extract_svg_colors(svg_str) | |
| # Compute stats | |
| svg_bytes = len(svg_str.encode('utf-8')) | |
| path_count = svg_str.count('<path') | |
| if svg_bytes > 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'''<div id="svg-preview-container" style=" | |
| background: repeating-conic-gradient(#e8e8e8 0% 25%, white 0% 50%) 50% / 20px 20px; | |
| border-radius: 12px; padding: 16px; | |
| display: flex; align-items: center; justify-content: center; | |
| min-height: 300px; max-height: 600px; overflow: auto; | |
| "> | |
| <div id="svg-render" style="max-width:100%; max-height:560px;"> | |
| {svg_str} | |
| </div> | |
| </div>''' | |
| def build_color_editor_html(colors: list) -> str: | |
| """Build HTML color editor with interactive pickers.""" | |
| if not colors: | |
| return '<p style="color:#888;text-align:center;padding:20px;">No colors detected in SVG</p>' | |
| pickers_html = "" | |
| for i, color in enumerate(colors[:30]): # Limit to 30 colors | |
| pickers_html += f''' | |
| <div style="display:flex;align-items:center;gap:8px;margin:4px 0;padding:6px 10px; | |
| background:rgba(255,255,255,0.05);border-radius:8px;"> | |
| <input type="color" value="{color}" | |
| data-original="{color}" data-index="{i}" | |
| style="width:36px;height:36px;border:2px solid rgba(255,255,255,0.2); | |
| border-radius:6px;cursor:pointer;padding:0;background:none;" | |
| onchange="updateColorPreview(this)"> | |
| <code style="font-size:13px;color:#ccc;font-family:monospace;">{color}</code> | |
| </div>''' | |
| return f''' | |
| <div style="max-height:400px;overflow-y:auto;padding:4px;"> | |
| <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:4px;"> | |
| {pickers_html} | |
| </div> | |
| <div style="margin-top:12px;text-align:center;"> | |
| <button onclick="applyColorChanges()" | |
| style="background:linear-gradient(135deg,#7c3aed,#2563eb);color:white; | |
| border:none;padding:10px 28px;border-radius:8px;cursor:pointer; | |
| font-weight:600;font-size:14px; | |
| box-shadow:0 4px 14px rgba(124,58,237,0.4); | |
| transition:all 0.2s ease;"> | |
| 🎨 Apply Color Changes | |
| </button> | |
| <button onclick="resetColors()" | |
| style="background:rgba(255,255,255,0.1);color:#ccc; | |
| border:1px solid rgba(255,255,255,0.2);padding:10px 20px; | |
| border-radius:8px;cursor:pointer;font-size:14px;margin-left:8px; | |
| transition:all 0.2s ease;"> | |
| ↺ Reset | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| function updateColorPreview(input) {{ | |
| // Live preview: update SVG in real-time | |
| const svgContainer = document.querySelector('#svg-render'); | |
| if (!svgContainer) return; | |
| const svg = svgContainer.querySelector('svg'); | |
| if (!svg) return; | |
| const oldColor = input.dataset.original; | |
| const newColor = input.value; | |
| svg.querySelectorAll('*').forEach(el => {{ | |
| ['fill', 'stroke', 'style'].forEach(attr => {{ | |
| const val = el.getAttribute(attr); | |
| if (val && val.toLowerCase().includes(oldColor.toLowerCase())) {{ | |
| el.setAttribute(attr, val.replace(new RegExp(oldColor.replace('#', '\\\\#'), 'gi'), newColor)); | |
| }} | |
| }}); | |
| }}); | |
| input.dataset.original = newColor; | |
| }} | |
| function applyColorChanges() {{ | |
| // Trigger the Gradio event to update the SVG data | |
| const inputs = document.querySelectorAll('input[type="color"][data-index]'); | |
| const changes = {{}}; | |
| inputs.forEach(inp => {{ | |
| if (inp.value !== inp.dataset.original) {{ | |
| changes[inp.dataset.original] = inp.value; | |
| }} | |
| }}); | |
| // The live preview already applies changes in real-time | |
| const btn = event.target; | |
| btn.textContent = '✅ Applied!'; | |
| btn.style.background = 'linear-gradient(135deg,#059669,#0d9488)'; | |
| setTimeout(() => {{ | |
| btn.textContent = '🎨 Apply Color Changes'; | |
| btn.style.background = 'linear-gradient(135deg,#7c3aed,#2563eb)'; | |
| }}, 1500); | |
| }} | |
| function resetColors() {{ | |
| window.location.reload(); | |
| }} | |
| </script>''' | |
| # ─── 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(''' | |
| <div class="app-title"> | |
| <h1>⚡ VectorForge</h1> | |
| </div> | |
| <div class="app-subtitle"> | |
| Professional Image → SVG Converter · Edge Detection · Color Quantization · Live Editing | |
| </div> | |
| ''') | |
| 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='''<div style="display:flex;align-items:center;justify-content:center; | |
| min-height:400px;border-radius:16px; | |
| background:rgba(255,255,255,0.02); | |
| border:2px dashed rgba(255,255,255,0.08);"> | |
| <div style="text-align:center;color:#555;"> | |
| <p style="font-size:3em;margin:0;">🎨</p> | |
| <p style="font-size:1.1em;margin-top:8px;">SVG preview will appear here</p> | |
| </div> | |
| </div>''', | |
| 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='<p style="color:#888;text-align:center;padding:20px;">Convert an image first to edit colors</p>' | |
| ) | |
| # ── Footer ── | |
| gr.HTML(''' | |
| <div class="footer"> | |
| <p>Built with ❤️ using <strong>VTracer</strong> · <strong>OpenCV</strong> · <strong>Scour</strong> · <strong>Gradio</strong></p> | |
| <p>Powered by Hugging Face Spaces</p> | |
| </div> | |
| ''') | |
| # ── 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, | |
| ) |