AkshitShubham commited on
Commit
2792611
·
verified ·
1 Parent(s): fe6deb2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +362 -235
app.py CHANGED
@@ -4,299 +4,426 @@ import json
4
  import math
5
  import random
6
  import gradio as gr
 
7
 
8
  # Set random seed for reproducibility
9
  random.seed(42)
10
  np.random.seed(42)
11
 
12
- # --- Image Processing and Shape Detection ---
13
 
14
- def detect_and_trace_shapes(img_bgr_np, canny_low_threshold, canny_high_threshold,
15
- shape_detection_tolerance, min_contour_area, output_mode):
 
 
 
 
 
 
 
 
16
  """
17
- Detects shapes and lines of all colors in a BGR numpy image and converts them
18
- into Excalidraw elements.
 
 
 
 
 
 
19
  """
20
- if img_bgr_np is None:
21
  return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- # Convert to grayscale for edge detection
24
- gray = cv2.cvtColor(img_bgr_np, cv2.COLOR_BGR2GRAY)
 
 
 
 
 
 
 
25
 
26
- # Apply Gaussian blur to reduce noise and help with edge detection
27
- blurred = cv2.GaussianBlur(gray, (5, 5), 0)
 
 
 
 
 
 
 
 
28
 
29
- # Canny Edge Detection
30
- edges = cv2.Canny(blurred, canny_low_threshold, canny_high_threshold)
 
 
 
 
 
 
 
 
 
 
31
 
32
- # Find contours from the edges
33
- contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
 
 
 
 
 
34
 
35
  elements = []
36
-
37
- for contour in contours:
38
- # Filter out small contours (noise)
39
- if cv2.contourArea(contour) < min_contour_area:
 
 
 
 
40
  continue
41
-
42
- # Get bounding box to sample color
43
- x, y, w, h = cv2.boundingRect(contour)
44
-
45
- # Sample average color from the original image within the bounding box
46
- # Ensure coordinates are within image bounds
47
- x1, y1 = max(0, x), max(0, y)
48
- x2, y2 = min(img_bgr_np.shape[1], x + w), min(img_bgr_np.shape[0], y + h)
49
 
50
- if x2 <= x1 or y2 <= y1: # Handle empty or invalid bounding boxes
 
51
  continue
52
-
53
- color_region = img_bgr_np[y1:y2, x1:x2]
54
- if color_region.size == 0:
55
- avg_color_bgr = [0, 0, 0] # Default to black if region is empty
56
- else:
57
- avg_color_bgr = cv2.mean(color_region)[:3] # Get BGR values
58
 
59
- # Convert BGR to hex color string
60
- stroke_color = f"#{int(avg_color_bgr[2]):02x}{int(avg_color_bgr[1]):02x}{int(avg_color_bgr[0]):02x}"
 
 
 
 
 
 
 
 
61
 
62
- # Default style properties (from user's example)
63
  default_style = {
64
  "fillStyle": "cross-hatch",
65
- "strokeWidth": 1,
66
  "strokeStyle": "solid",
67
  "roughness": 1,
68
  "opacity": 100,
69
- "strokeColor": stroke_color, # Use detected color
70
- "backgroundColor": "transparent", # Default to transparent for shapes unless filled
71
- "roundness": {"type": 3}, # Default for rectangles
72
  "seed": random.randint(1000, 1000000),
73
  "version": 1,
74
  "versionNonce": random.randint(1000, 1000000)
75
  }
76
-
77
- if output_mode == "Plain Lines":
78
- # Just create a line element from the simplified contour
79
- simplified_points = simplify_contour_high_fidelity(contour)
80
- if len(simplified_points) < 2:
81
  continue
82
-
83
- # Excalidraw line points are relative to its x,y
84
- start_x, start_y = simplified_points[0][0], simplified_points[0][1]
85
- relative_points = [[p[0] - start_x, p[1] - start_y] for p in simplified_points]
86
-
87
- line_element = {
88
- "id": generate_id("line"), "type": "line",
89
- "x": float(start_x), "y": float(start_y), "angle": 0,
90
- "points": relative_points,
91
- **default_style,
92
- "backgroundColor": "transparent", # Lines don't have background
93
- "roundness": {"type": 2} # Lines often use type 2
94
- }
95
- elements.append(line_element)
96
-
97
- elif output_mode == "Geometric Shapes":
98
- # Try to approximate shapes
99
- epsilon = shape_detection_tolerance * cv2.arcLength(contour, True)
100
- approx_polygon = cv2.approxPolyDP(contour, epsilon, True)
101
 
