From 089d98e44ce2a2f46e2db53afb71944e7bf4a935 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Mon, 4 Nov 2019 19:08:11 -0500 Subject: [PATCH] Switch to using three-point change of basis --- code/bubble_sheet_reader.py | 13 +++++--- code/corner_finding.py | 53 +++++++++++--------------------- code/geometry_utils.py | 61 ++++++++++++++++++++++++------------- code/grid_reading.py | 32 ++++++++----------- 4 files changed, 77 insertions(+), 82 deletions(-) diff --git a/code/bubble_sheet_reader.py b/code/bubble_sheet_reader.py index c5ec9b8..9649aff 100644 --- a/code/bubble_sheet_reader.py +++ b/code/bubble_sheet_reader.py @@ -15,13 +15,15 @@ folders_prompt = user_interface.MainWindow() input_folder = folders_prompt.input_folder -image_paths = file_handling.filter_images(file_handling.list_file_paths(input_folder)) +image_paths = file_handling.filter_images( + file_handling.list_file_paths(input_folder)) output_folder = folders_prompt.output_folder multi_answers_as_f = folders_prompt.multi_answers_as_f keys_file = folders_prompt.keys_file arrangement_file = folders_prompt.arrangement_file -progress = user_interface.ProgressTracker(folders_prompt.root, len(image_paths)) +progress = user_interface.ProgressTracker(folders_prompt.root, + len(image_paths)) for image_path in image_paths: progress.set_status(f"Processing '{image_path.name}'.") @@ -32,7 +34,9 @@ try: corners = corner_finding.find_corner_marks(prepared_image) except corner_finding.CornerFindingError: - progress.set_status(f"Error with '{image_path.name}': couldn't find corners. Skipping...") + progress.set_status( + f"Error with '{image_path.name}': couldn't find corners. Skipping..." + ) time.sleep(1) continue @@ -78,7 +82,8 @@ scores.save(output_folder / "scores.csv") success_string += "✔️ All scored results processed and saved to 'scores.csv'." if arrangement_file: - data_exporting.save_reordered_version(scores, arrangement_file, output_folder / "reordered.csv") + data_exporting.save_reordered_version(scores, arrangement_file, + output_folder / "reordered.csv") success_string += "✔️ Reordered results saved to 'reordered.csv'." else: success_string += "No exam keys were found, so no scoring was performed." diff --git a/code/corner_finding.py b/code/corner_finding.py index 8e5f70e..d710e66 100644 --- a/code/corner_finding.py +++ b/code/corner_finding.py @@ -1,4 +1,3 @@ -import math import typing import numpy as np @@ -126,12 +125,10 @@ def find_corner_marks(image: np.ndarray) -> geometry_utils.Polygon: except WrongShapeError: continue - [a, b] = l_mark.polygon[0:2] - rotation = math.atan2(b.y - a.y, b.x - a.x) - math.radians(90) - to_new_basis, from_new_basis = geometry_utils.create_change_of_basis( - a, rotation) - nominal_to_right_side = 49.5 * l_mark.unit_length - nominal_to_bottom = -(66.5 * l_mark.unit_length) + to_new_basis, _ = geometry_utils.create_change_of_basis( + l_mark.polygon[0], l_mark.polygon[5], l_mark.polygon[4]) + nominal_to_right_side = 50 - 0.5 + nominal_to_bottom = ((64 - 0.5) / 2) tolerance = 0.1 * nominal_to_right_side top_right_squares = [] @@ -147,22 +144,19 @@ def find_corner_marks(image: np.ndarray) -> geometry_utils.Polygon: centroid_new_basis = to_new_basis(centroid) if math_utils.is_within_tolerance( - centroid_new_basis.x, -0.5 * l_mark.unit_length, + centroid_new_basis.x, nominal_to_right_side, tolerance) and math_utils.is_within_tolerance( - centroid_new_basis.y, nominal_to_right_side, - tolerance): + centroid_new_basis.y, 0.5, tolerance): top_right_squares.append(square) elif math_utils.is_within_tolerance( - centroid_new_basis.x, nominal_to_bottom, + centroid_new_basis.x, 0.5, tolerance) and math_utils.is_within_tolerance( - centroid_new_basis.y, -0.5 * l_mark.unit_length, - tolerance): + centroid_new_basis.y, nominal_to_bottom, tolerance): bottom_left_squares.append(square) elif math_utils.is_within_tolerance( - centroid_new_basis.x, nominal_to_bottom, + centroid_new_basis.x, nominal_to_right_side, tolerance) and math_utils.is_within_tolerance( - centroid_new_basis.y, nominal_to_right_side, - tolerance): + centroid_new_basis.y, nominal_to_bottom, tolerance): bottom_right_squares.append(square) 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: # TODO: When multiple, either progressively decrease tolerance or # choose closest to centroid - top_right_square = [ - to_new_basis(p) for p in top_right_squares[0].polygon - ] - bottom_left_square = [ - to_new_basis(p) for p in bottom_left_squares[0].polygon - ] - bottom_right_square = [ - to_new_basis(p) for p in bottom_right_squares[0].polygon - ] - - top_left_corner = a - top_right_corner = from_new_basis( - geometry_utils.get_corner(top_right_square, - geometry_utils.Corner.TR)) - bottom_left_corner = from_new_basis( - geometry_utils.get_corner(bottom_left_square, - geometry_utils.Corner.BL)) - bottom_right_corner = from_new_basis( - geometry_utils.get_corner(bottom_right_square, - geometry_utils.Corner.BR)) + top_left_corner = l_mark.polygon[0] + top_right_corner = geometry_utils.get_corner( + top_right_squares[0].polygon, geometry_utils.Corner.TR) + bottom_right_corner = geometry_utils.get_corner( + bottom_right_squares[0].polygon, geometry_utils.Corner.BR) + bottom_left_corner = geometry_utils.get_corner( + bottom_left_squares[0].polygon, geometry_utils.Corner.BL) return [ top_left_corner, top_right_corner, bottom_right_corner, diff --git a/code/geometry_utils.py b/code/geometry_utils.py index a0b256a..b8e5417 100644 --- a/code/geometry_utils.py +++ b/code/geometry_utils.py @@ -193,33 +193,50 @@ def extend_ray(a: Point, b: Point, distance: float): def create_change_of_basis( - new_origin: Point, - theta: float) -> typing.Tuple[typing.Callable[[Point], Point], typing. - Callable[[Point], Point]]: - """Returns functions that will convert points from the current coordinate - system to a new one where the origin is translated to `new_origin` and the - axis are rotated `theta` radians CCW. + origin: Point, bottom_left: Point, bottom_right: Point +) -> typing.Tuple[typing.Callable[[Point], Point], typing. + Callable[[Point], Point]]: + """Returns functions that will convert points to/from the current coordinate + system to a new one where the passed `origin` point becomes `0,0`, the + `bottom_left` point becomes `0,1`, and the `bottom_right` point becomes `1,1`. Returns: A tuple where the first element is a function that converts - points to the new system, and the second is a function that converts + points _to_ the new system, and the second is a function that converts them back. """ - origin = Point(0, 0) + target_origin = Point(0, 0) + target_bl = Point(0, 1) + target_br = Point(1, 1) + target_matrix = np.array([[target_origin.x], [target_bl.x], [target_br.x], + [target_origin.y], [target_bl.y], [target_br.y]], + float) + + from_matrix = np.array([[origin.x, origin.y, 1, 0, 0, 0], + [bottom_left.x, bottom_left.y, 1, 0, 0, 0], + [bottom_right.x, bottom_right.y, 1, 0, 0, 0], + [0, 0, 0, origin.x, origin.y, 1], + [0, 0, 0, bottom_left.x, bottom_left.y, 1], + [0, 0, 0, bottom_right.x, bottom_right.y, 1]], + float) + + result = np.matmul(np.linalg.inv(from_matrix), target_matrix) + transformation_matrix = np.array([[result[0][0], result[1][0]], + [result[3][0], result[4][0]]]) + transformation_matrix_inv = np.linalg.inv(transformation_matrix) + rotation_matrix = np.array([[result[2][0]], [result[5][0]]]) def to_basis(point: Point) -> Point: - translated = Point(point.x - new_origin.x, point.y - new_origin.y) - r = calc_2d_dist(origin, translated) - phi = math.atan2(translated.y, translated.x) - to_phi = phi - theta - return Point(r * math.cos(to_phi), r * math.sin(to_phi)) + point_vector = np.array([[point.x], [point.y]], float) + result = np.matmul(transformation_matrix, + point_vector) + rotation_matrix + return Point(result[0][0], result[1][0]) def from_basis(point: Point) -> Point: - r = calc_2d_dist(origin, point) - phi = math.atan2(point.y, point.x) - to_phi = phi + theta - rotated = Point(r * math.cos(to_phi), r * math.sin(to_phi)) - return Point(rotated.x + new_origin.x, rotated.y + new_origin.y) + point_vector = np.array([[point.x], [point.y]], float) + result = np.matmul(transformation_matrix_inv, + (point_vector - rotation_matrix)) + return Point(result[0][0], result[1][0]) return to_basis, from_basis @@ -248,16 +265,16 @@ def get_corner(square: Polygon, corner: Corner) -> Point: """Gets the point representing the given corner of the square. Square should be pretty close to vertical - horizontal. """ xs = [p.x for p in square] - highest_xs = list_utils.find_greatest_value_indexes(xs, 2) + highest_xs = sorted(list_utils.find_greatest_value_indexes(xs, 2)) side_points = [ p for i, p in enumerate(square) - if (corner.value[0] == 1 and i in highest_xs) or ( - corner.value[0] == 0 and i not in highest_xs) + if (corner.value[1] == 1 and i in highest_xs) or ( + corner.value[1] == 0 and i not in highest_xs) ] side_ys = [p.y for p in side_points] [highest_y] = list_utils.find_greatest_value_indexes(side_ys, 1) corner_point = side_points[highest_y] if ( - corner.value[1] == 1) else side_points[list_utils.next_index( + corner.value[0] == 0) else side_points[list_utils.next_index( side_points, highest_y)] return corner_point diff --git a/code/grid_reading.py b/code/grid_reading.py index 7e8a03a..c5c7b76 100644 --- a/code/grid_reading.py +++ b/code/grid_reading.py @@ -1,7 +1,6 @@ """Functions for establishing and reading the grid.""" import abc -import math import typing import numpy as np @@ -10,12 +9,10 @@ import geometry_utils import grid_info import image_utils - """ Percent fill past which a grid cell is considered filled.""" # This was found by averaging the empty fill percents of all bubbles and adding # 10% to that number. -GRID_CELL_FILL_THRESHOLD = 0.6 - +GRID_CELL_FILL_THRESHOLD = 0.59 """ The fraction cropped from each cell (the percentage of the box around each cell that is empty space)""" GRID_CELL_CROP_FRACTION = 0.4 @@ -41,17 +38,11 @@ def __init__(self, corners: geometry_utils.Polygon, horizontal_cells: int, self.corners = corners self.horizontal_cells = horizontal_cells self.vertical_cells = vertical_cells - [a, b] = corners[0:2] - theta = math.atan2(b.y - a.y, b.x - a.x) self._to_grid_basis, self._from_grid_basis = geometry_utils.create_change_of_basis( - corners[0], theta) - - corners_in_basis = [self._to_grid_basis(c) for c in corners] - self.width = corners_in_basis[1].x - self.height = corners_in_basis[3].y + corners[0], corners[3], corners[2]) - self.horizontal_cell_size = self.width / self.horizontal_cells - self.vertical_cell_size = self.height / self.vertical_cells + self.horizontal_cell_size = 1 / self.horizontal_cells + self.vertical_cell_size = 1 / self.vertical_cells self.image = image @@ -211,17 +202,16 @@ def read_value(self) -> typing.List[typing.List[str]]: return typing.cast(typing.List[typing.List[str]], super().read_value()) -def get_group_from_info(info: grid_info.GridGroupInfo, grid: Grid) -> _GridFieldGroup: +def get_group_from_info(info: grid_info.GridGroupInfo, + grid: Grid) -> _GridFieldGroup: if info.fields_type is grid_info.FieldType.LETTER: return LetterGridFieldGroup(grid, info.horizontal_start, info.vertical_start, info.num_fields, - info.field_length, - info.field_orientation) + info.field_length, info.field_orientation) else: return NumberGridFieldGroup(grid, info.horizontal_start, info.vertical_start, info.num_fields, - info.field_length, - info.field_orientation) + info.field_length, info.field_orientation) def read_field( @@ -235,7 +225,8 @@ def read_answer( question: int, grid: Grid ) -> typing.List[typing.Union[typing.List[str], typing.List[int]]]: """Shortcut to read a field given just the key for it and the grid object.""" - return get_group_from_info(grid_info.questions_info[question], grid).read_value() + return get_group_from_info(grid_info.questions_info[question], + grid).read_value() def field_group_to_string( @@ -258,7 +249,8 @@ def read_field_as_string(field: grid_info.Field, grid: Grid) -> str: return field_group_to_string(read_field(field, grid)) -def read_answer_as_string(question: int, grid: Grid, multi_answers_as_f: bool) -> str: +def read_answer_as_string(question: int, grid: Grid, + multi_answers_as_f: bool) -> str: """Shortcut to read a question's answer and format it as a string, given just the question number and the grid object. """ answer = field_group_to_string(read_answer(question, grid))