Build an OMR Recognition Core
Build a Python engine that reads and scores standardized OMR sheets.
Introduction
30 Second Summary
A marked exam sheet looks simple until a shadow or camera angle shifts every bubble away from where the scanner expects it. One uncertain set mark can also attach all 100 responses to the wrong answer key.
In this project, you will build a local fixed-layout OMR recognition engine with OpenCV. It will turn a distorted 100-question Set A sheet into a confidence-aware JSON evaluation.
What You'll Build
You will run the finished workflow to watch a skewed phone-like image recover into 98 accepted responses before a 96.75 score appears beside a 100-question audit trail.
By the end of this project, you'll have:
- A standardized 100-question OMR sheet that you can open to see Set A selected beside four reference markers. You can inspect all 100 rows of A to D bubbles.
- A visible fixed-coordinate failure on the distorted sheet. The corrected scan recovers Set A with 98 accepted responses.
- A review-ready evaluation record showing 97 correct responses with one wrong response. It also preserves every unanswered or multiple response with its confidence and awarded marks.
- Secret Mission: Prove that sheets with both or neither question-paper set marked enter REVIEW_REQUIRED without receiving a score.
Are there any prerequisites?
You should be comfortable writing Python functions and reading JSON. No computer-vision experience is required.
Before We Start
This first checkpoint locks in your target: a fixed-layout OMR core for a 100-question A-D sheet with four alignment markers plus exactly one selected paper set. If both or neither Set A and Set B are marked, evaluation stops with REVIEW_REQUIRED before scoring.
Set Up the Python CV Workspace
A reproducible recognition test needs one supported runtime with exact dependencies. Different library versions can change how images are loaded or measured.
In this step, you'll prepare an omr-recognition-core workspace in Visual Studio Code with Python 3.13. A virtual environment will isolate the pinned OpenCV plus NumPy packages.
In this step, get ready to:
- Confirm that Python 3.13 is available through the Windows Python launcher.
- Prepare an isolated omr-recognition-core workspace in Visual Studio Code.
- Install the pinned image-processing dependencies inside the workspace.
Confirm Python 3.13
The project targets Python 3.13 so every learner starts from the same runtime. The first check reveals which Python version your current terminal finds.
- Press the Windows key to open the search bar.
- Type Command Prompt into the search bar.
- Press Enter to open Command Prompt.
- Check your current Python version by running this command:
python --version
What does this check prove?
The python --version command reports the runtime found by the current terminal. Use the matching tab below to reach Python 3.13.
✔️ I see Python 3.13
Your supported runtime is ready. That removes the first source of variation from the recognition workspace.
- Continue to the workspace setup below.
ⓧ I see a different version
Windows can keep multiple Python runtimes side by side. The Python launcher lets this project target version 3.13 without replacing your other runtime.
- Install Python 3.13 by running this command:
py install 3.13
What does this command do?
The Windows Python launcher installs the maintained Python 3.13 runtime. Your existing Python installation can remain available for other projects.
- Verify the installed runtime through the launcher by running:
py -V:3.13
What should I see?
The output identifies Python 3.13. This confirms that the launcher can select the runtime required by this project.
Python 3.13 did not install?
Check that your internet connection is available. Run the install command again from a fresh Command Prompt window.
If the Python launcher is unavailable, use the recommended 64-bit Windows installer from the official Python 3.13.15 release page.
Help me install Python 3.13 on Windows.
ⓧ Command not found
The current terminal cannot find a default Python runtime. The Windows Python launcher can install the required version.
- Install Python 3.13 by running this command:
py install 3.13
What does this command do?
The Python launcher downloads the Python 3.13 runtime for Windows. It also makes that runtime available through the launcher.
Is the Python launcher missing too?
Use the recommended 64-bit Windows installer from the official Python 3.13.15 release page. Open a fresh Command Prompt window after installation finishes.
Help me install the Python launcher on Windows.
- Verify the installed runtime through the launcher by running:
py -V:3.13
What should I see?
The output identifies Python 3.13. The Windows Python launcher is now ready to create the project environment.
Create the isolated workspace
The workspace keeps every file for the recognition core in one named folder. Visual Studio Code also gives you an integrated terminal that starts from this folder.
- Press the Windows key to open the search bar.
- Type Visual Studio Code into the search bar.
- Press Enter to open Visual Studio Code.
You'll see the Visual Studio Code start window. The workspace is ready to be placed on your Desktop.
- Click File in the top menu.
- Select Open Folder.
You'll see the Windows folder picker. This is where you create the empty workspace folder.
- Select Desktop as the folder location.
- Click New folder.
You'll see a field for the new folder name.
- Type omr-recognition-core as the folder name.
- Press Enter to create the folder.
The new omr-recognition-core folder now appears on your Desktop.
- Select the omr-recognition-core folder.
- Click Select Folder to open the workspace.
You'll see omr-recognition-core at the top of the Explorer sidebar. The empty workspace is now open.
- Click Terminal in the top menu.
- Select New Terminal to open an integrated terminal inside the workspace.
The terminal prompt points to the omr-recognition-core folder. Commands run here now affect this workspace.
- Create the Python 3.13 virtual environment by running:
py -3.13 -m venv .venv
What does this command do?
The launcher selects Python 3.13 for the new environment. The environment files live inside the .venv folder.
Packages installed after activation stay isolated from your other Python projects. This keeps the OMR results reproducible.
- Confirm that .venv appears in the Visual Studio Code Explorer sidebar.
The .venv folder proves that Python created the isolated environment inside the correct workspace.
Do not see the .venv folder?
Confirm that the terminal path ends with the omr-recognition-core folder. Check that the Python launcher verification succeeded before running the environment command again.
Help me create the virtual environment.
Activation directs package commands into the new environment. Choose the tab that matches the shell name shown in your Visual Studio Code terminal.
Command Prompt
- Activate .venv from Command Prompt by running:
.venv\Scripts\activate.bat
What should change?
The terminal prompt gains the virtual environment name. Future package commands in this terminal now use the isolated environment.
PowerShell
- Activate .venv from PowerShell by running:
.venv\Scripts\Activate.ps1
What should change?
The terminal prompt gains the virtual environment name. Future package commands in this terminal now use the isolated environment.
PowerShell blocked activation?
Use the Command Prompt activation tab instead. This keeps the project moving without changing your PowerShell execution policy.
Help me activate the virtual environment.
Your isolated workspace is active. The next package installation stays contained inside omr-recognition-core.
Install and verify the pinned packages
A requirements file records the exact package builds used by the recognition core. Anyone can install the same environment from this two-line file.
- Click the New File button in the Visual Studio Code Explorer sidebar.
- Enter requirements.txt as the file name.
You'll see an empty requirements.txt editor tab inside the omr-recognition-core workspace.
- Set the exact dependency pins by pasting these lines into requirements.txt:
opencv-python==5.0.0.93
numpy==2.5.3
What does this file control?
- The opencv-python==5.0.0.93 pin provides OpenCV through its cv2 import namespace.
- The numpy==2.5.3 pin provides the array operations used for image coordinates plus masks.
- The exact pins keep package upgrades from changing the project during this build.
- Press Ctrl+S to save requirements.txt.
✔️ Awesome, I've got everything!
Great. Your saved requirements.txt file now defines both pinned dependencies.
ⓧ I'd like to double check the full code
- Compare your requirements.txt file with this complete version:
opencv-python==5.0.0.93
numpy==2.5.3
What should match?
Your file should contain these two package pins in the same order. Each version must match exactly.
The active environment is ready to receive the dependencies recorded in requirements.txt. The installation may pause while the package files download.
- Install the pinned dependencies into the active environment by running:
py -m pip install -r requirements.txt
What does this command do?
The command asks pip to read requirements.txt. Pip installs the exact OpenCV plus NumPy versions recorded there.
You'll see download plus installation progress in the terminal. A successful completion returns control to the active environment prompt.
Did the package installation fail?
Confirm that requirements.txt is saved inside omr-recognition-core. Check that your terminal prompt still shows the virtual environment name.
Make sure the two lines in the file match the double-check tab exactly. A changed package name or version prevents the intended environment from installing.
Help me fix the dependency installation.
Before you run the final check, which two pinned packages do you expect to find?
- Inspect the active environment's installed packages by running:
py -m pip list
What should I see?
The package list includes numpy 2.5.3 plus opencv-python 5.0.0.93. These entries confirm that the recognition workspace contains the required dependency versions.
That's the workspace ready. Python can now import the exact image-processing libraries used throughout the recognition core.
Your Python computer-vision workspace is reproducible and ready. Next up, you'll generate the standardized 100-question OMR sheet that every scan is measured against.
Generate the OMR Sheet
Your Python 3.13 workspace can now run OpenCV and NumPy. The recognition engine still needs a standardized sheet whose marker positions and bubble coordinates are known in advance.
In this step, you will define that canonical coordinate map. You will use it to generate a 100-question Set A sheet plus a perspective-distorted test image.
In this step, get ready to:
- Define the fixed sheet geometry plus separate Set A and Set B answer keys.
- Render four alignment markers plus 100 rows of answer bubbles.
- Generate a canonical sample plus a perspective-distorted copy.
Define the canonical grid and keys
A fixed-layout OMR sheet assigns every marker and bubble a known pixel coordinate. Those coordinates become the shared map used by the generator now and the scanner later.
- Click the New File icon in the Visual Studio Code Explorer sidebar.
- Enter omr_layout.py as the file name.
- Add the imports plus the canonical sheet geometry by pasting this code into omr_layout.py:
import cv2 as cv
import numpy as np
SHEET_WIDTH = 1200
SHEET_HEIGHT = 1600
MARKER_HALF_SIZE = 20
MARKER_CENTERS = np.array(
[(70, 70), (1130, 70), (1130, 1530), (70, 1530)],
dtype="float32",
)
OPTIONS = ("A", "B", "C", "D")
SET_CENTERS = {"A": (510, 245), "B": (680, 245)}
QUESTION_COLUMN_X = (70, 350, 630, 910)
QUESTION_START_Y = 355
QUESTION_ROW_GAP = 46
OPTION_X_OFFSETS = (70, 120, 170, 220)
BUBBLE_RADIUS = 10
FILL_RADIUS = 6
What does this code define?
- The sheet canvas measures 1200 pixels wide by 1600 pixels tall.
- The four marker centers anchor the corners of the readable area.
- The question-grid values place four columns of 25 questions.
- The bubble radii separate each printed outline from its filled center.
- Save omr_layout.py.
- Confirm the geometry loads by running this command in the activated terminal:
python -c "import omr_layout; print(omr_layout.SHEET_WIDTH, omr_layout.SHEET_HEIGHT)"
What should I see?
You should see 1200 1600 in the terminal. This confirms that Python can import the new module and read its sheet dimensions.
- Add the two deterministic answer keys below the layout constants:
def demo_answer_keys():
set_a = [OPTIONS[index % 4] for index in range(100)]
set_b = [OPTIONS[(index + 2) % 4] for index in range(100)]
return {"A": set_a, "B": set_b}
How are the keys different?
The Set A key cycles through A to D. The Set B key shifts that cycle by two positions so each set has an independent sequence.
- Save omr_layout.py.
- Confirm that the Set A key contains 100 answers by running:
python -c "import omr_layout; print(len(omr_layout.demo_answer_keys()['A']))"
What does the result prove?
You should see 100. The generated Set A key now covers every question on the sheet.
- Add strict answer-key validation below demo_answer_keys():
def validate_answer_key(set_code, answers):
if set_code not in ("A", "B"):
raise ValueError("Set code must be A or B.")
if len(answers) != 100:
raise ValueError(f"Set {set_code} must contain exactly 100 answers.")
if any(answer not in OPTIONS for answer in answers):
raise ValueError(f"Set {set_code} contains an invalid or blank answer.")
Why validate both keys?
The validator accepts only Set A or Set B. It also requires exactly 100 answers from the allowed A to D options.
These checks stop an incomplete key from entering the scoring workflow.
- Add the coordinate helper below validate_answer_key():
def answer_center(question_number, option):
if not 1 <= question_number <= 100:
raise ValueError("Question number must be between 1 and 100.")
if option not in OPTIONS:
raise ValueError("Option must be A, B, C, or D.")
column = (question_number - 1) // 25
row = (question_number - 1) % 25
x = QUESTION_COLUMN_X[column] + OPTION_X_OFFSETS[OPTIONS.index(option)]
y = QUESTION_START_Y + row * QUESTION_ROW_GAP
return x, y
How does the coordinate map work?
Each group of 25 questions occupies one column. The helper converts a question number plus an answer option into the center pixel of that bubble.
- Save omr_layout.py.
- Check the key validation plus the first A bubble coordinate by running:
python -c "import omr_layout as m; m.validate_answer_key('A', m.demo_answer_keys()['A']); print(m.answer_center(1, 'A'))"
What should I see?
You should see (140, 355). The valid Set A key passes without an error before Python prints the first bubble coordinate.
Seeing an import or syntax error?
Confirm that the file is named omr_layout.py inside the open omr-recognition-core folder. Check that each function body keeps its four-space indentation.
Help me debug my OMR layout constants and answer-key functions.
Render controlled responses
A useful test sheet needs known response states. This sample keeps most answers correct while placing one wrong answer at question 10, one blank at question 20, and an AC multiple response at question 30.
- Add the controlled response builder below answer_center():
def make_demo_responses(answer_key):
responses = answer_key.copy()
original = responses[9]
responses[9] = OPTIONS[(OPTIONS.index(original) + 1) % len(OPTIONS)]
responses[19] = ""
responses[29] = "AC"
return responses
What does this function control?
The copied key starts with 100 correct responses. The three indexed changes create the wrong, unanswered, and multiple states that later scanner steps must preserve.
- Start create_sheet() below make_demo_responses() with the validation, blank canvas, markers, and title:
def create_sheet(set_selection, responses):
if len(responses) != 100:
raise ValueError("A sheet must contain exactly 100 response states.")
if any(not set(response).issubset(OPTIONS) for response in responses):
raise ValueError("Responses may contain only A, B, C, and D marks.")
sheet = np.ones((SHEET_HEIGHT, SHEET_WIDTH), dtype="uint8") * 255
for center_x, center_y in MARKER_CENTERS.astype(int):
cv.rectangle(
sheet,
(center_x - MARKER_HALF_SIZE, center_y - MARKER_HALF_SIZE),
(center_x + MARKER_HALF_SIZE, center_y + MARKER_HALF_SIZE),
0,
-1,
)
cv.putText(
sheet,
"STANDARDIZED 100-QUESTION OMR",
(250, 105),
cv.FONT_HERSHEY_SIMPLEX,
1.05,
0,
2,
cv.LINE_AA,
)
How does the canvas start?
A NumPy array creates the white grayscale canvas. OpenCV fills four black squares at the known marker centers before drawing the sheet title.
- Continue create_sheet() with the student fields plus the Set A and Set B bubbles:
cv.putText(sheet, "Student Name: ____________________", (120, 160), cv.FONT_HERSHEY_SIMPLEX, 0.65, 0, 1, cv.LINE_AA)
cv.putText(sheet, "Student ID: ______________________", (650, 160), cv.FONT_HERSHEY_SIMPLEX, 0.65, 0, 1, cv.LINE_AA)
cv.putText(sheet, "Class/Section: ___________________", (120, 200), cv.FONT_HERSHEY_SIMPLEX, 0.65, 0, 1, cv.LINE_AA)
cv.putText(sheet, "QUESTION PAPER SET", (360, 255), cv.FONT_HERSHEY_SIMPLEX, 0.7, 0, 2, cv.LINE_AA)
for code, center in SET_CENTERS.items():
cv.circle(sheet, center, BUBBLE_RADIUS, 0, 2)
cv.putText(sheet, f"SET {code}", (center[0] + 18, center[1] + 6), cv.FONT_HERSHEY_SIMPLEX, 0.55, 0, 1, cv.LINE_AA)
if code in set_selection:
cv.circle(sheet, center, FILL_RADIUS, 0, -1)
How is the paper set recorded?
Both set bubbles are always drawn. The selected code receives a filled center so the scanner can determine which answer key applies.
- Finish create_sheet() with the 100 numbered question rows:
for question_number in range(1, 101):
column = (question_number - 1) // 25
row = (question_number - 1) % 25
base_x = QUESTION_COLUMN_X[column]
y = QUESTION_START_Y + row * QUESTION_ROW_GAP
cv.putText(sheet, f"{question_number:03d}", (base_x, y + 5), cv.FONT_HERSHEY_SIMPLEX, 0.38, 0, 1, cv.LINE_AA)
response = responses[question_number - 1]
for option in OPTIONS:
center = answer_center(question_number, option)
cv.circle(sheet, center, BUBBLE_RADIUS, 0, 2)
cv.putText(sheet, option, (center[0] + 13, center[1] + 4), cv.FONT_HERSHEY_SIMPLEX, 0.28, 0, 1, cv.LINE_AA)
if option in response:
cv.circle(sheet, center, FILL_RADIUS, 0, -1)
return sheet
How are 100 questions rendered?
The outer loop assigns each question to one of four columns. The inner loop draws A to D bubbles and fills every option found in that question's controlled response.
- Save omr_layout.py.
- Check the rendered array plus the three controlled response states by running:
python -c "import omr_layout as m; k=m.demo_answer_keys(); r=m.make_demo_responses(k['A']); print(m.create_sheet('A', r).shape, (r[9], r[19], r[29]))"
What should I see?
You should see (1600, 1200) ('C', '', 'AC'). This confirms the image dimensions plus the wrong, blank, and multiple test states.
Seeing a drawing error?
Check that every continuation of create_sheet() remains indented by four spaces. Confirm that the final return sheet aligns with the two validation checks at the top of the function.
Help me fix my OMR sheet rendering function.
Generate and inspect the images
A canonical image proves that the coordinate map renders correctly. A second image applies a controlled perspective transformation that resembles an angled phone capture.
- Add the perspective distortion function below create_sheet():
def distort_sheet(sheet):
source = np.array(
[(0, 0), (SHEET_WIDTH - 1, 0), (SHEET_WIDTH - 1, SHEET_HEIGHT - 1), (0, SHEET_HEIGHT - 1)],
dtype="float32",
)
destination = np.array(
[(120, 180), (SHEET_WIDTH - 100, 40), (SHEET_WIDTH - 30, SHEET_HEIGHT - 140), (40, SHEET_HEIGHT - 20)],
dtype="float32",
)
matrix = cv.getPerspectiveTransform(source, destination)
return cv.warpPerspective(
sheet,
matrix,
(SHEET_WIDTH, SHEET_HEIGHT),
flags=cv.INTER_LINEAR,
borderMode=cv.BORDER_CONSTANT,
borderValue=235,
)
What does the distortion do?
Four source corners map to four offset destination points. OpenCV uses that mapping to stretch the canonical image while keeping the output canvas at the standard dimensions.
- Add the generator entry point below distort_sheet():
def main():
answer_keys = demo_answer_keys()
validate_answer_key("A", answer_keys["A"])
validate_answer_key("B", answer_keys["B"])
responses = make_demo_responses(answer_keys["A"])
canonical = create_sheet("A", responses)
skewed = distort_sheet(canonical)
cv.imwrite("sample_set_a.png", canonical)
cv.imwrite("sample_set_a_skewed.png", skewed)
print("Created sample_set_a.png")
print("Created sample_set_a_skewed.png")
if __name__ == "__main__":
main()
What does the entry point create?
The entry point validates both answer keys before building responses from Set A. It saves the canonical image plus its distorted copy as separate PNG files.
- Save omr_layout.py.
Before you run the generator, which two image files do you expect it to create?
- Generate both OMR images by running this command in the activated terminal:
python omr_layout.py
What should I see?
You should see Created sample_set_a.png followed by Created sample_set_a_skewed.png. Both image files should also appear in the Visual Studio Code Explorer.
- Click sample_set_a.png in the Explorer sidebar.
You should see four black corner markers plus Set A filled near the top. The page should contain 100 numbered rows with A to D bubbles across four columns.
- Click sample_set_a_skewed.png in the Explorer sidebar.
You should see the same sheet pulled into an angled perspective. This distorted copy becomes the controlled failure case for fixed-coordinate scanning.
Images missing or incomplete?
Confirm that the terminal still shows the activated .venv environment. Check the terminal output for the function name and line number associated with the failure.
If the images exist but the preview looks incomplete, compare the indentation inside create_sheet() with the full file below.
Help me troubleshoot my generated OMR images.
✔️ Awesome, I've got everything!
Great work. Your canonical Set A sheet plus its perspective-distorted copy are saved in omr-recognition-core.
ⓧ I'd like to double check the full code
Compare your complete omr_layout.py file with this version:
import cv2 as cv
import numpy as np
SHEET_WIDTH = 1200
SHEET_HEIGHT = 1600
MARKER_HALF_SIZE = 20
MARKER_CENTERS = np.array(
[(70, 70), (1130, 70), (1130, 1530), (70, 1530)],
dtype="float32",
)
OPTIONS = ("A", "B", "C", "D")
SET_CENTERS = {"A": (510, 245), "B": (680, 245)}
QUESTION_COLUMN_X = (70, 350, 630, 910)
QUESTION_START_Y = 355
QUESTION_ROW_GAP = 46
OPTION_X_OFFSETS = (70, 120, 170, 220)
BUBBLE_RADIUS = 10
FILL_RADIUS = 6
def demo_answer_keys():
set_a = [OPTIONS[index % 4] for index in range(100)]
set_b = [OPTIONS[(index + 2) % 4] for index in range(100)]
return {"A": set_a, "B": set_b}
def validate_answer_key(set_code, answers):
if set_code not in ("A", "B"):
raise ValueError("Set code must be A or B.")
if len(answers) != 100:
raise ValueError(f"Set {set_code} must contain exactly 100 answers.")
if any(answer not in OPTIONS for answer in answers):
raise ValueError(f"Set {set_code} contains an invalid or blank answer.")
def answer_center(question_number, option):
if not 1 <= question_number <= 100:
raise ValueError("Question number must be between 1 and 100.")
if option not in OPTIONS:
raise ValueError("Option must be A, B, C, or D.")
column = (question_number - 1) // 25
row = (question_number - 1) % 25
x = QUESTION_COLUMN_X[column] + OPTION_X_OFFSETS[OPTIONS.index(option)]
y = QUESTION_START_Y + row * QUESTION_ROW_GAP
return x, y
def make_demo_responses(answer_key):
responses = answer_key.copy()
original = responses[9]
responses[9] = OPTIONS[(OPTIONS.index(original) + 1) % len(OPTIONS)]
responses[19] = ""
responses[29] = "AC"
return responses
def create_sheet(set_selection, responses):
if len(responses) != 100:
raise ValueError("A sheet must contain exactly 100 response states.")
if any(not set(response).issubset(OPTIONS) for response in responses):
raise ValueError("Responses may contain only A, B, C, and D marks.")
sheet = np.ones((SHEET_HEIGHT, SHEET_WIDTH), dtype="uint8") * 255
for center_x, center_y in MARKER_CENTERS.astype(int):
cv.rectangle(
sheet,
(center_x - MARKER_HALF_SIZE, center_y - MARKER_HALF_SIZE),
(center_x + MARKER_HALF_SIZE, center_y + MARKER_HALF_SIZE),
0,
-1,
)
cv.putText(
sheet,
"STANDARDIZED 100-QUESTION OMR",
(250, 105),
cv.FONT_HERSHEY_SIMPLEX,
1.05,
0,
2,
cv.LINE_AA,
)
cv.putText(sheet, "Student Name: ____________________", (120, 160), cv.FONT_HERSHEY_SIMPLEX, 0.65, 0, 1, cv.LINE_AA)
cv.putText(sheet, "Student ID: ______________________", (650, 160), cv.FONT_HERSHEY_SIMPLEX, 0.65, 0, 1, cv.LINE_AA)
cv.putText(sheet, "Class/Section: ___________________", (120, 200), cv.FONT_HERSHEY_SIMPLEX, 0.65, 0, 1, cv.LINE_AA)
cv.putText(sheet, "QUESTION PAPER SET", (360, 255), cv.FONT_HERSHEY_SIMPLEX, 0.7, 0, 2, cv.LINE_AA)
for code, center in SET_CENTERS.items():
cv.circle(sheet, center, BUBBLE_RADIUS, 0, 2)
cv.putText(sheet, f"SET {code}", (center[0] + 18, center[1] + 6), cv.FONT_HERSHEY_SIMPLEX, 0.55, 0, 1, cv.LINE_AA)
if code in set_selection:
cv.circle(sheet, center, FILL_RADIUS, 0, -1)
for question_number in range(1, 101):
column = (question_number - 1) // 25
row = (question_number - 1) % 25
base_x = QUESTION_COLUMN_X[column]
y = QUESTION_START_Y + row * QUESTION_ROW_GAP
cv.putText(sheet, f"{question_number:03d}", (base_x, y + 5), cv.FONT_HERSHEY_SIMPLEX, 0.38, 0, 1, cv.LINE_AA)
response = responses[question_number - 1]
for option in OPTIONS:
center = answer_center(question_number, option)
cv.circle(sheet, center, BUBBLE_RADIUS, 0, 2)
cv.putText(sheet, option, (center[0] + 13, center[1] + 4), cv.FONT_HERSHEY_SIMPLEX, 0.28, 0, 1, cv.LINE_AA)
if option in response:
cv.circle(sheet, center, FILL_RADIUS, 0, -1)
return sheet
def distort_sheet(sheet):
source = np.array(
[(0, 0), (SHEET_WIDTH - 1, 0), (SHEET_WIDTH - 1, SHEET_HEIGHT - 1), (0, SHEET_HEIGHT - 1)],
dtype="float32",
)
destination = np.array(
[(120, 180), (SHEET_WIDTH - 100, 40), (SHEET_WIDTH - 30, SHEET_HEIGHT - 140), (40, SHEET_HEIGHT - 20)],
dtype="float32",
)
matrix = cv.getPerspectiveTransform(source, destination)
return cv.warpPerspective(
sheet,
matrix,
(SHEET_WIDTH, SHEET_HEIGHT),
flags=cv.INTER_LINEAR,
borderMode=cv.BORDER_CONSTANT,
borderValue=235,
)
def main():
answer_keys = demo_answer_keys()
validate_answer_key("A", answer_keys["A"])
validate_answer_key("B", answer_keys["B"])
responses = make_demo_responses(answer_keys["A"])
canonical = create_sheet("A", responses)
skewed = distort_sheet(canonical)
cv.imwrite("sample_set_a.png", canonical)
cv.imwrite("sample_set_a_skewed.png", skewed)
print("Created sample_set_a.png")
print("Created sample_set_a_skewed.png")
if __name__ == "__main__":
main()
Your controlled OMR test pair is ready. Next, you will measure its filled bubbles with fixed coordinates and watch the distorted image expose the weakness in that approach.
Read Bubbles with Fixed Coordinates
Your canonical OMR sheet now provides a known grid for an OpenCV scanner to measure. This turns the generated marks into data that your evaluation logic can use.
A fixed-layout OMR scan depends on every bubble staying at its expected coordinate. The distorted sample moves those bubbles away from the grid. This step makes that failure visible before marker correction solves it.
In this step, get ready to:
- Measure the ink inside each bubble with a circular mask.
- Classify the set field and all 100 responses with confidence values.
- Compare fixed-coordinate scanning on the canonical and distorted sheets.
Measure ink inside each bubble
The scanner first converts each grayscale image into an ink mask through image thresholding. Dark pixels become measurable white pixels in the mask.
- Select the New File button in the Visual Studio Code Explorer sidebar.
- Type scanner.py in the file name field.
- Press Enter to create the file.
- Add the scanner imports and image loader by pasting this code into scanner.py:
import cv2 as cv
import numpy as np
from omr_layout import (
BUBBLE_RADIUS,
OPTIONS,
SET_CENTERS,
answer_center,
)
LOW_CONFIDENCE_RATIO = 0.25
MARKED_RATIO = 0.65
def load_grayscale(path):
image = cv.imread(path, cv.IMREAD_GRAYSCALE)
if image is None:
raise FileNotFoundError(f"Could not read image: {path}")
return image
What does this code do?
- The layout imports give the scanner the exact bubble radius and coordinates used to draw the sheet.
- The two ratio constants define the uncertain range and the strong-mark boundary.
- The load_grayscale() function loads one image as shades of gray. It stops immediately if the path cannot be read.
- Save scanner.py.
- Confirm that scanner.py now appears beside omr_layout.py in the Explorer sidebar.
Is the scanner file missing?
Check that you created scanner.py inside the existing omr-recognition-core folder. A file created outside that folder does not appear beside the layout generator.
Ask for help checking the file location and imports.
A rectangular sample would include the printed bubble outline. A circular NumPy mask measures only the inner area where a student mark belongs.
- Add the thresholding and circular fill measurement below load_grayscale() by pasting this code:
def make_ink_mask(image):
_, binary = cv.threshold(image, 180, 255, cv.THRESH_BINARY_INV)
return binary
def fill_ratio(binary, center):
sample_radius = BUBBLE_RADIUS - 4
center_x, center_y = center
roi = binary[
center_y - sample_radius : center_y + sample_radius + 1,
center_x - sample_radius : center_x + sample_radius + 1,
]
expected_size = sample_radius * 2 + 1
if roi.shape != (expected_size, expected_size):
return 0.0
mask = np.zeros(roi.shape, dtype="uint8")
cv.circle(mask, (sample_radius, sample_radius), sample_radius, 255, -1)
pixels = roi[mask == 255]
return float(pixels.mean() / 255.0)
How is a fill ratio measured?
- The threshold of 180 converts darker pixels into white ink pixels against a black background.
- The region of interest surrounds the expected bubble center.
- The circular mask excludes corners and most of the printed outline.
- The pixel mean becomes a ratio from empty to filled.
- Save scanner.py.
- Expand the Visual Studio Code Outline panel to confirm that make_ink_mask and fill_ratio are listed.
Are the helper functions missing?
Check the indentation beneath each function definition. Also check that the closing square brackets around roi align with its assignment.
Ask for help finding an indentation or bracket problem.
Classify the fixed grid
A single fill ratio does not tell the whole story. Confidence-based classification compares every bubble in a group. It preserves uncertain marks as review states instead of forcing a letter.
- Add the bubble classifier below fill_ratio() by pasting this code:
def classify_bubbles(binary, centers, labels):
ratios = [fill_ratio(binary, center) for center in centers]
ranked = sorted(range(len(ratios)), key=lambda index: ratios[index], reverse=True)
top_index = ranked[0]
second_index = ranked[1]
strong = [index for index, ratio in enumerate(ratios) if ratio >= MARKED_RATIO]
if len(strong) > 1:
state = "MULTIPLE"
confidence = min(ratios[index] for index in strong)
elif len(strong) == 1 and ratios[second_index] < LOW_CONFIDENCE_RATIO:
state = labels[top_index]
confidence = ratios[top_index]
elif ratios[top_index] < LOW_CONFIDENCE_RATIO:
state = "UNANSWERED"
confidence = 1.0 - ratios[top_index]
else:
state = "UNCLEAR"
confidence = ratios[top_index]
return {
"state": state,
"confidence": round(confidence * 100.0, 2),
"fill_ratios": {
label: round(ratio, 3) for label, ratio in zip(labels, ratios)
},
}
How are response states chosen?
- Two strong ratios produce MULTIPLE because more than one bubble is filled.
- One strong ratio with a quiet runner-up produces its matching label.
- A low top ratio produces UNANSWERED because no bubble contains enough ink.
- A result between those boundaries produces UNCLEAR for review.
- Save scanner.py.
- Check the Outline panel to confirm that classify_bubbles appears below fill_ratio.
Is the classifier missing from Outline?
Check that classify_bubbles() begins at the left edge of the file. An extra indent can accidentally place it inside fill_ratio().
Ask for help checking the classifier structure.
The scan function applies the same classifier to the two set bubbles and each four-option question. Every question keeps its detected state and evidence for later review.
- Start the image scanner below classify_bubbles() by pasting this code:
def scan_image(image, normalize=False):
binary = make_ink_mask(image)
set_labels = tuple(SET_CENTERS.keys())
set_detection = classify_bubbles(
binary,
[SET_CENTERS[label] for label in set_labels],
set_labels,
)
detected_set = set_detection["state"]
set_code = detected_set if detected_set in ("A", "B") else None
answers = []
for question_number in range(1, 101):
centers = [answer_center(question_number, option) for option in OPTIONS]
detection = classify_bubbles(binary, centers, OPTIONS)
answers.append(
{
"question_number": question_number,
"detected_answer": detection["state"],
"confidence": detection["confidence"],
"fill_ratios": detection["fill_ratios"],
}
)
How does the scanner use the grid?
- The set centers classify the two question-paper bubbles as one group.
- The answer_center() function supplies four expected coordinates for each question.
- Each answer record preserves the state and confidence from its classification.
- Each record also preserves all four fill ratios for later inspection.
- Save scanner.py.
- Check the Outline panel to confirm that scan_image appears as a top-level function.
Is scan_image nested incorrectly?
Check that def scan_image starts at the left edge. Keep the answer-building lines indented beneath it.
Ask for help checking the scanner indentation.
- Complete scan_image() directly below the answer loop by pasting this code:
review_required = set_code is None or any(
answer["detected_answer"] in ("MULTIPLE", "UNCLEAR")
for answer in answers
)
accepted_count = sum(
answer["detected_answer"] in OPTIONS for answer in answers
)
return {
"detected_set": detected_set,
"set_code": set_code,
"set_confidence": set_detection["confidence"],
"accepted_count": accepted_count,
"processing_status": "REVIEW_REQUIRED" if review_required else "READY_TO_SAVE",
"answers": answers,
}
Why keep a review status?
- An uncertain set makes the entire scan unsafe to evaluate.
- A multiple or unclear answer requires human review without changing the detected evidence.
- The accepted count includes only responses classified as A, B, C, or D.
- Save scanner.py.
- Confirm that the final return object remains indented inside scan_image().
Does the return block look detached?
Keep every line in this chunk indented by four spaces. The block belongs inside scan_image() after the answer loop finishes.
Ask for help aligning the scan summary block.
Compare aligned and distorted sheets
Both sample images now pass through the same fixed-coordinate scanner. The canonical sheet keeps every bubble on the grid. The distorted sheet moves the marks while the scanner keeps looking in the original locations.
- Add the file wrapper and comparison entry point at the bottom of scanner.py by pasting this code:
def scan_sheet(path, normalize=False):
return scan_image(load_grayscale(path), normalize=normalize)
def main():
canonical = scan_sheet("sample_set_a.png", normalize=False)
skewed_naive = scan_sheet("sample_set_a_skewed.png", normalize=False)
print(
"Canonical without correction: "
f"set={canonical['detected_set']}, "
f"accepted={canonical['accepted_count']}/100"
)
print(
"Skewed without correction: "
f"set={skewed_naive['detected_set']}, "
f"accepted={skewed_naive['accepted_count']}/100"
)
if __name__ == "__main__":
main()
What does the comparison test?
- The scan_sheet() wrapper loads an image before passing it to the scanner.
- The first scan uses the canonical sheet as the known-good baseline.
- The second scan uses the perspective-distorted copy without correcting its geometry.
- The two printed summaries make coordinate drift measurable.
- Save scanner.py.
Before you run the scanner, predict whether fixed coordinates can reproduce the canonical set and accepted count after the sheet is distorted.
- Run the fixed-coordinate comparison in the activated terminal with this command:
python scanner.py
What should you see?
The canonical line reports set=A, accepted=98/100. That count includes the 97 correct letter responses plus the one deliberately wrong letter response.
The uncorrected skewed line does not reproduce the same set and accepted count. You have exposed the intended limitation of fixed coordinates.
Is the canonical result different?
Regenerate the samples from the unchanged omr_layout.py file. Then check that the threshold and ratio constants in scanner.py match this step.
Ask for help comparing the canonical scan result.
✔️ Awesome, I've got everything!
Your fixed-coordinate scanner reads the canonical sheet as Set A with 98 accepted responses. The distorted comparison also demonstrates the grid mismatch.
ⓧ I'd like to double check the full code
Compare your complete scanner.py file with this reference:
import cv2 as cv
import numpy as np
from omr_layout import (
BUBBLE_RADIUS,
OPTIONS,
SET_CENTERS,
answer_center,
)
LOW_CONFIDENCE_RATIO = 0.25
MARKED_RATIO = 0.65
def load_grayscale(path):
image = cv.imread(path, cv.IMREAD_GRAYSCALE)
if image is None:
raise FileNotFoundError(f"Could not read image: {path}")
return image
def make_ink_mask(image):
_, binary = cv.threshold(image, 180, 255, cv.THRESH_BINARY_INV)
return binary
def fill_ratio(binary, center):
sample_radius = BUBBLE_RADIUS - 4
center_x, center_y = center
roi = binary[
center_y - sample_radius : center_y + sample_radius + 1,
center_x - sample_radius : center_x + sample_radius + 1,
]
expected_size = sample_radius * 2 + 1
if roi.shape != (expected_size, expected_size):
return 0.0
mask = np.zeros(roi.shape, dtype="uint8")
cv.circle(mask, (sample_radius, sample_radius), sample_radius, 255, -1)
pixels = roi[mask == 255]
return float(pixels.mean() / 255.0)
def classify_bubbles(binary, centers, labels):
ratios = [fill_ratio(binary, center) for center in centers]
ranked = sorted(range(len(ratios)), key=lambda index: ratios[index], reverse=True)
top_index = ranked[0]
second_index = ranked[1]
strong = [index for index, ratio in enumerate(ratios) if ratio >= MARKED_RATIO]
if len(strong) > 1:
state = "MULTIPLE"
confidence = min(ratios[index] for index in strong)
elif len(strong) == 1 and ratios[second_index] < LOW_CONFIDENCE_RATIO:
state = labels[top_index]
confidence = ratios[top_index]
elif ratios[top_index] < LOW_CONFIDENCE_RATIO:
state = "UNANSWERED"
confidence = 1.0 - ratios[top_index]
else:
state = "UNCLEAR"
confidence = ratios[top_index]
return {
"state": state,
"confidence": round(confidence * 100.0, 2),
"fill_ratios": {
label: round(ratio, 3) for label, ratio in zip(labels, ratios)
},
}
def scan_image(image, normalize=False):
binary = make_ink_mask(image)
set_labels = tuple(SET_CENTERS.keys())
set_detection = classify_bubbles(
binary,
[SET_CENTERS[label] for label in set_labels],
set_labels,
)
detected_set = set_detection["state"]
set_code = detected_set if detected_set in ("A", "B") else None
answers = []
for question_number in range(1, 101):
centers = [answer_center(question_number, option) for option in OPTIONS]
detection = classify_bubbles(binary, centers, OPTIONS)
answers.append(
{
"question_number": question_number,
"detected_answer": detection["state"],
"confidence": detection["confidence"],
"fill_ratios": detection["fill_ratios"],
}
)
review_required = set_code is None or any(
answer["detected_answer"] in ("MULTIPLE", "UNCLEAR")
for answer in answers
)
accepted_count = sum(
answer["detected_answer"] in OPTIONS for answer in answers
)
return {
"detected_set": detected_set,
"set_code": set_code,
"set_confidence": set_detection["confidence"],
"accepted_count": accepted_count,
"processing_status": "REVIEW_REQUIRED" if review_required else "READY_TO_SAVE",
"answers": answers,
}
def scan_sheet(path, normalize=False):
return scan_image(load_grayscale(path), normalize=normalize)
def main():
canonical = scan_sheet("sample_set_a.png", normalize=False)
skewed_naive = scan_sheet("sample_set_a_skewed.png", normalize=False)
print(
"Canonical without correction: "
f"set={canonical['detected_set']}, "
f"accepted={canonical['accepted_count']}/100"
)
print(
"Skewed without correction: "
f"set={skewed_naive['detected_set']}, "
f"accepted={skewed_naive['accepted_count']}/100"
)
if __name__ == "__main__":
main()
What should match?
Check the imports and constants first. Then compare each helper in order through main().
You have proved that fixed coordinates can read a standardized sheet while its grid stays aligned. Next, the four reference markers restore that alignment after perspective distortion.
Correct Perspective with Markers
Your fixed-coordinate scanner reads the canonical sheet correctly. Perspective distortion moves the bubbles away from that coordinate map, so the skewed sheet produces a mismatched result.
Four alignment markers give OpenCV known points around the page. A perspective transformation maps those points back to the canonical grid before classification begins.
In this step, get ready to:
- Filter the thresholded contours down to four alignment markers.
- Map the ordered marker centers onto the canonical sheet coordinates.
- Confirm the corrected scan recovers Set A with 98 accepted responses.
Find the four marker contours
The black squares around the sheet create four stable landmarks. Contour detection finds those landmarks before the scanner reads any bubbles.
- In scanner.py, replace the existing import block from omr_layout.py with this expanded block:
from omr_layout import (
BUBBLE_RADIUS,
MARKER_CENTERS,
OPTIONS,
SET_CENTERS,
SHEET_HEIGHT,
SHEET_WIDTH,
answer_center,
)
What Do These Imports Add?
- The MARKER_CENTERS array provides the four destination points in the canonical layout.
- The SHEET_WIDTH value defines the normalized image width.
- The SHEET_HEIGHT value defines the normalized image height.
- Save scanner.py.
- Check that the expanded import block loads by running the scanner:
python scanner.py
What Should You See?
The canonical result still reports set=A with accepted=98/100. The skewed result still fails to reproduce that combination, which preserves the comparison you established earlier.
Seeing an Import Error?
Confirm that omr_layout.py still defines MARKER_CENTERS, SHEET_WIDTH, and SHEET_HEIGHT exactly as shown in the layout generator.
Check the spelling and commas in the expanded import block. Help me troubleshoot the layout imports in scanner.py.
Marker centers can arrive in any order from contour detection. The scanner needs a consistent top-left, top-right, bottom-right, and bottom-left sequence before it can calculate the transformation.
- In scanner.py, add the order_points() function immediately below classify_bubbles() by pasting this code:
def order_points(points):
tuples = [tuple(float(value) for value in point) for point in points]
top_left = min(tuples, key=lambda point: point[0] + point[1])
bottom_right = max(tuples, key=lambda point: point[0] + point[1])
top_right = min(tuples, key=lambda point: point[1] - point[0])
bottom_left = max(tuples, key=lambda point: point[1] - point[0])
return np.array(
[top_left, top_right, bottom_right, bottom_left],
dtype="float32",
)
How Does Point Ordering Work?
- The sum of each point's coordinates identifies the top-left and bottom-right corners.
- The difference between each point's coordinates identifies the remaining two corners.
- The returned array uses the same corner order as MARKER_CENTERS, which creates valid source-to-destination pairs.
- Save scanner.py.
- Confirm that Python loads order_points() by running the scanner again:
python scanner.py
What Does This Check Prove?
The two fixed-coordinate results still print without a syntax error. Python can now load the marker-ordering function while preserving the existing scan behavior.
Seeing a Syntax Error?
Check that the returned point list stays inside np.array(). Confirm that each assignment inside order_points() has the same indentation.
Help me check the order_points function in scanner.py.
The marker search starts from the same thresholded ink mask as the bubble classifier. It keeps only outer contours whose bounding boxes match the expected square size and shape.
- Add find_marker_centers() directly below order_points() in scanner.py by pasting this code:
def find_marker_centers(image):
binary = make_ink_mask(image)
contours, _ = cv.findContours(
binary,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
)
candidates = []
for contour in contours:
x, y, width, height = cv.boundingRect(contour)
box_area = width * height
aspect_ratio = width / float(height)
if 900 <= box_area <= 3000 and 0.72 <= aspect_ratio <= 1.28:
candidates.append((x + width / 2.0, y + height / 2.0))
if len(candidates) != 4:
raise ValueError(
f"Expected four alignment markers, found {len(candidates)}."
)
return order_points(candidates)
How Are Marker Candidates Filtered?
- The external-contour pass isolates the outermost ink shapes so nested bubble outlines stay out of the candidate list.
- Each candidate needs a bounding-box area between 900 and 3000 pixels.
- Each candidate needs an aspect ratio between 0.72 and 1.28, which keeps the shape close to square.
- The exact-count check rejects incomplete or ambiguous marker geometry before any answers are interpreted.
- Save scanner.py.
- Confirm that the marker-detection function loads by running the scanner:
python scanner.py
What Does This Run Confirm?
The scanner prints the existing canonical success and skewed mismatch without a syntax error. The marker search is now available for the normalization stage.
Scanner Stops Before Printing?
Check that find_marker_centers() remains above scan_image() in scanner.py. Confirm that the candidate validation sits outside the contour loop.
Help me debug the marker contour function.
Restore the canonical grid
The detected markers describe where the sheet corners landed in the distorted image. Normalization maps those source points onto the known marker positions in the original layout.
- Add normalize_sheet() directly below find_marker_centers() in scanner.py by pasting this code:
def normalize_sheet(image):
source_markers = find_marker_centers(image)
destination_markers = MARKER_CENTERS.astype("float32")
matrix = cv.getPerspectiveTransform(source_markers, destination_markers)
return cv.warpPerspective(
image,
matrix,
(SHEET_WIDTH, SHEET_HEIGHT),
flags=cv.INTER_LINEAR,
borderMode=cv.BORDER_CONSTANT,
borderValue=255,
)
What Does Normalization Do?
- The detected marker centers become the source coordinates from the captured image.
- The canonical marker centers become the destination coordinates for the standardized layout.
- The transformation matrix describes how pixels move between those two coordinate systems.
- The final warp recreates a white 1200 by 1600 image that matches the original sheet dimensions.
- Save scanner.py.
- Confirm that the normalization helper loads by running the scanner:
python scanner.py
What Does This Check Show?
The previous fixed-grid comparison still prints successfully. The new transformation helper loads without disrupting the established scanner behavior.
Seeing a Transformation Error?
Confirm that both marker arrays use float32 values. Check that SHEET_WIDTH appears before SHEET_HEIGHT in the output dimensions.
Help me troubleshoot the perspective normalization function.
The classifier already understands the canonical grid. The scan path now needs to normalize its input before creating the ink mask, while preserving an option to test the uncorrected image.
- In scan_image(), replace the function signature and first mask-building line with this normalized input setup:
def scan_image(image, normalize=True):
working_image = normalize_sheet(image) if normalize else image
binary = make_ink_mask(working_image)
How Does the Scan Path Change?
The normalize flag now defaults to True. The existing classifier receives either a corrected image or the untouched input through one shared working_image variable.
- In scan_sheet(), replace the existing function with this default-normalized version:
def scan_sheet(path, normalize=True):
return scan_image(load_grayscale(path), normalize=normalize)
Why Change the Default?
A file-based scan now corrects perspective unless the caller explicitly disables it. This makes normalization the safe main path while retaining the uncorrected mode for comparison.
- Save scanner.py.
- Confirm that both scan functions still support the earlier comparison by running the scanner:
python scanner.py
What Does the Comparison Prove?
The existing calls still print the canonical success and skewed mismatch because they explicitly disable normalization. This confirms that the fixed-coordinate test path remains available.
Seeing a Name Error?
Check that working_image is created before make_ink_mask() uses it. Confirm that the rest of scan_image() still uses the resulting binary mask.
Help me wire normalization into the scanner.
Verify the corrected scan
The final comparison sends the same distorted image through both paths. One path keeps the displaced grid, while the other restores the canonical marker geometry before classification.
- In scanner.py, replace the existing main() function with this three-way comparison:
def main():
canonical = scan_sheet("sample_set_a.png", normalize=False)
skewed_naive = scan_sheet("sample_set_a_skewed.png", normalize=False)
skewed_corrected = scan_sheet("sample_set_a_skewed.png", normalize=True)
print(
"Canonical without correction: "
f"set={canonical['detected_set']}, "
f"accepted={canonical['accepted_count']}/100"
)
print(
"Skewed without correction: "
f"set={skewed_naive['detected_set']}, "
f"accepted={skewed_naive['accepted_count']}/100"
)
print(
"Corrected skewed: "
f"set={skewed_corrected['detected_set']}, "
f"accepted={skewed_corrected['accepted_count']}/100"
)
What Does the Final Comparison Test?
- The canonical scan establishes the expected fixed-grid result.
- The naive skewed scan preserves the failure caused by displaced coordinates.
- The corrected skewed scan tests whether marker normalization recovers the expected result from the same distorted image.
- Save scanner.py.
Before you run the finished scanner, do you expect the corrected skewed result to match the canonical result?
- Run the completed comparison in the activated terminal:
python scanner.py
What Should You See?
- The first line reports Canonical without correction: set=A, accepted=98/100.
- The second line reports a skewed result that does not reproduce the canonical set and accepted count.
- The third line reports Corrected skewed: set=A, accepted=98/100.
That closes the geometry gap. Your skewed capture now lands on the same coordinate map as the canonical sheet.
Corrected Result Still Mismatched?
Confirm that the corrected scan passes normalize=True. Check that normalize_sheet() maps the detected source markers to MARKER_CENTERS in the same corner order.
Make sure sample_set_a_skewed.png is still the generated distorted sample from omr_layout.py. Help me debug the mismatched corrected scan.
✔️ Awesome, I've got everything!
Your corrected skewed scan now matches the canonical result. Confirm that you saved scanner.py before continuing.
ⓧ I'd like to double check the full code
Compare your complete scanner.py file with this version:
import cv2 as cv
import numpy as np
from omr_layout import (
BUBBLE_RADIUS,
MARKER_CENTERS,
OPTIONS,
SET_CENTERS,
SHEET_HEIGHT,
SHEET_WIDTH,
answer_center,
)
LOW_CONFIDENCE_RATIO = 0.25
MARKED_RATIO = 0.65
def load_grayscale(path):
image = cv.imread(path, cv.IMREAD_GRAYSCALE)
if image is None:
raise FileNotFoundError(f"Could not read image: {path}")
return image
def make_ink_mask(image):
_, binary = cv.threshold(image, 180, 255, cv.THRESH_BINARY_INV)
return binary
def fill_ratio(binary, center):
sample_radius = BUBBLE_RADIUS - 4
center_x, center_y = center
roi = binary[
center_y - sample_radius : center_y + sample_radius + 1,
center_x - sample_radius : center_x + sample_radius + 1,
]
expected_size = sample_radius * 2 + 1
if roi.shape != (expected_size, expected_size):
return 0.0
mask = np.zeros(roi.shape, dtype="uint8")
cv.circle(mask, (sample_radius, sample_radius), sample_radius, 255, -1)
pixels = roi[mask == 255]
return float(pixels.mean() / 255.0)
def classify_bubbles(binary, centers, labels):
ratios = [fill_ratio(binary, center) for center in centers]
ranked = sorted(range(len(ratios)), key=lambda index: ratios[index], reverse=True)
top_index = ranked[0]
second_index = ranked[1]
strong = [index for index, ratio in enumerate(ratios) if ratio >= MARKED_RATIO]
if len(strong) > 1:
state = "MULTIPLE"
confidence = min(ratios[index] for index in strong)
elif len(strong) == 1 and ratios[second_index] < LOW_CONFIDENCE_RATIO:
state = labels[top_index]
confidence = ratios[top_index]
elif ratios[top_index] < LOW_CONFIDENCE_RATIO:
state = "UNANSWERED"
confidence = 1.0 - ratios[top_index]
else:
state = "UNCLEAR"
confidence = ratios[top_index]
return {
"state": state,
"confidence": round(confidence * 100.0, 2),
"fill_ratios": {
label: round(ratio, 3) for label, ratio in zip(labels, ratios)
},
}
def order_points(points):
tuples = [tuple(float(value) for value in point) for point in points]
top_left = min(tuples, key=lambda point: point[0] + point[1])
bottom_right = max(tuples, key=lambda point: point[0] + point[1])
top_right = min(tuples, key=lambda point: point[1] - point[0])
bottom_left = max(tuples, key=lambda point: point[1] - point[0])
return np.array(
[top_left, top_right, bottom_right, bottom_left],
dtype="float32",
)
def find_marker_centers(image):
binary = make_ink_mask(image)
contours, _ = cv.findContours(
binary,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
)
candidates = []
for contour in contours:
x, y, width, height = cv.boundingRect(contour)
box_area = width * height
aspect_ratio = width / float(height)
if 900 <= box_area <= 3000 and 0.72 <= aspect_ratio <= 1.28:
candidates.append((x + width / 2.0, y + height / 2.0))
if len(candidates) != 4:
raise ValueError(
f"Expected four alignment markers, found {len(candidates)}."
)
return order_points(candidates)
def normalize_sheet(image):
source_markers = find_marker_centers(image)
destination_markers = MARKER_CENTERS.astype("float32")
matrix = cv.getPerspectiveTransform(source_markers, destination_markers)
return cv.warpPerspective(
image,
matrix,
(SHEET_WIDTH, SHEET_HEIGHT),
flags=cv.INTER_LINEAR,
borderMode=cv.BORDER_CONSTANT,
borderValue=255,
)
def scan_image(image, normalize=True):
working_image = normalize_sheet(image) if normalize else image
binary = make_ink_mask(working_image)
set_labels = tuple(SET_CENTERS.keys())
set_detection = classify_bubbles(
binary,
[SET_CENTERS[label] for label in set_labels],
set_labels,
)
detected_set = set_detection["state"]
set_code = detected_set if detected_set in ("A", "B") else None
answers = []
for question_number in range(1, 101):
centers = [answer_center(question_number, option) for option in OPTIONS]
detection = classify_bubbles(binary, centers, OPTIONS)
answers.append(
{
"question_number": question_number,
"detected_answer": detection["state"],
"confidence": detection["confidence"],
"fill_ratios": detection["fill_ratios"],
}
)
review_required = set_code is None or any(
answer["detected_answer"] in ("MULTIPLE", "UNCLEAR")
for answer in answers
)
accepted_count = sum(
answer["detected_answer"] in OPTIONS for answer in answers
)
return {
"detected_set": detected_set,
"set_code": set_code,
"set_confidence": set_detection["confidence"],
"accepted_count": accepted_count,
"processing_status": "REVIEW_REQUIRED" if review_required else "READY_TO_SAVE",
"answers": answers,
}
def scan_sheet(path, normalize=True):
return scan_image(load_grayscale(path), normalize=normalize)
def main():
canonical = scan_sheet("sample_set_a.png", normalize=False)
skewed_naive = scan_sheet("sample_set_a_skewed.png", normalize=False)
skewed_corrected = scan_sheet("sample_set_a_skewed.png", normalize=True)
print(
"Canonical without correction: "
f"set={canonical['detected_set']}, "
f"accepted={canonical['accepted_count']}/100"
)
print(
"Skewed without correction: "
f"set={skewed_naive['detected_set']}, "
f"accepted={skewed_naive['accepted_count']}/100"
)
print(
"Corrected skewed: "
f"set={skewed_corrected['detected_set']}, "
f"accepted={skewed_corrected['accepted_count']}/100"
)
if __name__ == "__main__":
main()
Perspective normalization now recovers the known response grid from the distorted sample. Next, you'll enforce the Set A and Set B evaluation rules and create the review-ready audit record.
Evaluate the Detected Set
Your scanner now restores the distorted sheet to its canonical grid. It recovers Set A with 98 accepted responses.
A score can still be corrupted by an invalid answer key or an ambiguous set selection. This step adds answer-key validation plus deterministic scoring that preserves every detection for review.
In this step, get ready to:
- Validate both 100-answer keys before evaluation.
- Score every detected response with configurable marks.
- Write a review-ready JSON audit record.
Validate both answer keys
A detected set is safe to evaluate only when both configured keys are complete. The evaluator also needs a closed failure path for scans without one valid set.
- Use the file sidebar in Visual Studio Code to create evaluate.py inside omr-recognition-core.
- Add the imports plus the invalid-set branch by pasting this code into evaluate.py:
import json
from omr_layout import OPTIONS, demo_answer_keys, validate_answer_key
from scanner import scan_sheet
def evaluate_scan(scan, answer_keys, correct_mark=1.0, wrong_mark=0.0):
validate_answer_key("A", answer_keys.get("A", []))
validate_answer_key("B", answer_keys.get("B", []))
if scan["set_code"] not in ("A", "B"):
return {
"processing_status": "REVIEW_REQUIRED",
"detected_set": scan["detected_set"],
"set_code": None,
"reason": "A single question-paper set could not be determined.",
"marks_obtained": None,
"maximum_marks": None,
"percentage": None,
"answers": [],
}
What does this code do?
- The imports reuse the project’s validated answer keys plus the normalized sheet scanner.
- The two validate_answer_key() calls require exactly 100 valid A-D answers in each key.
- The invalid-set branch returns REVIEW_REQUIRED with null score fields.
- An ambiguous set produces no question-level evaluations.
- Save evaluate.py.
- Check that the imports plus the invalid-set branch parse by running this command in the activated terminal:
python evaluate.py
You’ll return to the activated prompt without a traceback. This confirms that the first evaluator branch is valid Python.
Seeing a Python error?
- Check that evaluate.py sits beside omr_layout.py plus scanner.py.
- Check that each dictionary entry remains inside the invalid-set return block.
- Confirm that the terminal prompt still begins with (.venv).
Help me diagnose the first evaluator block.
Build the question audit
A valid set code chooses one answer key for the entire sheet. Each detection then becomes an audit record that keeps the scanner’s original answer visible.
- Add the valid-set setup directly below the invalid-set return block by pasting this code:
set_code = scan["set_code"]
selected_key = answer_keys[set_code]
counts = {
"correct": 0,
"wrong": 0,
"unanswered": 0,
"multiple": 0,
"unclear": 0,
}
marks_obtained = 0.0
evaluated_answers = []
for detection in scan["answers"]:
question_number = detection["question_number"]
detected_answer = detection["detected_answer"]
correct_answer = selected_key[question_number - 1]
How does set selection work?
- The validated set_code indexes one key from answer_keys.
- The count dictionary tracks every evaluation state separately.
- The loop pairs each detected answer with the correct answer from the selected key.
- No response from the other set’s key enters the evaluation.
- Save evaluate.py.
- Confirm that the three loop assignments sit inside the for detection in scan["answers"]: block.
Is the loop indentation uneven?
- Keep the counter setup at four spaces inside evaluate_scan().
- Keep the three detection assignments at eight spaces inside the loop.
Help me check the evaluator loop indentation.
- Complete the response classification directly below the correct_answer assignment by pasting this code:
if detected_answer in OPTIONS:
is_correct = detected_answer == correct_answer
status = "CORRECT" if is_correct else "WRONG"
counts["correct" if is_correct else "wrong"] += 1
marks_awarded = correct_mark if is_correct else wrong_mark
elif detected_answer == "UNANSWERED":
is_correct = False
status = "UNANSWERED"
counts["unanswered"] += 1
marks_awarded = 0.0
elif detected_answer == "MULTIPLE":
is_correct = False
status = "MULTIPLE"
counts["multiple"] += 1
marks_awarded = 0.0
else:
is_correct = False
status = "NEEDS_REVIEW"
counts["unclear"] += 1
marks_awarded = 0.0
How are responses classified?
- A detected A-D option becomes CORRECT or WRONG after comparison with the selected key.
- An UNANSWERED response receives zero marks.
- A MULTIPLE response receives zero marks plus its own review state.
- Any remaining scanner state becomes NEEDS_REVIEW.
- Save evaluate.py.
- Confirm that every classification branch remains inside the response loop.
Are responses entering the wrong branch?
- Check that the first branch uses detected_answer in OPTIONS.
- Check that UNANSWERED plus MULTIPLE each have their own branch.
- Keep the final else branch aligned with the preceding conditions.
Help me trace a response through the evaluator branches.
- Record the awarded marks plus the full question audit by pasting this code below the classification branches:
marks_obtained += marks_awarded
evaluated_answers.append(
{
"question_number": question_number,
"detected_answer": detected_answer,
"final_answer": detected_answer,
"correct_answer": correct_answer,
"status": status,
"confidence": detection["confidence"],
"manually_corrected": False,
"marks_awarded": marks_awarded,
}
)
What makes this record review-ready?
- The running score receives the configured marks for the current response.
- The detected_answer preserves the scanner output.
- The initial final_answer matches the untouched detection.
- The confidence plus manually_corrected fields support later human review.
- Save evaluate.py.
- Confirm that evaluated_answers.append() remains inside the response loop.
Is the answer list staying empty?
- Check that evaluated_answers.append( has eight leading spaces.
- Check that the record’s closing parenthesis aligns with evaluated_answers.append(.
Help me fix the question audit append block.
- Finish evaluate_scan() with score calculations plus the summary result by pasting this code after the response loop:
maximum_marks = len(evaluated_answers) * correct_mark
percentage = (
round((marks_obtained / maximum_marks) * 100.0, 2)
if maximum_marks > 0
else 0.0
)
return {
"processing_status": scan["processing_status"],
"detected_set": scan["detected_set"],
"set_code": set_code,
"correct_count": counts["correct"],
"wrong_count": counts["wrong"],
"unanswered_count": counts["unanswered"],
"multiple_count": counts["multiple"],
"unclear_count": counts["unclear"],
"marks_obtained": round(marks_obtained, 2),
"maximum_marks": round(maximum_marks, 2),
"percentage": percentage,
"answers": evaluated_answers,
}
How is the summary calculated?
- Maximum marks come from the number of evaluated answers plus the configured positive mark.
- The percentage uses the accumulated marks against that maximum.
- The returned summary keeps the scanner’s processing status plus every response count.
- The answers field carries the complete question audit.
- Save evaluate.py.
- Check that the complete evaluator function parses by running this command:
python evaluate.py
You’ll return to the activated prompt without a traceback. The evaluator function is now ready for a runner.
Seeing an indentation or bracket error?
- Align maximum_marks with the for statement to place it outside the loop.
- Check that the final return dictionary closes with one brace.
- Check that the percentage expression closes before the return block begins.
Help me check the completed evaluate_scan function.
Write and inspect the review result
The evaluator needs a controlled scan plus explicit marking rules. Its runner uses the normalized distorted sheet to prove the same set-selection path works on camera-like input.
- Add the runner below evaluate_scan() by pasting this code at the end of evaluate.py:
def main():
answer_keys = demo_answer_keys()
scan = scan_sheet("sample_set_a_skewed.png", normalize=True)
result = evaluate_scan(
scan,
answer_keys,
correct_mark=1.0,
wrong_mark=-0.25,
)
with open("review_result.json", "w", encoding="utf-8") as output_file:
json.dump(result, output_file, indent=2)
print(f"Set: {result['set_code']}")
print(f"Status: {result['processing_status']}")
print(f"Correct: {result['correct_count']}")
print(f"Wrong: {result['wrong_count']}")
print(f"Unanswered: {result['unanswered_count']}")
print(f"Multiple: {result['multiple_count']}")
print(
f"Marks: {result['marks_obtained']}/{result['maximum_marks']}"
)
print("Created review_result.json")
if __name__ == "__main__":
main()
What does the runner prove?
- The distorted sample passes through marker normalization before evaluation.
- A correct answer awards 1.0 mark.
- A wrong answer awards -0.25 marks.
- The complete result is written to review_result.json before the summary is printed.
- Save evaluate.py.
Before you run it, consider which status plus score the controlled sheet should produce.
- Evaluate the normalized distorted sheet by running this command:
python evaluate.py
You’ll see Set A with REVIEW_REQUIRED. The summary reports 97 correct responses.
You’ll also see 1 wrong response plus 1 unanswered response. The multiple-response count is 1.
The score is 96.75/100.0. The final line confirms that review_result.json was created.
Why is review still required?
The evaluator can calculate marks for all accepted answers while preserving the multiple response. That unresolved response keeps the sheet in REVIEW_REQUIRED.
The score remains auditable because the uncertain detection receives zero marks plus its own record.
That’s the scoring core working. Your normalized scan now produces a deterministic result without hiding the response that needs review.
- Select review_result.json from the file sidebar.
- Inspect the top-level summary near the start of the file.
- Scroll into the answers array to inspect the question records.
What should I see?
- The summary shows processing_status as REVIEW_REQUIRED.
- The response counts are 97 correct plus 1 wrong plus 1 unanswered plus 1 multiple.
- The score fields show 96.75 marks from a maximum of 100.0.
- The answers array contains 100 records with detection details plus awarded marks.
Missing the result file or expected score?
- Check that sample_set_a_skewed.png remains inside omr-recognition-core.
- Check that scan_sheet() receives normalize=True.
- Check that wrong_mark is -0.25.
Help me diagnose my evaluation result.
✔️ Awesome, I've got everything!
Great work. Confirm that evaluate.py is saved plus review_result.json contains 100 answer records.
ⓧ I'd like to double check the full code
The complete evaluate.py file should match this reference.
import json
from omr_layout import OPTIONS, demo_answer_keys, validate_answer_key
from scanner import scan_sheet
def evaluate_scan(scan, answer_keys, correct_mark=1.0, wrong_mark=0.0):
validate_answer_key("A", answer_keys.get("A", []))
validate_answer_key("B", answer_keys.get("B", []))
if scan["set_code"] not in ("A", "B"):
return {
"processing_status": "REVIEW_REQUIRED",
"detected_set": scan["detected_set"],
"set_code": None,
"reason": "A single question-paper set could not be determined.",
"marks_obtained": None,
"maximum_marks": None,
"percentage": None,
"answers": [],
}
set_code = scan["set_code"]
selected_key = answer_keys[set_code]
counts = {
"correct": 0,
"wrong": 0,
"unanswered": 0,
"multiple": 0,
"unclear": 0,
}
marks_obtained = 0.0
evaluated_answers = []
for detection in scan["answers"]:
question_number = detection["question_number"]
detected_answer = detection["detected_answer"]
correct_answer = selected_key[question_number - 1]
if detected_answer in OPTIONS:
is_correct = detected_answer == correct_answer
status = "CORRECT" if is_correct else "WRONG"
counts["correct" if is_correct else "wrong"] += 1
marks_awarded = correct_mark if is_correct else wrong_mark
elif detected_answer == "UNANSWERED":
is_correct = False
status = "UNANSWERED"
counts["unanswered"] += 1
marks_awarded = 0.0
elif detected_answer == "MULTIPLE":
is_correct = False
status = "MULTIPLE"
counts["multiple"] += 1
marks_awarded = 0.0
else:
is_correct = False
status = "NEEDS_REVIEW"
counts["unclear"] += 1
marks_awarded = 0.0
marks_obtained += marks_awarded
evaluated_answers.append(
{
"question_number": question_number,
"detected_answer": detected_answer,
"final_answer": detected_answer,
"correct_answer": correct_answer,
"status": status,
"confidence": detection["confidence"],
"manually_corrected": False,
"marks_awarded": marks_awarded,
}
)
maximum_marks = len(evaluated_answers) * correct_mark
percentage = (
round((marks_obtained / maximum_marks) * 100.0, 2)
if maximum_marks > 0
else 0.0
)
return {
"processing_status": scan["processing_status"],
"detected_set": scan["detected_set"],
"set_code": set_code,
"correct_count": counts["correct"],
"wrong_count": counts["wrong"],
"unanswered_count": counts["unanswered"],
"multiple_count": counts["multiple"],
"unclear_count": counts["unclear"],
"marks_obtained": round(marks_obtained, 2),
"maximum_marks": round(maximum_marks, 2),
"percentage": percentage,
"answers": evaluated_answers,
}
def main():
answer_keys = demo_answer_keys()
scan = scan_sheet("sample_set_a_skewed.png", normalize=True)
result = evaluate_scan(
scan,
answer_keys,
correct_mark=1.0,
wrong_mark=-0.25,
)
with open("review_result.json", "w", encoding="utf-8") as output_file:
json.dump(result, output_file, indent=2)
print(f"Set: {result['set_code']}")
print(f"Status: {result['processing_status']}")
print(f"Correct: {result['correct_count']}")
print(f"Wrong: {result['wrong_count']}")
print(f"Unanswered: {result['unanswered_count']}")
print(f"Multiple: {result['multiple_count']}")
print(
f"Marks: {result['marks_obtained']}/{result['maximum_marks']}"
)
print("Created review_result.json")
if __name__ == "__main__":
main()
Secret mission
Reject Ambiguous Set Selections
A score is only trustworthy when one question-paper set is selected. Build two invalid samples that exercise the existing recognition pipeline. Prove that both ambiguous selections enter review without receiving a score.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the local workspace, close it for later, or delete it entirely. All processing stays on your computer with no ongoing cost.
Resources you used:
- The omr-recognition-core project folder with every local artifact from this project.
Keep everything running
No action is needed. This option keeps every script, generated sheet, test image, virtual-environment package, and evaluation result available.
- Keep the omr-recognition-core folder in its current location.
- Retain review_result.json as the audit record for the valid Set A sample.
- Retain sample_both_sets.png with sample_no_set.png as regression fixtures for ambiguous set detection.
Pause - I'll come back to this later
Pausing means closing the active workspace while preserving every local file. You can return without reinstalling the project dependencies.
- Stop any running Python script by pressing Ctrl+C in the terminal.
- Close Visual Studio Code after saving any open files.
- Leave the omr-recognition-core folder in place for your next session.
Delete - I don't want to use this again
Deleting the workspace is permanent. Your other local folders stay untouched.
- Copy any generated image or JSON result you want to keep to a folder outside omr-recognition-core.
- Stop any running Python script by pressing Ctrl+C in the terminal.
- Close every generated PNG image that is open in another app.
- Close the activated terminal from earlier.
- In the Visual Studio Code Terminal panel, start a new Command Prompt terminal.
- Confirm the new prompt ends in omr-recognition-core.
- Remove the entire workspace by running these commands:
cd ..
rmdir /s /q omr-recognition-core
dir
What Do These Commands Remove?
- The first line moves Command Prompt to the folder above omr-recognition-core.
- The rmdir command removes the project folder.
- The /s flag includes every nested file and folder.
- The /q flag suppresses the confirmation prompt.
- The dir command lists the remaining folders for verification.
You should see the parent folder listing without omr-recognition-core. The virtual environment, scripts, generated images, and JSON result are now removed.
Is the Folder Still Listed?
- Close any generated image that another app still has open.
- Confirm the Command Prompt path points to the folder that previously contained omr-recognition-core.
- Help me remove the local OMR workspace.
Nice Work!
Nice Work!
You did it! Your local OMR recognition core now produces a review-ready result from a perspective-distorted sheet without guessing uncertain marks.
You've learned how to:
- Built a standardized 100-question OMR template with a dedicated question-paper set field. Its controlled samples make the failure of fixed-coordinate scanning visible.
- Added OpenCV perspective normalization that restores a distorted sheet to its canonical bubble grid. Your confidence-based classifier preserves unanswered, multiple, or unclear responses for review.
- Validated separate Set A and Set B answer keys before scoring. The detected set chooses one key. review_result.json stores the complete 100-question audit trail.
- Secret Mission: Rejected both-marked set fields. Rejected unmarked set fields. Both cases return REVIEW_REQUIRED without a score or question-level evaluation.
Ready to quiz yourself?