Skip to content
This repository was archived by the owner on Feb 9, 2023. It is now read-only.

Commit 43cf916

Browse files
committed
Initial (public) commit
0 parents  commit 43cf916

File tree

10 files changed

+1708
-0
lines changed

10 files changed

+1708
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.swp

img/answered-sheet-photo-result.png

484 KB
Loading

img/answered-sheet-photo.jpg

695 KB
Loading

img/corner-full.png

1.09 KB
Loading

img/corner.png

1.58 KB
Loading

img/rotated-answeredsheet.png

190 KB
Loading

omr.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import argparse
2+
import cv2
3+
import math
4+
import numpy as np
5+
6+
7+
CORNER_FEATS = (
8+
0.322965313273202,
9+
0.19188334690998524,
10+
1.1514327482234812,
11+
0.998754685666376,
12+
)
13+
14+
TRANSF_SIZE = 512
15+
16+
17+
def normalize(im):
18+
return cv2.normalize(im, np.zeros(im.shape), 0, 255, norm_type=cv2.NORM_MINMAX)
19+
20+
def get_approx_contour(contour, tol=.01):
21+
"""Get rid of 'useless' points in the contour"""
22+
epsilon = tol * cv2.arcLength(contour, True)
23+
return cv2.approxPolyDP(contour, epsilon, True)
24+
25+
def get_contours(image_gray):
26+
im2, contours, hierarchy = cv2.findContours(
27+
image_gray, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
28+
29+
return map(get_approx_contour, contours)
30+
31+
def get_corners(contours):
32+
return sorted(
33+
contours,
34+
key=lambda c: features_distance(CORNER_FEATS, get_features(c)))[:4]
35+
36+
def get_bounding_rect(contour):
37+
rect = cv2.minAreaRect(contour)
38+
box = cv2.boxPoints(rect)
39+
return np.int0(box)
40+
41+
def get_convex_hull(contour):
42+
return cv2.convexHull(contour)
43+
44+
def get_contour_area_by_hull_area(contour):
45+
return (cv2.contourArea(contour) /
46+
cv2.contourArea(get_convex_hull(contour)))
47+
48+
def get_contour_area_by_bounding_box_area(contour):
49+
return (cv2.contourArea(contour) /
50+
cv2.contourArea(get_bounding_rect(contour)))
51+
52+
def get_contour_perim_by_hull_perim(contour):
53+
return (cv2.arcLength(contour, True) /
54+
cv2.arcLength(get_convex_hull(contour), True))
55+
56+
def get_contour_perim_by_bounding_box_perim(contour):
57+
return (cv2.arcLength(contour, True) /
58+
cv2.arcLength(get_bounding_rect(contour), True))
59+
60+
def get_features(contour):
61+
try:
62+
return (
63+
get_contour_area_by_hull_area(contour),
64+
get_contour_area_by_bounding_box_area(contour),
65+
get_contour_perim_by_hull_perim(contour),
66+
get_contour_perim_by_bounding_box_perim(contour),
67+
)
68+
except ZeroDivisionError:
69+
return 4*[np.inf]
70+
71+
def features_distance(f1, f2):
72+
return np.linalg.norm(np.array(f1) - np.array(f2))
73+
74+
# Default mutable arguments should be harmless here
75+
def draw_point(point, img, radius=5, color=(0, 0, 255)):
76+
cv2.circle(img, tuple(point), radius, color, -1)
77+
78+
def get_centroid(contour):
79+
m = cv2.moments(contour)
80+
x = int(m["m10"] / m["m00"])
81+
y = int(m["m01"] / m["m00"])
82+
return (x, y)
83+
84+
def order_points(points):
85+
"""Order points counter-clockwise-ly."""
86+
origin = np.mean(points, axis=0)
87+
88+
def positive_angle(p):
89+
x, y = p - origin
90+
ang = np.arctan2(y, x)
91+
return 2 * np.pi + ang if ang < 0 else ang
92+
93+
return sorted(points, key=positive_angle)
94+
95+
def get_outmost_points(contours):
96+
all_points = np.concatenate(contours)
97+
return get_bounding_rect(all_points)
98+
99+
def perspective_transform(img, points):
100+
"""Transform img so that points are the new corners"""
101+
102+
source = np.array(
103+
points,
104+
dtype="float32")
105+
106+
dest = np.array([
107+
[TRANSF_SIZE, TRANSF_SIZE],
108+
[0, TRANSF_SIZE],
109+
[0, 0],
110+
[TRANSF_SIZE, 0]],
111+
dtype="float32")
112+
113+
img_dest = img.copy()
114+
transf = cv2.getPerspectiveTransform(source, dest)
115+
warped = cv2.warpPerspective(img, transf, (TRANSF_SIZE, TRANSF_SIZE))
116+
return warped
117+
118+
def sheet_coord_to_transf_coord(x, y):
119+
return map(lambda n: int(np.round(n)), (
120+
TRANSF_SIZE * x/744.055,
121+
TRANSF_SIZE * (1 - y/1052.362)
122+
))
123+
124+
def get_question_patch(transf, q_number):
125+
# Top left
126+
tl = sheet_coord_to_transf_coord(
127+
200,
128+
850 - 80 * (q_number - 1)
129+
)
130+
131+
# Bottom right
132+
br = sheet_coord_to_transf_coord(
133+
650,
134+
800 - 80 * (q_number - 1)
135+
)
136+
return transf[tl[1]:br[1], tl[0]:br[0]]
137+
138+
def get_question_patches(transf):
139+
for i in xrange(1, 11):
140+
yield get_question_patch(transf, i)
141+
142+
def get_alternative_patches(question_patch):
143+
for i in xrange(5):
144+
x0, _ = sheet_coord_to_transf_coord(100 * i, 0)
145+
x1, _ = sheet_coord_to_transf_coord(50 + 100 * i, 0)
146+
yield question_patch[:, x0:x1]
147+
148+
def draw_marked_alternative(question_patch, index):
149+
cx, cy = sheet_coord_to_transf_coord(
150+
50 * (2 * index + .5),
151+
50/2)
152+
draw_point((cx, TRANSF_SIZE - cy), question_patch, radius=5, color=(255, 0, 0))
153+
154+
def get_marked_alternative(alternative_patches):
155+
means = map(np.mean, alternative_patches)
156+
sorted_means = sorted(means)
157+
158+
# Simple heuristic
159+
if sorted_means[0]/sorted_means[1] > .7:
160+
return None
161+
162+
return np.argmin(means)
163+
164+
def get_letter(alt_index):
165+
return ["A", "B", "C", "D", "E"][alt_index] if alt_index is not None else "N/A"
166+
167+
def get_answers(source_file):
168+
"""Run the full pipeline:
169+
170+
- Load image
171+
- Convert to grayscale
172+
- Filter out high frequencies with a Gaussian kernel
173+
- Apply threshold
174+
- Find contours
175+
- Find corners among all contours
176+
- Find 'outmost' points of all corners
177+
- Apply perpsective transform to get a bird's eye view
178+
- Scan each line for the marked answer
179+
"""
180+
181+
im_orig = cv2.imread(source_file)
182+
183+
blurred = cv2.GaussianBlur(im_orig, (11, 11), 10)
184+
185+
im = normalize(cv2.cvtColor(blurred, cv2.COLOR_BGR2GRAY))
186+
187+
ret, im = cv2.threshold(im, 127, 255, cv2.THRESH_BINARY)
188+
189+
contours = get_contours(im)
190+
corners = get_corners(contours)
191+
192+
cv2.drawContours(im_orig, corners, -1, (0, 255, 0), 3)
193+
194+
outmost = order_points(get_outmost_points(corners))
195+
196+
transf = perspective_transform(im_orig, outmost)
197+
198+
answers = []
199+
for i, q_patch in enumerate(get_question_patches(transf)):
200+
alt_index = get_marked_alternative(get_alternative_patches(q_patch))
201+
202+
if alt_index is not None:
203+
draw_marked_alternative(q_patch, alt_index)
204+
205+
answers.append(get_letter(alt_index))
206+
207+
#cv2.imshow('orig', im_orig)
208+
#cv2.imshow('blurred', blurred)
209+
#cv2.imshow('bw', im)
210+
211+
return answers, transf
212+
213+
def main():
214+
parser = argparse.ArgumentParser()
215+
216+
parser.add_argument(
217+
"--input",
218+
help="Input image filename",
219+
required=True,
220+
type=str)
221+
222+
parser.add_argument(
223+
"--output",
224+
help="Output image filename",
225+
type=str)
226+
227+
parser.add_argument(
228+
"--show",
229+
action="store_true",
230+
help="Displays annotated image")
231+
232+
args = parser.parse_args()
233+
234+
answers, im = get_answers(args.input)
235+
236+
for i, answer in enumerate(answers):
237+
print "Q{}: {}".format(i + 1, answer)
238+
239+
if args.output:
240+
cv2.imwrite(args.output, im)
241+
print "Wrote image to {}".format(args.output)
242+
243+
if args.show:
244+
cv2.imshow('trans', im)
245+
246+
print "Close image window and hit ^C to quit."
247+
while True:
248+
cv2.waitKey()
249+
250+
if __name__ == '__main__':
251+
main()

sheet/sheet.pdf

8.65 KB
Binary file not shown.

sheet/sheet.png

80.4 KB
Loading

0 commit comments

Comments
 (0)