Skip to content

Commit a3b592c

Browse files
iansan5653N1m6gajendhirGajendra Singh Dhir
authored
Release v1.2.0 (#62)
* Update release branch with latest from main (#53) * Create funding.yml to enable Sponsor button (#50) * Add PDF report link to readme (#51) * Add a CLI (#58) * Create process_input.py to wrap input processing from main.py. Rename main.py to main_gui.py and modify it to call new process_input module. Create new main.py which implement command line interface. * Change main.py to main_gui.py in fifth instruction in build_instructions.md. Adding instructions how to run cli or gui from source to readme.md. * Update code/main.py Co-authored-by: Ian Sanders <[email protected]> * Update code/main.py Co-authored-by: Ian Sanders <[email protected]> * Updating main.py Removing name and usage parameters to ArgumentParser call * Little doc string Co-authored-by: Ian Sanders <[email protected]> * Fix startup error on Linux by only setting TKinter window icon on Windows (#56) * Disable TKinter window icon on non-Windows devices * Expand 'Running From Source' readme section * Add source file column (with resolved conflicts) (#60) * Add "Source File" column to output CSV files (#48) * source_file column added to results output * Source File Name in Keys File * changes as recommended by Ian Co-authored-by: Gajendra Singh Dhir <[email protected]> * Fix type annotation syntax error in process_input.py * Improve field_data dict initialization Co-authored-by: Gajendra Singh Dhir <[email protected]> Co-authored-by: Gajendra Singh Dhir <[email protected]> * Improve handling of corner-finding failures (#61) * Add example to demonstrate issue #59 * Update debug config to run GUI instead of CLI * Improve handling of corner-finding failures Instead of rejecting the entire run if corners can't be found for a file, the status message will now indicate that one or more files failed to process and a rejected_files.csv will be output. Co-authored-by: N1m6 <[email protected]> Co-authored-by: Gajendra Singh Dhir <[email protected]> Co-authored-by: Gajendra Singh Dhir <[email protected]>
1 parent dfd1390 commit a3b592c

12 files changed

+350
-206
lines changed

.vscode/launch.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
"version": "0.2.0",
66
"configurations": [
77
{
8-
"name": "Python: main.py",
8+
"name": "Python: main_gui.py",
99
"type": "python",
1010
"request": "launch",
11-
"program": "code/main.py",
11+
"program": "code/main_gui.py",
1212
"console": "integratedTerminal"
1313
}
1414
]

build_instructions.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ file that can be run to install the software.
1212
system's PATH variable.
1313
5. Run the build command:
1414
```sh
15-
pyinstaller -p code --add-data="code;." -y -w --icon=code/assets/icon.ico code/main.py; makensis installer.nsi
15+
pyinstaller -p code --add-data="code;." -y -w --icon=code/assets/icon.ico code/main_gui.py; makensis installer.nsi
1616
```

code/data_exporting.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
Field.TEST_FORM_CODE: "Test Form Code",
1818
Field.STUDENT_ID: "Student ID",
1919
Field.COURSE_ID: "Course ID",
20+
Field.IMAGE_FILE: 'Source File',
2021
VirtualField.SCORE: "Total Score (%)",
2122
VirtualField.POINTS: "Total Points"
2223
}
@@ -57,15 +58,16 @@ class OutputSheet():
5758
num_questions: int
5859
row_count: int
5960
first_question_column_index: int
60-
form_code_column_index: int
61+
form_code_column_index: tp.Optional[int]
6162

6263
def __init__(self, columns: tp.List[RealOrVirtualField], num_questions: int):
6364
self.field_columns = columns
6465
self.num_questions = num_questions
6566
field_column_names = [COLUMN_NAMES[column] for column in columns]
6667
answer_columns = [f"Q{i + 1}" for i in range(self.num_questions)]
6768
self.first_question_column_index = len(field_column_names)
68-
self.form_code_column_index = self.field_columns.index(Field.TEST_FORM_CODE)
69+
self.form_code_column_index = self.field_columns.index(
70+
Field.TEST_FORM_CODE) if (Field.TEST_FORM_CODE in self.field_columns) else None
6971
self.data = [field_column_names + answer_columns]
7072
self.row_count = 0
7173

code/grid_info.py

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Field(enum.Enum):
1717
TEST_FORM_CODE = enum.auto()
1818
STUDENT_ID = enum.auto()
1919
COURSE_ID = enum.auto()
20+
IMAGE_FILE = enum.auto()
2021

2122

2223
class VirtualField(enum.Enum):

code/main.py

+71-188
Original file line numberDiff line numberDiff line change
@@ -1,191 +1,74 @@
1-
import textwrap
2-
import typing as tp
3-
from datetime import datetime
1+
import argparse
2+
import sys
3+
from pathlib import Path
44

5-
import corner_finding
6-
import data_exporting
75
import file_handling
86
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)

code/main_gui.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import file_handling
2+
import grid_info as grid_i
3+
import user_interface
4+
import sys
5+
from process_input import process_input
6+
7+
user_input = user_interface.MainWindow()
8+
if (user_input.cancelled):
9+
sys.exit(0)
10+
11+
input_folder = user_input.input_folder
12+
image_paths = file_handling.filter_images(
13+
file_handling.list_file_paths(input_folder))
14+
output_folder = user_input.output_folder
15+
multi_answers_as_f = user_input.multi_answers_as_f
16+
empty_answers_as_g = user_input.empty_answers_as_g
17+
keys_file = user_input.keys_file
18+
arrangement_file = user_input.arrangement_map
19+
sort_results = user_input.sort_results
20+
output_mcta = user_input.output_mcta
21+
debug_mode_on = user_input.debug_mode
22+
form_variant = grid_i.form_150q if user_input.form_variant == user_interface.FormVariantSelection.VARIANT_150_Q else grid_i.form_75q
23+
progress_tracker = user_input.create_and_pack_progress(maximum=len(image_paths))
24+
25+
process_input(image_paths,
26+
output_folder,
27+
multi_answers_as_f,
28+
empty_answers_as_g,
29+
keys_file,
30+
arrangement_file,
31+
sort_results,
32+
output_mcta,
33+
debug_mode_on,
34+
form_variant,
35+
progress_tracker)

0 commit comments

Comments
 (0)