Spaces:
Sleeping
Sleeping
import cv2 | |
import numpy as np | |
import json | |
import math | |
import random | |
import gradio as gr | |
# Set random seed for reproducibility | |
random.seed(42) | |
np.random.seed(42) | |
# --- Utility Functions --- | |
def generate_id(prefix="el"): | |
"""Generate a random ID for Excalidraw elements.""" | |
return f"{prefix}_{''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=9))}" | |
def bgr_to_hex(bgr_color): | |
"""Convert BGR color tuple to a hex string.""" | |
b, g, r = [int(c) for c in bgr_color] | |
return f"#{r:02x}{g:02x}{b:02x}" | |
def simplify_contour(contour, epsilon_factor=0.002): | |
""" | |
Simplifies a contour to create smooth, crisp lines using a small epsilon factor. | |
Args: | |
contour: OpenCV contour (numpy array of points). | |
epsilon_factor: Factor to determine the approximation accuracy. A smaller | |
value results in more points and a line closer to the original shape. | |
Returns: | |
List of [x, y] coordinate pairs representing the simplified line. | |
""" | |
if len(contour) < 2: | |
return [] | |
# Calculate epsilon based on the contour's perimeter for responsive simplification | |
epsilon = epsilon_factor * cv2.arcLength(contour, True) | |
# Apply the Douglas-Peucker algorithm to simplify the contour | |
simplified = cv2.approxPolyDP(contour, epsilon, True) | |
# Convert from OpenCV's format (n, 1, 2) to a simple list of [x, y] points | |
return [[float(point[0][0]), float(point[0][1])] for point in simplified] | |
def image_to_excalidraw_json(image_np, min_shape_size): | |
""" | |
Main function to convert an image with a black background into Excalidraw JSON. | |
Args: | |
image_np: The input image as a NumPy array (from Gradio). | |
min_shape_size: The minimum area for a contour to be considered a shape. | |
Returns: | |
A string containing the Excalidraw JSON, or an error message. | |
""" | |
if image_np is None: | |
return "Please upload an image to begin." | |
# Convert the input image from RGB (Gradio) to BGR (OpenCV) | |
img_bgr = cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR) | |
# --- Shape Detection --- | |
# Convert the image to grayscale to easily find non-black areas | |
img_gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) | |
# Threshold the image to create a binary mask. All pixels that are not black (value > 5) | |
# will be turned white (255). This isolates the shapes from the background. | |
_, binary_mask = cv2.threshold(img_gray, 5, 255, cv2.THRESH_BINARY) | |
# Find contours (outlines of the shapes) in the binary mask | |
# RETR_EXTERNAL gets only the outer contours, which is what we want. | |
# CHAIN_APPROX_SIMPLE compresses horizontal, vertical, and diagonal segments, saving memory. | |
contours, _ = cv2.findContours(binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
excalidraw_elements = [] | |
# Process each detected contour | |
for contour in contours: | |
# Filter out very small contours that are likely just noise | |
if cv2.contourArea(contour) < min_shape_size: | |
continue | |
# --- Color Extraction --- | |
# Create a mask for the current contour to isolate it | |
mask = np.zeros_like(img_gray) | |
cv2.drawContours(mask, [contour], -1, (255), thickness=cv2.FILLED) | |
# Calculate the average color of the shape from the original color image | |
# The mask ensures we only consider pixels belonging to the current shape. | |
mean_color_bgr = cv2.mean(img_bgr, mask=mask)[:3] | |
stroke_color_hex = bgr_to_hex(mean_color_bgr) | |
# --- Point Simplification --- | |
# Simplify the contour to get smooth points for the Excalidraw line | |
points = simplify_contour(contour) | |
if len(points) < 2: | |
continue | |
# --- Element Creation --- | |
# The first point is the anchor (x, y), subsequent points are relative to it. | |
start_x, start_y = points[0] | |
relative_points = [[p[0] - start_x, p[1] - start_y] for p in points] | |
# Define the style for the Excalidraw element | |
element_style = { | |
"strokeColor": stroke_color_hex, | |
"backgroundColor": "transparent", | |
"fillStyle": "hachure", | |
"strokeWidth": 2, | |
"strokeStyle": "solid", | |
"roughness": 0, | |
"opacity": 100, | |
"roundness": {"type": 2}, # Creates smooth, rounded corners | |
"seed": random.randint(1_000_000, 9_999_999), | |
"version": 1, | |
"versionNonce": random.randint(1_000_000, 9_999_999), | |
} | |
# Create the Excalidraw line element dictionary | |
line_element = { | |
"id": generate_id("line"), | |
"type": "line", | |
"x": float(start_x), | |
"y": float(start_y), | |
"angle": 0, | |
"points": relative_points, | |
**element_style, | |
} | |
excalidraw_elements.append(line_element) | |
# Create the final Excalidraw clipboard structure | |
final_excalidraw_structure = { | |
"type": "excalidraw/clipboard", | |
"elements": excalidraw_elements, | |
"files": {} | |
} | |
# Convert the Python dictionary to a nicely formatted JSON string | |
return json.dumps(final_excalidraw_structure, indent=2) | |
# --- Gradio User Interface --- | |
def create_interface(): | |
"""Creates and configures the Gradio web interface.""" | |
with gr.Blocks(title="Image to Excalidraw", theme=gr.themes.Soft()) as iface: | |
gr.Markdown("# ✏️ Image to Excalidraw Converter") | |
gr.Markdown("Upload an image with colored shapes on a **black background**. This tool will trace the shapes and generate Excalidraw JSON for you to copy and paste.") | |
with gr.Row(variant="panel"): | |
with gr.Column(scale=1): | |
image_input = gr.Image(type="numpy", label="Upload Your Image") | |
min_shape_size = gr.Slider( | |
minimum=10, | |
maximum=1000, | |
value=50, | |
step=10, | |
label="Minimum Shape Size", | |
info="Filters out small noise. Increase if you see unwanted tiny specks." | |
) | |
process_btn = gr.Button("✅ Generate Excalidraw Code", variant="primary") | |
with gr.Column(scale=2): | |
output_json = gr.Textbox( | |
label="Excalidraw JSON Output", | |
info="Click to copy the JSON, then paste it directly into Excalidraw (Ctrl+V or Cmd+V).", | |
lines=20, | |
max_lines=30, | |
show_copy_button=True | |
) | |
# Connect the button click event to the processing function | |
process_btn.click( | |
fn=image_to_excalidraw_json, | |
inputs=[image_input, min_shape_size], | |
outputs=output_json | |
) | |
# For better user experience, also trigger processing when the image or slider changes | |
image_input.change( | |
fn=image_to_excalidraw_json, | |
inputs=[image_input, min_shape_size], | |
outputs=output_json | |
) | |
min_shape_size.release( | |
fn=image_to_excalidraw_json, | |
inputs=[image_input, min_shape_size], | |
outputs=output_json | |
) | |
return iface | |
# --- Launch the App --- | |
if __name__ == "__main__": | |
app = create_interface() | |
app.launch() | |