102
- element_added = False
103
-
104
- # Check for rectangle
105
- if len(approx_polygon) == 4:
106
- # Calculate angles to verify it's roughly a rectangle
107
- # (This is a simplified check, more robust checks involve dot products of vectors)
108
- is_rectangle = True
109
- for i in range(4):
110
- p1 = approx_polygon[i][0]
111
- p2 = approx_polygon[(i + 1) % 4][0]
112
- p3 = approx_polygon[(i + 2) % 4][0]
113
-
114
- v1 = np.array(p2) - np.array(p1)
115
- v2 = np.array(p3) - np.array(p2)
116
-
117
- dot_product = np.dot(v1, v2)
118
- len_v1 = np.linalg.norm(v1)
119
- len_v2 = np.linalg.norm(v2)
120
-
121
- if len_v1 == 0 or len_v2 == 0:
122
- is_rectangle = False
123
- break
124
-
125
- angle_rad = np.arccos(np.clip(dot_product / (len_v1 * len_v2), -1.0, 1.0))
126
- angle_deg = math.degrees(angle_rad)
127
-
128
- # Check if angle is roughly 90 degrees (with tolerance)
129
- if not (80 <= angle_deg <= 100):
130
- is_rectangle = False
131
- break
132
-
133
- if is_rectangle:
134
- # Get rotated bounding box for better fit
135
- rect = cv2.minAreaRect(contour)
136
- box = cv2.boxPoints(rect)
137
- box = box.astype(int) # Corrected: Use .astype(int) instead of np.int0
138
-
139
- # Calculate width, height and angle
140
- width = np.linalg.norm(box[0] - box[1])
141
- height = np.linalg.norm(box[1] - box[2])
142
- angle = rect[2] # angle in degrees
143
-
144
- # Excalidraw angles are in radians, counter-clockwise from positive x-axis
145
- # OpenCV's minAreaRect angle is usually in range [-90, 0)
146
- # Adjust angle for Excalidraw:
147
- if width < height: # If height is the longer side (portrait)
148
- angle += 90
149
- width, height = height, width # Swap to make width the longer side
150
-
151
- angle_rad = math.radians(angle)
152
-
153
- # Center of the rectangle
154
- center_x, center_y = rect[0]
155
-
156
- rect_element = {
157
- "id": generate_id("rect"), "type": "rectangle",
158
- "x": float(center_x - width/2), "y": float(center_y - height/2),
159
- "width": float(width), "height": float(height),
160
- "angle": angle_rad,
161
- **default_style,
162
- "backgroundColor": stroke_color, # Use detected color for background
163
- "fillStyle": "solid" # Default to solid for detected shapes
164
- }
165
- elements.append(rect_element)
166
- element_added = True
167
-
168
- # Check for ellipse/circle if not a rectangle
169
- if not element_added and len(contour) >= 5: # min points for ellipse fitting
170
- (x_center, y_center), (minor_axis, major_axis), angle = cv2.fitEllipse(contour)
171
-
172
- # Simple check for circularity (aspect ratio close to 1)
173
- aspect_ratio = minor_axis / major_axis if major_axis > 0 else 0
174
- if 0.8 <= aspect_ratio <= 1.2: # Treat as ellipse/circle
175
- ellipse_element = {
176
- "id": generate_id("ellipse"), "type": "ellipse",
177
- "x": float(x_center - major_axis/2), "y": float(y_center - major_axis/2),
178
- "width": float(major_axis), "height": float(major_axis), # Use major axis for both for circle
179
- "angle": math.radians(angle),
180
- **default_style,
181
- "backgroundColor": stroke_color, # Use detected color for background
182
- "fillStyle": "solid", # Default to solid for detected shapes
183
- "roundness": {"type": 2} # Ellipses often use type 2
184
- }
185
- elements.append(ellipse_element)
186
- element_added = True
187
-
188
- # Fallback to line if no specific shape detected
189
- if not element_added:
190
- simplified_points = simplify_contour_high_fidelity(contour)
191
  if len(simplified_points) < 2:
