Skip to content

Commit 089d98e

Browse files
committed
Switch to using three-point change of basis
1 parent 73b99e3 commit 089d98e

4 files changed

+77
-82
lines changed

code/bubble_sheet_reader.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515

1616
folders_prompt = user_interface.MainWindow()
1717
input_folder = folders_prompt.input_folder
18-
image_paths = file_handling.filter_images(file_handling.list_file_paths(input_folder))
18+
image_paths = file_handling.filter_images(
19+
file_handling.list_file_paths(input_folder))
1920
output_folder = folders_prompt.output_folder
2021
multi_answers_as_f = folders_prompt.multi_answers_as_f
2122
keys_file = folders_prompt.keys_file
2223
arrangement_file = folders_prompt.arrangement_file
2324

24-
progress = user_interface.ProgressTracker(folders_prompt.root, len(image_paths))
25+
progress = user_interface.ProgressTracker(folders_prompt.root,
26+
len(image_paths))
2527

2628
for image_path in image_paths:
2729
progress.set_status(f"Processing '{image_path.name}'.")
@@ -32,7 +34,9 @@
3234
try:
3335
corners = corner_finding.find_corner_marks(prepared_image)
3436
except corner_finding.CornerFindingError:
35-
progress.set_status(f"Error with '{image_path.name}': couldn't find corners. Skipping...")
37+
progress.set_status(
38+
f"Error with '{image_path.name}': couldn't find corners. Skipping..."
39+
)
3640
time.sleep(1)
3741
continue
3842

@@ -78,7 +82,8 @@
7882
scores.save(output_folder / "scores.csv")
7983
success_string += "✔️ All scored results processed and saved to 'scores.csv'."
8084
if arrangement_file:
81-
data_exporting.save_reordered_version(scores, arrangement_file, output_folder / "reordered.csv")
85+
data_exporting.save_reordered_version(scores, arrangement_file,
86+
output_folder / "reordered.csv")
8287
success_string += "✔️ Reordered results saved to 'reordered.csv'."
8388
else:
8489
success_string += "No exam keys were found, so no scoring was performed."

code/corner_finding.py

+17-36
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import math
21
import typing
32

43
import numpy as np
@@ -126,12 +125,10 @@ def find_corner_marks(image: np.ndarray) -> geometry_utils.Polygon:
126125
except WrongShapeError:
127126
continue
128127

129-
[a, b] = l_mark.polygon[0:2]
130-
rotation = math.atan2(b.y - a.y, b.x - a.x) - math.radians(90)
131-
to_new_basis, from_new_basis = geometry_utils.create_change_of_basis(
132-
a, rotation)
133-
nominal_to_right_side = 49.5 * l_mark.unit_length
134-
nominal_to_bottom = -(66.5 * l_mark.unit_length)
128+
to_new_basis, _ = geometry_utils.create_change_of_basis(
129+
l_mark.polygon[0], l_mark.polygon[5], l_mark.polygon[4])
130+
nominal_to_right_side = 50 - 0.5
131+
nominal_to_bottom = ((64 - 0.5) / 2)
135132
tolerance = 0.1 * nominal_to_right_side
136133

137134
top_right_squares = []
@@ -147,22 +144,19 @@ def find_corner_marks(image: np.ndarray) -> geometry_utils.Polygon:
147144
centroid_new_basis = to_new_basis(centroid)
148145

149146
if math_utils.is_within_tolerance(
150-
centroid_new_basis.x, -0.5 * l_mark.unit_length,
147+
centroid_new_basis.x, nominal_to_right_side,
151148
tolerance) and math_utils.is_within_tolerance(
152-
centroid_new_basis.y, nominal_to_right_side,
153-
tolerance):
149+
centroid_new_basis.y, 0.5, tolerance):
154150
top_right_squares.append(square)
155151
elif math_utils.is_within_tolerance(
156-
centroid_new_basis.x, nominal_to_bottom,
152+
centroid_new_basis.x, 0.5,
157153
tolerance) and math_utils.is_within_tolerance(
158-
centroid_new_basis.y, -0.5 * l_mark.unit_length,
159-
tolerance):
154+
centroid_new_basis.y, nominal_to_bottom, tolerance):
160155
bottom_left_squares.append(square)
161156
elif math_utils.is_within_tolerance(
162-
centroid_new_basis.x, nominal_to_bottom,
157+
centroid_new_basis.x, nominal_to_right_side,
163158
tolerance) and math_utils.is_within_tolerance(
164-
centroid_new_basis.y, nominal_to_right_side,
165-
tolerance):
159+
centroid_new_basis.y, nominal_to_bottom, tolerance):
166160
bottom_right_squares.append(square)
167161

