File size: 7,594 Bytes
5ddec87
 
ea23993
5ddec87
 
10966fd
ea23993
10966fd
5ddec87
 
 
2792611
5ddec87
2792611
 
 
 
 
23e2090
 
2792611
 
23e2090
ea23993
23e2090
2792611
 
23e2090
 
 
2792611
 
23e2090
ea23993
2792611
10966fd
2792611
23e2090
2792611
 
23e2090
2792611
 
23e2090
 
5ddec87
23e2090
 
 
5cd7253
23e2090
 
 
5cd7253
23e2090
 
 
 
 
5cd7253
23e2090
 
2792611
23e2090
 
 
2792611
23e2090
 
 
5ddec87
23e2090
 
 
 
2792611
23e2090
2792611
23e2090
 
 
 
10966fd
2792611
23e2090
 
 
 
5ddec87
23e2090
 
 
 
2792611
23e2090
 
 
2792611
23e2090
 
 
 
 
 
 
ea23993
23e2090
 
 
 
 
2792611
10966fd
23e2090
10966fd
23e2090
 
10966fd
23e2090
10966fd
2792611
23e2090
 
 
 
 
 
 
 
 
 
 
 
 
5ddec87
 
23e2090
5ddec87
 
2792611
23e2090
5ddec87
 
23e2090
2792611
23e2090
 
 
 
2792611
23e2090
2792611
23e2090
2792611
23e2090
2792611
23e2090
 
 
 
 
2792611
 
23e2090
2792611
 
 
 
23e2090
2792611
23e2090
 
2792611
 
23e2090
 
 
 
2792611
868cb43
2792611
23e2090
2792611
23e2090
 
2792611
 
23e2090
 
 
 
 
 
2792611
868cb43
23e2090
868cb43
2792611
23e2090
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
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()