192
  continue
 
 
193
  start_x, start_y = simplified_points[0][0], simplified_points[0][1]
194
  relative_points = [[p[0] - start_x, p[1] - start_y] for p in simplified_points]
195
-
196
  line_element = {
197
- "id": generate_id("line"), "type": "line",
198
- "x": float(start_x), "y": float(start_y), "angle": 0,
 
 
 
199
  "points": relative_points,
200
  **default_style,
201
  "backgroundColor": "transparent",
202
  "roundness": {"type": 2}
203
  }
204
  elements.append(line_element)
205
-
206
- return elements
207
-
208
- def simplify_contour_high_fidelity(contour, epsilon_factor=0.01):
209
- """
210
- Simplifies a contour while preserving important features.
211
-
212
- Args:
213
- contour: OpenCV contour (numpy array of points)
214
- epsilon_factor: Factor to multiply with arc length for approximation tolerance
215
- Lower values = more points preserved (higher fidelity)
216
- Higher values = fewer points (more simplified)
217
-
218
- Returns:
219
- List of [x, y] coordinate pairs
220
- """
221
- if len(contour) < 2:
222
- return []
223
-
224
- # Calculate epsilon based on contour perimeter
225
- epsilon = epsilon_factor * cv2.arcLength(contour, True)
226
-
227
- # Apply Douglas-Peucker algorithm for polygon approximation
228
- simplified = cv2.approxPolyDP(contour, epsilon, True)
229
-
230
- # Convert from OpenCV format to simple [x, y] list
231
- points = []
232
- for point in simplified:
233
- x, y = point[0] # OpenCV contours have shape (n, 1, 2)
234
- points.append([float(x), float(y)])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
- return points
237
-
238
- def generate_id(prefix="el"):
239
- """Generate a random ID for Excalidraw elements."""
240
- return f"{prefix}_{''.join(random.choices('abcdefghijklmnopqrstuvwxyz013456789', k=9))}"
241
-
242
- # --- Gradio UI Function ---
243
 
244
- def generate_excalidraw_json_from_image_new(
245
- image_np,
246
- canny_low_threshold,
247
- canny_high_threshold,
248
- shape_detection_tolerance,
249
- min_contour_area,
250
- output_mode
 
251
  ):
252
  """
253
- Main function to generate Excalidraw JSON from an uploaded image with new controls.
254
  """
255
  if image_np is None:
256
  return "Please upload an image to generate Excalidraw elements."
257
-
258
- # Gradio's image input is typically RGB, OpenCV expects BGR
259
  img_bgr = cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR)
260
-
261
- final_elements = detect_and_trace_shapes(
262
- img_bgr,
263
- canny_low_threshold,
264
- canny_high_threshold,
265
- shape_detection_tolerance,
266
- min_contour_area,
267
- output_mode
 
 
268
  )
269
-
 
270
  final_excalidraw_structure = {
271
  "type": "excalidraw/clipboard",
272
  "elements": final_elements,
273
  "files": {}
274
  }
 
275
  return json.dumps(final_excalidraw_structure, indent=2)
276
 
277
- # Gradio interface definition
278
- iface = gr.Interface(
279
- fn=generate_excalidraw_json_from_image_new,
280
- inputs=[
281
- gr.Image(type="numpy", label="Upload Image"),
282
- gr.Slider(minimum=0, maximum=255, value=50, label="Canny Low Threshold", info="Lower values detect more edges."),
283
- gr.Slider(minimum=0, maximum=255, value=150, label="Canny High Threshold", info="Higher values require stronger edges."),
284
- gr.Slider(minimum=0.001, maximum=0.1, value=0.04, label="Shape Detection Tolerance", info="Lower values for more precise polygon approximation. Affects rectangle/ellipse detection."),
285
- gr.Slider(minimum=10, maximum=1000, value=100, label="Minimum Contour Area", info="Filter out small noise contours."),
286
- gr.Dropdown(
287
- label="Output Mode",
288
- choices=["Plain Lines", "Geometric Shapes"],
289
- value="Geometric Shapes",
290
- info="Choose to output raw lines or try to detect geometric shapes."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  )
292
- ],
293
- outputs=gr.Textbox(label="Excalidraw JSON Output (Copy & Paste into Excalidraw)"),
294
- title="Excalidraw Universal Shape Detector & Tracer",
295
- description="Upload an image. This tool will detect shapes and lines of all colors, "
296
- "and convert them into Excalidraw elements. You can control the detection "
297
- "sensitivity and choose between raw lines or approximated geometric shapes."
298
- )
 
 
 
 
 
 
 
 
 
 
299
 
 
300
  if __name__ == "__main__":
