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