├── .gitignore
├── .gitattributes
├── photo.png
├── thresh.png
├── photo_out.png
├── requirements.txt
├── app.py
├── data.json
├── README.md
├── main.py
├── GUI.py
└── flowchart_recognition.py
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | *.pyc
3 | venv/
4 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/photo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SnoopyDevelops/hand_drawn_flowchart_recognition/HEAD/photo.png
--------------------------------------------------------------------------------
/thresh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SnoopyDevelops/hand_drawn_flowchart_recognition/HEAD/thresh.png
--------------------------------------------------------------------------------
/photo_out.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SnoopyDevelops/hand_drawn_flowchart_recognition/HEAD/photo_out.png
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask~=2.3.2
2 | opencv-python~=4.7.0.72
3 | imutils~=0.5.4
4 | pytesseract~=0.3.10
5 | tabulate~=0.9.0
6 | requests~=2.31.0
7 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | from flask import Flask
2 |
3 | UPLOAD_FOLDER = 'uploads'
4 |
5 | app = Flask(__name__)
6 | app.secret_key = "secret key"
7 | app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
8 | app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
9 |
--------------------------------------------------------------------------------
/data.json:
--------------------------------------------------------------------------------
1 | {"Node": [{"Id": 0, "Name": "DATABASE\n\f", "Position": "Inside", "Shape": "square", "Line": ""}, {"Id": 2, "Name": "LORD BALANCER\n\f", "Position": "Inside", "Shape": "square", "Line": ""}, {"Id": 4, "Name": "SERVER\n\f", "Position": "Inside", "Shape": "square", "Line": ""}]}
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # HandWritten Flowchart Recognition with Flask Backend Service
2 |
3 | Sample Input Image
4 | 
5 |
6 | - command line: python flowchart_recognition.py "image file name" [parameters]
7 | If image file name were not given, it'll be asking image file name
8 |
9 | - GUI: python GUI.py
10 |
11 | - flask backend service: python main.py
12 |
13 | Result
14 |
15 | | Id | Name | Position | Shape | Line |
16 | | - | - | - | - | - |
17 | | 0 | DATABASE| Inside | square | |
18 | | 2 | LORD BALANCER | Inside | square | DATABASE |
19 | | 4 | LMN | Inside | square | LORD BALANCER|
20 |
21 | * Position: inside / outside
22 | * Shape: triangle / square / circle
23 | * Line: connected nodes to go
24 |
25 | parameters:
26 |
27 | - padding: the distance range between inside text and boundingRect of shapes
28 | - offset: used to decide direction(AC or BD) of curved lines
29 | - arrow: length of arrow
30 |
31 | Output Image for Sample Input Image
32 | 
33 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from flask import request, jsonify
4 | from werkzeug.utils import secure_filename
5 |
6 | from app import app
7 | from flowchart_recognition import flowchart
8 |
9 | ALLOWED_EXTENSIONS = {'jpg', 'png'}
10 |
11 |
12 | def allowed_file(filename):
13 | return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
14 |
15 |
16 | @app.route('/', methods=['POST'])
17 | def upload_file():
18 | # check if the post request has the file part
19 | if 'file' not in request.files:
20 | resp = jsonify({'message': 'No file part in the request', 'status': 400})
21 | resp.status_code = 400
22 | return resp
23 |
24 | file = request.files['file']
25 | if file.filename == '':
26 | resp = jsonify({'message': 'No file selected for uploading', 'status': 400})
27 | resp.status_code = 400
28 | return resp
29 |
30 | if file and allowed_file(file.filename):
31 | filename = secure_filename(file.filename)
32 | path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
33 | # path = secure_filename(file.filename)
34 | file.save(path)
35 |
36 | if '-p' in request.values.keys():
37 | padding = int(request.values['-p'])
38 | else:
39 | padding = 25
40 |
41 | if '-o' in request.values.keys():
42 | offset = int(request.values['-o'])
43 | else:
44 | offset = 10
45 |
46 | if '-a' in request.values.keys():
47 | arrow = int(request.values['-a'])
48 | else:
49 | arrow = 30
50 |
51 | nodes = flowchart(
52 | filename=path, padding=padding, offset=offset, arrow=arrow, gui=False
53 | )
54 |
55 | os.remove(path)
56 | resp = jsonify({'message': 'File successfully uploaded', 'data': nodes, 'status': 200})
57 | resp.status_code = 201
58 | return resp
59 |
60 | else:
61 | resp = jsonify({'message': 'Allowed file types are jpg and png', 'status': 400})
62 | resp.status_code = 400
63 | return resp
64 |
65 |
66 | if __name__ == "__main__":
67 | app.run(host="0.0.0.0", port=80)
68 |
--------------------------------------------------------------------------------
/GUI.py:
--------------------------------------------------------------------------------
1 | from tkinter import Tk, Label, Button, filedialog, Menu, Toplevel
2 |
3 | import cv2
4 |
5 | from flowchart_recognition import flowchart
6 |
7 |
8 | def alert_popup(title, message):
9 | """Generate a pop-up window for special messages."""
10 | root = Tk()
11 | root.title(title)
12 | w, h = 400, 200 # popup window width and height
13 | sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
14 | x, y = (sw - w) / 2, (sh - h) / 2
15 | root.geometry('%dx%d+%d+%d' % (w, h, x, y))
16 | m = message + '\n'
17 | w = Label(root, text=m, width=50, height=10)
18 | w.pack()
19 | b = Button(root, text="OK", command=root.destroy, width=5)
20 | b.pack()
21 |
22 |
23 | def database():
24 | # pytesseract.tesseract_cmd = 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe'
25 | try:
26 | filename = root.filename
27 | except:
28 | alert_popup('Error message!!!', 'Select a file first')
29 | return
30 | if filename == '':
31 | alert_popup('Error message!!!', 'Select a file first')
32 | return
33 |
34 | popup = Toplevel()
35 | Label(popup, text="Processing..").grid(row=0, column=0)
36 |
37 | popup.pack_slaves()
38 | popup.update()
39 | flowchart(filename)
40 | popup.destroy()
41 |
42 | root.filename = ''
43 | alert_popup('Completed', 'Your task has been completed..')
44 |
45 |
46 | def OpenFile():
47 | root.filename = filedialog.askopenfilename(
48 | title="Select file",
49 | filetypes=(("jpeg files", "*.jpg"), ("png files", "*.png"), ("all files", "*.*"))
50 | )
51 | cv2.namedWindow("output", cv2.WINDOW_NORMAL) # Create window with freedom of dimensions
52 | cv2.imshow("output", cv2.resize(cv2.imread(root.filename), (2000, 750))) # Show image
53 | cv2.waitKey(0)
54 |
55 |
56 | def About():
57 | popup = Toplevel()
58 | Label(popup, text="Recognition of Hand Drawn Flowcharts").grid(row=10, column=1)
59 |
60 |
61 | def show():
62 | root.filename = filedialog.askopenfilename(
63 | title="Select file",
64 | filetypes=(("png files", "*.png"), ("jpeg files", "*.jpg"), ("all files", "*.*"))
65 | )
66 |
67 |
68 | if __name__ == "__main__":
69 | root = Tk()
70 | root.geometry('500x400')
71 | root.title("hand_drawn_flowchart_recognition")
72 |
73 | menu = Menu(root)
74 | root.config(menu=menu)
75 |
76 | filemenu = Menu(menu)
77 | menu.add_cascade(label="File", menu=filemenu)
78 |
79 | filemenu.add_command(label="Open...", command=OpenFile)
80 | filemenu.add_separator()
81 |
82 | filemenu.add_command(label="Exit", command=root.destroy)
83 |
84 | help_menu = Menu(menu)
85 | menu.add_cascade(label="Help", menu=help_menu)
86 | help_menu.add_command(label="About...")
87 |
88 | label_0 = Label(root, text="Hand-Drawn Flowchart \n Recognition", width=20, font=("bold", 20))
89 | label_0.place(x=100, y=50)
90 |
91 | Button(root, text='Select Image', width=30, bg='blue', fg='white', command=show).place(x=150, y=150)
92 | Button(root, text='Start', width=30, bg='brown', fg='white', command=database).place(x=150, y=250)
93 |
94 | root.mainloop()
95 |
--------------------------------------------------------------------------------
/flowchart_recognition.py:
--------------------------------------------------------------------------------
1 | import os
2 | from argparse import ArgumentParser
3 | from json import dump
4 | from math import sqrt
5 | from operator import itemgetter
6 | from sys import argv
7 |
8 | import cv2
9 | import numpy as np
10 | from imutils import resize
11 | from numpy.linalg import norm
12 | from pytesseract import pytesseract
13 | from tabulate import tabulate
14 |
15 |
16 | def contour_circumstance(contour):
17 | temp, circumstance = contour[0], 0
18 | for point in contour[1:]:
19 | circumstance += sqrt((point[0][0] - temp[0][0]) ** 2 + (point[0][1] - temp[0][1]) ** 2)
20 | temp = point
21 | return circumstance
22 |
23 |
24 | def distance(A, B, P):
25 | """ segment line AB, point P, where each one is an array([x, y]) """
26 | if np.arccos(np.dot((P - A) / norm(P - A), (B - A) / norm(B - A))) > np.pi / 2:
27 | return norm(P - A)
28 | if np.arccos(np.dot((P - B) / norm(P - B), (A - B) / norm(A - B))) > np.pi / 2:
29 | return norm(P - B)
30 | return norm(np.cross(A - B, A - P)) / norm(B - A)
31 |
32 |
33 | def flowchart(filename, padding=25, offset=10, arrow=30, gui=True):
34 | img = cv2.imread(filename, 0)
35 |
36 | thresh = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 111, 48)
37 | cv2.imwrite('thresh.png', thresh)
38 | img = cv2.imread('thresh.png')
39 |
40 | image = img.copy()
41 | blur = cv2.GaussianBlur(image, (5, 5), 0)
42 |
43 | # resize the image
44 | resized = resize(blur, width=2 * image.shape[1])
45 | ratio = 0.5
46 |
47 | # convert the resized image to grayscale
48 | gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
49 |
50 | # histogram
51 | clahe = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(4, 4))
52 | cl = clahe.apply(gray)
53 |
54 | denoised_cl = cv2.fastNlMeansDenoising(cl, None, 270, 7, 21)
55 |
56 | # Otsu's thresholding
57 | blur = cv2.GaussianBlur(denoised_cl, (25, 25), 0)
58 |
59 | _, thresh = cv2.threshold(blur, 10, 100, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
60 |
61 | # detect edges
62 | edge = cv2.Canny(thresh, 0, 255, apertureSize=3)
63 |
64 | # opening image for less noise
65 | blur = cv2.GaussianBlur(edge, (9, 9), 0)
66 | kernel = np.ones((3, 3), np.uint8)
67 | opening = cv2.morphologyEx(blur, cv2.MORPH_OPEN, kernel)
68 |
69 | # find contours in the thresholded image and initialize the shape detector
70 | contours, _ = cv2.findContours(opening.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
71 |
72 | # construct the list of bounding boxes
73 | boundingBoxes = [cv2.boundingRect(contour) for contour in contours]
74 | contours, boundingBoxes = zip(*sorted(zip(contours, boundingBoxes), key=lambda b: b[1][1], reverse=False))
75 |
76 | # contours = contours[0] if imutils.is_cv2() else contours[1]
77 |
78 | nodes, index, outside_texts, shapes, arrow_lines = {}, 1, {}, [], {}
79 | # loop over the contours
80 | for idx, contour in enumerate(contours):
81 | # compute the center of the contour, then detect the name of the shape using only the contour
82 | if len(contour) >= 5:
83 | area = cv2.contourArea(contour)
84 | _, (MA, ma), angle = cv2.fitEllipse(contour)
85 | perimeter = cv2.arcLength(contour, True)
86 | approx = cv2.approxPolyDP(contour, 0.04 * perimeter, True)
87 |
88 | # (x,y) be the top-left coordinate of the rectangle and (w,h) be its width and height.
89 | x, y, w, h = cv2.boundingRect(approx)
90 | x = int(x * ratio)
91 | y = int(y * ratio)
92 | w = int(w * ratio)
93 | h = int(h * ratio)
94 |
95 | if (int(ma) < int(0.5 * resized.shape[0]) & int(MA) < int(0.5 * resized.shape[1])) or \
96 | area > 0.001 * (image.shape[0] * image.shape[1]):
97 | M = cv2.moments(contour)
98 | cx = int((M["m10"] / (M["m00"])) * ratio)
99 | cy = int((M["m01"] / (M["m00"])) * ratio)
100 |
101 | circumstance = contour_circumstance(contour)
102 |
103 | if area / circumstance < 30:
104 | shape = 'arrow'
105 | else:
106 | perimeter = cv2.arcLength(contour, True)
107 | approx = cv2.approxPolyDP(contour, 0.04 * perimeter, True)
108 |
109 | # if 3 vertices, triangle
110 | if len(approx) == 3:
111 | shape = "triangle"
112 |
113 | # if 4 vertices, square
114 | elif len(approx) == 4:
115 | shape = "square"
116 |
117 | # otherwise, we assume the shape is a circle
118 | else:
119 | shape = "circle"
120 |
121 | # multiply the contour (x, y)-coordinates by the resize ratio,
122 | # then draw the contours and the name of the shape on the image
123 | contour = contour.astype("float")
124 | contour *= ratio
125 | contour = contour.astype("int")
126 |
127 | if shape in ['square', 'circle', 'triangle']:
128 | shapes.append(idx)
129 |
130 | cropped = img.copy()
131 | cv2.drawContours(cropped, [contour], -1, (255, 255, 255), padding)
132 | cropped = cropped[y:y + h, x:x + w]
133 | text = pytesseract.image_to_string(cropped)
134 | # print(text)
135 | if text == '':
136 | position = 'Outside'
137 | else:
138 | position = 'Inside'
139 |
140 | nodes[idx] = {
141 | 'Id': idx,
142 | 'Name': text,
143 | 'Position': position,
144 | 'Shape': shape,
145 | 'Line': []
146 | }
147 | index += 1
148 | else:
149 | margin = 10
150 | if y <= margin or x <= margin or y + h >= img.shape[1] - margin or x + w >= img.shape[0] - margin:
151 | continue
152 | text_offset = 5
153 | cropped = img[y - text_offset:y + h + text_offset, x - text_offset:x + w + text_offset]
154 | text = pytesseract.image_to_string(cropped)
155 | if text != '':
156 | shape = 'text'
157 | outside_texts[idx] = text
158 | else:
159 | # print(idx)
160 | A, B, C, D = (x, y), (x, y + h), (x + w, y + h), (x + w, y)
161 | start_point, end_point = None, None
162 | for point in contour:
163 | if point[0][0] < x + offset and point[0][1] < y + offset:
164 | start_point = A
165 | # print('A')
166 | break
167 | elif point[0][0] < x + offset and point[0][1] > y + h - offset:
168 | start_point = B
169 | # print('B')
170 | break
171 | if start_point is None:
172 | start_point = A
173 | # print(start_point)
174 |
175 | for point in contour:
176 | if point[0][0] > x + w - offset and point[0][1] > y + h - offset:
177 | end_point = C
178 | # print('C')
179 | break
180 | elif point[0][0] > x + w - offset and point[0][1] < y + offset:
181 | end_point = D
182 | # print('D')
183 | break
184 | if end_point is None:
185 | end_point = C
186 | # print(end_point)
187 |
188 | start_contour_count, end_contour_count = 0, 0
189 | for point in contour:
190 | if (point[0][0] - start_point[0]) ** 2 + (point[0][1] - start_point[1]) ** 2 < arrow ** 2:
191 | start_contour_count += 1
192 | elif (point[0][0] - end_point[0]) ** 2 + (point[0][1] - end_point[1]) ** 2 < arrow ** 2:
193 | end_contour_count += 1
194 |
195 | # print(idx, start_point, end_point)
196 | if start_contour_count > end_contour_count:
197 | # print(start_contour_count, end_contour_count)
198 | reverse = True
199 | else:
200 | reverse = False
201 | # print(idx, reverse)
202 | arrow_lines[idx] = (np.array(start_point), np.array(end_point), reverse)
203 |
204 | # print(idx, shape, area/circumstance)
205 | cv2.drawContours(image, [contour], -1, (0, 255, 0), 2)
206 | cv2.putText(
207 | img=image, text=' '.join([str(idx), shape]), org=(cx + 10, cy - 20),
208 | fontFace=cv2.FONT_HERSHEY_COMPLEX_SMALL, fontScale=1, color=(255, 0, 0), thickness=2
209 | )
210 | cv2.putText(
211 | img=image, text=text, org=(cx + 10, cy + 40),
212 | fontFace=cv2.FONT_HERSHEY_COMPLEX_SMALL, fontScale=1, color=(255, 0, 0), thickness=2
213 | )
214 |
215 | else:
216 | cv2.drawContours(image, contours, -1, (0, 0, 0), -1)
217 |
218 | l = len(contours)
219 | for i in nodes.keys():
220 | if nodes[i]['Name'] == '':
221 | for j in range(1, max(i + 1, l - i)):
222 | mn, mx = min(i + j, l - 1), max(0, i - j)
223 |
224 | if mn in outside_texts.keys():
225 | nodes[i]['Name'] = outside_texts[mn]
226 | break
227 | elif mx in outside_texts.keys():
228 | nodes[i]['Name'] = outside_texts[mx]
229 | break
230 |
231 | for arrow_line in arrow_lines.keys():
232 | start_point, end_point, reverse = arrow_lines[arrow_line]
233 | # print(arrow_line, start_point, end_point, reverse)
234 | start_point *= 2
235 | end_point *= 2
236 |
237 | start_distances, end_distances = {}, {}
238 | for i in range(l):
239 | if i in shapes:
240 | x, y, w, h = boundingBoxes[i]
241 | A, B, C, D = np.array((x, y)), np.array((x, y + h)), np.array((x + w, y + h)), np.array((x + w, y))
242 |
243 | start_distance_bc = distance(B, C, start_point)
244 | start_distance_cd = distance(C, D, start_point)
245 | start_distances[i] = min(start_distance_bc, start_distance_cd)
246 |
247 | end_distance_ab = distance(A, B, end_point)
248 | end_distance_da = distance(D, A, end_point)
249 | end_distances[i] = min(end_distance_ab, end_distance_da)
250 |
251 | if len(start_distances) > 0:
252 | start_node, _ = sorted(start_distances.items(), key=itemgetter(1))[0]
253 | end_node, _ = sorted(end_distances.items(), key=itemgetter(1))[0]
254 | # print(arrow_line, start_node, end_node)
255 |
256 | if reverse:
257 | nodes[end_node]['Line'].append(nodes[start_node]['Name'])
258 | else:
259 | nodes[start_node]['Line'].append(nodes[end_node]['Name'])
260 |
261 | for node in nodes.keys():
262 | nodes[node]['Line'] = ', '.join(nodes[node]['Line'])
263 | nodes = list(nodes.values())
264 | print(tabulate(nodes, headers='keys'))
265 |
266 | filename = filename.replace('.jpg', '_out.jpg')
267 | filename = filename.replace('.png', '_out.png')
268 | cv2.imwrite(filename, image)
269 | with open('data.json', 'w') as file:
270 | dump({'Node': nodes}, file)
271 |
272 | if gui:
273 | cv2.imshow("Image", image)
274 | cv2.waitKey(0)
275 |
276 | try:
277 | os.remove("text.png")
278 | except:
279 | pass
280 |
281 | return nodes
282 |
283 |
284 | if __name__ == '__main__':
285 | if len(argv) > 1:
286 | ap = ArgumentParser()
287 |
288 | ap.add_argument('-f', '--filename', required=True)
289 | ap.add_argument('-p', '--padding', type=int, default=25, required=False)
290 | ap.add_argument('-o', '--offset', type=int, default=10, required=False)
291 | ap.add_argument('-a', '--arrow', type=int, default=30, required=False)
292 |
293 | args = ap.parse_args()
294 |
295 | flowchart(**vars(args))
296 | else:
297 | input_file = input('image file: ')
298 | flowchart(filename=input_file)
299 |
--------------------------------------------------------------------------------