301
- iface.launch()
302
-
 
4
  import math
5
  import random
6
  import gradio as gr
7
+ from sklearn.cluster import KMeans
8
 
9
  # Set random seed for reproducibility
10
  random.seed(42)
11
  np.random.seed(42)
12
 
13
+ # --- Utility Functions ---
14
 
15
+ def generate_id(prefix="el"):
16
+ """Generate a random ID for Excalidraw elements."""
17
+ return f"{prefix}_{''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=9))}"
18
+
19
+ def bgr_to_hex(bgr_color):
20
+ """Convert BGR color to hex string."""
21
+ b, g, r = [int(c) for c in bgr_color[:3]]
22
+ return f"#{r:02x}{g:02x}{b:02x}"
23
+
24
+ def simplify_contour_high_fidelity(contour, epsilon_factor=0.01):
25
  """
26
+ Simplifies a contour while preserving important features.
27
+
28
+ Args:
29
+ contour: OpenCV contour (numpy array of points)
30
+ epsilon_factor: Factor to multiply with arc length for approximation tolerance
31
+
32
+ Returns:
33
+ List of [x, y] coordinate pairs
34
  """
35
+ if len(contour) < 2:
36
  return []
37
+
38
+ # Calculate epsilon based on contour perimeter
39
+ epsilon = epsilon_factor * cv2.arcLength(contour, True)
40
+
41
+ # Apply Douglas-Peucker algorithm for polygon approximation
42
+ simplified = cv2.approxPolyDP(contour, epsilon, True)
43
+
44
+ # Convert from OpenCV format to simple [x, y] list
45
+ points = []
46
+ for point in simplified:
47
+ x, y = point[0] # OpenCV contours have shape (n, 1, 2)
48
+ points.append([float(x), float(y)])
49
+
50
+ return points
51
 
52
+ def extract_dominant_colors(image, n_colors=8):
53
+ """Extract dominant colors from the image using K-means clustering."""
54
+ # Reshape image to be a list of pixels
55
+ data = image.reshape((-1, 3))
56
+
57
+ # Remove black pixels (background) for better color detection
58
+ non_black_mask = np.sum(data, axis=1) > 30 # Threshold to exclude near-black pixels
59
+ if np.sum(non_black_mask) > 0:
60
+ data = data[non_black_mask]
61
 
62
+ if len(data) == 0:
63
+ return [(0, 0, 0)] # Return black if no colors found
64
+
65
+ # Apply K-means clustering
66
+ kmeans = KMeans(n_clusters=min(n_colors, len(data)), random_state=42, n_init=10)
67
+ kmeans.fit(data)
68
+
69
+ # Get the colors
70
+ colors = kmeans.cluster_centers_.astype(int)
71
+ return [tuple(color) for color in colors]
72
 
73
+ def create_color_mask(image, target_color, tolerance=50):
74
+ """Create a mask for pixels close to the target color."""
75
+ # Convert target color to numpy array
76
+ target = np.array(target_color, dtype=np.uint8)
77
+
78
+ # Calculate color distance
79
+ diff = np.abs(image.astype(int) - target.astype(int))
80
+ distance = np.sqrt(np.sum(diff**2, axis=2))
81
+
82
+ # Create mask where distance is less than tolerance
83
+ mask = distance < tolerance
84
+ return mask.astype(np.uint8) * 255
85
 
86
+ def detect_shapes_by_color(img_bgr_np, canny_low, canny_high, min_area,
87
+ shape_tolerance, output_mode, color_tolerance):
88
+ """
89
+ Detect shapes by processing each dominant color separately.
90
+ """
91
+ if img_bgr_np is None:
92
+ return []
93
 