168162
if len(top_right_squares) == 0 or len(bottom_left_squares) == 0 or len(
@@ -172,26 +166,13 @@ def find_corner_marks(image: np.ndarray) -> geometry_utils.Polygon:
172166
# TODO: When multiple, either progressively decrease tolerance or
173167
# choose closest to centroid
174168

175-
top_right_square = [
176-
to_new_basis(p) for p in top_right_squares[0].polygon
177-
]
178-
bottom_left_square = [
179-
to_new_basis(p) for p in bottom_left_squares[0].polygon
180-
]
181-
bottom_right_square = [
182-
to_new_basis(p) for p in bottom_right_squares[0].polygon
183-
]
184-
185-
top_left_corner = a
186-
top_right_corner = from_new_basis(
187-
geometry_utils.get_corner(top_right_square,
188-
geometry_utils.Corner.TR))
189-
bottom_left_corner = from_new_basis(
190-
geometry_utils.get_corner(bottom_left_square,
191-
geometry_utils.Corner.BL))
192-
bottom_right_corner = from_new_basis(
193-
geometry_utils.get_corner(bottom_right_square,
194-
geometry_utils.Corner.BR))
169+
top_left_corner = l_mark.polygon[0]
170+
top_right_corner = geometry_utils.get_corner(
171+
top_right_squares[0].polygon, geometry_utils.Corner.TR)
172+
bottom_right_corner = geometry_utils.get_corner(
173+
bottom_right_squares[0].polygon, geometry_utils.Corner.BR)
174+
bottom_left_corner = geometry_utils.get_corner(
175+
bottom_left_squares[0].polygon, geometry_utils.Corner.BL)
195176

196177
return [
197178
top_left_corner, top_right_corner, bottom_right_corner,

code/geometry_utils.py

+39-22
Original file line numberDiff line numberDiff line change
@@ -193,33 +193,50 @@ def extend_ray(a: Point, b: Point, distance: float):
193193

194194

195195
def create_change_of_basis(
196-
new_origin: Point,
197-
theta: float) -> typing.Tuple[typing.Callable[[Point], Point], typing.
198-
Callable[[Point], Point]]:
199-
"""Returns functions that will convert points from the current coordinate
200-
system to a new one where the origin is translated to `new_origin` and the
201-
axis are rotated `theta` radians CCW.
196+
origin: Point, bottom_left: Point, bottom_right: Point
197+
) -> typing.Tuple[typing.Callable[[Point], Point], typing.
198+
Callable[[Point], Point]]:
199+
"""Returns functions that will convert points to/from the current coordinate
200+
system to a new one where the passed `origin` point becomes `0,0`, the
201+
`bottom_left` point becomes `0,1`, and the `bottom_right` point becomes `1,1`.
202202
203203
Returns:
204204
A tuple where the first element is a function that converts
205-
points to the new system, and the second is a function that converts
205+
points _to_ the new system, and the second is a function that converts
206206
them back.
207207
"""
208-
origin = Point(0, 0)
208+
target_origin = Point(0, 0)
209+
target_bl = Point(0, 1)
210+
target_br = Point(1, 1)
211+
target_matrix = np.array([[target_origin.x], [target_bl.x], [target_br.x],
212+
[target_origin.y], [target_bl.y], [target_br.y]],
213+
float)
214+
215+
from_matrix = np.array([[origin.x, origin.y, 1, 0, 0, 0],
216+
[bottom_left.x, bottom_left.y, 1, 0, 0, 0],
217+
[bottom_right.x, bottom_right.y, 1, 0, 0, 0],
218+
[0, 0, 0, origin.x, origin.y, 1],
219+
[0, 0, 0, bottom_left.x, bottom_left.y, 1],
220+
[0, 0, 0, bottom_right.x, bottom_right.y, 1]],
221+
float)
222+
223+
result = np.matmul(np.linalg.inv(from_matrix), target_matrix)
224+
transformation_matrix = np.array([[result[0][0], result[1][0]],
225+
[result[3][0], result[4][0]]])
226+
transformation_matrix_inv = np.linalg.inv(transformation_matrix)
227+
rotation_matrix = np.array([[result[2][0]], [result[5][0]]])
209228

210229
def to_basis(point: Point) -> Point:
211-
translated = Point(point.x - new_origin.x, point.y - new_origin.y)
212-
r = calc_2d_dist(origin, translated)
213-
phi = math.atan2(translated.y, translated.x)
214-
to_phi = phi - theta
215-
return Point(r * math.cos(to_phi), r * math.sin(to_phi))
230+
point_vector = np.array([[point.x], [point.y]], float)
231+
result = np.matmul(transformation_matrix,
232+
point_vector) + rotation_matrix
233+
return Point(result[0][0], result[1][0])
216234

217235
def from_basis(point: Point) -> Point:
218-
r = calc_2d_dist(origin, point)
219-
phi = math.atan2(point.y, point.x)
220-
to_phi = phi + theta
221-
rotated = Point(r * math.cos(to_phi), r * math.sin(to_phi))
222-
return Point(rotated.x + new_origin.x, rotated.y + new_origin.y)
236+
point_vector = np.array([[point.x], [point.y]], float)
237+
result = np.matmul(transformation_matrix_inv,
238+
(point_vector - rotation_matrix))
239+
return Point(result[0][0], result[1][0])
223240

224241
return to_basis, from_basis
225242

@@ -248,16 +265,16 @@ def get_corner(square: Polygon, corner: Corner) -> Point:
248265
"""Gets the point representing the given corner of the square. Square should
249266
be pretty close to vertical - horizontal. """
250267
xs = [p.x for p in square]
251-
highest_xs = list_utils.find_greatest_value_indexes(xs, 2)
268+
highest_xs = sorted(list_utils.find_greatest_value_indexes(xs, 2))
252269
side_points = [
253270
p for i, p in enumerate(square)
254-
if (corner.value[0] == 1 and i in highest_xs) or (
255-
corner.value[0] == 0 and i not in highest_xs)
271+
if (corner.value[1] == 1 and i in highest_xs) or (
272+
corner.value[1] == 0 and i not in highest_xs)
256273
]
257274
side_ys = [p.y for p in side_points]
258275
[highest_y] = list_utils.find_greatest_value_indexes(side_ys, 1)
259276
corner_point = side_points[highest_y] if (
260-
corner.value[1] == 1) else side_points[list_utils.next_index(
277+
corner.value[0] == 0) else side_points[list_utils.next_index(
261278
side_points, highest_y)]
262279
return corner_point
263280

code/grid_reading.py

+12-20
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Functions for establishing and reading the grid."""
22

33
import abc
4-
import math
54
import typing
65

76
import numpy as np
@@ -10,12 +9,10 @@
109
import geometry_utils
1110
import grid_info
1211
import image_utils
13-
1412
""" Percent fill past which a grid cell is considered filled."""
1513
# This was found by averaging the empty fill percents of all bubbles and adding
1614
# 10% to that number.
17-
GRID_CELL_FILL_THRESHOLD = 0.6
18-
15+
GRID_CELL_FILL_THRESHOLD = 0.59
1916
""" The fraction cropped from each cell (the percentage of the box around each
2017
cell that is empty space)"""
2118
GRID_CELL_CROP_FRACTION = 0.4
@@ -41,17 +38,11 @@ def __init__(self, corners: geometry_utils.Polygon, horizontal_cells: int,
4138
self.corners = corners
4239
self.horizontal_cells = horizontal_cells
4340
self.vertical_cells = vertical_cells
44-
[a, b] = corners[0:2]
45-
theta = math.atan2(b.y - a.y, b.x - a.x)
4641
self._to_grid_basis, self._from_grid_basis = geometry_utils.create_change_of_basis(
47-
corners[0], theta)
48-
49-
corners_in_basis = [self._to_grid_basis(c) for c in corners]
50-
self.width = corners_in_basis[1].x
51-
self.height = corners_in_basis[3].y
42+
corners[0], corners[3], corners[2])
5243

53-
self.horizontal_cell_size = self.width / self.horizontal_cells
54-
self.vertical_cell_size = self.height / self.vertical_cells
44+
self.horizontal_cell_size = 1 / self.horizontal_cells
45+
self.vertical_cell_size = 1 / self.vertical_cells
5546

5647
self.image = image
5748

@@ -211,17 +202,16 @@ def read_value(self) -> typing.List[typing.List[str]]:
211202
return typing.cast(typing.List[typing.List[str]], super().read_value())
212203

213204

214-
def get_group_from_info(info: grid_info.GridGroupInfo, grid: Grid) -> _GridFieldGroup:
205+
def get_group_from_info(info: grid_info.GridGroupInfo,
206+
grid: Grid) -> _GridFieldGroup:
215207
if info.fields_type is grid_info.FieldType.LETTER:
216208
return LetterGridFieldGroup(grid, info.horizontal_start,
217209
info.vertical_start, info.num_fields,
218-
info.field_length,
219-
info.field_orientation)
210+
info.field_length, info.field_orientation)
220211
else:
221212
return NumberGridFieldGroup(grid, info.horizontal_start,
222213
info.vertical_start, info.num_fields,
223-
info.field_length,
224-
info.field_orientation)
214+
info.field_length, info.field_orientation)
225215

226216

227217
def read_field(
@@ -235,7 +225,8 @@ def read_answer(
235225
question: int, grid: Grid
236226
) -> typing.List[typing.Union[typing.List[str], typing.List[int]]]:
237227
"""Shortcut to read a field given just the key for it and the grid object."""
238-
return get_group_from_info(grid_info.questions_info[question], grid).read_value()
228+
return get_group_from_info(grid_info.questions_info[question],
229+
grid).read_value()
239230

240231

241232
def field_group_to_string(
@@ -258,7 +249,8 @@ def read_field_as_string(field: grid_info.Field, grid: Grid) -> str:
258249
return field_group_to_string(read_field(field, grid))
259250

260251

261-
def read_answer_as_string(question: int, grid: Grid, multi_answers_as_f: bool) -> str:
252+
def read_answer_as_string(question: int, grid: Grid,
253+
multi_answers_as_f: bool) -> str:
262254
"""Shortcut to read a question's answer and format it as a string, given
263255
just the question number and the grid object. """
264256
answer = field_group_to_string(read_answer(question, grid))

0 commit comments

Comments
 (0)