|
1 |
| -import textwrap |
2 |
| -import typing as tp |
3 |
| -from datetime import datetime |
| 1 | +import argparse |
| 2 | +import sys |
| 3 | +from pathlib import Path |
4 | 4 |
|
5 |
| -import corner_finding |
6 |
| -import data_exporting |
7 | 5 | import file_handling
|
8 | 6 | import grid_info as grid_i
|
9 |
| -import grid_reading as grid_r |
10 |
| -import image_utils |
11 |
| -from mcta_processing import transform_and_save_mcta_output |
12 |
| -import scoring |
13 |
| -import user_interface |
14 |
| -import sys |
15 |
| - |
16 |
| -user_input = user_interface.MainWindow() |
17 |
| -if (user_input.cancelled): |
18 |
| - sys.exit(0) |
19 |
| - |
20 |
| -input_folder = user_input.input_folder |
21 |
| -image_paths = file_handling.filter_images( |
22 |
| - file_handling.list_file_paths(input_folder)) |
23 |
| -output_folder = user_input.output_folder |
24 |
| -multi_answers_as_f = user_input.multi_answers_as_f |
25 |
| -empty_answers_as_g = user_input.empty_answers_as_g |
26 |
| -keys_file = user_input.keys_file |
27 |
| -arrangement_file = user_input.arrangement_map |
28 |
| -sort_results = user_input.sort_results |
29 |
| -output_mcta = user_input.output_mcta |
30 |
| -debug_mode_on = user_input.debug_mode |
31 |
| -form_variant = grid_i.form_150q if user_input.form_variant == user_interface.FormVariantSelection.VARIANT_150_Q else grid_i.form_75q |
32 |
| - |
33 |
| -answers_results = data_exporting.OutputSheet([x for x in grid_i.Field], |
34 |
| - form_variant.num_questions) |
35 |
| -keys_results = data_exporting.OutputSheet([grid_i.Field.TEST_FORM_CODE], |
36 |
| - form_variant.num_questions) |
37 |
| - |
38 |
| -progress = user_input.create_and_pack_progress(maximum=len(image_paths)) |
39 |
| - |
40 |
| -files_timestamp = datetime.now().replace(microsecond=0) |
41 |
| - |
42 |
| -debug_dir = output_folder / ( |
43 |
| - data_exporting.format_timestamp_for_file(files_timestamp) + "__debug") |
44 |
| -if debug_mode_on: |
45 |
| - data_exporting.make_dir_if_not_exists(debug_dir) |
46 |
| - |
47 |
| -try: |
48 |
| - for image_path in image_paths: |
49 |
| - if debug_mode_on: |
50 |
| - debug_path = debug_dir / image_path.stem |
51 |
| - data_exporting.make_dir_if_not_exists(debug_path) |
52 |
| - else: |
53 |
| - debug_path = None |
54 |
| - |
55 |
| - progress.set_status(f"Processing '{image_path.name}'.") |
56 |
| - image = image_utils.get_image(image_path, save_path=debug_path) |
57 |
| - prepared_image = image_utils.prepare_scan_for_processing( |
58 |
| - image, save_path=debug_path) |
59 |
| - |
60 |
| - corners = corner_finding.find_corner_marks(prepared_image, |
61 |
| - save_path=debug_path) |
62 |
| - |
63 |
| - # Dilates the image - removes black pixels from edges, which preserves |
64 |
| - # solid shapes while destroying nonsolid ones. By doing this after noise |
65 |
| - # removal and thresholding, it eliminates irregular things like W and M |
66 |
| - morphed_image = image_utils.dilate(prepared_image, |
67 |
| - save_path=debug_path) |
68 |
| - |
69 |
| - # Establish a grid |
70 |
| - grid = grid_r.Grid(corners, |
71 |
| - grid_i.GRID_HORIZONTAL_CELLS, |
72 |
| - grid_i.GRID_VERTICAL_CELLS, |
73 |
| - morphed_image, |
74 |
| - save_path=debug_path) |
75 |
| - |
76 |
| - # Calculate fill percent for every bubble |
77 |
| - field_fill_percents = { |
78 |
| - key: grid_r.get_group_from_info(value, |
79 |
| - grid).get_all_fill_percents() |
80 |
| - for key, value in form_variant.fields.items() if value is not None |
81 |
| - } |
82 |
| - answer_fill_percents = [ |
83 |
| - grid_r.get_group_from_info(question, grid).get_all_fill_percents() |
84 |
| - for question in form_variant.questions |
85 |
| - ] |
86 |
| - |
87 |
| - # Calculate the fill threshold |
88 |
| - threshold = grid_r.calculate_bubble_fill_threshold( |
89 |
| - field_fill_percents, |
90 |
| - answer_fill_percents, |
91 |
| - save_path=debug_path, |
92 |
| - form_variant=form_variant) |
93 |
| - |
94 |
| - # Get the answers for questions |
95 |
| - answers = [ |
96 |
| - grid_r.read_answer_as_string(i, grid, multi_answers_as_f, |
97 |
| - threshold, form_variant, |
98 |
| - answer_fill_percents[i]) |
99 |
| - for i in range(form_variant.num_questions) |
100 |
| - ] |
101 |
| - |
102 |
| - field_data: tp.Dict[grid_i.RealOrVirtualField, str] = {} |
103 |
| - |
104 |
| - # Read the Student ID. If it indicates this exam is a key, treat it as such |
105 |
| - student_id = grid_r.read_field_as_string( |
106 |
| - grid_i.Field.STUDENT_ID, grid, threshold, form_variant, |
107 |
| - field_fill_percents[grid_i.Field.STUDENT_ID]) |
108 |
| - if student_id == grid_i.KEY_STUDENT_ID: |
109 |
| - form_code_field = grid_i.Field.TEST_FORM_CODE |
110 |
| - field_data[form_code_field] = grid_r.read_field_as_string( |
111 |
| - form_code_field, grid, threshold, form_variant, |
112 |
| - field_fill_percents[form_code_field]) or "" |
113 |
| - keys_results.add(field_data, answers) |
114 |
| - |
115 |
| - else: |
116 |
| - for field in form_variant.fields.keys(): |
117 |
| - field_value = grid_r.read_field_as_string( |
118 |
| - field, grid, threshold, form_variant, |
119 |
| - field_fill_percents[field]) |
120 |
| - if field_value is not None: |
121 |
| - field_data[field] = field_value |
122 |
| - answers_results.add(field_data, answers) |
123 |
| - progress.step_progress() |
124 |
| - |
125 |
| - answers_results.clean_up( |
126 |
| - replace_empty_with="G" if empty_answers_as_g else "") |
127 |
| - answers_results.save(output_folder, |
128 |
| - "results", |
129 |
| - sort_results, |
130 |
| - timestamp=files_timestamp) |
131 |
| - |
132 |
| - success_string = "✔️ All exams processed and saved.\n" |
133 |
| - |
134 |
| - if keys_file: |
135 |
| - keys_results.add_file(keys_file) |
136 |
| - |
137 |
| - if (keys_results.row_count == 0): |
138 |
| - success_string += "No exam keys were found, so no scoring was performed." |
139 |
| - elif (arrangement_file and keys_results.row_count == 1): |
140 |
| - answers_results.reorder(arrangement_file) |
141 |
| - keys_results.data[1][keys_results.field_columns.index( |
142 |
| - grid_i.Field.TEST_FORM_CODE)] = "" |
143 |
| - |
144 |
| - answers_results.save(output_folder, |
145 |
| - "rearranged_results", |
146 |
| - sort_results, |
147 |
| - timestamp=files_timestamp) |
148 |
| - success_string += "✔️ Results rearranged based on arrangement file.\n" |
149 |
| - |
150 |
| - keys_results.delete_field_column(grid_i.Field.TEST_FORM_CODE) |
151 |
| - keys_results.save(output_folder, |
152 |
| - "key", |
153 |
| - sort_results, |
154 |
| - timestamp=files_timestamp, |
155 |
| - transpose=True) |
156 |
| - |
157 |
| - success_string += "✔️ Key processed and saved.\n" |
158 |
| - |
159 |
| - scores = scoring.score_results(answers_results, keys_results, |
160 |
| - form_variant.num_questions) |
161 |
| - scores.save(output_folder, |
162 |
| - "rearranged_scores", |
163 |
| - sort_results, |
164 |
| - timestamp=files_timestamp) |
165 |
| - success_string += "✔️ Scored results processed and saved." |
166 |
| - elif (arrangement_file): |
167 |
| - success_string += "❌ Arrangement file and keys were ignored because more than one key was found." |
168 |
| - else: |
169 |
| - keys_results.save(output_folder, |
170 |
| - "keys", |
171 |
| - sort_results, |
172 |
| - timestamp=files_timestamp) |
173 |
| - success_string += "✔️ All keys processed and saved.\n" |
174 |
| - scores = scoring.score_results(answers_results, keys_results, |
175 |
| - form_variant.num_questions) |
176 |
| - scores.save(output_folder, |
177 |
| - "scores", |
178 |
| - sort_results, |
179 |
| - timestamp=files_timestamp) |
180 |
| - success_string += "✔️ All scored results processed and saved." |
181 |
| - |
182 |
| - if (output_mcta): |
183 |
| - transform_and_save_mcta_output(answers_results, keys_results, files_timestamp, output_folder) |
184 |
| - |
185 |
| - progress.set_status(success_string, False) |
186 |
| -except (RuntimeError, ValueError) as e: |
187 |
| - wrapped_err = "\n".join(textwrap.wrap(str(e), 70)) |
188 |
| - progress.set_status(f"Error: {wrapped_err}", False) |
189 |
| - if debug_mode_on: |
190 |
| - raise |
191 |
| -progress.show_exit_button_and_wait() |
| 7 | +from process_input import process_input |
| 8 | + |
| 9 | + |
| 10 | +if __name__ == '__main__': |
| 11 | + parser = argparse.ArgumentParser(description='OpenMCR: An accurate and simple exam bubble sheet reading tool.\n' |
| 12 | + 'Reads sheets from input folder, process and saves result in output folder.', |
| 13 | + formatter_class=argparse.RawTextHelpFormatter) |
| 14 | + parser.add_argument('input_folder', |
| 15 | + help='Path to a folder containing scanned input sheets.\n' |
| 16 | + 'Sheets with student ID of "9999999999" treated as keys. Ignores subfolders.', |
| 17 | + type=Path) |
| 18 | + parser.add_argument('output_folder', |
| 19 | + help='Path to a folder to save result to.', |
| 20 | + type=Path) |
| 21 | + parser.add_argument('--anskeys', |
| 22 | + help='Answer Keys CSV file path. If given, will be used over other keys.', |
| 23 | + type=Path) |
| 24 | + parser.add_argument('--formmap', |
| 25 | + help='Form Arrangement Map CSV file path. If given, only one answer key may be provided.', |
| 26 | + type=Path) |
| 27 | + parser.add_argument('--variant', |
| 28 | + default='75', |
| 29 | + choices=['75', '150'], |
| 30 | + help='Form variant either 75 questions (default) or 150 questions.') |
| 31 | + parser.add_argument('-ml', '--multiple', |
| 32 | + action='store_true', |
| 33 | + help='Convert multiple answers in a question to F, instead of [A|B].') |
| 34 | + parser.add_argument('-e', '--empty', |
| 35 | + action='store_true', |
| 36 | + help='Save empty answers as G. By default, they will be saved as blank values.') |
| 37 | + parser.add_argument('-s', '--sort', |
| 38 | + action='store_true', |
| 39 | + help="Sort output by students' name.") |
| 40 | + parser.add_argument('-d', '--debug', |
| 41 | + action='store_true', |
| 42 | + help='Turn debug mode on. Additional directory with debug data will be created.') |
| 43 | + parser.add_argument('--mcta', |
| 44 | + action='store_true', |
| 45 | + help='Output additional files for Multiple Choice Test Analysis.') |
| 46 | + |
| 47 | + # prints help and exits when called w/o arguments |
| 48 | + if len(sys.argv) == 1: |
| 49 | + parser.print_help(sys.stderr) |
| 50 | + sys.exit(1) |
| 51 | + |
| 52 | + args = parser.parse_args() |
| 53 | + |
| 54 | + image_paths = file_handling.filter_images(file_handling.list_file_paths(args.input_folder)) |
| 55 | + output_folder = args.output_folder |
| 56 | + multi_answers_as_f = args.multiple |
| 57 | + empty_answers_as_g = args.empty |
| 58 | + keys_file = args.anskeys |
| 59 | + arrangement_file = args.formmap |
| 60 | + sort_results = args.sort |
| 61 | + output_mcta = args.mcta |
| 62 | + debug_mode_on = args.debug |
| 63 | + form_variant = grid_i.form_150q if args.variant == '150' else grid_i.form_75q |
| 64 | + process_input(image_paths, |
| 65 | + output_folder, |
| 66 | + multi_answers_as_f, |
| 67 | + empty_answers_as_g, |
| 68 | + keys_file, |
| 69 | + arrangement_file, |
| 70 | + sort_results, |
| 71 | + output_mcta, |
| 72 | + debug_mode_on, |
| 73 | + form_variant, |
| 74 | + None) |
0 commit comments