94
  elements = []
95
+
96
+ # Extract dominant colors from the image
97
+ dominant_colors = extract_dominant_colors(img_bgr_np, n_colors=6)
98
+
99
+ # Process each dominant color
100
+ for color_bgr in dominant_colors:
101
+ # Skip very dark colors (likely background)
102
+ if sum(color_bgr) < 50:
103
  continue
104
+
105
+ # Create mask for this color
106
+ color_mask = create_color_mask(img_bgr_np, color_bgr, color_tolerance)
 
 
 
 
 
107
 
108
+ # Skip if mask is too small
109
+ if np.sum(color_mask) < min_area:
110
  continue
 
 
 
 
 
 
111
 
112
+ # Apply morphological operations to clean up the mask
113
+ kernel = np.ones((3,3), np.uint8)
114
+ color_mask = cv2.morphologyEx(color_mask, cv2.MORPH_CLOSE, kernel)
115
+ color_mask = cv2.morphologyEx(color_mask, cv2.MORPH_OPEN, kernel)
116
+
117
+ # Find contours in this color mask
118
+ contours, _ = cv2.findContours(color_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
119
+
120
+ # Convert color to hex for Excalidraw
121
+ stroke_color = bgr_to_hex(color_bgr)
122
 
123
+ # Default style properties
124
  default_style = {
125
  "fillStyle": "cross-hatch",
126
+ "strokeWidth": 2,
127
  "strokeStyle": "solid",
128
  "roughness": 1,
129
  "opacity": 100,
130
+ "strokeColor": stroke_color,
131
+ "backgroundColor": "transparent",
132
+ "roundness": {"type": 3},
133
  "seed": random.randint(1000, 1000000),
134
  "version": 1,
135
  "versionNonce": random.randint(1000, 1000000)
136
  }
137
+
138
+ # Process each contour
139
+ for contour in contours:
140
+ if cv2.contourArea(contour) < min_area:
 
141
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
+ if output_mode == "Plain Lines":
144
+ # Create line elements
145
+ simplified_points = simplify_contour_high_fidelity(contour, epsilon_factor=0.005)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  if len(simplified_points) < 2:
147
  continue
148
+
149
+ # Create line element
150
  start_x, start_y = simplified_points[0][0], simplified_points[0][1]
151
  relative_points = [[p[0] - start_x, p[1] - start_y] for p in simplified_points]
152
+
153
  line_element = {
154
+ "id": generate_id("line"),
155
+ "type": "line",
156
+ "x": float(start_x),
157
+ "y": float(start_y),
158
+ "angle": 0,
159
  "points": relative_points,
160
  **default_style,
161
  "backgroundColor": "transparent",
162
  "roundness": {"type": 2}
163
  }
164
  elements.append(line_element)
165
+
166
+ elif output_mode == "Geometric Shapes":
167
+ # Try to detect geometric shapes
168
+ epsilon = shape_tolerance * cv2.arcLength(contour, True)
169
+ approx_polygon = cv2.approxPolyDP(contour, epsilon, True)
170
+
171
+ element_added = False
172
+
173
+ # Check for rectangle (4 corners)
174
+ if len(approx_polygon) == 4:
175
+ # Verify it's roughly rectangular
176
+ is_rectangle = True
177
+ angles = []
178
+
179
+ for i in range(4):
180
+ p1 = approx_polygon[i][0]
181
+ p2 = approx_polygon[(i + 1) % 4][0]
182
+ p3 = approx_polygon[(i + 2) % 4][0]
183
+
184
+ v1 = np.array(p2) - np.array(p1)
185
+ v2 = np.array(p3) - np.array(p2)
186
+
187
+ # Calculate angle between vectors
188
+ if np.linalg.norm(v1) > 0 and np.linalg.norm(v2) > 0:
189
+ cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
190
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
191
+ angle = math.degrees(math.acos(cos_angle))
192
+ angles.append(angle)
193
+
194
+ # Check if angles are close to 90 degrees
195
+ if len(angles) == 4 and all(75 <= angle <= 105 for angle in angles):
196
+ # Create rectangle
197
+ rect = cv2.minAreaRect(contour)
198
+ box = cv2.boxPoints(rect)
199
+
200
+ width = np.linalg.norm(box[0] - box[1])
201
+ height = np.linalg.norm(box[1] - box[2])
202
+ angle = rect[2]
203
+
204
+ if width < height:
205
+ angle += 90
206
+ width, height = height, width
207
+
208
+ center_x, center_y = rect[0]
209
+
210
+ rect_element = {
211
+ "id": generate_id("rect"),
212
+ "type": "rectangle",
213
+ "x": float(center_x - width/2),
214
+ "y": float(center_y - height/2),
215
+ "width": float(width),
216
+ "height": float(height),
217
+ "angle": math.radians(angle),
218
+ **default_style,
219
+ "backgroundColor": stroke_color,
220
+ "fillStyle": "solid"
221
+ }
222
+ elements.append(rect_element)
223
+ element_added = True
224
+
225
+ # Check for circle/ellipse
226
+ if not element_added and len(contour) >= 5:
227
+ try:
228
+ (x_center, y_center), (minor_axis, major_axis), angle = cv2.fitEllipse(contour)
229
+
230
+ # Check if it's roughly circular
231
+ if major_axis > 0:
232
+ aspect_ratio = minor_axis / major_axis
233
+ if 0.7 <= aspect_ratio <= 1.3: # Allow some tolerance for circles
234
+ avg_radius = (minor_axis + major_axis) / 2
235
+
236
+ ellipse_element = {
237
+ "id": generate_id("ellipse"),
238
+ "type": "ellipse",
239
+ "x": float(x_center - avg_radius/2),
240
+ "y": float(y_center - avg_radius/2),
241
+ "width": float(avg_radius),
242
+ "height": float(avg_radius),
243
+ "angle": math.radians(angle),
244
+ **default_style,
245
+ "backgroundColor": stroke_color,
246
+ "fillStyle": "solid",
247
+ "roundness": {"type": 2}
248
+ }
249
+ elements.append(ellipse_element)
250
+ element_added = True
251
+ except:
252
+ pass # Skip if ellipse fitting fails
253
+
254
+ # Fallback to line if no shape detected
255
+ if not element_added:
256
+ simplified_points = simplify_contour_high_fidelity(contour, epsilon_factor=0.005)
257
+ if len(simplified_points) >= 2:
258
+ start_x, start_y = simplified_points[0][0], simplified_points[0][1]
259
+ relative_points = [[p[0] - start_x, p[1] - start_y] for p in simplified_points]
260
+
261
+ line_element = {
262
+ "id": generate_id("line"),
263
+ "type": "line",
264
+ "x": float(start_x),
265
+ "y": float(start_y),
266
+ "angle": 0,
267
+ "points": relative_points,
268
+ **default_style,
269
+ "backgroundColor": "transparent",
270
+ "roundness": {"type": 2}
271
+ }
272
+ elements.append(line_element)
273
 
274
+ return elements
 
 
 
 
 
 
275
 
276
+ def generate_excalidraw_json_from_image(
277
+ image_np,
278
+ canny_low_threshold,
279
+ canny_high_threshold,
280
+ shape_detection_tolerance,
281
+ min_contour_area,
282
+ output_mode,
283
+ color_tolerance
284
  ):
285
  """
286
+ Main function to generate Excalidraw JSON from an uploaded image.
287
  """
288
  if image_np is None:
289
  return "Please upload an image to generate Excalidraw elements."
290
+
291
+ # Convert RGB to BGR for OpenCV
292
  img_bgr = cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR)
293
+
294
+ # Detect shapes by color
295
+ final_elements = detect_shapes_by_color(
296
+ img_bgr,
297
+ canny_low_threshold,
298
+ canny_high_threshold,
299
+ min_contour_area,
300
+ shape_detection_tolerance,
301
+ output_mode,
302
+ color_tolerance
303
  )
304
+
305
+ # Create final Excalidraw structure
306
  final_excalidraw_structure = {
307
  "type": "excalidraw/clipboard",
308
  "elements": final_elements,
309
  "files": {}
310
  }
311
+
312
  return json.dumps(final_excalidraw_structure, indent=2)
313
 
314
+ # Create Gradio interface
315
+ def create_interface():
316
+ with gr.Blocks(title="Excalidraw Color Shape Detector", theme=gr.themes.Soft()) as iface:
317
+ gr.Markdown("# 🎨 Excalidraw Color Shape Detector")
318
+ gr.Markdown("Upload an image with colorful line art, and this tool will detect shapes by color and convert them to Excalidraw elements.")
319
+
320
+ with gr.Row():
321
+ with gr.Column(scale=1):
322
+ image_input = gr.Image(type="numpy", label="Upload Image")
323
+
324
+ gr.Markdown("### Detection Settings")
325
+
326
+ output_mode = gr.Dropdown(
327
+ label="Output Mode",
328
+ choices=["Plain Lines", "Geometric Shapes"],
329
+ value="Geometric Shapes",
330
+ info="Choose between raw lines or geometric shape detection"
331
+ )
332
+
333
+ color_tolerance = gr.Slider(
334
+ minimum=10,
335
+ maximum=100,
336
+ value=40,
337
+ label="Color Tolerance",
338
+ info="How similar colors are grouped together (lower = more precise)"
339
+ )
340
+
341
+ min_contour_area = gr.Slider(
342
+ minimum=50,
343
+ maximum=2000,
344
+ value=200,
345
+ label="Minimum Shape Area",
346
+ info="Filter out small noise (higher = fewer small shapes)"
347
+ )
348
+
349
+ shape_detection_tolerance = gr.Slider(
350
+ minimum=0.005,
351
+ maximum=0.05,
352
+ value=0.02,
353
+ label="Shape Detection Tolerance",
354
+ info="How precise shape detection should be (lower = more precise)"
355
+ )
356
+
357
+ gr.Markdown("### Advanced Settings")
358
+
359
+ canny_low_threshold = gr.Slider(
360
+ minimum=10,
361
+ maximum=100,
362
+ value=30,
363
+ label="Edge Detection - Low Threshold"
364
+ )
365
+
366
+ canny_high_threshold = gr.Slider(
367
+ minimum=50,
368
+ maximum=200,
369
+ value=100,
370
+ label="Edge Detection - High Threshold"
371
+ )
372
+
373
+ process_btn = gr.Button("🔄 Process Image", variant="primary")
374
+
375
+ with gr.Column(scale=2):
376
+ output_json = gr.Textbox(
377
+ label="Excalidraw JSON Output",
378
+ info="Copy this JSON and paste it into Excalidraw (Ctrl+V)",
379
+ lines=20,
380
+ max_lines=30
381
+ )
382
+
383
+ gr.Markdown("### Instructions:")
384
+ gr.Markdown("""
385
+ 1. Upload an image with colorful shapes or line art
386
+ 2. Adjust the settings if needed:
387
+ - **Color Tolerance**: Lower values for more precise color matching
388
+ - **Minimum Shape Area**: Higher values to filter out noise
389
+ - **Shape Detection**: Lower values for more precise geometric shapes
390
+ 3. Click "Process Image"
391
+ 4. Copy the JSON output and paste it into Excalidraw
392
+ """)
393
+
394
+ # Set up the processing function
395
+ process_btn.click(
396
+ fn=generate_excalidraw_json_from_image,
397
+ inputs=[
398
+ image_input,
399
+ canny_low_threshold,
400
+ canny_high_threshold,
401
+ shape_detection_tolerance,
402
+ min_contour_area,
403
+ output_mode,
404
+ color_tolerance
405
+ ],
406
+ outputs=output_json
407
  )
408
+
409
+ # Example processing on image upload
410
+ image_input.change(
411
+ fn=generate_excalidraw_json_from_image,
412
+ inputs=[
413
+ image_input,
414
+ canny_low_threshold,
415
+ canny_high_threshold,
416
+ shape_detection_tolerance,
417
+ min_contour_area,
418
+ output_mode,
419
+ color_tolerance
420
+ ],
421
+ outputs=output_json
422
+ )
423
+
424
+ return iface
425
 
426
+ # Launch the app
427
  if __name__ == "__main__":
428
+ app = create_interface()
429
+ app.launch()