diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 03ce2149b..000000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..d68b17e1b
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,12 @@
+# These are supported funding model platforms
+
+github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: # Replace with a single Ko-fi username
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+otechie: # Replace with a single Otechie username
+custom: ['https://www.paypal.me/Udayraj123/','https://www.buymeacoffee.com/Udayraj123']
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..408ab293d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,34 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: "[Bug]"
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Use sample '...'
+2. Command(s) used '....'
+3. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. MacOS, Linux, Windows]
+ - Python version
+ - OpenCV version
+
+
+**Additional context**
+Add any other context about the problem here.
+
+Error Stack trace. Sample images used, etc
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..604341ba8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea/enhancement for this project
+title: "[Feature]"
+labels: enhancement
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I think implementing [...] will help everyone.
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/pre-commit.yml b/.github/pre-commit.yml
new file mode 100644
index 000000000..d6b9d9a69
--- /dev/null
+++ b/.github/pre-commit.yml
@@ -0,0 +1,13 @@
+name: Pre-Commit Hook
+
+on: [push, pull_request]
+
+jobs:
+ pre-commit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-python@v3
+ with:
+ python-version: '3.11'
+ - uses: pre-commit/action@v3.0.0
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..3207aa721
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,60 @@
+name: Test
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - "src/**/*.py"
+ - "tests/**/*.py"
+ pull_request:
+ branches: [master]
+ # paths:
+ # - "src/**/*.py"
+ # - "tests/**/*.py"
+
+jobs:
+ test-and-coverage:
+ name: "Test and Coverage"
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ python-version: ["3.11"]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Python ${{ matrix.python-version }} with uv
+ uses: ./.github/actions/setup-python-with-uv
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Creating .github-pytest and coverage folders
+ run: mkdir -p ./.github-pytest/coverage
+
+ - name: Run pytest
+ run: |
+ set -o pipefail
+ uv run pytest --cov-fail-under=70 --junitxml=./.github-pytest/pytest-output.xml | tee .github-pytest/pytest-coverage.txt
+ env:
+ OMR_CHECKER_CONTAINER: true
+ # Note: TZ override no longer needed as we're now setting it during test execution itself.
+ # TZ: Asia/Kolkata
+
+ - name: Pytest coverage comment
+ uses: MishaKav/pytest-coverage-comment@v1
+ with:
+ pytest-coverage-path: ./.github-pytest/pytest-coverage.txt
+ junitxml-path: ./.github-pytest/pytest-output.xml
+
+ - name: Coverage Badge
+ uses: tj-actions/coverage-badge-py@v2
+ with:
+ output: .github-pytest/coverage/coverage.svg
+
+ - name: Publish coverage report to coverage-badge branch
+ uses: JamesIves/github-pages-deploy-action@v4
+ with:
+ branch: coverage-badge
+ folder: .github-pytest/coverage
diff --git a/.gitignore b/.gitignore
index 2778cf2c0..a7d4046be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,14 @@
-**/__pycache__
-**/CheckedOMRs
-**/ignore
-**/.DS_Store
-OMRChecker.wiki/
+# Any directory starting with a dot
+**/\.*/
+# Except .github
+!.github/
+
# Everything in inputs/ and outputs/
inputs/*
outputs/*
-# Except *.json and OMR_Files/
-# !inputs/OMR_Files/
-# !inputs/*.json
-# !inputs/omr_marker.jpg
\ No newline at end of file
+
+# Misc
+**/.DS_Store
+**/__pycache__
+venv/
+OMRChecker.wiki/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 000000000..382f8c7a1
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,59 @@
+exclude: "__snapshots__/.*$"
+default_install_hook_types: [pre-commit, pre-push]
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.4.0
+ hooks:
+ - id: check-yaml
+ stages: [commit]
+ - id: check-added-large-files
+ args: ['--maxkb=300']
+ fail_fast: false
+ stages: [commit]
+ - id: pretty-format-json
+ args: ['--autofix', '--no-sort-keys']
+ - id: end-of-file-fixer
+ exclude_types: ["csv", "json"]
+ stages: [commit]
+ - id: trailing-whitespace
+ stages: [commit]
+ - repo: https://github.com/pycqa/isort
+ rev: 5.12.0
+ hooks:
+ - id: isort
+ args: ["--profile", "black"]
+ stages: [commit]
+ - repo: https://github.com/psf/black
+ rev: 23.3.0
+ hooks:
+ - id: black
+ fail_fast: true
+ stages: [commit]
+ - repo: https://github.com/pycqa/flake8
+ rev: 6.0.0
+ hooks:
+ - id: flake8
+ args:
+ - "--ignore=E501,W503,E203,E225,E713,E741,F541" # Line too long, Line break occurred before a binary operator, Whitespace before ':'
+ fail_fast: true
+ stages: [commit]
+ - repo: local
+ hooks:
+ - id: pytest-on-commit
+ name: Running single sample test
+ entry: python3 -m pytest -rfpsxEX --disable-warnings --verbose -k sample1
+ language: system
+ pass_filenames: false
+ always_run: true
+ fail_fast: true
+ stages: [commit]
+ - repo: local
+ hooks:
+ - id: pytest-on-push
+ name: Running all tests before push...
+ entry: python3 -m pytest -rfpsxEX --disable-warnings --verbose --durations=3
+ language: system
+ pass_filenames: false
+ always_run: true
+ fail_fast: true
+ stages: [push]
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 000000000..405ce2e7f
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,43 @@
+[BASIC]
+# Regular expression matching correct variable names. Overrides variable-naming-style.
+# snake_case with single letter regex -
+variable-rgx=[a-z0-9_]{1,30}$
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=x,y,pt
+
+[MESSAGES CONTROL]
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then reenable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=import-error,
+ unresolved-import,
+ too-few-public-methods,
+ missing-docstring,
+ relative-beyond-top-level,
+ too-many-instance-attributes,
+ bad-continuation,
+ no-member
+
+# Note: bad-continuation is a false positive showing bug in pylint
+# https://github.com/psf/black/issues/48
+
+
+[REPORTS]
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+output-format=text
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..5a01f7a6c
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,133 @@
+
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+[INSERT CONTACT METHOD].
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..dbf459d2e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,32 @@
+# How to contribute
+So you want to write code and get it landed in the official OMRChecker repository?
+First, fork our repository into your own GitHub account, and create a local clone of it as described in the installation instructions.
+The latter will be used to get new features implemented or bugs fixed.
+
+Once done and you have the code locally on the disk, you can get started. We advise you to not work directly on the master branch,
+but to create a separate branch for each issue you are working on. That way you can easily switch between different work,
+and you can update each one for the latest changes on the upstream master individually.
+
+
+# Writing Code
+For writing the code just follow the [Pep8 Python style](https://peps.python.org/pep-0008/) guide, If there is something unclear about the style, just look at existing code which might help you to understand it better.
+
+Also, try to use commits with [conventional messages](https://www.conventionalcommits.org/en/v1.0.0/#summary).
+
+
+# Code Formatting
+Before committing your code, make sure to run the following command to format your code according to the PEP8 style guide:
+```.sh
+pip install -r requirements.dev.txt && pre-commit install
+```
+
+Run `pre-commit` before committing your changes:
+```.sh
+git add .
+pre-commit run -a
+```
+
+# Where to contribute from
+
+- You can pickup any open [issues](https://github.com/Udayraj123/OMRChecker/issues) to solve.
+- You can also check out the [ideas list](https://github.com/users/Udayraj123/projects/2/views/1)
diff --git a/Contributors.md b/Contributors.md
new file mode 100644
index 000000000..3f95b2453
--- /dev/null
+++ b/Contributors.md
@@ -0,0 +1,22 @@
+# Contributors
+
+- [Udayraj123](https://github.com/Udayraj123)
+- [leongwaikay](https://github.com/leongwaikay)
+- [deepakgouda](https://github.com/deepakgouda)
+- [apurva91](https://github.com/apurva91)
+- [sparsh2706](https://github.com/sparsh2706)
+- [namit2saxena](https://github.com/namit2saxena)
+- [Harsh-Kapoorr](https://github.com/Harsh-Kapoorr)
+- [Sandeep-1507](https://github.com/Sandeep-1507)
+- [SpyzzVVarun](https://github.com/SpyzzVVarun)
+- [asc249](https://github.com/asc249)
+- [05Alston](https://github.com/05Alston)
+- [Antibodyy](https://github.com/Antibodyy)
+- [infinity1729](https://github.com/infinity1729)
+- [Rohan-G](https://github.com/Rohan-G)
+- [UjjwalMahar](https://github.com/UjjwalMahar)
+- [Kurtsley](https://github.com/Kurtsley)
+- [gaursagar21](https://github.com/gaursagar21)
+- [aayushibansal2001](https://github.com/aayushibansal2001)
+- [ShamanthVallem](https://github.com/ShamanthVallem)
+- [rudrapsc](https://github.com/rudrapsc)
diff --git a/LICENSE b/LICENSE
index fbbea73ab..3942f30ca 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,621 +1,22 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
@@ -71,29 +95,65 @@ Output: A CSV sheet containing the detected responses and evaluated scores:
-#### There are many visuals in the wiki. [Check them out!](https://github.com/Udayraj123/OMRChecker/wiki/Rich-Visuals) +We now support [colored outputs](https://github.com/Udayraj123/OMRChecker/wiki/%5Bv2%5D-About-Evaluation) as well. Here's a sample output on another image - +
+
+
+
+
python3 -m pip install --user -r requirements.txt
+python3 -m pip install --user --upgrade pip
+
[](https://www.paypal.me/Udayraj123/500)
+
+_Find OMRChecker on_ [**_Product Hunt_**](https://www.producthunt.com/posts/omr-checker/) **|** [**_Reddit_**](https://www.reddit.com/r/computervision/comments/ccbj6f/omrchecker_grade_exams_using_python_and_opencv/) **|** [**Discord**](https://discord.gg/qFv2Vqf) **|** [**Linkedin**](https://www.linkedin.com/pulse/open-source-talks-udayraj-udayraj-deshmukh/) **|** [**goodfirstissue.dev**](https://goodfirstissue.dev/language/python) **|** [**codepeak.tech**](https://www.codepeak.tech/) **|** [**fossoverflow.dev**](https://fossoverflow.dev/projects) **|** [**Interview on Console by CodeSee**](https://console.substack.com/p/console-140) **|** [**Open Source Hub**](https://opensourcehub.io/udayraj123/omrchecker)
-
-*Find OMRChecker on* [***Product Hunt***](https://www.producthunt.com/posts/omr-checker/) **|** [***Hacker News***](https://news.ycombinator.com/item?id=20420602) **|** [***Reddit***](https://www.reddit.com/r/computervision/comments/ccbj6f/omrchecker_grade_exams_using_python_and_opencv/) **|** [***Swyya***](https://www.swyya.com/projects/omrchecker) **|** [](https://discord.gg/qFv2Vqf)
+
+
diff --git a/docs/assets/colored_output.jpg b/docs/assets/colored_output.jpg
new file mode 100644
index 000000000..3cafa473b
Binary files /dev/null and b/docs/assets/colored_output.jpg differ
diff --git a/globals.py b/globals.py
deleted file mode 100644
index 5f9cd929b..000000000
--- a/globals.py
+++ /dev/null
@@ -1,140 +0,0 @@
-"""
-
-Designed and Developed by-
-Udayraj Deshmukh
-https://github.com/Udayraj123
-
-"""
-
-"""
-Constants
-"""
-display_height = int(480)
-display_width = int(640)
-windowWidth = 1280
-windowHeight = 720
-
-saveMarked = 1
-saveCropped = 1
-showimglvl = 4
-saveimglvl = 0
-PRELIM_CHECKS = 0
-saveImgList = {}
-resetpos = [0, 0]
-explain = 0
-# autorotate=1
-
-BATCH_NO = 1000
-NO_MARKER_ERR = 12
-MULTI_BUBBLE_WARN = 15
-
-# name of template file
-TEMPLATE_FILE = 'template.json'
-MARKER_FILE = "omr_marker.jpg"
-
-# For preProcessing
-GAMMA_LOW = 0.7
-GAMMA_HIGH = 1.25
-
-ERODE_SUB_OFF = 1
-
-# For new ways of determining threshold
-MIN_GAP, MIN_STD = 30, 25
-MIN_JUMP = 25
-# If only not confident, take help of globalTHR
-CONFIDENT_JUMP = MIN_JUMP + 15
-JUMP_DELTA = 30
-# MIN_GAP : worst case gap of black and gray
-
-# Templ alignment parameters
-ALIGN_RANGE = range(-5, 6, 1)
-# TODO ^THIS SHOULD BE IN LAYOUT FILE AS ITS RELATED TO DIMENSIONS
-# ALIGN_RANGE = [-6,-4,-2,-1,0,1,2,4,6]
-
-# max threshold difference for template matching
-thresholdVar = 0.41
-
-# TODO: remove unnec variables here-
-thresholdCircle = 0.3
-marker_rescale_range = (35, 100)
-marker_rescale_steps = 10
-
-# Presentation variables
-uniform_height = int(1231 / 1.5)
-uniform_width = int(1000 / 1.5)
-# Original dims are about (3527, 2494)
-
-# Any input images should be resized to this--
-uniform_width_hd = int(uniform_width * 1.5)
-uniform_height_hd = int(uniform_height * 1.5)
-
-TEXT_SIZE = 0.95
-CLR_BLACK = (50, 150, 150)
-CLR_WHITE = (250, 250, 250)
-CLR_GRAY = (130, 130, 130)
-# CLR_DARK_GRAY = (190,190,190)
-CLR_DARK_GRAY = (100, 100, 100)
-
-MIN_PAGE_AREA = 80000
-
-# Filepaths
-
-
-class Paths:
- def __init__(self, output):
- self.output = output
- self.saveMarkedDir = f'{output}/CheckedOMRs/'
- self.resultDir = f'{output}/Results/'
- self.manualDir = f'{output}/Manual/'
- self.errorsDir = f'{self.manualDir}ErrorFiles/'
- self.badRollsDir = f'{self.manualDir}BadRollNosFiles/'
- self.multiMarkedDir = f'{self.manualDir}MultiMarkedFiles/'
-
-
-"""
-Variables
-"""
-filesMoved = 0
-filesNotMoved = 0
-
-# for positioning image windows
-windowX, windowY = 0, 0
-
-
-# TODO: move to template or similar json
-Answers = {
- 'J': {
- 'q1': ['B'], 'q2': ['B'], 'q3': ['B'], 'q4': ['C'], 'q5': ['0', '00'], 'q6': ['0', '00'], 'q7': ['4', '04'],
- 'q8': ['9', '09'], 'q9': ['11', '11'], 'q10': ['C'], 'q11': ['C'], 'q12': ['B'], 'q13': ['C'],
- 'q14': ['C'], 'q15': ['B'], 'q16': ['C'], 'q17': ['BONUS'], 'q18': ['A'], 'q19': ['C'], 'q20': ['B']},
- 'H': {
- 'q1': ['B'], 'q2': ['BONUS'], 'q3': ['A'], 'q4': ['B'], 'q5': ['A'], 'q6': ['B'], 'q7': ['B'],
- 'q8': ['C'], 'q9': ['4', '04'], 'q10': ['4', '04'], 'q11': ['5', '05'], 'q12': ['1', '01'], 'q13': ['28'],
- 'q14': ['C'], 'q15': ['B'], 'q16': ['C'], 'q17': ['C'], 'q18': ['C'], 'q19': ['B'], 'q20': ['C']},
- 'JK': {
- 'q1': ['B'], 'q2': ['B'], 'q3': ['B'], 'q4': ['C'], 'q5': ['0', '00'], 'q6': ['0', '00'], 'q7': ['4', '04'],
- 'q8': ['9', '09'], 'q9': ['11', '11'], 'q10': ['C'], 'q11': ['C'], 'q12': ['B'], 'q13': ['C'],
- 'q14': ['C'], 'q15': ['B'], 'q16': ['C'], 'q17': ['BONUS'], 'q18': ['A'], 'q19': ['C'], 'q20': ['B']},
- 'HK': {
- 'q1': ['B'], 'q2': ['BONUS'], 'q3': ['A'], 'q4': ['B'], 'q5': ['B'], 'q6': ['B'], 'q7': ['B'],
- 'q8': ['C'], 'q9': ['4', '04'], 'q10': ['4', '04'], 'q11': ['5', '05'], 'q12': ['1', '01'], 'q13': ['28'],
- 'q14': ['C'], 'q15': ['B'], 'q16': ['C'], 'q17': ['C'], 'q18': ['C'], 'q19': ['B'], 'q20': ['C']},
-}
-
-# TODO: Make this generalized and move it to samples
-Sections = {
- 'J': {
- 'Fibo1': {'ques': [1, 2, 3, 4], '+seq': [2, 3, 5, 8], '-seq': [0, 1, 1, 2]},
- 'Power1': {'ques': [5, 6, 7, 8, 9], '+seq': [1, 2, 4, 8, 16], '-seq': [0, 0, 0, 0, 0]},
- 'Fibo2': {'ques': [10, 11, 12, 13], '+seq': [2, 3, 5, 8], '-seq': [0, 1, 1, 2]},
- 'allNone1': {'ques': [14, 15, 16], 'marks': 12},
- 'Boom1': {'ques': [17, 18, 19, 20], '+seq': [3, 3, 3, 3], '-seq': [1, 1, 1, 1]},
- },
- 'H': {
- 'Boom1': {'ques': [1, 2, 3, 4], '+seq': [3, 3, 3, 3], '-seq': [1, 1, 1, 1]},
- 'Fibo1': {'ques': [5, 6, 7, 8], '+seq': [2, 3, 5, 8], '-seq': [0, 1, 1, 2]},
- 'Power1': {'ques': [9, 10, 11, 12, 13], '+seq': [1, 2, 4, 8, 16], '-seq': [0, 0, 0, 0, 0]},
- 'allNone1': {'ques': [14, 15, 16], 'marks': 12},
- 'Boom2': {'ques': [17, 18, 19, 20], '+seq': [3, 3, 3, 3], '-seq': [1, 1, 1, 1]},
- },
-}
diff --git a/main.py b/main.py
index eac70a50a..dfecbca3f 100644
--- a/main.py
+++ b/main.py
@@ -1,546 +1,99 @@
"""
-Designed and Developed by-
-Udayraj Deshmukh
-https://github.com/Udayraj123
+ OMRChecker
+
+ Author: Udayraj Deshmukh
+ Github: https://github.com/Udayraj123
"""
-import re
-import os
-import cv2
import argparse
-import numpy as np
-import pandas as pd
-import matplotlib.pyplot as plt
-
-# from utils import * #Now imported via template
-from globals import *
-from template import *
-from glob import glob
-from csv import QUOTE_NONNUMERIC
-from time import localtime, strftime, time
-
-
-# TODO(beginner task) :-
-# from colorama import init
-# init()
-# from colorama import Fore, Back, Style
-
-def process_dir(root_dir, subdir, template):
- curr_dir = os.path.join(root_dir, subdir)
-
- # Look for template in current dir
- template_file = os.path.join(curr_dir, TEMPLATE_FILE)
- if os.path.exists(template_file):
- template = Template(template_file)
-
- # look for images in current dir to process
- paths = Paths(os.path.join(args['output_dir'], subdir))
- exts = ('*.png', '*.jpg')
- omr_files = sorted(
- [f for ext in exts for f in glob(os.path.join(curr_dir, ext))])
-
- # Exclude marker image if exists
- if(template and template.marker_path):
- omr_files = [f for f in omr_files if f != template.marker_path]
-
- subfolders = sorted([file for file in os.listdir(
- curr_dir) if os.path.isdir(os.path.join(curr_dir, file))])
- if omr_files:
- args_local = args.copy()
- if("OverrideFlags" in template.options):
- args_local.update(template.options["OverrideFlags"])
- print('\n------------------------------------------------------------------')
- print(f'Processing directory "{curr_dir}" with settings- ')
- print("\tTotal images : %d" % (len(omr_files)))
- print("\tCropping Enabled : " + str(not args_local["noCropping"]))
- print("\tAuto Alignment : " + str(args_local["autoAlign"]))
- print("\tUsing Template : " + str(template.path) if(template) else "N/A")
- print("\tUsing Marker : " + str(template.marker_path)
- if(template.marker is not None) else "N/A")
- print('')
-
- if not template:
- print(f'Error: No template file when processing {curr_dir}.')
- print(
- f' Place {TEMPLATE_FILE} in the directory or specify a template using -t.')
- return
-
- setup_dirs(paths)
- output_set = setup_output(paths, template)
- process_files(omr_files, template, args_local, output_set)
- elif(len(subfolders) == 0):
- # the directory should have images or be non-leaf
- print(f'Note: No valid images or subfolders found in {curr_dir}')
-
- # recursively process subfolders
- for folder in subfolders:
- process_dir(root_dir, os.path.join(subdir, folder), template)
-
-
-def checkAndMove(error_code, filepath, filepath2):
- # print("Dummy Move: "+filepath, " --> ",filepath2)
- global filesNotMoved
- filesNotMoved += 1
- return True
-
- global filesMoved
- if(not os.path.exists(filepath)):
- print('File already moved')
- return False
- if(os.path.exists(filepath2)):
- print('ERROR : Duplicate file at ' + filepath2)
- return False
-
- print("Moved: " + filepath, " --> ", filepath2)
- os.rename(filepath, filepath2)
- filesMoved += 1
- return True
-
-
-def processOMR(template, omrResp):
- # Note: This is a reference function. It is not part of the OMR checker
- # So its implementation is completely subjective to user's requirements.
- csvResp = {}
-
- # symbol for absent response
- UNMARKED_SYMBOL = ''
-
- # print("omrResp",omrResp)
-
- # Multi-column/multi-row questions which need to be concatenated
- for qNo, respKeys in template.concats.items():
- csvResp[qNo] = ''.join([omrResp.get(k, UNMARKED_SYMBOL)
- for k in respKeys])
-
- # Single-column/single-row questions
- for qNo in template.singles:
- csvResp[qNo] = omrResp.get(qNo, UNMARKED_SYMBOL)
-
- # Note: Concatenations and Singles together should be mutually exclusive
- # and should cover all questions in the template(exhaustive)
- # TODO: ^add a warning if omrResp has unused keys remaining
- return csvResp
-
-
-def report(
- Status,
- streak,
- scheme,
- qNo,
- marked,
- ans,
- prevmarks,
- currmarks,
- marks):
- print(
- '%s \t %s \t\t %s \t %s \t %s \t %s \t %s ' % (qNo,
- Status,
- str(streak),
- '[' + scheme + '] ',
- (str(prevmarks) + ' + ' + str(currmarks) + ' =' + str(marks)),
- str(marked),
- str(ans)))
-
-# check sectionwise only.
-
-
-def evaluate(resp, squad="H", explain=False):
- # TODO: @contributors - Need help generalizing this function
- global Answers, Sections
- marks = 0
- answers = Answers[squad]
- if(explain):
- print('Question\tStatus \t Streak\tSection \tMarks_Update\tMarked:\tAnswer:')
- for scheme, section in Sections[squad].items():
- sectionques = section['ques']
- prevcorrect = None
- allflag = 1
- streak = 0
- for q in sectionques:
- qNo = 'q' + str(q)
- ans = answers[qNo]
- marked = resp.get(qNo, 'X')
- firstQ = sectionques[0]
- lastQ = sectionques[len(sectionques) - 1]
- unmarked = marked == 'X' or marked == ''
- bonus = 'BONUS' in ans
- correct = bonus or (marked in ans)
- inrange = 0
-
- if(unmarked or int(q) == firstQ):
- streak = 0
- elif(prevcorrect == correct):
- streak += 1
- else:
- streak = 0
-
- if('allNone' in scheme):
- # loop on all sectionques
- allflag = allflag and correct
- if(q == lastQ):
- # at the end check allflag
- prevcorrect = correct
- currmarks = section['marks'] if allflag else 0
- else:
- currmarks = 0
-
- elif('Proxy' in scheme):
- a = int(ans[0])
- # proximity check
- inrange = 1 if unmarked else (
- float(abs(int(marked) - a)) / float(a) <= 0.25)
- currmarks = section['+marks'] if correct else (
- 0 if inrange else -section['-marks'])
-
- elif('Fibo' in scheme or 'Power' in scheme or 'Boom' in scheme):
- currmarks = section['+seq'][streak] if correct else (
- 0 if unmarked else -section['-seq'][streak])
- elif('TechnoFin' in scheme):
- currmarks = 0
- else:
- print('Invalid Sections')
- prevmarks = marks
- marks += currmarks
-
- if(explain):
- if bonus:
- report('BonusQ', streak, scheme, qNo, marked,
- ans, prevmarks, currmarks, marks)
- elif correct:
- report('Correct', streak, scheme, qNo, marked,
- ans, prevmarks, currmarks, marks)
- elif unmarked:
- report('Unmarked', streak, scheme, qNo, marked,
- ans, prevmarks, currmarks, marks)
- elif inrange:
- report('InProximity', streak, scheme, qNo,
- marked, ans, prevmarks, currmarks, marks)
- else:
- report('Incorrect', streak, scheme, qNo,
- marked, ans, prevmarks, currmarks, marks)
-
- prevcorrect = correct
-
- return marks
-
-
-def setup_output(paths, template):
- ns = argparse.Namespace()
- print("\nChecking Files...")
-
- # Include current output paths
- ns.paths = paths
-
- # custom sort: To use integer order in question names instead of
- # alphabetical - avoids q1, q10, q2 and orders them q1, q2, ..., q10
- ns.respCols = sorted(list(template.concats.keys()) + template.singles,
- key=lambda x: int(x[1:]) if ord(x[1]) in range(48, 58) else 0)
- ns.emptyResp = [''] * len(ns.respCols)
- ns.sheetCols = ['file_id', 'input_path',
- 'output_path', 'score'] + ns.respCols
- ns.OUTPUT_SET = []
- ns.filesObj = {}
- ns.filesMap = {
- "Results": paths.resultDir + 'Results_' + timeNowHrs + '.csv',
- "MultiMarked": paths.manualDir + 'MultiMarkedFiles_.csv',
- "Errors": paths.manualDir + 'ErrorFiles_.csv',
- "BadRollNos": paths.manualDir + 'BadRollNoFiles_.csv'
- }
-
- for fileKey, fileName in ns.filesMap.items():
- if(not os.path.exists(fileName)):
- print("Note: Created new file: %s" % (fileName))
- # still append mode req [THINK!]
- ns.filesObj[fileKey] = open(fileName, 'a')
- # Create Header Columns
- pd.DataFrame([ns.sheetCols], dtype=str).to_csv(
- ns.filesObj[fileKey], quoting=QUOTE_NONNUMERIC, header=False, index=False)
- else:
- print('Present : appending to %s' % (fileName))
- ns.filesObj[fileKey] = open(fileName, 'a')
-
- return ns
-
-
-''' TODO: Refactor into new process flow.
- Currently I have no idea what this does so I left it out'''
-
-
-def preliminary_check():
- filesCounter = 0
- # PRELIM_CHECKS for thresholding
- if(PRELIM_CHECKS):
- # TODO: add more using unit testing
- ALL_WHITE = 255 * \
- np.ones((TEMPLATE.dims[1], TEMPLATE.dims[0]), dtype='uint8')
- OMRresponseDict, final_marked, MultiMarked, multiroll = readResponse(
- ALL_WHITE, name="ALL_WHITE", savedir=None, autoAlign=True)
- print("ALL_WHITE", OMRresponseDict)
- if(OMRresponseDict != {}):
- print("Preliminary Checks Failed.")
- exit(12)
- ALL_BLACK = np.zeros(
- (TEMPLATE.dims[1], TEMPLATE.dims[0]), dtype='uint8')
- OMRresponseDict, final_marked, MultiMarked, multiroll = readResponse(
- ALL_BLACK, name="ALL_BLACK", savedir=None, autoAlign=True)
- print("ALL_BLACK", OMRresponseDict)
- show("Confirm : All bubbles are black", final_marked, 1, 1)
-
-
-def process_files(omr_files, template, args, out):
- start_time = int(time())
- filesCounter = 0
- filesNotMoved = 0
-
- for filepath in omr_files:
- filesCounter += 1
- # For windows filesystem support: all '\' will be replaced by '/'
- filepath = filepath.replace(os.sep, '/')
-
- # Prefixing a 'r' to use raw string (escape character '\' is taken
- # literally)
- finder = re.search(r'.*/(.*)/(.*)', filepath, re.IGNORECASE)
- if(finder):
- inputFolderName, filename = finder.groups()
- else:
- print("Error: Filepath not matching to Regex: " + filepath)
- continue
- # set global var for reading
-
- inOMR = cv2.imread(filepath, cv2.IMREAD_GRAYSCALE)
- print(
- '\n[%d] Processing image: \t' %
- (filesCounter),
- filepath,
- "\tResolution: ",
- inOMR.shape)
-
- OMRCrop = getROI(inOMR, filename, noCropping=args["noCropping"])
-
- if(OMRCrop is None):
- # Error OMR - could not crop
- newfilepath = out.paths.errorsDir + filename
- out.OUTPUT_SET.append([filename] + out.emptyResp)
- if(checkAndMove(NO_MARKER_ERR, filepath, newfilepath)):
- err_line = [filename, filepath,
- newfilepath, "NA"] + out.emptyResp
- pd.DataFrame(
- err_line,
- dtype=str).T.to_csv(
- out.filesObj["Errors"],
- quoting=QUOTE_NONNUMERIC,
- header=False,
- index=False)
- continue
-
- if template.marker is not None:
- OMRCrop = handle_markers(OMRCrop, template.marker, filename)
-
- if(args["setLayout"]):
- templateLayout = drawTemplateLayout(
- OMRCrop, template, shifted=False, border=2)
- show("Template Layout", templateLayout, 1, 1)
- continue
-
- # uniquify
- file_id = inputFolderName + '_' + filename
- savedir = out.paths.saveMarkedDir
- OMRresponseDict, final_marked, MultiMarked, multiroll = \
- readResponse(template, OMRCrop, name=file_id,
- savedir=savedir, autoAlign=args["autoAlign"])
-
- # concatenate roll nos, set unmarked responses, etc
- resp = processOMR(template, OMRresponseDict)
- print("\nRead Response: \t", resp)
-
- # This evaluates and returns the score attribute
- score = evaluate(resp, explain=explain)
- respArray = []
- for k in out.respCols:
- respArray.append(resp[k])
-
- out.OUTPUT_SET.append([filename] + respArray)
-
- # TODO: Add roll number validation here
- if(MultiMarked == 0):
- filesNotMoved += 1
- newfilepath = savedir + file_id
- # Enter into Results sheet-
- results_line = [filename, filepath, newfilepath, score] + respArray
- # Write/Append to results_line file(opened in append mode)
- pd.DataFrame(
- results_line,
- dtype=str).T.to_csv(
- out.filesObj["Results"],
- quoting=QUOTE_NONNUMERIC,
- header=False,
- index=False)
- print("[%d] Graded with score: %.2f" %
- (filesCounter, score), '\t file_id: ', file_id)
- # print(filesCounter,file_id,resp['Roll'],'score : ',score)
- else:
- # MultiMarked file
- print('[%d] MultiMarked, moving File: %s' %
- (filesCounter, file_id))
- newfilepath = out.paths.multiMarkedDir + filename
- if(checkAndMove(MULTI_BUBBLE_WARN, filepath, newfilepath)):
- mm_line = [filename, filepath, newfilepath, "NA"] + respArray
- pd.DataFrame(
- mm_line,
- dtype=str).T.to_csv(
- out.filesObj["MultiMarked"],
- quoting=QUOTE_NONNUMERIC,
- header=False,
- index=False)
- # else:
- # TODO: Add appropriate record handling here
- # pass
-
- # flush after every 20 files for a live view
- if(filesCounter % 20 == 0 or filesCounter == len(omr_files)):
- for fileKey in out.filesMap.keys():
- out.filesObj[fileKey].flush()
-
- timeChecking = round(time() - start_time, 2) if filesCounter else 1
- print('')
- print('Total files moved : %d ' % (filesMoved))
- print('Total files not moved : %d ' % (filesNotMoved))
- print('------------------------------')
- print(
- 'Total files processed : %d (%s)' %
- (filesCounter,
- 'Sum Tallied!' if filesCounter == (
- filesMoved +
- filesNotMoved) else 'Not Tallying!'))
-
- if(showimglvl <= 0):
- print(
- '\nFinished Checking %d files in %.1f seconds i.e. ~%.1f minutes.' %
- (filesCounter, timeChecking, timeChecking / 60))
- print('OMR Processing Rate :\t ~ %.2f seconds/OMR' %
- (timeChecking / filesCounter))
- print('OMR Processing Speed :\t ~ %.2f OMRs/minute' %
- ((filesCounter * 60) / timeChecking))
- else:
- print("\nTotal script time :", timeChecking, "seconds")
-
- if(showimglvl <= 1):
- # TODO: colorama this
- print(
- "\nTip: To see some awesome visuals, open globals.py and increase 'showimglvl'")
-
- evaluate_correctness(template, out)
-
- # Use this data to train as +ve feedback
- if(showimglvl >= 0 and filesCounter > 10):
- # TODO: Find good parameters to plot and depict image set quality
- for x in [thresholdCircles]: # ,badThresholds,veryBadPoints, , mbs]:
- if(x != []):
- x = pd.DataFrame(x)
- print(x.describe())
- plt.plot(range(len(x)), x)
- plt.title("Mystery Plot")
- plt.show()
- else:
- print(x)
-
-
-# Evaluate accuracy based on OMRDataset file generated through moderation
-# portal on the same set of images
-def evaluate_correctness(template, out):
- # TODO: TEST_FILE WOULD BE RELATIVE TO INPUT SUBDIRECTORY NOW-
- TEST_FILE = 'inputs/OMRDataset.csv'
- if(os.path.exists(TEST_FILE)):
- print("\nStarting evaluation for: " + TEST_FILE)
-
- TEST_COLS = ['file_id'] + out.respCols
- y_df = pd.read_csv(
- TEST_FILE, dtype=str)[TEST_COLS].replace(
- np.nan, '', regex=True).set_index('file_id')
-
- if(np.any(y_df.index.duplicated)):
- y_df_filtered = y_df.loc[~y_df.index.duplicated(keep='first')]
- print(
- "WARNING: Found duplicate File-ids in file %s. Removed %d rows from testing data. Rows remaining: %d" %
- (TEST_FILE, y_df.shape[0] - y_df_filtered.shape[0], y_df_filtered.shape[0]))
- y_df = y_df_filtered
-
- x_df = pd.DataFrame(
- out.OUTPUT_SET,
- dtype=str,
- columns=TEST_COLS).set_index('file_id')
- # print("x_df",x_df.head())
- # print("\ny_df",y_df.head())
- intersection = y_df.index.intersection(x_df.index)
-
- # Checking if the merge is okay
- if(intersection.size == x_df.index.size):
- y_df = y_df.loc[intersection]
- x_df['TestResult'] = (x_df == y_df).all(axis=1).astype(int)
- print(x_df.head())
- print("\n\t Accuracy on the %s Dataset: %.6f" %
- (TEST_FILE, (x_df['TestResult'].sum() / x_df.shape[0])))
- else:
- print(
- "\nERROR: Insufficient Testing Data: Have you appended MultiMarked data yet?")
- print("Missing File-ids: ",
- list(x_df.index.difference(intersection)))
-
-
-timeNowHrs = strftime("%I%p", localtime())
-
-# construct the argument parse and parse the arguments
-argparser = argparse.ArgumentParser()
-# https://docs.python.org/3/howto/argparse.html
-# store_true: if the option is specified, assign the value True to
-# args.verbose. Not specifying it implies False.
-argparser.add_argument(
- "-c",
- "--noCropping",
- required=False,
- dest='noCropping',
- action='store_true',
- help="Disables page contour detection - used when page boundary is not visible e.g. document scanner.")
-argparser.add_argument(
- "-a",
- "--autoAlign",
- required=False,
- dest='autoAlign',
- action='store_true',
- help="(experimental) Enables automatic template alignment - use if the scans show slight misalignments.")
-argparser.add_argument(
- "-l",
- "--setLayout",
- required=False,
- dest='setLayout',
- action='store_true',
- help="Set up OMR template layout - modify your json file and run again until the template is set.")
-argparser.add_argument("-i", "--inputDir", required=False, action='append',
- dest='input_dir', help="Specify an input directory.")
-argparser.add_argument("-o", "--outputDir", default='outputs', required=False,
- dest='output_dir', help="Specify an output directory.")
-argparser.add_argument(
- "-t",
- "--template",
- required=False,
- dest='template',
- help="Specify a default template if no template file in input directories.")
-
-
-args, unknown = argparser.parse_known_args()
-args = vars(args)
-if(len(unknown) > 0):
- print("\nError: Unknown arguments:", unknown)
- argparser.print_help()
- exit(11)
-
-if args['template']:
- args['template'] = Template(args['template'])
-
-if args['input_dir'] is None:
- args['input_dir'] = ['inputs']
-
-for root in args['input_dir']:
- process_dir(root, '', args['template'])
+import sys
+from pathlib import Path
+
+from src.entry import entry_point
+from src.logger import logger
+
+
+def parse_args():
+ # construct the argument parse and parse the arguments
+ argparser = argparse.ArgumentParser()
+
+ argparser.add_argument(
+ "-i",
+ "--inputDir",
+ default=["inputs"],
+ # https://docs.python.org/3/library/argparse.html#nargs
+ nargs="*",
+ required=False,
+ type=str,
+ dest="input_paths",
+ help="Specify an input directory.",
+ )
+
+ argparser.add_argument(
+ "-d",
+ "--debug",
+ required=False,
+ dest="debug",
+ action="store_false",
+ help="Enables debugging mode for showing detailed errors",
+ )
+
+ argparser.add_argument(
+ "-o",
+ "--outputDir",
+ default="outputs",
+ required=False,
+ dest="output_dir",
+ help="Specify an output directory.",
+ )
+
+ argparser.add_argument(
+ "-a",
+ "--autoAlign",
+ required=False,
+ dest="autoAlign",
+ action="store_true",
+ help="(experimental) Enables automatic template alignment - \
+ use if the scans show slight misalignments.",
+ )
+
+ argparser.add_argument(
+ "-l",
+ "--setLayout",
+ required=False,
+ dest="setLayout",
+ action="store_true",
+ help="Set up OMR template layout - modify your json file and \
+ run again until the template is set.",
+ )
+
+ (
+ args,
+ unknown,
+ ) = argparser.parse_known_args()
+
+ args = vars(args)
+
+ if len(unknown) > 0:
+ logger.warning(f"\nError: Unknown arguments: {unknown}", unknown)
+ argparser.print_help()
+ exit(11)
+ return args
+
+
+def entry_point_for_args(args):
+ if args["debug"] is True:
+ # Disable tracebacks
+ sys.tracebacklimit = 0
+ for root in args["input_paths"]:
+ entry_point(
+ Path(root),
+ args,
+ )
+
+
+if __name__ == "__main__":
+ args = parse_args()
+ entry_point_for_args(args)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 000000000..060309be5
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,18 @@
+[tool.black]
+exclude = '''
+(
+ /(
+ \.eggs # exclude a few common directories in the
+ | \.git # root of the project
+ | \.venv
+ | _build
+ | build
+ | dist
+ )/
+ | foo.py # also separately exclude a file named foo.py in
+ # the root of the project
+)
+'''
+include = '\.pyi?$'
+line-length = 88
+target-version = ['py37']
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 000000000..84008a213
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,6 @@
+# pytest.ini
+[pytest]
+minversion = 7.0
+addopts = -qq --capture=no
+testpaths =
+ src/tests
diff --git a/requirements.dev.txt b/requirements.dev.txt
new file mode 100644
index 000000000..722ab0dd7
--- /dev/null
+++ b/requirements.dev.txt
@@ -0,0 +1,7 @@
+-r requirements.txt
+flake8>=6.0.0
+freezegun>=1.2.2
+pre-commit>=3.3.3
+pytest-mock>=3.11.1
+pytest>=7.4.0
+syrupy>=4.0.4
diff --git a/requirements.txt b/requirements.txt
index 505865c98..761d2e517 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,8 @@
-imutils>=0.5.2
-matplotlib>=3.0.2
-numpy>=1.16.0
-pandas>=0.24.0
+deepmerge>=1.1.0
+dotmap>=1.3.30
+jsonschema>=4.17.3
+matplotlib>=3.7.1
+numpy>=1.25.0
+pandas>=2.0.2
+rich>=13.4.2
+screeninfo>=0.8.1
diff --git a/samples/answer-key/using-csv/adrian_omr.png b/samples/answer-key/using-csv/adrian_omr.png
new file mode 100644
index 000000000..d8db0994d
Binary files /dev/null and b/samples/answer-key/using-csv/adrian_omr.png differ
diff --git a/samples/answer-key/using-csv/answer_key.csv b/samples/answer-key/using-csv/answer_key.csv
new file mode 100644
index 000000000..566201d1a
--- /dev/null
+++ b/samples/answer-key/using-csv/answer_key.csv
@@ -0,0 +1,5 @@
+q1,C
+q2,E
+q3,A
+q4,B
+q5,B
\ No newline at end of file
diff --git a/samples/answer-key/using-csv/evaluation.json b/samples/answer-key/using-csv/evaluation.json
new file mode 100644
index 000000000..14a3db255
--- /dev/null
+++ b/samples/answer-key/using-csv/evaluation.json
@@ -0,0 +1,14 @@
+{
+ "source_type": "csv",
+ "options": {
+ "answer_key_csv_path": "answer_key.csv",
+ "should_explain_scoring": true
+ },
+ "marking_schemes": {
+ "DEFAULT": {
+ "correct": "1",
+ "incorrect": "0",
+ "unmarked": "0"
+ }
+ }
+}
diff --git a/samples/answer-key/using-csv/template.json b/samples/answer-key/using-csv/template.json
new file mode 100644
index 000000000..41ec9ffaf
--- /dev/null
+++ b/samples/answer-key/using-csv/template.json
@@ -0,0 +1,35 @@
+{
+ "pageDimensions": [
+ 300,
+ 400
+ ],
+ "bubbleDimensions": [
+ 25,
+ 25
+ ],
+ "preProcessors": [
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [
+ 10,
+ 10
+ ]
+ }
+ }
+ ],
+ "fieldBlocks": {
+ "MCQ_Block_1": {
+ "fieldType": "QTYPE_MCQ5",
+ "origin": [
+ 65,
+ 60
+ ],
+ "fieldLabels": [
+ "q1..5"
+ ],
+ "labelsGap": 52,
+ "bubblesGap": 41
+ }
+ }
+}
diff --git a/samples/answer-key/weighted-answers/evaluation.json b/samples/answer-key/weighted-answers/evaluation.json
new file mode 100644
index 000000000..c0daefcf1
--- /dev/null
+++ b/samples/answer-key/weighted-answers/evaluation.json
@@ -0,0 +1,35 @@
+{
+ "source_type": "custom",
+ "options": {
+ "questions_in_order": [
+ "q1..5"
+ ],
+ "answers_in_order": [
+ "C",
+ "E",
+ [
+ "A",
+ "C"
+ ],
+ [
+ [
+ "B",
+ 2
+ ],
+ [
+ "C",
+ "3/2"
+ ]
+ ],
+ "C"
+ ],
+ "should_explain_scoring": true
+ },
+ "marking_schemes": {
+ "DEFAULT": {
+ "correct": "3",
+ "incorrect": "-1",
+ "unmarked": "0"
+ }
+ }
+}
diff --git a/samples/answer-key/weighted-answers/images/adrian_omr.png b/samples/answer-key/weighted-answers/images/adrian_omr.png
new file mode 100644
index 000000000..69a53823d
Binary files /dev/null and b/samples/answer-key/weighted-answers/images/adrian_omr.png differ
diff --git a/samples/answer-key/weighted-answers/images/adrian_omr_2.png b/samples/answer-key/weighted-answers/images/adrian_omr_2.png
new file mode 100644
index 000000000..d8db0994d
Binary files /dev/null and b/samples/answer-key/weighted-answers/images/adrian_omr_2.png differ
diff --git a/samples/answer-key/weighted-answers/template.json b/samples/answer-key/weighted-answers/template.json
new file mode 100644
index 000000000..41ec9ffaf
--- /dev/null
+++ b/samples/answer-key/weighted-answers/template.json
@@ -0,0 +1,35 @@
+{
+ "pageDimensions": [
+ 300,
+ 400
+ ],
+ "bubbleDimensions": [
+ 25,
+ 25
+ ],
+ "preProcessors": [
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [
+ 10,
+ 10
+ ]
+ }
+ }
+ ],
+ "fieldBlocks": {
+ "MCQ_Block_1": {
+ "fieldType": "QTYPE_MCQ5",
+ "origin": [
+ 65,
+ 60
+ ],
+ "fieldLabels": [
+ "q1..5"
+ ],
+ "labelsGap": 52,
+ "bubblesGap": 41
+ }
+ }
+}
diff --git a/samples/community/Antibodyy/simple_omr_sheet.jpg b/samples/community/Antibodyy/simple_omr_sheet.jpg
new file mode 100644
index 000000000..661d5f4fa
Binary files /dev/null and b/samples/community/Antibodyy/simple_omr_sheet.jpg differ
diff --git a/samples/community/Antibodyy/template.json b/samples/community/Antibodyy/template.json
new file mode 100644
index 000000000..7e962bbfd
--- /dev/null
+++ b/samples/community/Antibodyy/template.json
@@ -0,0 +1,35 @@
+{
+ "pageDimensions": [
+ 299,
+ 398
+ ],
+ "bubbleDimensions": [
+ 42,
+ 42
+ ],
+ "fieldBlocks": {
+ "MCQBlock1": {
+ "fieldType": "QTYPE_MCQ5",
+ "origin": [
+ 65,
+ 79
+ ],
+ "bubblesGap": 43,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "q1..6"
+ ]
+ }
+ },
+ "preProcessors": [
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [
+ 10,
+ 10
+ ]
+ }
+ }
+ ]
+}
diff --git a/samples/community/DomBrzezinski/config.json b/samples/community/DomBrzezinski/config.json
new file mode 100644
index 000000000..df01a23d2
--- /dev/null
+++ b/samples/community/DomBrzezinski/config.json
@@ -0,0 +1,11 @@
+{
+ "dimensions": {
+ "display_height": 1240,
+ "display_width": 820,
+ "processing_height": 820,
+ "processing_width": 666
+ },
+ "outputs": {
+ "show_image_level": 0
+ }
+}
diff --git a/samples/community/DomBrzezinski/photos/omr_survey_1.jpg b/samples/community/DomBrzezinski/photos/omr_survey_1.jpg
new file mode 100644
index 000000000..043b2bfbd
Binary files /dev/null and b/samples/community/DomBrzezinski/photos/omr_survey_1.jpg differ
diff --git a/samples/community/DomBrzezinski/photos/omr_survey_2.jpg b/samples/community/DomBrzezinski/photos/omr_survey_2.jpg
new file mode 100644
index 000000000..3aca7e968
Binary files /dev/null and b/samples/community/DomBrzezinski/photos/omr_survey_2.jpg differ
diff --git a/samples/community/DomBrzezinski/template.json b/samples/community/DomBrzezinski/template.json
new file mode 100644
index 000000000..2d501b01a
--- /dev/null
+++ b/samples/community/DomBrzezinski/template.json
@@ -0,0 +1,40 @@
+{
+ "pageDimensions": [500, 700],
+ "bubbleDimensions": [10, 10],
+ "customLabels": {},
+ "fieldBlocks": {
+ "MCQBlock1": {
+ "fieldType": "QTYPE_MCQ5",
+ "origin": [342, 196],
+ "fieldLabels": [
+ "Overall, how friendly were the hotel staff members?",
+ "Overall, how polite were the hotel staff members?",
+ "Overall, how professional were the hotel staff members?",
+ "How quick was the check-in process?",
+ "How clean was your room upon arrival?",
+ "How well did the housekeeping staff clean your room?",
+ "Overall, how well-equipped was your room?",
+ "How helpful was the concierge throughout your stay?",
+ "How comfortable were your bed linens?",
+ "Overall, how quickly did the hotel staff respond to your requests?",
+ "How convenient were the hours of the food service options at our hotel?",
+ "How delicious was the hotel breakfast service?",
+ "How pleased were you with the quality of the food offered at our hotel?",
+ "How affordable was the hotel breakfast service?",
+ "How affordable was your stay at our hotel?",
+ "How likely are you to stay at our hotel again?",
+ "How likely are you to discourage others from staying at our hotel?"
+ ],
+ "bubblesGap": 25.5,
+ "labelsGap": 14.3
+ }
+ },
+ "preProcessors": [
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [10, 10]
+ }
+ }
+ ]
+}
diff --git a/samples/community/EricSoOSU/README.md b/samples/community/EricSoOSU/README.md
new file mode 100644
index 000000000..b14097548
--- /dev/null
+++ b/samples/community/EricSoOSU/README.md
@@ -0,0 +1,38 @@
+# Right-To-Left (RTL) Custom Fields
+
+## Background
+
+Some OMR sheets used in RTL languages have answer bubbles that read from right to left (e.g. D, C, B, A) instead of the more typical left to right (e.g. A, B, C, D).
+
+## Using Built-in RTL Field Types
+OMRChecker includes two built-in RTL field types to be used in your template.
+
+`QTYPE_MCQ4_RTL` - Representing 4 choice questions with reversed bubble values ["D", "C", "B", "A"]
+
+`QTYPE_MCQ5_RTL` - Representing 5 choice questions with reversed bubble values ["E", "D", "C", "B", "A"]
+
+These can be used in your `template.json` similar to the other field type:
+```
+"MCQBlock_RTL": {
+ "fieldType": "QTYPE_MCQ4_RTL",
+ "fieldLabels": ["q1..10"],
+ "bubblesGap": 40,
+ "labelsGap": 50,
+ "origin": [100, 100]
+}
+```
+## Creating Custom RTL Field
+For situations where you might need a different number of choices, you can create a custom reversed field type inline by specifying `bubbleValues` and `direction` in the field block.
+```
+"MCQBlock_CUSTOM_RTL": {
+ "bubbleValues": ["G", "F", "E", "D", "C", "B", "A"],
+ "direction": "horizontal",
+ "fieldLabels": ["q1..7"],
+ "bubblesGap": 40,
+ "labelsGap": 50,
+ "origin": [100, 100]
+}
+```
+Alternatively, you can also just add the custom field type in `src/constants/common.py` under `FIELD_TYPES` for reuse.
+
+Reference to Issue #234 for original issue discussion on RTL feature.
diff --git a/samples/community/EricSoOSU/RTLSampleBubbleSheet.jpg b/samples/community/EricSoOSU/RTLSampleBubbleSheet.jpg
new file mode 100644
index 000000000..65b82ecb5
Binary files /dev/null and b/samples/community/EricSoOSU/RTLSampleBubbleSheet.jpg differ
diff --git a/samples/community/EricSoOSU/template.json b/samples/community/EricSoOSU/template.json
new file mode 100644
index 000000000..f261c8006
--- /dev/null
+++ b/samples/community/EricSoOSU/template.json
@@ -0,0 +1,25 @@
+{
+ "pageDimensions": [340, 500],
+ "bubbleDimensions": [20, 20],
+
+ "fieldBlocks": {
+
+ "MCQBlock_RTL_Builtin": {
+ "fieldType": "QTYPE_MCQ4_RTL",
+ "fieldLabels": ["q1..5"],
+ "bubblesGap": 40,
+ "labelsGap": 50,
+ "origin": [50, 50]
+ },
+
+ "MCQBlock_RTL_Custom": {
+ "bubbleValues": ["G", "F", "E", "D", "C", "B", "A"],
+ "direction": "horizontal",
+ "fieldLabels": ["q6..7"],
+ "bubblesGap": 42,
+ "labelsGap": 50,
+ "origin": [30, 380]
+ }
+ },
+ "preProcessors": []
+}
diff --git a/samples/community/Sandeep-1507/omr-1.png b/samples/community/Sandeep-1507/omr-1.png
new file mode 100644
index 000000000..8c40655e1
Binary files /dev/null and b/samples/community/Sandeep-1507/omr-1.png differ
diff --git a/samples/community/Sandeep-1507/omr-2.png b/samples/community/Sandeep-1507/omr-2.png
new file mode 100644
index 000000000..aabef0a59
Binary files /dev/null and b/samples/community/Sandeep-1507/omr-2.png differ
diff --git a/samples/community/Sandeep-1507/omr-3.png b/samples/community/Sandeep-1507/omr-3.png
new file mode 100644
index 000000000..8f1cb5c89
Binary files /dev/null and b/samples/community/Sandeep-1507/omr-3.png differ
diff --git a/samples/community/Sandeep-1507/template.json b/samples/community/Sandeep-1507/template.json
new file mode 100644
index 000000000..b35404e2c
--- /dev/null
+++ b/samples/community/Sandeep-1507/template.json
@@ -0,0 +1,234 @@
+{
+ "pageDimensions": [
+ 1189,
+ 1682
+ ],
+ "bubbleDimensions": [
+ 15,
+ 15
+ ],
+ "preProcessors": [
+ {
+ "name": "GaussianBlur",
+ "options": {
+ "kSize": [
+ 3,
+ 3
+ ],
+ "sigmaX": 0
+ }
+ }
+ ],
+ "customLabels": {
+ "Booklet_No": [
+ "b1..7"
+ ]
+ },
+ "fieldBlocks": {
+ "Booklet_No": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 112,
+ 530
+ ],
+ "fieldLabels": [
+ "b1..7"
+ ],
+ "emptyValue": "no",
+ "bubblesGap": 28,
+ "labelsGap": 26.5
+ },
+ "MCQBlock1a1": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q1..10"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 476,
+ 100
+ ]
+ },
+ "MCQBlock1a2": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q11..20"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 476,
+ 370
+ ]
+ },
+ "MCQBlock1a3": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q21..35"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 476,
+ 638
+ ]
+ },
+ "MCQBlock2a1": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q51..60"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 645,
+ 100
+ ]
+ },
+ "MCQBlock2a2": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q61..70"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 645,
+ 370
+ ]
+ },
+ "MCQBlock2a3": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q71..85"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 645,
+ 638
+ ]
+ },
+ "MCQBlock3a1": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q101..110"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 815,
+ 100
+ ]
+ },
+ "MCQBlock3a2": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q111..120"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 815,
+ 370
+ ]
+ },
+ "MCQBlock3a3": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q121..135"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 815,
+ 638
+ ]
+ },
+ "MCQBlock4a1": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q151..160"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 983,
+ 100
+ ]
+ },
+ "MCQBlock4a2": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q161..170"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 983,
+ 370
+ ]
+ },
+ "MCQBlock4a3": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q171..185"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 983,
+ 638
+ ]
+ },
+ "MCQBlock1a": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q36..50"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 480,
+ 1061
+ ]
+ },
+ "MCQBlock2a": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q86..100"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 648,
+ 1061
+ ]
+ },
+ "MCQBlock3a": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q136..150"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.7,
+ "origin": [
+ 815,
+ 1061
+ ]
+ },
+ "MCQBlock4a": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q186..200"
+ ],
+ "bubblesGap": 28.7,
+ "labelsGap": 26.6,
+ "origin": [
+ 986,
+ 1061
+ ]
+ }
+ }
+}
diff --git a/samples/community/Shamanth/omr_sheet_01.png b/samples/community/Shamanth/omr_sheet_01.png
new file mode 100644
index 000000000..ead17dd1c
Binary files /dev/null and b/samples/community/Shamanth/omr_sheet_01.png differ
diff --git a/samples/community/Shamanth/template.json b/samples/community/Shamanth/template.json
new file mode 100644
index 000000000..fb18d975b
--- /dev/null
+++ b/samples/community/Shamanth/template.json
@@ -0,0 +1,25 @@
+{
+ "pageDimensions": [
+ 300,
+ 400
+ ],
+ "bubbleDimensions": [
+ 20,
+ 20
+ ],
+ "fieldBlocks": {
+ "MCQBlock1": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 78,
+ 41
+ ],
+ "fieldLabels": [
+ "q21..28"
+ ],
+ "bubblesGap": 56,
+ "labelsGap": 46
+ }
+ },
+ "preProcessors": []
+}
diff --git a/samples/community/UPSC-mock/answer_key.jpg b/samples/community/UPSC-mock/answer_key.jpg
new file mode 100644
index 000000000..7a229c1b6
Binary files /dev/null and b/samples/community/UPSC-mock/answer_key.jpg differ
diff --git a/samples/community/UPSC-mock/config.json b/samples/community/UPSC-mock/config.json
new file mode 100644
index 000000000..0edf9d22d
--- /dev/null
+++ b/samples/community/UPSC-mock/config.json
@@ -0,0 +1,11 @@
+{
+ "dimensions": {
+ "display_height": 1800,
+ "display_width": 2400,
+ "processing_height": 2400,
+ "processing_width": 1800
+ },
+ "outputs": {
+ "show_image_level": 0
+ }
+}
diff --git a/samples/community/UPSC-mock/evaluation.json b/samples/community/UPSC-mock/evaluation.json
new file mode 100644
index 000000000..42fbec8b4
--- /dev/null
+++ b/samples/community/UPSC-mock/evaluation.json
@@ -0,0 +1,18 @@
+{
+ "source_type": "csv",
+ "options": {
+ "answer_key_csv_path": "answer_key.csv",
+ "answer_key_image_path": "answer_key.jpg",
+ "questions_in_order": [
+ "q1..100"
+ ],
+ "should_explain_scoring": true
+ },
+ "marking_schemes": {
+ "DEFAULT": {
+ "correct": "2",
+ "incorrect": "-2/3",
+ "unmarked": "0"
+ }
+ }
+}
diff --git a/samples/community/UPSC-mock/scan-angles/angle-1.jpg b/samples/community/UPSC-mock/scan-angles/angle-1.jpg
new file mode 100644
index 000000000..9c75d7be1
Binary files /dev/null and b/samples/community/UPSC-mock/scan-angles/angle-1.jpg differ
diff --git a/samples/community/UPSC-mock/scan-angles/angle-2.jpg b/samples/community/UPSC-mock/scan-angles/angle-2.jpg
new file mode 100644
index 000000000..edab60600
Binary files /dev/null and b/samples/community/UPSC-mock/scan-angles/angle-2.jpg differ
diff --git a/samples/community/UPSC-mock/scan-angles/angle-3.jpg b/samples/community/UPSC-mock/scan-angles/angle-3.jpg
new file mode 100644
index 000000000..f1da4af99
Binary files /dev/null and b/samples/community/UPSC-mock/scan-angles/angle-3.jpg differ
diff --git a/samples/community/UPSC-mock/template.json b/samples/community/UPSC-mock/template.json
new file mode 100644
index 000000000..a1ef5c6be
--- /dev/null
+++ b/samples/community/UPSC-mock/template.json
@@ -0,0 +1,195 @@
+{
+ "pageDimensions": [
+ 1800,
+ 2400
+ ],
+ "bubbleDimensions": [
+ 30,
+ 25
+ ],
+ "customLabels": {
+ "Subject Code": [
+ "subjectCode1",
+ "subjectCode2"
+ ],
+ "Roll": [
+ "roll1..10"
+ ]
+ },
+ "fieldBlocks": {
+ "bookletNo": {
+ "origin": [
+ 595,
+ 545
+ ],
+ "bubblesGap": 68,
+ "labelsGap": 0,
+ "fieldLabels": [
+ "bookletNo"
+ ],
+ "bubbleValues": [
+ "A",
+ "B",
+ "C",
+ "D"
+ ],
+ "direction": "vertical"
+ },
+ "subjectCode": {
+ "origin": [
+ 912,
+ 512
+ ],
+ "bubblesGap": 33,
+ "labelsGap": 42.5,
+ "fieldLabels": [
+ "subjectCode1",
+ "subjectCode2"
+ ],
+ "fieldType": "QTYPE_INT"
+ },
+ "roll": {
+ "origin": [
+ 1200,
+ 510
+ ],
+ "bubblesGap": 33,
+ "labelsGap": 42.8,
+ "fieldLabels": [
+ "roll1..10"
+ ],
+ "fieldType": "QTYPE_INT"
+ },
+ "q01block": {
+ "origin": [
+ 500,
+ 927
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q1..10"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q11block": {
+ "origin": [
+ 500,
+ 1258
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q11..20"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q21block": {
+ "origin": [
+ 500,
+ 1589
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q21..30"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q31block": {
+ "origin": [
+ 495,
+ 1925
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q31..40"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q41block": {
+ "origin": [
+ 811,
+ 927
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q41..50"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q51block": {
+ "origin": [
+ 811,
+ 1258
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q51..60"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q61block": {
+ "origin": [
+ 811,
+ 1589
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q61..70"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q71block": {
+ "origin": [
+ 811,
+ 1925
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q71..80"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q81block": {
+ "origin": [
+ 1125,
+ 927
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q81..90"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q91block": {
+ "origin": [
+ 1125,
+ 1258
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q91..100"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ }
+ },
+ "preProcessors": [
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [
+ 10,
+ 10
+ ]
+ }
+ }
+ ]
+}
diff --git a/samples/community/UmarFarootAPS/answer_key.csv b/samples/community/UmarFarootAPS/answer_key.csv
new file mode 100644
index 000000000..b40e89597
--- /dev/null
+++ b/samples/community/UmarFarootAPS/answer_key.csv
@@ -0,0 +1,200 @@
+q1,C
+q2,C
+q3,"D,E"
+q4,"A,AB"
+q5,"[['A', '1'], ['B', '2']]"
+q6,"['A', 'B']"
+q7,C
+q8,D
+q9,B
+q10,B
+q11,A
+q12,A
+q13,C
+q14,B
+q15,D
+q16,B
+q17,C
+q18,A
+q19,B
+q20,D
+q21,D
+q22,C
+q23,A
+q24,C
+q25,D
+q26,C
+q27,C
+q28,B
+q29,A
+q30,D
+q31,C
+q32,B
+q33,B
+q34,C
+q35,A
+q36,D
+q37,C
+q38,B
+q39,C
+q40,A
+q41,A
+q42,C
+q43,D
+q44,D
+q45,B
+q46,C
+q47,C
+q48,A
+q49,C
+q50,B
+q51,B
+q52,C
+q53,D
+q54,C
+q55,B
+q56,B
+q57,A
+q58,A
+q59,D
+q60,C
+q61,C
+q62,B
+q63,A
+q64,C
+q65,D
+q66,C
+q67,B
+q68,A
+q69,B
+q70,B
+q71,C
+q72,B
+q73,C
+q74,A
+q75,A
+q76,C
+q77,D
+q78,D
+q79,B
+q80,A
+q81,B
+q82,C
+q83,D
+q84,C
+q85,A
+q86,C
+q87,D
+q88,B
+q89,C
+q90,B
+q91,B
+q92,A
+q93,C
+q94,D
+q95,C
+q96,B
+q97,B
+q98,A
+q99,A
+q100,A
+q101,A
+q102,B
+q103,C
+q104,C
+q105,A
+q106,D
+q107,B
+q108,A
+q109,C
+q110,B
+q111,B
+q112,C
+q113,C
+q114,B
+q115,D
+q116,B
+q117,A
+q118,C
+q119,D
+q120,C
+q121,C
+q122,A
+q123,B
+q124,C
+q125,D
+q126,C
+q127,C
+q128,D
+q129,D
+q130,A
+q131,A
+q132,C
+q133,B
+q134,C
+q135,D
+q136,B
+q137,C
+q138,A
+q139,B
+q140,D
+q141,D
+q142,C
+q143,D
+q144,A
+q145,A
+q146,C
+q147,A
+q148,D
+q149,D
+q150,B
+q151,A
+q152,B
+q153,B
+q154,D
+q155,D
+q156,B
+q157,A
+q158,B
+q159,A
+q160,C
+q161,D
+q162,C
+q163,A
+q164,B
+q165,D
+q166,D
+q167,C
+q168,C
+q169,C
+q170,D
+q171,A
+q172,A
+q173,C
+q174,C
+q175,B
+q176,D
+q177,A
+q178,B
+q179,B
+q180,C
+q181,D
+q182,C
+q183,B
+q184,B
+q185,C
+q186,D
+q187,D
+q188,A
+q189,A
+q190,B
+q191,C
+q192,B
+q193,D
+q194,C
+q195,B
+q196,B
+q197,A
+q198,B
+q199,B
+q200,A
diff --git a/samples/community/UmarFarootAPS/config.json b/samples/community/UmarFarootAPS/config.json
new file mode 100644
index 000000000..fde8f5b28
--- /dev/null
+++ b/samples/community/UmarFarootAPS/config.json
@@ -0,0 +1,11 @@
+{
+ "dimensions": {
+ "display_height": 960,
+ "display_width": 1280,
+ "processing_height": 1640,
+ "processing_width": 1332
+ },
+ "outputs": {
+ "show_image_level": 0
+ }
+}
diff --git a/samples/community/UmarFarootAPS/evaluation.json b/samples/community/UmarFarootAPS/evaluation.json
new file mode 100644
index 000000000..14a3db255
--- /dev/null
+++ b/samples/community/UmarFarootAPS/evaluation.json
@@ -0,0 +1,14 @@
+{
+ "source_type": "csv",
+ "options": {
+ "answer_key_csv_path": "answer_key.csv",
+ "should_explain_scoring": true
+ },
+ "marking_schemes": {
+ "DEFAULT": {
+ "correct": "1",
+ "incorrect": "0",
+ "unmarked": "0"
+ }
+ }
+}
diff --git a/samples/sample3/omr_marker.jpg b/samples/community/UmarFarootAPS/omr_marker.jpg
similarity index 100%
rename from samples/sample3/omr_marker.jpg
rename to samples/community/UmarFarootAPS/omr_marker.jpg
diff --git a/samples/community/UmarFarootAPS/scans/scan-type-1.jpg b/samples/community/UmarFarootAPS/scans/scan-type-1.jpg
new file mode 100644
index 000000000..0932b240f
Binary files /dev/null and b/samples/community/UmarFarootAPS/scans/scan-type-1.jpg differ
diff --git a/samples/community/UmarFarootAPS/scans/scan-type-2.jpg b/samples/community/UmarFarootAPS/scans/scan-type-2.jpg
new file mode 100644
index 000000000..2eef7c32a
Binary files /dev/null and b/samples/community/UmarFarootAPS/scans/scan-type-2.jpg differ
diff --git a/samples/community/UmarFarootAPS/template.json b/samples/community/UmarFarootAPS/template.json
new file mode 100644
index 000000000..f096f5189
--- /dev/null
+++ b/samples/community/UmarFarootAPS/template.json
@@ -0,0 +1,188 @@
+{
+ "pageDimensions": [
+ 2550,
+ 3300
+ ],
+ "bubbleDimensions": [
+ 32,
+ 32
+ ],
+ "preProcessors": [
+ {
+ "name": "CropOnMarkers",
+ "options": {
+ "relativePath": "omr_marker.jpg",
+ "sheetToMarkerWidthRatio": 17
+ }
+ }
+ ],
+ "customLabels": {
+ "Roll_no": [
+ "r1",
+ "r2",
+ "r3",
+ "r4"
+ ]
+ },
+ "fieldBlocks": {
+ "Roll_no": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 2169,
+ 180
+ ],
+ "fieldLabels": [
+ "r1",
+ "r2",
+ "r3",
+ "r4"
+ ],
+ "bubblesGap": 61,
+ "labelsGap": 93
+ },
+ "MCQBlock1a1": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 197,
+ 300
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q1..17"
+ ]
+ },
+ "MCQBlock1a2": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 197,
+ 1310
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q18..34"
+ ]
+ },
+ "MCQBlock1a3": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 197,
+ 2316
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q35..50"
+ ]
+ },
+ "MCQBlock1a4": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 725,
+ 300
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q51..67"
+ ]
+ },
+ "MCQBlock1a5": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 725,
+ 1310
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q68..84"
+ ]
+ },
+ "MCQBlock1a6": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 725,
+ 2316
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q85..100"
+ ]
+ },
+ "MCQBlock1a7": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 1250,
+ 300
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q101..117"
+ ]
+ },
+ "MCQBlock1a8": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 1250,
+ 1310
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q118..134"
+ ]
+ },
+ "MCQBlock1a9": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 1250,
+ 2316
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q135..150"
+ ]
+ },
+ "MCQBlock1a10": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 1770,
+ 300
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q151..167"
+ ]
+ },
+ "MCQBlock1a11": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 1770,
+ 1310
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q168..184"
+ ]
+ },
+ "MCQBlock1a12": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 1770,
+ 2316
+ ],
+ "bubblesGap": 92,
+ "labelsGap": 59.6,
+ "fieldLabels": [
+ "q185..200"
+ ]
+ }
+ }
+}
diff --git a/samples/community/dxuian/omrcollegesheet.jpg b/samples/community/dxuian/omrcollegesheet.jpg
new file mode 100644
index 000000000..b9d70c6be
Binary files /dev/null and b/samples/community/dxuian/omrcollegesheet.jpg differ
diff --git a/samples/community/dxuian/template.json b/samples/community/dxuian/template.json
new file mode 100644
index 000000000..43bf2be6e
--- /dev/null
+++ b/samples/community/dxuian/template.json
@@ -0,0 +1,48 @@
+{
+ "pageDimensions": [707, 484],
+ "bubbleDimensions": [15, 10],
+ "fieldBlocks": {
+ "Column1": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [82, 35],
+ "bubblesGap": 21,
+ "labelsGap": 22.7,
+ "bubbleCount": 20,
+ "fieldLabels": ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "Q9", "Q10", "Q11", "Q12", "Q13", "Q14", "Q15", "Q16", "Q17", "Q18", "Q19", "Q20"]
+ },
+ "Column2": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [205, 35],
+ "bubblesGap": 21,
+ "labelsGap": 22.7,
+ "bubbleCount": 20,
+ "fieldLabels": ["Q21", "Q22", "Q23", "Q24", "Q25", "Q26", "Q27", "Q28", "Q29", "Q30", "Q31", "Q32", "Q33", "Q34", "Q35", "Q36", "Q37", "Q38", "Q39", "Q40"]
+ },
+ "Column3": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [327, 35],
+ "bubblesGap": 21,
+ "labelsGap": 22.7,
+ "bubbleCount": 20,
+ "fieldLabels": ["Q41", "Q42", "Q43", "Q44", "Q45", "Q46", "Q47", "Q48", "Q49", "Q50", "Q51", "Q52", "Q53", "Q54", "Q55", "Q56", "Q57", "Q58", "Q59", "Q60"]
+ },
+ "Column4": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [450, 35],
+ "bubblesGap": 21,
+ "labelsGap": 22.7,
+ "bubbleCount": 20,
+ "fieldLabels": ["Q61", "Q62", "Q63", "Q64", "Q65", "Q66", "Q67", "Q68", "Q69", "Q70", "Q71", "Q72", "Q73", "Q74", "Q75", "Q76", "Q77", "Q78", "Q79", "Q80"]
+ },
+ "Column5": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [573, 35],
+ "bubblesGap": 21,
+ "labelsGap": 22.7,
+ "bubbleCount": 20,
+ "fieldLabels": ["Q81", "Q82", "Q83", "Q84", "Q85", "Q86", "Q87", "Q88", "Q89", "Q90", "Q91", "Q92", "Q93", "Q94", "Q95", "Q96", "Q97", "Q98", "Q99", "Q100"]
+ }
+ },
+
+ "emptyValue": "-"
+}
\ No newline at end of file
diff --git a/samples/community/ibrahimkilic/template.json b/samples/community/ibrahimkilic/template.json
new file mode 100644
index 000000000..f0cb9cf3a
--- /dev/null
+++ b/samples/community/ibrahimkilic/template.json
@@ -0,0 +1,30 @@
+{
+ "pageDimensions": [
+ 299,
+ 328
+ ],
+ "bubbleDimensions": [
+ 20,
+ 20
+ ],
+ "emptyValue": "no",
+ "fieldBlocks": {
+ "YesNoBlock1": {
+ "direction": "horizontal",
+ "bubbleValues": [
+ "yes"
+ ],
+ "origin": [
+ 15,
+ 55
+ ],
+ "emptyValue": "no",
+ "bubblesGap": 48,
+ "labelsGap": 48,
+ "fieldLabels": [
+ "q1..5"
+ ]
+ }
+ },
+ "preProcessors": []
+}
diff --git a/samples/community/ibrahimkilic/yes_no_questionnarie.jpg b/samples/community/ibrahimkilic/yes_no_questionnarie.jpg
new file mode 100644
index 000000000..e9436f41e
Binary files /dev/null and b/samples/community/ibrahimkilic/yes_no_questionnarie.jpg differ
diff --git a/samples/community/samuelIkoli/template.json b/samples/community/samuelIkoli/template.json
new file mode 100644
index 000000000..759f6972f
--- /dev/null
+++ b/samples/community/samuelIkoli/template.json
@@ -0,0 +1,28 @@
+{
+ "pageDimensions": [630, 404],
+ "bubbleDimensions": [20, 15],
+ "customLabels": {},
+ "fieldBlocks": {
+ "MCQBlock1": {
+ "fieldType": "QTYPE_MCQ5",
+ "origin": [33, 6],
+ "fieldLabels": ["q1", "q2", "q3", "q4", "q5", "q6", "q7", "q8", "q9", "q10", "q11", "q12", "q13", "q14", "q15", "q16", "q17", "q18", "q19", "q20"],
+ "bubblesGap": 33,
+ "labelsGap": 20
+ },
+ "MCQBlock2": {
+ "fieldType": "QTYPE_MCQ5",
+ "origin": [248, 6],
+ "fieldLabels": ["q21", "q22", "q23", "q24", "q25", "q26", "q27", "q28", "q29", "q30", "q31", "q32", "q33", "q34", "q35", "q36", "q37", "q38", "q39", "q40"],
+ "bubblesGap": 33,
+ "labelsGap": 20
+ },
+ "MCQBlock3": {
+ "fieldType": "QTYPE_MCQ5",
+ "origin": [465, 6],
+ "fieldLabels": ["q41", "q42", "q43", "q44", "q45", "q46", "q47", "q48", "q49", "q50", "q51", "q52", "q53", "q54", "q55", "q56", "q57", "q58", "q59", "q60"],
+ "bubblesGap": 33,
+ "labelsGap": 20
+ }
+ }
+}
diff --git a/samples/community/samuelIkoli/waec_sample.jpeg b/samples/community/samuelIkoli/waec_sample.jpeg
new file mode 100644
index 000000000..fcd61e808
Binary files /dev/null and b/samples/community/samuelIkoli/waec_sample.jpeg differ
diff --git a/samples/sample1/config.json b/samples/sample1/config.json
new file mode 100644
index 000000000..a9daf4491
--- /dev/null
+++ b/samples/sample1/config.json
@@ -0,0 +1,11 @@
+{
+ "dimensions": {
+ "display_height": 2480,
+ "display_width": 1640,
+ "processing_height": 820,
+ "processing_width": 666
+ },
+ "outputs": {
+ "show_image_level": 5
+ }
+}
diff --git a/samples/sample1/template.json b/samples/sample1/template.json
index ec0f9c68c..466357749 100644
--- a/samples/sample1/template.json
+++ b/samples/sample1/template.json
@@ -1,260 +1,197 @@
{
- "Dimensions": [
+ "pageDimensions": [
1846,
1500
],
- "BubbleDimensions": [
+ "bubbleDimensions": [
40,
40
],
- "Options": {
- "Marker": {
- "RelativePath": "omr_marker.jpg",
- "SheetToMarkerWidthRatio": 17
- }
- },
- "Concatenations": {
+ "customLabels": {
"Roll": [
- "Squad",
"Medium",
- "roll0",
- "roll1",
- "roll2",
- "roll3",
- "roll4",
- "roll5",
- "roll6",
- "roll7",
- "roll8"
+ "roll1..9"
],
"q5": [
- "q5.1",
- "q5.2"
+ "q5_1",
+ "q5_2"
],
"q6": [
- "q6.1",
- "q6.2"
+ "q6_1",
+ "q6_2"
],
"q7": [
- "q7.1",
- "q7.2"
+ "q7_1",
+ "q7_2"
],
- "q9": [
- "q9.1",
- "q9.2"
+ "q8": [
+ "q8_1",
+ "q8_2"
],
- "q11": [
- "q8.1",
- "q8.2"
+ "q9": [
+ "q9_1",
+ "q9_2"
]
},
- "Singles": [
- "q1",
- "q2",
- "q3",
- "q4",
- "q10",
- "q11",
- "q12",
- "q13",
- "q14",
- "q15",
- "q16",
- "q17",
- "q18",
- "q19",
- "q20"
- ],
- "QBlocks": {
+ "fieldBlocks": {
"Medium": {
- "qType": "QTYPE_MED",
- "orig": [
- 160,
- 285
- ],
- "bigGaps": [
- 115,
- 11
- ],
- "gaps": [
- 59,
- 46
- ],
- "qNos": [
- [
- [
- "Medium"
- ]
- ]
+ "bubblesGap": 41,
+ "bubbleValues": [
+ "E",
+ "H"
+ ],
+ "direction": "vertical",
+ "fieldLabels": [
+ "Medium"
+ ],
+ "labelsGap": 0,
+ "origin": [
+ 170,
+ 282
]
},
"Roll": {
- "qType": "QTYPE_ROLL",
- "orig": [
- 218,
- 285
- ],
- "bigGaps": [
- 115,
- 11
- ],
- "gaps": [
- 58,
- 46
- ],
- "qNos": [
- [
- [
- "roll0",
- "roll1",
- "roll2",
- "roll3",
- "roll4",
- "roll5",
- "roll6",
- "roll7",
- "roll8"
- ]
- ]
+ "fieldType": "QTYPE_INT",
+ "fieldLabels": [
+ "roll1..9"
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 58,
+ "origin": [
+ 225,
+ 282
]
},
- "Int1": {
- "qType": "QTYPE_INT",
- "orig": [
+ "Int_Block_Q5": {
+ "fieldType": "QTYPE_INT",
+ "fieldLabels": [
+ "q5_1",
+ "q5_2"
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 60,
+ "origin": [
903,
- 285
- ],
- "bigGaps": [
- 128,
- 11
- ],
- "gaps": [
- 59,
- 46
- ],
- "qNos": [
- [
- [
- "q5.1",
- "q5.2"
- ],
- [
- "q6.1",
- "q6.2"
- ],
- [
- "q7.1",
- "q7.2"
- ]
- ]
+ 282
]
},
- "Int2": {
- "qType": "QTYPE_INT",
- "orig": [
- 1418,
- 285
- ],
- "bigGaps": [
- 128,
- 11
- ],
- "gaps": [
- 59,
- 46
- ],
- "qNos": [
- [
- [
- "q8.1",
- "q8.2"
- ],
- [
- "q9.1",
- "q9.2"
- ]
- ]
+ "Int_Block_Q6": {
+ "fieldType": "QTYPE_INT",
+ "fieldLabels": [
+ "q6_1",
+ "q6_2"
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 60,
+ "origin": [
+ 1077,
+ 282
]
},
- "Mcq1": {
- "qType": "QTYPE_MCQ4",
- "orig": [
- 118,
- 857
- ],
- "bigGaps": [
- 115,
- 181
- ],
- "gaps": [
- 59,
- 53
- ],
- "qNos": [
- [
- [
- "q1",
- "q2",
- "q3",
- "q4"
- ],
- [
- "q10",
- "q11",
- "q12",
- "q13"
- ]
- ]
+ "Int_Block_Q7": {
+ "fieldType": "QTYPE_INT",
+ "fieldLabels": [
+ "q7_1",
+ "q7_2"
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 60,
+ "origin": [
+ 1240,
+ 282
+ ]
+ },
+ "Int_Block_Q8": {
+ "fieldType": "QTYPE_INT",
+ "fieldLabels": [
+ "q8_1",
+ "q8_2"
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 57,
+ "origin": [
+ 1410,
+ 282
+ ]
+ },
+ "Int_Block_Q9": {
+ "fieldType": "QTYPE_INT",
+ "fieldLabels": [
+ "q9_1",
+ "q9_2"
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 57,
+ "origin": [
+ 1580,
+ 282
]
},
- "Mcq2": {
- "qType": "QTYPE_MCQ4",
- "orig": [
+ "MCQ_Block_Q1": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q1..4"
+ ],
+ "bubblesGap": 59,
+ "labelsGap": 50,
+ "origin": [
+ 121,
+ 860
+ ]
+ },
+ "MCQ_Block_Q10": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q10..13"
+ ],
+ "bubblesGap": 59,
+ "labelsGap": 50,
+ "origin": [
+ 121,
+ 1195
+ ]
+ },
+ "MCQ_Block_Q14": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q14..16"
+ ],
+ "bubblesGap": 57,
+ "labelsGap": 50,
+ "origin": [
905,
860
- ],
- "bigGaps": [
- 115,
- 180
- ],
- "gaps": [
- 59,
- 53
- ],
- "qNos": [
- [
- [
- "q14",
- "q15",
- "q16"
- ]
- ]
]
},
- "Mcq3": {
- "qType": "QTYPE_MCQ4",
- "orig": [
+ "MCQ_Block_Q17": {
+ "fieldType": "QTYPE_MCQ4",
+ "fieldLabels": [
+ "q17..20"
+ ],
+ "bubblesGap": 57,
+ "labelsGap": 50,
+ "origin": [
905,
- 1198
- ],
- "bigGaps": [
- 115,
- 180
- ],
- "gaps": [
- 59,
- 53
- ],
- "qNos": [
- [
- [
- "q17",
- "q18",
- "q19",
- "q20"
- ]
- ]
+ 1195
]
}
- }
-}
\ No newline at end of file
+ },
+ "preProcessors": [
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [
+ 10,
+ 10
+ ]
+ }
+ },
+ {
+ "name": "CropOnMarkers",
+ "options": {
+ "relativePath": "omr_marker.jpg",
+ "sheetToMarkerWidthRatio": 17
+ }
+ }
+ ]
+}
diff --git a/samples/sample2/AdrianSample/adrian_omr.png b/samples/sample2/AdrianSample/adrian_omr.png
index dc0081450..69a53823d 100644
Binary files a/samples/sample2/AdrianSample/adrian_omr.png and b/samples/sample2/AdrianSample/adrian_omr.png differ
diff --git a/samples/sample2/AdrianSample/adrian_omr_2.png b/samples/sample2/AdrianSample/adrian_omr_2.png
new file mode 100644
index 000000000..d8db0994d
Binary files /dev/null and b/samples/sample2/AdrianSample/adrian_omr_2.png differ
diff --git a/samples/sample2/config.json b/samples/sample2/config.json
new file mode 100644
index 000000000..a9daf4491
--- /dev/null
+++ b/samples/sample2/config.json
@@ -0,0 +1,11 @@
+{
+ "dimensions": {
+ "display_height": 2480,
+ "display_width": 1640,
+ "processing_height": 820,
+ "processing_width": 666
+ },
+ "outputs": {
+ "show_image_level": 5
+ }
+}
diff --git a/samples/sample2/template.json b/samples/sample2/template.json
index e82a9051a..41ec9ffaf 100644
--- a/samples/sample2/template.json
+++ b/samples/sample2/template.json
@@ -1,46 +1,35 @@
{
- "Dimensions": [
+ "pageDimensions": [
300,
400
],
- "BubbleDimensions": [
+ "bubbleDimensions": [
25,
25
],
- "Concatenations": {},
- "Singles": [
- "q1",
- "q2",
- "q3",
- "q4",
- "q5"
+ "preProcessors": [
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [
+ 10,
+ 10
+ ]
+ }
+ }
],
- "QBlocks": {
- "MCQBlock1": {
- "qType": "QTYPE_MCQ5",
- "orig": [
+ "fieldBlocks": {
+ "MCQ_Block_1": {
+ "fieldType": "QTYPE_MCQ5",
+ "origin": [
65,
60
],
- "qNos": [
- [
- [
- "q1",
- "q2",
- "q3",
- "q4",
- "q5"
- ]
- ]
- ],
- "gaps": [
- 41,
- 52
+ "fieldLabels": [
+ "q1..5"
],
- "bigGaps": [
- 30,
- 30
- ]
+ "labelsGap": 52,
+ "bubblesGap": 41
}
}
-}
\ No newline at end of file
+}
diff --git a/samples/sample3/README.md b/samples/sample3/README.md
new file mode 100644
index 000000000..d764c8b3d
--- /dev/null
+++ b/samples/sample3/README.md
@@ -0,0 +1,31 @@
+## Observation
+The OMR layout is slightly different on colored thick papers vs on xeroxed thin papers. The shift becomes noticible in case of OMR with large number of questions.
+
+We overlapped a colored OMR sheet with a xerox copy of the same OMR sheet(both printed on A4 papers) and observed that there is a great amount of layout sheet as we reach the bottom of the OMR.
+
+Link to an explainer with a real life example: [Google Drive](https://drive.google.com/drive/folders/1GpZTmpEhEjSALJEMjHwDafzWKgEoCTOI?usp=sharing)
+
+## Reasons for shifts in Template layout:
+Listing out a few reasons for the above observation:
+### Printer margin setting
+The margin settings for different printers may be different for the same OMR layout. Thus causing the print to become elongated either horizontally, vertically or both ways.
+
+### The Fan-out effect
+The fan-out effect is usually observed in a sheet fed offset press. Depending on how the papers are made, their dimensions have a tendency to change when they are exposed to moisture or water.
+
+The standard office papers(80 gsm) can easily capture moisture and change shape e.g. in case they get stored for multiple days in a place where the weather is highly humid.
+
+Below are some examples of the GSM ranges:
+
+- 74gsm to 90gsm β This is the basic standard office paper, used in your laser printers.
+- 100gsm to 120gsm β This is stationary paper used for standard letterheads, complimentary slips.
+- 130 to 170gsm β Mostly used for leaflets, posters, single-sided flyers, and brochures.
+
+## Solution
+
+It is recommended to scan each types of prints into different folders and use a separate template.json layout for each of the folders. The same is presented in this sample folder.
+
+## References
+
+- [Paper dimensional stability in sheet-fed offset printing](https://www.diva-portal.org/smash/get/diva2:517895/FULLTEXT01.pdf)
+- An analysis of a few ["Interesting" bubble sheets](https://imgur.com/a/10qwL)
diff --git a/samples/sample3/colored-thick-sheet/rgb-100-gsm.jpg b/samples/sample3/colored-thick-sheet/rgb-100-gsm.jpg
new file mode 100644
index 000000000..4a34731a7
Binary files /dev/null and b/samples/sample3/colored-thick-sheet/rgb-100-gsm.jpg differ
diff --git a/samples/sample3/colored-thick-sheet/template.json b/samples/sample3/colored-thick-sheet/template.json
new file mode 100644
index 000000000..743723d67
--- /dev/null
+++ b/samples/sample3/colored-thick-sheet/template.json
@@ -0,0 +1,143 @@
+{
+ "pageDimensions": [
+ 1800,
+ 2400
+ ],
+ "bubbleDimensions": [
+ 23,
+ 20
+ ],
+ "fieldBlocks": {
+ "q01block": {
+ "origin": [
+ 504,
+ 927
+ ],
+ "bubblesGap": 60.35,
+ "labelsGap": 31.75,
+ "fieldLabels": [
+ "q1..10"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q11block": {
+ "origin": [
+ 504,
+ 1242
+ ],
+ "bubblesGap": 60.35,
+ "labelsGap": 31.75,
+ "fieldLabels": [
+ "q11..20"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q21block": {
+ "origin": [
+ 500,
+ 1562
+ ],
+ "bubblesGap": 61.25,
+ "labelsGap": 32.5,
+ "fieldLabels": [
+ "q21..30"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q31block": {
+ "origin": [
+ 500,
+ 1885
+ ],
+ "bubblesGap": 62.25,
+ "labelsGap": 33.5,
+ "fieldLabels": [
+ "q31..40"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q41block": {
+ "origin": [
+ 811,
+ 927
+ ],
+ "bubblesGap": 60.35,
+ "labelsGap": 31.75,
+ "fieldLabels": [
+ "q41..50"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q51block": {
+ "origin": [
+ 811,
+ 1242
+ ],
+ "bubblesGap": 60.35,
+ "labelsGap": 31.75,
+ "fieldLabels": [
+ "q51..60"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q61block": {
+ "origin": [
+ 811,
+ 1562
+ ],
+ "bubblesGap": 61.25,
+ "labelsGap": 32.5,
+ "fieldLabels": [
+ "q61..70"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q71block": {
+ "origin": [
+ 811,
+ 1885
+ ],
+ "bubblesGap": 62.25,
+ "labelsGap": 33.5,
+ "fieldLabels": [
+ "q71..80"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q81block": {
+ "origin": [
+ 1120,
+ 927
+ ],
+ "bubblesGap": 60.35,
+ "labelsGap": 31.75,
+ "fieldLabels": [
+ "q81..90"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q91block": {
+ "origin": [
+ 1120,
+ 1242
+ ],
+ "bubblesGap": 60.35,
+ "labelsGap": 31.75,
+ "fieldLabels": [
+ "q91..100"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ }
+ },
+ "preProcessors": [
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [
+ 10,
+ 10
+ ]
+ }
+ }
+ ]
+}
diff --git a/samples/sample3/config.json b/samples/sample3/config.json
new file mode 100644
index 000000000..a9daf4491
--- /dev/null
+++ b/samples/sample3/config.json
@@ -0,0 +1,11 @@
+{
+ "dimensions": {
+ "display_height": 2480,
+ "display_width": 1640,
+ "processing_height": 820,
+ "processing_width": 666
+ },
+ "outputs": {
+ "show_image_level": 5
+ }
+}
diff --git a/samples/sample3/template.json b/samples/sample3/template.json
deleted file mode 100644
index 2dddf37e8..000000000
--- a/samples/sample3/template.json
+++ /dev/null
@@ -1,248 +0,0 @@
-{
- "Dimensions": [
- 1846,
- 1500
- ],
- "BubbleDimensions": [
- 40,
- 40
- ],
- "Options": {
- "Marker": {
- "RelativePath": "omr_marker.jpg",
- "SheetToMarkerWidthRatio": 17
- },
- "OverrideFlags":{
- "noCropping": true
- }
- },
- "Concatenations": {
- "Roll": [
- "Squad",
- "Medium",
- "roll0",
- "roll1",
- "roll2",
- "roll3",
- "roll4",
- "roll5",
- "roll6",
- "roll7",
- "roll8"
- ],
- "q6": [
- "q6.1",
- "q6.2"
- ],
- "q7": [
- "q7.1",
- "q7.2"
- ],
- "q8": [
- "q8.1",
- "q8.2"
- ],
- "q9": [
- "q9.1",
- "q9.2"
- ],
- "q10": [
- "q10.1",
- "q10.2"
- ]
- },
- "Singles": [
- "q1",
- "q2",
- "q3",
- "q4",
- "q5",
- "q11",
- "q12",
- "q13",
- "q14",
- "q15",
- "q16",
- "q17",
- "q18",
- "q19",
- "q20",
- "q21",
- "q22"
- ],
- "QBlocks": {
- "Medium": {
- "qType": "QTYPE_MED",
- "orig": [
- 208,
- 205
- ],
- "bigGaps": [
- 115,
- 11
- ],
- "gaps": [
- 59,
- 46
- ],
- "qNos": [
- [
- [
- "Medium"
- ]
- ]
- ]
- },
- "Roll": {
- "qType": "QTYPE_ROLL",
- "orig": [
- 261,
- 210
- ],
- "bigGaps": [
- 115,
- 11
- ],
- "gaps": [
- 58,
- 46
- ],
- "qNos": [
- [
- [
- "roll0",
- "roll1",
- "roll2",
- "roll3",
- "roll4",
- "roll5",
- "roll6",
- "roll7",
- "roll8"
- ]
- ]
- ]
- },
- "Int1": {
- "qType": "QTYPE_INT",
- "orig": [
- 935,
- 211
- ],
- "bigGaps": [
- 124,
- 11
- ],
- "gaps": [
- 57,
- 46
- ],
- "qNos": [
- [
- [
- "q6.1",
- "q6.2"
- ],
- [
- "q7.1",
- "q7.2"
- ],
- [
- "q8.1",
- "q8.2"
- ],
- [
- "q9.1",
- "q9.2"
- ],
- [
- "q10.1",
- "q10.2"
- ]
- ]
- ]
- },
- "Mcq1": {
- "qType": "QTYPE_MCQ4",
- "orig": [
- 198,
- 826
- ],
- "bigGaps": [
- 115,
- 183
- ],
- "gaps": [
- 93,
- 62
- ],
- "qNos": [
- [
- [
- "q1",
- "q2",
- "q3",
- "q4",
- "q5"
- ]
- ]
- ]
- },
- "Mcq2": {
- "qType": "QTYPE_MCQ4",
- "orig": [
- 833,
- 830
- ],
- "bigGaps": [
- 127,
- 254
- ],
- "gaps": [
- 71,
- 61
- ],
- "qNos": [
- [
- [
- "q11",
- "q12",
- "q13",
- "q14"
- ],
- [
- "q15",
- "q16",
- "q17",
- "q18"
- ]
- ]
- ]
- },
- "Mcq3": {
- "qType": "QTYPE_MCQ4",
- "orig": [
- 1481,
- 830
- ],
- "bigGaps": [
- 115,
- 183
- ],
- "gaps": [
- 73,
- 61
- ],
- "qNos": [
- [
- [
- "q19",
- "q20",
- "q21",
- "q22"
- ]
- ]
- ]
- }
- }
-}
\ No newline at end of file
diff --git a/samples/sample3/xeroxed-thin-sheet/grayscale-80-gsm.jpg b/samples/sample3/xeroxed-thin-sheet/grayscale-80-gsm.jpg
new file mode 100644
index 000000000..3e0e3efa7
Binary files /dev/null and b/samples/sample3/xeroxed-thin-sheet/grayscale-80-gsm.jpg differ
diff --git a/samples/sample3/xeroxed-thin-sheet/template.json b/samples/sample3/xeroxed-thin-sheet/template.json
new file mode 100644
index 000000000..8aa4e41c2
--- /dev/null
+++ b/samples/sample3/xeroxed-thin-sheet/template.json
@@ -0,0 +1,143 @@
+{
+ "pageDimensions": [
+ 1800,
+ 2400
+ ],
+ "bubbleDimensions": [
+ 23,
+ 20
+ ],
+ "fieldBlocks": {
+ "q01block": {
+ "origin": [
+ 492,
+ 924
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q1..10"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q11block": {
+ "origin": [
+ 492,
+ 1258
+ ],
+ "bubblesGap": 59.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q11..20"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q21block": {
+ "origin": [
+ 492,
+ 1589
+ ],
+ "bubblesGap": 60.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q21..30"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q31block": {
+ "origin": [
+ 487,
+ 1920
+ ],
+ "bubblesGap": 61.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q31..40"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q41block": {
+ "origin": [
+ 807,
+ 924
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q41..50"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q51block": {
+ "origin": [
+ 803,
+ 1258
+ ],
+ "bubblesGap": 59.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q51..60"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q61block": {
+ "origin": [
+ 803,
+ 1589
+ ],
+ "bubblesGap": 60.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q61..70"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q71block": {
+ "origin": [
+ 803,
+ 1920
+ ],
+ "bubblesGap": 60.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q71..80"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q81block": {
+ "origin": [
+ 1115,
+ 924
+ ],
+ "bubblesGap": 58.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q81..90"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ },
+ "q91block": {
+ "origin": [
+ 1115,
+ 1258
+ ],
+ "bubblesGap": 59.75,
+ "labelsGap": 32.65,
+ "fieldLabels": [
+ "q91..100"
+ ],
+ "fieldType": "QTYPE_MCQ4"
+ }
+ },
+ "preProcessors": [
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [
+ 10,
+ 10
+ ]
+ }
+ }
+ ]
+}
diff --git a/samples/sample4/Class1/sheet1.jpg b/samples/sample4/Class1/sheet1.jpg
deleted file mode 100644
index f32489033..000000000
Binary files a/samples/sample4/Class1/sheet1.jpg and /dev/null differ
diff --git a/samples/sample4/Class2/sheet1.jpg b/samples/sample4/Class2/sheet1.jpg
deleted file mode 100644
index f32489033..000000000
Binary files a/samples/sample4/Class2/sheet1.jpg and /dev/null differ
diff --git a/samples/sample4/IMG_20201116_143512.jpg b/samples/sample4/IMG_20201116_143512.jpg
new file mode 100644
index 000000000..2f591c7ac
Binary files /dev/null and b/samples/sample4/IMG_20201116_143512.jpg differ
diff --git a/samples/sample4/IMG_20201116_150717658.jpg b/samples/sample4/IMG_20201116_150717658.jpg
new file mode 100644
index 000000000..592fe4256
Binary files /dev/null and b/samples/sample4/IMG_20201116_150717658.jpg differ
diff --git a/samples/sample4/IMG_20201116_150750830.jpg b/samples/sample4/IMG_20201116_150750830.jpg
new file mode 100644
index 000000000..6846ef31e
Binary files /dev/null and b/samples/sample4/IMG_20201116_150750830.jpg differ
diff --git a/samples/sample4/config.json b/samples/sample4/config.json
new file mode 100644
index 000000000..df59a460a
--- /dev/null
+++ b/samples/sample4/config.json
@@ -0,0 +1,13 @@
+{
+ "dimensions": {
+ "display_width": 1189,
+ "display_height": 1682
+ },
+ "threshold_params": {
+ "MIN_JUMP": 30
+ },
+ "outputs": {
+ "filter_out_multimarked_files": false,
+ "show_image_level": 5
+ }
+}
diff --git a/samples/sample4/evaluation.json b/samples/sample4/evaluation.json
new file mode 100644
index 000000000..50db0fbec
--- /dev/null
+++ b/samples/sample4/evaluation.json
@@ -0,0 +1,34 @@
+{
+ "source_type": "custom",
+ "options": {
+ "questions_in_order": [
+ "q1..11"
+ ],
+ "answers_in_order": [
+ "B",
+ "D",
+ "C",
+ "B",
+ "D",
+ "C",
+ [
+ "B",
+ "C",
+ "BC"
+ ],
+ "A",
+ "C",
+ "D",
+ "C"
+ ],
+ "should_explain_scoring": true,
+ "enable_evaluation_table_to_csv": true
+ },
+ "marking_schemes": {
+ "DEFAULT": {
+ "correct": "3",
+ "incorrect": "-1",
+ "unmarked": "0"
+ }
+ }
+}
diff --git a/samples/sample4/omr_marker.jpg b/samples/sample4/omr_marker.jpg
deleted file mode 100644
index 201dab0a1..000000000
Binary files a/samples/sample4/omr_marker.jpg and /dev/null differ
diff --git a/samples/sample4/template.json b/samples/sample4/template.json
index 958d6ef81..0d615b87a 100644
--- a/samples/sample4/template.json
+++ b/samples/sample4/template.json
@@ -1,263 +1,45 @@
{
- "Dimensions": [
- 1846,
- 1500
+ "pageDimensions": [
+ 1189,
+ 1682
],
- "BubbleDimensions": [
- 40,
- 40
+ "bubbleDimensions": [
+ 30,
+ 30
],
- "Options": {
- "Marker": {
- "RelativePath": "omr_marker.jpg",
- "SheetToMarkerWidthRatio": 17
+ "preProcessors": [
+ {
+ "name": "GaussianBlur",
+ "options": {
+ "kSize": [
+ 3,
+ 3
+ ],
+ "sigmaX": 0
+ }
},
- "OverrideFlags": {
- "noCropping": true
+ {
+ "name": "CropPage",
+ "options": {
+ "morphKernel": [
+ 10,
+ 10
+ ]
+ }
}
- },
- "Concatenations": {
- "Roll": [
- "Squad",
- "Medium",
- "roll0",
- "roll1",
- "roll2",
- "roll3",
- "roll4",
- "roll5",
- "roll6",
- "roll7",
- "roll8"
- ],
- "q5": [
- "q5.1",
- "q5.2"
- ],
- "q6": [
- "q6.1",
- "q6.2"
- ],
- "q7": [
- "q7.1",
- "q7.2"
- ],
- "q9": [
- "q9.1",
- "q9.2"
- ],
- "q11": [
- "q8.1",
- "q8.2"
- ]
- },
- "Singles": [
- "q1",
- "q2",
- "q3",
- "q4",
- "q10",
- "q11",
- "q12",
- "q13",
- "q14",
- "q15",
- "q16",
- "q17",
- "q18",
- "q19",
- "q20"
],
- "QBlocks": {
- "Medium": {
- "qType": "QTYPE_MED",
- "orig": [
- 160,
- 285
- ],
- "bigGaps": [
- 115,
- 11
- ],
- "gaps": [
- 59,
- 46
- ],
- "qNos": [
- [
- [
- "Medium"
- ]
- ]
- ]
- },
- "Roll": {
- "qType": "QTYPE_ROLL",
- "orig": [
- 218,
- 285
- ],
- "bigGaps": [
- 115,
- 11
- ],
- "gaps": [
- 58,
- 46
- ],
- "qNos": [
- [
- [
- "roll0",
- "roll1",
- "roll2",
- "roll3",
- "roll4",
- "roll5",
- "roll6",
- "roll7",
- "roll8"
- ]
- ]
- ]
- },
- "Int1": {
- "qType": "QTYPE_INT",
- "orig": [
- 903,
- 285
- ],
- "bigGaps": [
- 128,
- 11
- ],
- "gaps": [
- 59,
- 46
- ],
- "qNos": [
- [
- [
- "q5.1",
- "q5.2"
- ],
- [
- "q6.1",
- "q6.2"
- ],
- [
- "q7.1",
- "q7.2"
- ]
- ]
- ]
- },
- "Int2": {
- "qType": "QTYPE_INT",
- "orig": [
- 1418,
- 285
- ],
- "bigGaps": [
- 128,
- 11
- ],
- "gaps": [
- 59,
- 46
- ],
- "qNos": [
- [
- [
- "q8.1",
- "q8.2"
- ],
- [
- "q9.1",
- "q9.2"
- ]
- ]
- ]
- },
- "Mcq1": {
- "qType": "QTYPE_MCQ4",
- "orig": [
- 118,
- 857
- ],
- "bigGaps": [
- 115,
- 181
- ],
- "gaps": [
- 59,
- 53
- ],
- "qNos": [
- [
- [
- "q1",
- "q2",
- "q3",
- "q4"
- ],
- [
- "q10",
- "q11",
- "q12",
- "q13"
- ]
- ]
- ]
- },
- "Mcq2": {
- "qType": "QTYPE_MCQ4",
- "orig": [
- 905,
- 860
- ],
- "bigGaps": [
- 115,
- 180
- ],
- "gaps": [
- 59,
- 53
- ],
- "qNos": [
- [
- [
- "q14",
- "q15",
- "q16"
- ]
- ]
- ]
- },
- "Mcq3": {
- "qType": "QTYPE_MCQ4",
- "orig": [
- 905,
- 1198
- ],
- "bigGaps": [
- 115,
- 180
- ],
- "gaps": [
- 59,
- 53
- ],
- "qNos": [
- [
- [
- "q17",
- "q18",
- "q19",
- "q20"
- ]
- ]
- ]
+ "fieldBlocks": {
+ "MCQBlock1": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 134,
+ 684
+ ],
+ "fieldLabels": [
+ "q1..11"
+ ],
+ "bubblesGap": 79,
+ "labelsGap": 62
}
}
-}
\ No newline at end of file
+}
diff --git a/samples/sample5/README.md b/samples/sample5/README.md
new file mode 100644
index 000000000..b5ed8ccb2
--- /dev/null
+++ b/samples/sample5/README.md
@@ -0,0 +1,6 @@
+### OMRChecker Sample
+
+This sample demonstrates multiple things, namely -
+- Running OMRChecker on images scanned using popular document scanning apps
+- Using a common template.json file for sub-folders (e.g. multiple scan batches)
+- Using evaluation.json file with custom marking (without streak-based marking)
diff --git a/samples/sample3/CamScanner/sheet2.jpg b/samples/sample5/ScanBatch1/camscanner-1.jpg
similarity index 100%
rename from samples/sample3/CamScanner/sheet2.jpg
rename to samples/sample5/ScanBatch1/camscanner-1.jpg
diff --git a/samples/sample3/CamScanner/sheet1.jpg b/samples/sample5/ScanBatch2/camscanner-2.jpg
similarity index 100%
rename from samples/sample3/CamScanner/sheet1.jpg
rename to samples/sample5/ScanBatch2/camscanner-2.jpg
diff --git a/samples/sample5/config.json b/samples/sample5/config.json
new file mode 100644
index 000000000..a9daf4491
--- /dev/null
+++ b/samples/sample5/config.json
@@ -0,0 +1,11 @@
+{
+ "dimensions": {
+ "display_height": 2480,
+ "display_width": 1640,
+ "processing_height": 820,
+ "processing_width": 666
+ },
+ "outputs": {
+ "show_image_level": 5
+ }
+}
diff --git a/samples/sample5/evaluation.json b/samples/sample5/evaluation.json
new file mode 100644
index 000000000..3332fe6ab
--- /dev/null
+++ b/samples/sample5/evaluation.json
@@ -0,0 +1,93 @@
+{
+ "source_type": "custom",
+ "options": {
+ "questions_in_order": [
+ "q1..22"
+ ],
+ "answers_in_order": [
+ "C",
+ "C",
+ "B",
+ "C",
+ "C",
+ [
+ "1",
+ "01"
+ ],
+ "19",
+ "10",
+ "10",
+ "18",
+ "D",
+ "A",
+ "D",
+ "D",
+ "D",
+ "C",
+ "C",
+ "C",
+ "C",
+ "D",
+ "B",
+ "A"
+ ],
+ "should_explain_scoring": true
+ },
+ "marking_schemes": {
+ "DEFAULT": {
+ "correct": "1",
+ "incorrect": "0",
+ "unmarked": "0"
+ },
+ "BOOMERANG_1": {
+ "questions": [
+ "q1..5"
+ ],
+ "marking": {
+ "correct": 4,
+ "incorrect": -1,
+ "unmarked": 0
+ }
+ },
+ "PROXIMITY_1": {
+ "questions": [
+ "q6..10"
+ ],
+ "marking": {
+ "correct": 3,
+ "incorrect": -1,
+ "unmarked": 0
+ }
+ },
+ "FIBONACCI_SECTION_1": {
+ "questions": [
+ "q11..14"
+ ],
+ "marking": {
+ "correct": 2,
+ "incorrect": -1,
+ "unmarked": 0
+ }
+ },
+ "POWER_SECTION_1": {
+ "questions": [
+ "q15..18"
+ ],
+ "marking": {
+ "correct": 1,
+ "incorrect": 0,
+ "unmarked": 0
+ }
+ },
+ "FIBONACCI_SECTION_2": {
+ "questions": [
+ "q19..22"
+ ],
+ "marking": {
+ "correct": 2,
+ "incorrect": -1,
+ "unmarked": 0
+ }
+ }
+ }
+}
diff --git a/samples/sample5/omr_marker.jpg b/samples/sample5/omr_marker.jpg
new file mode 100644
index 000000000..0929feec8
Binary files /dev/null and b/samples/sample5/omr_marker.jpg differ
diff --git a/samples/sample5/template.json b/samples/sample5/template.json
new file mode 100644
index 000000000..39f6e83df
--- /dev/null
+++ b/samples/sample5/template.json
@@ -0,0 +1,188 @@
+{
+ "pageDimensions": [
+ 1846,
+ 1500
+ ],
+ "bubbleDimensions": [
+ 40,
+ 40
+ ],
+ "preProcessors": [
+ {
+ "name": "CropOnMarkers",
+ "options": {
+ "relativePath": "omr_marker.jpg",
+ "sheetToMarkerWidthRatio": 17
+ }
+ }
+ ],
+ "customLabels": {
+ "Roll": [
+ "Medium",
+ "roll1..9"
+ ],
+ "q6": [
+ "q6_1",
+ "q6_2"
+ ],
+ "q7": [
+ "q7_1",
+ "q7_2"
+ ],
+ "q8": [
+ "q8_1",
+ "q8_2"
+ ],
+ "q9": [
+ "q9_1",
+ "q9_2"
+ ],
+ "q10": [
+ "q10_1",
+ "q10_2"
+ ]
+ },
+ "fieldBlocks": {
+ "Medium": {
+ "bubbleValues": [
+ "E",
+ "H"
+ ],
+ "direction": "vertical",
+ "origin": [
+ 200,
+ 215
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 0,
+ "fieldLabels": [
+ "Medium"
+ ]
+ },
+ "Roll": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 261,
+ 210
+ ],
+ "bubblesGap": 46.5,
+ "labelsGap": 58,
+ "fieldLabels": [
+ "roll1..9"
+ ]
+ },
+ "Int1": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 935,
+ 211
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 57,
+ "fieldLabels": [
+ "q6_1",
+ "q6_2"
+ ]
+ },
+ "Int2": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 1100,
+ 211
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 57,
+ "fieldLabels": [
+ "q7_1",
+ "q7_2"
+ ]
+ },
+ "Int3": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 1275,
+ 211
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 57,
+ "fieldLabels": [
+ "q8_1",
+ "q8_2"
+ ]
+ },
+ "Int4": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 1449,
+ 211
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 57,
+ "fieldLabels": [
+ "q9_1",
+ "q9_2"
+ ]
+ },
+ "Int5": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 1620,
+ 211
+ ],
+ "bubblesGap": 46,
+ "labelsGap": 57,
+ "fieldLabels": [
+ "q10_1",
+ "q10_2"
+ ]
+ },
+ "Mcq1": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 198,
+ 826
+ ],
+ "bubblesGap": 93,
+ "labelsGap": 62,
+ "fieldLabels": [
+ "q1..5"
+ ]
+ },
+ "Mcq2": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 833,
+ 830
+ ],
+ "bubblesGap": 71,
+ "labelsGap": 61,
+ "fieldLabels": [
+ "q11..14"
+ ]
+ },
+ "Mcq3": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 833,
+ 1270
+ ],
+ "bubblesGap": 71,
+ "labelsGap": 61,
+ "fieldLabels": [
+ "q15..18"
+ ]
+ },
+ "Mcq4": {
+ "fieldType": "QTYPE_MCQ4",
+ "origin": [
+ 1481,
+ 830
+ ],
+ "bubblesGap": 73,
+ "labelsGap": 61,
+ "fieldLabels": [
+ "q19..22"
+ ]
+ }
+ }
+}
diff --git a/samples/sample6/config.json b/samples/sample6/config.json
new file mode 100644
index 000000000..6f39f0ca9
--- /dev/null
+++ b/samples/sample6/config.json
@@ -0,0 +1,11 @@
+{
+ "dimensions": {
+ "display_width": 2480,
+ "display_height": 3508,
+ "processing_width": 1653,
+ "processing_height": 2339
+ },
+ "outputs": {
+ "show_image_level": 5
+ }
+}
diff --git a/samples/sample6/doc-scans/sample_roll_01.jpg b/samples/sample6/doc-scans/sample_roll_01.jpg
new file mode 100644
index 000000000..25093a370
Binary files /dev/null and b/samples/sample6/doc-scans/sample_roll_01.jpg differ
diff --git a/samples/sample6/doc-scans/sample_roll_02.jpg b/samples/sample6/doc-scans/sample_roll_02.jpg
new file mode 100644
index 000000000..a81ee4fca
Binary files /dev/null and b/samples/sample6/doc-scans/sample_roll_02.jpg differ
diff --git a/samples/sample6/doc-scans/sample_roll_03.jpg b/samples/sample6/doc-scans/sample_roll_03.jpg
new file mode 100644
index 000000000..94296f4e3
Binary files /dev/null and b/samples/sample6/doc-scans/sample_roll_03.jpg differ
diff --git a/samples/sample6/readme.md b/samples/sample6/readme.md
new file mode 100644
index 000000000..2a02ee908
--- /dev/null
+++ b/samples/sample6/readme.md
@@ -0,0 +1,21 @@
+# Demo for feature-based alignment
+
+## Background
+OMR is used to match student roll on final exam scripts. Scripts are scanned using a document scanner and the cover pages are extracted for OMR. Even though a document scanner does not produce any warpped perspective, the alignment is not perfect, causing some rotation and translation in the scans.
+
+The scripts in this sample were specifically selected incorrectly marked scripts to demonstrate how feature-based alignment can correct transformation errors using a reference image. In the actual batch. 156 out of 532 scripts were incorrectly marked. With feature-based alignment, all scripts were correctly marked.
+
+## Usage
+Two template files are given in the sample folder, one with feature-based alignment (template_fb_align), the other without (template_no_fb_align).
+
+## Additional Notes
+
+### Reference Image
+When using a reference image for feature-based alignment, it is better not to have many repeated patterns as it is causes ambiguity when trying to match similar feature points. The bubbles in an OMR form are identical and should not be used for feature-extraction.
+
+Thus, the reference image should be cleared of any bubbles. Forms with lots of text as in this example would be effective.
+
+Note the reference image in this sample was generated from a vector pdf, and not from a scanned blank, producing in a perfectly aligned reference.
+
+### Level adjustment
+The bubbles on the scripts were not shaded dark enough. Thus, a level adjustment was done to bring the black point to 70% to darken the light shading. White point was brought down to 80% to remove the light-grey background in the columns.
diff --git a/samples/sample6/reference.png b/samples/sample6/reference.png
new file mode 100644
index 000000000..5351267f9
Binary files /dev/null and b/samples/sample6/reference.png differ
diff --git a/samples/sample6/template.json b/samples/sample6/template.json
new file mode 100644
index 000000000..57605a6cb
--- /dev/null
+++ b/samples/sample6/template.json
@@ -0,0 +1,110 @@
+{
+ "pageDimensions": [
+ 2480,
+ 3508
+ ],
+ "bubbleDimensions": [
+ 42,
+ 42
+ ],
+ "preProcessors": [
+ {
+ "name": "Levels",
+ "options": {
+ "low": 0.7,
+ "high": 0.8
+ }
+ },
+ {
+ "name": "GaussianBlur",
+ "options": {
+ "kSize": [
+ 3,
+ 3
+ ],
+ "sigmaX": 0
+ }
+ }
+ ],
+ "customLabels": {
+ "Roll": [
+ "stu",
+ "roll1..7",
+ "check_1",
+ "check_2"
+ ]
+ },
+ "fieldBlocks": {
+ "Check1": {
+ "origin": [
+ 2033,
+ 1290
+ ],
+ "bubblesGap": 50,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "check_1"
+ ],
+ "bubbleValues": [
+ "A",
+ "B",
+ "E",
+ "H",
+ "J",
+ "L",
+ "M"
+ ],
+ "direction": "vertical"
+ },
+ "Check2": {
+ "origin": [
+ 2083,
+ 1290
+ ],
+ "bubblesGap": 50,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "check_2"
+ ],
+ "bubbleValues": [
+ "N",
+ "R",
+ "U",
+ "W",
+ "X",
+ "Y"
+ ],
+ "direction": "vertical"
+ },
+ "Stu": {
+ "origin": [
+ 1636,
+ 1290
+ ],
+ "bubblesGap": 50,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "stu"
+ ],
+ "bubbleValues": [
+ "U",
+ "A",
+ "HT",
+ "GT"
+ ],
+ "direction": "vertical"
+ },
+ "Roll": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 1685,
+ 1290
+ ],
+ "bubblesGap": 50.5,
+ "labelsGap": 50.5,
+ "fieldLabels": [
+ "roll1..7"
+ ]
+ }
+ }
+}
diff --git a/samples/sample6/template_fb_align.json b/samples/sample6/template_fb_align.json
new file mode 100644
index 000000000..7c0c23975
--- /dev/null
+++ b/samples/sample6/template_fb_align.json
@@ -0,0 +1,118 @@
+{
+ "pageDimensions": [
+ 2480,
+ 3508
+ ],
+ "bubbleDimensions": [
+ 42,
+ 42
+ ],
+ "preProcessors": [
+ {
+ "name": "Levels",
+ "options": {
+ "low": 0.7,
+ "high": 0.8
+ }
+ },
+ {
+ "name": "FeatureBasedAlignment",
+ "options": {
+ "reference": "reference.png",
+ "maxFeatures": 1000,
+ "2d": true
+ }
+ },
+ {
+ "name": "GaussianBlur",
+ "options": {
+ "kSize": [
+ 3,
+ 3
+ ],
+ "sigmaX": 0
+ }
+ }
+ ],
+ "customLabels": {
+ "Roll": [
+ "stu",
+ "roll1..7",
+ "check_1",
+ "check_2"
+ ]
+ },
+ "fieldBlocks": {
+ "Check1": {
+ "origin": [
+ 2033,
+ 1290
+ ],
+ "bubblesGap": 50,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "check_1"
+ ],
+ "bubbleValues": [
+ "A",
+ "B",
+ "E",
+ "H",
+ "J",
+ "L",
+ "M"
+ ],
+ "direction": "vertical"
+ },
+ "Check2": {
+ "origin": [
+ 2083,
+ 1290
+ ],
+ "bubblesGap": 50,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "check_2"
+ ],
+ "bubbleValues": [
+ "N",
+ "R",
+ "U",
+ "W",
+ "X",
+ "Y"
+ ],
+ "direction": "vertical"
+ },
+ "Stu": {
+ "origin": [
+ 1636,
+ 1290
+ ],
+ "bubblesGap": 50,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "stu"
+ ],
+ "bubbleValues": [
+ "U",
+ "A",
+ "HT",
+ "GT"
+ ],
+ "direction": "vertical"
+ },
+ "Roll": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 1685,
+ 1290
+ ],
+ "bubblesGap": 50.5,
+ "labelsGap": 50.5,
+ "fieldLabels": [
+ "roll1..7"
+ ]
+ }
+ }
+}
diff --git a/samples/sample6/template_no_fb_align.json b/samples/sample6/template_no_fb_align.json
new file mode 100644
index 000000000..57605a6cb
--- /dev/null
+++ b/samples/sample6/template_no_fb_align.json
@@ -0,0 +1,110 @@
+{
+ "pageDimensions": [
+ 2480,
+ 3508
+ ],
+ "bubbleDimensions": [
+ 42,
+ 42
+ ],
+ "preProcessors": [
+ {
+ "name": "Levels",
+ "options": {
+ "low": 0.7,
+ "high": 0.8
+ }
+ },
+ {
+ "name": "GaussianBlur",
+ "options": {
+ "kSize": [
+ 3,
+ 3
+ ],
+ "sigmaX": 0
+ }
+ }
+ ],
+ "customLabels": {
+ "Roll": [
+ "stu",
+ "roll1..7",
+ "check_1",
+ "check_2"
+ ]
+ },
+ "fieldBlocks": {
+ "Check1": {
+ "origin": [
+ 2033,
+ 1290
+ ],
+ "bubblesGap": 50,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "check_1"
+ ],
+ "bubbleValues": [
+ "A",
+ "B",
+ "E",
+ "H",
+ "J",
+ "L",
+ "M"
+ ],
+ "direction": "vertical"
+ },
+ "Check2": {
+ "origin": [
+ 2083,
+ 1290
+ ],
+ "bubblesGap": 50,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "check_2"
+ ],
+ "bubbleValues": [
+ "N",
+ "R",
+ "U",
+ "W",
+ "X",
+ "Y"
+ ],
+ "direction": "vertical"
+ },
+ "Stu": {
+ "origin": [
+ 1636,
+ 1290
+ ],
+ "bubblesGap": 50,
+ "labelsGap": 50,
+ "fieldLabels": [
+ "stu"
+ ],
+ "bubbleValues": [
+ "U",
+ "A",
+ "HT",
+ "GT"
+ ],
+ "direction": "vertical"
+ },
+ "Roll": {
+ "fieldType": "QTYPE_INT",
+ "origin": [
+ 1685,
+ 1290
+ ],
+ "bubblesGap": 50.5,
+ "labelsGap": 50.5,
+ "fieldLabels": [
+ "roll1..7"
+ ]
+ }
+ }
+}
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 000000000..7fc528b45
--- /dev/null
+++ b/src/__init__.py
@@ -0,0 +1,5 @@
+# https://docs.python.org/3/tutorial/modules.html#:~:text=The%20__init__.py,on%20the%20module%20search%20path.
+from src.logger import logger
+
+# It takes a few seconds for the imports
+logger.info(f"Loading OMRChecker modules...")
diff --git a/src/constants/__init__.py b/src/constants/__init__.py
new file mode 100644
index 000000000..d0e6ead30
--- /dev/null
+++ b/src/constants/__init__.py
@@ -0,0 +1,4 @@
+"""
+Constants package for OMRChecker.
+
+"""
diff --git a/src/constants/common.py b/src/constants/common.py
new file mode 100644
index 000000000..07b65ad37
--- /dev/null
+++ b/src/constants/common.py
@@ -0,0 +1,62 @@
+"""
+
+ OMRChecker
+
+ Author: Udayraj Deshmukh
+ Github: https://github.com/Udayraj123
+
+"""
+from dotmap import DotMap
+
+# Filenames
+TEMPLATE_FILENAME = "template.json"
+EVALUATION_FILENAME = "evaluation.json"
+CONFIG_FILENAME = "config.json"
+
+FIELD_LABEL_NUMBER_REGEX = r"([^\d]+)(\d*)"
+#
+ERROR_CODES = DotMap(
+ {
+ "MULTI_BUBBLE_WARN": 1,
+ "NO_MARKER_ERR": 2,
+ },
+ _dynamic=False,
+)
+
+FIELD_TYPES = {
+ "QTYPE_INT": {
+ "bubbleValues": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"],
+ "direction": "vertical",
+ },
+ "QTYPE_INT_FROM_1": {
+ "bubbleValues": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
+ "direction": "vertical",
+ },
+ "QTYPE_MCQ4": {"bubbleValues": ["A", "B", "C", "D"], "direction": "horizontal"},
+ "QTYPE_MCQ5": {
+ "bubbleValues": ["A", "B", "C", "D", "E"],
+ "direction": "horizontal",
+ },
+ "QTYPE_MCQ4_RTL": {
+ "bubbleValues": ["D", "C", "B", "A"],
+ "direction": "horizontal",
+ },
+ "QTYPE_MCQ5_RTL": {
+ "bubbleValues": ["E", "D", "C", "B", "A"],
+ "direction": "horizontal",
+ },
+ #
+ # You can create and append custom field types here-
+ #
+}
+
+# TODO: move to interaction.py
+TEXT_SIZE = 0.95
+CLR_BLACK = (50, 150, 150)
+CLR_WHITE = (250, 250, 250)
+CLR_GRAY = (130, 130, 130)
+CLR_DARK_GRAY = (100, 100, 100)
+
+# TODO: move to config.json
+GLOBAL_PAGE_THRESHOLD_WHITE = 200
+GLOBAL_PAGE_THRESHOLD_BLACK = 100
diff --git a/src/constants/image_processing.py b/src/constants/image_processing.py
new file mode 100644
index 000000000..543080abd
--- /dev/null
+++ b/src/constants/image_processing.py
@@ -0,0 +1,45 @@
+"""
+Constants for image processing operations across OMRChecker.
+"""
+
+# General Image Processing
+DEFAULT_WHITE_COLOR = 255
+DEFAULT_BLACK_COLOR = 0
+DEFAULT_NORMALIZE_PARAMS = {"alpha": 0, "beta": 255}
+DEFAULT_LINE_WIDTH = 2
+DEFAULT_MARKER_LINE_WIDTH = 4
+DEFAULT_CONTOUR_COLOR = (0, 255, 0)
+DEFAULT_CONTOUR_LINE_WIDTH = 2
+DEFAULT_CONTOUR_FILL_COLOR = (255, 255, 255)
+DEFAULT_CONTOUR_FILL_WIDTH = 10
+DEFAULT_BORDER_REMOVE = 5
+
+DEFAULT_GAUSSIAN_BLUR_PARAMS_MARKER = {"kernel_size": (5, 5), "sigma_x": 0}
+
+# CropPage constants
+MIN_PAGE_AREA_THRESHOLD = 80000
+MAX_COSINE_THRESHOLD = 0.35
+DEFAULT_GAUSSIAN_BLUR_KERNEL = (3, 3)
+PAGE_THRESHOLD_PARAMS = {"threshold_value": 200, "max_pixel_value": 255}
+CANNY_PARAMS = {
+ # lower_threshold: lower bound for Canny edge detection
+ # upper_threshold: upper bound for Canny edge detection
+ "lower_threshold": 185,
+ "upper_threshold": 55,
+}
+APPROX_POLY_EPSILON_FACTOR = 0.025
+
+# CropOnMarkers constants
+QUADRANT_DIVISION = {"height_factor": 3, "width_factor": 2}
+MARKER_RECTANGLE_COLOR = (150, 150, 150)
+ERODE_RECT_COLOR = (50, 50, 50)
+NORMAL_RECT_COLOR = (155, 155, 155)
+EROSION_PARAMS = {"kernel_size": (5, 5), "iterations": 5}
+
+# FeatureBasedAlignment constants
+DEFAULT_MAX_FEATURES = 500
+DEFAULT_GOOD_MATCH_PERCENT = 0.15
+
+# Builtin processor constants
+DEFAULT_MEDIAN_BLUR_KERNEL_SIZE = 5
+DEFAULT_GAUSSIAN_BLUR_PARAMS = {"kernel_size": (3, 3), "sigma_x": 0}
diff --git a/src/core.py b/src/core.py
new file mode 100644
index 000000000..fe306815a
--- /dev/null
+++ b/src/core.py
@@ -0,0 +1,728 @@
+import os
+from collections import defaultdict
+from typing import Any
+
+import cv2
+import matplotlib.pyplot as plt
+import numpy as np
+
+from src.constants.common import (
+ CLR_BLACK,
+ CLR_DARK_GRAY,
+ CLR_GRAY,
+ GLOBAL_PAGE_THRESHOLD_BLACK,
+ GLOBAL_PAGE_THRESHOLD_WHITE,
+ TEXT_SIZE,
+)
+from src.logger import logger
+from src.utils.image import CLAHE_HELPER, ImageUtils
+from src.utils.interaction import InteractionUtils
+
+
+class ImageInstanceOps:
+ """Class to hold fine-tuned utilities for a group of images. One instance for each processing directory."""
+
+ save_img_list: Any = defaultdict(list)
+
+ def __init__(self, tuning_config):
+ super().__init__()
+ self.tuning_config = tuning_config
+ self.save_image_level = tuning_config.outputs.save_image_level
+
+ def apply_preprocessors(self, file_path, in_omr, template):
+ tuning_config = self.tuning_config
+ # resize to conform to template
+ in_omr = ImageUtils.resize_util(
+ in_omr,
+ tuning_config.dimensions.processing_width,
+ tuning_config.dimensions.processing_height,
+ )
+
+ # run pre_processors in sequence
+ for pre_processor in template.pre_processors:
+ in_omr = pre_processor.apply_filter(in_omr, file_path)
+ return in_omr
+
+ def read_omr_response(self, template, image, name, save_dir=None):
+ config = self.tuning_config
+ auto_align = config.alignment_params.auto_align
+ try:
+ img = image.copy()
+ # origDim = img.shape[:2]
+ img = ImageUtils.resize_util(
+ img, template.page_dimensions[0], template.page_dimensions[1]
+ )
+ if img.max() > img.min():
+ img = ImageUtils.normalize_util(img)
+ # Processing copies
+ transp_layer = img.copy()
+ final_marked = img.copy()
+
+ morph = img.copy()
+ self.append_save_img(3, morph)
+
+ if auto_align:
+ # Note: clahe is good for morphology, bad for thresholding
+ morph = CLAHE_HELPER.apply(morph)
+ self.append_save_img(3, morph)
+ # Remove shadows further, make columns/boxes darker (less gamma)
+ morph = ImageUtils.adjust_gamma(
+ morph, config.threshold_params.GAMMA_LOW
+ )
+ # TODO: all numbers should come from either constants or config
+ _, morph = cv2.threshold(morph, 220, 220, cv2.THRESH_TRUNC)
+ morph = ImageUtils.normalize_util(morph)
+ self.append_save_img(3, morph)
+ if config.outputs.show_image_level >= 4:
+ InteractionUtils.show("morph1", morph, 0, 1, config)
+
+ # Move them to data class if needed
+ # Overlay Transparencies
+ alpha = 0.65
+ omr_response = {}
+ multi_marked, multi_roll = 0, 0
+
+ # TODO Make this part useful for visualizing status checks
+ # blackVals=[0]
+ # whiteVals=[255]
+
+ if config.outputs.show_image_level >= 5:
+ all_c_box_vals = {"int": [], "mcq": []}
+ # TODO: simplify this logic
+ q_nums = {"int": [], "mcq": []}
+
+ # Find Shifts for the field_blocks --> Before calculating threshold!
+ if auto_align:
+ # print("Begin Alignment")
+ # Open : erode then dilate
+ v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 10))
+ morph_v = cv2.morphologyEx(
+ morph, cv2.MORPH_OPEN, v_kernel, iterations=3
+ )
+ _, morph_v = cv2.threshold(morph_v, 200, 200, cv2.THRESH_TRUNC)
+ morph_v = 255 - ImageUtils.normalize_util(morph_v)
+
+ if config.outputs.show_image_level >= 3:
+ InteractionUtils.show(
+ "morphed_vertical", morph_v, 0, 1, config=config
+ )
+
+ # InteractionUtils.show("morph1",morph,0,1,config=config)
+ # InteractionUtils.show("morphed_vertical",morph_v,0,1,config=config)
+
+ self.append_save_img(3, morph_v)
+
+ morph_thr = 60 # for Mobile images, 40 for scanned Images
+ _, morph_v = cv2.threshold(morph_v, morph_thr, 255, cv2.THRESH_BINARY)
+ # kernel best tuned to 5x5 now
+ morph_v = cv2.erode(morph_v, np.ones((5, 5), np.uint8), iterations=2)
+
+ self.append_save_img(3, morph_v)
+ # h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 2))
+ # morph_h = cv2.morphologyEx(morph, cv2.MORPH_OPEN, h_kernel, iterations=3)
+ # ret, morph_h = cv2.threshold(morph_h,200,200,cv2.THRESH_TRUNC)
+ # morph_h = 255 - normalize_util(morph_h)
+ # InteractionUtils.show("morph_h",morph_h,0,1,config=config)
+ # _, morph_h = cv2.threshold(morph_h,morph_thr,255,cv2.THRESH_BINARY)
+ # morph_h = cv2.erode(morph_h, np.ones((5,5),np.uint8), iterations = 2)
+ if config.outputs.show_image_level >= 3:
+ InteractionUtils.show(
+ "morph_thr_eroded", morph_v, 0, 1, config=config
+ )
+
+ self.append_save_img(6, morph_v)
+
+ # template relative alignment code
+ for field_block in template.field_blocks:
+ s, d = field_block.origin, field_block.dimensions
+
+ match_col, max_steps, align_stride, thk = map(
+ config.alignment_params.get,
+ [
+ "match_col",
+ "max_steps",
+ "stride",
+ "thickness",
+ ],
+ )
+ shift, steps = 0, 0
+ while steps < max_steps:
+ left_mean = np.mean(
+ morph_v[
+ s[1] : s[1] + d[1],
+ s[0] + shift - thk : -thk + s[0] + shift + match_col,
+ ]
+ )
+ right_mean = np.mean(
+ morph_v[
+ s[1] : s[1] + d[1],
+ s[0]
+ + shift
+ - match_col
+ + d[0]
+ + thk : thk
+ + s[0]
+ + shift
+ + d[0],
+ ]
+ )
+
+ # For demonstration purposes-
+ # if(field_block.name == "int1"):
+ # ret = morph_v.copy()
+ # cv2.rectangle(ret,
+ # (s[0]+shift-thk,s[1]),
+ # (s[0]+shift+thk+d[0],s[1]+d[1]),
+ # CLR_WHITE,
+ # 3)
+ # appendSaveImg(6,ret)
+ # print(shift, left_mean, right_mean)
+ left_shift, right_shift = left_mean > 100, right_mean > 100
+ if left_shift:
+ if right_shift:
+ break
+ else:
+ shift -= align_stride
+ else:
+ if right_shift:
+ shift += align_stride
+ else:
+ break
+ steps += 1
+
+ field_block.shift = shift
+ # print("Aligned field_block: ",field_block.name,"Corrected Shift:",
+ # field_block.shift,", dimensions:", field_block.dimensions,
+ # "origin:", field_block.origin,'\n')
+ # print("End Alignment")
+
+ final_align = None
+ if config.outputs.show_image_level >= 2:
+ initial_align = self.draw_template_layout(img, template, shifted=False)
+ final_align = self.draw_template_layout(
+ img, template, shifted=True, draw_qvals=True
+ )
+ # appendSaveImg(4,mean_vals)
+ self.append_save_img(2, initial_align)
+ self.append_save_img(2, final_align)
+
+ if auto_align:
+ final_align = np.hstack((initial_align, final_align))
+ self.append_save_img(5, img)
+
+ # Get mean bubbleValues n other stats
+ all_q_vals, all_q_strip_arrs, all_q_std_vals = [], [], []
+ total_q_strip_no = 0
+ for field_block in template.field_blocks:
+ box_w, box_h = field_block.bubble_dimensions
+ q_std_vals = []
+ for field_block_bubbles in field_block.traverse_bubbles:
+ q_strip_vals = []
+ for pt in field_block_bubbles:
+ # shifted
+ x, y = (pt.x + field_block.shift, pt.y)
+ rect = [y, y + box_h, x, x + box_w]
+ q_strip_vals.append(
+ cv2.mean(img[rect[0] : rect[1], rect[2] : rect[3]])[0]
+ # detectCross(img, rect) ? 100 : 0
+ )
+ q_std_vals.append(round(np.std(q_strip_vals), 2))
+ all_q_strip_arrs.append(q_strip_vals)
+ # _, _, _ = get_global_threshold(q_strip_vals, "QStrip Plot",
+ # plot_show=False, sort_in_plot=True)
+ # hist = getPlotImg()
+ # InteractionUtils.show("QStrip "+field_block_bubbles[0].field_label, hist, 0, 1,config=config)
+ all_q_vals.extend(q_strip_vals)
+ # print(total_q_strip_no, field_block_bubbles[0].field_label, q_std_vals[len(q_std_vals)-1])
+ total_q_strip_no += 1
+ all_q_std_vals.extend(q_std_vals)
+
+ global_std_thresh, _, _ = self.get_global_threshold(
+ all_q_std_vals
+ ) # , "Q-wise Std-dev Plot", plot_show=True, sort_in_plot=True)
+ # plt.show()
+ # hist = getPlotImg()
+ # InteractionUtils.show("StdHist", hist, 0, 1,config=config)
+
+ # Note: Plotting takes Significant times here --> Change Plotting args
+ # to support show_image_level
+ # , "Mean Intensity Histogram",plot_show=True, sort_in_plot=True)
+ global_thr, _, _ = self.get_global_threshold(all_q_vals, looseness=4)
+
+ logger.info(
+ f"Thresholding: \tglobal_thr: {round(global_thr, 2)} \tglobal_std_THR: {round(global_std_thresh, 2)}\t{'(Looks like a Xeroxed OMR)' if (global_thr == 255) else ''}"
+ )
+ # plt.show()
+ # hist = getPlotImg()
+ # InteractionUtils.show("StdHist", hist, 0, 1,config=config)
+
+ # if(config.outputs.show_image_level>=1):
+ # hist = getPlotImg()
+ # InteractionUtils.show("Hist", hist, 0, 1,config=config)
+ # appendSaveImg(4,hist)
+ # appendSaveImg(5,hist)
+ # appendSaveImg(2,hist)
+
+ per_omr_threshold_avg, total_q_strip_no, total_q_box_no = 0, 0, 0
+ for field_block in template.field_blocks:
+ block_q_strip_no = 1
+ box_w, box_h = field_block.bubble_dimensions
+ shift = field_block.shift
+ s, d = field_block.origin, field_block.dimensions
+ key = field_block.name[:3]
+ # cv2.rectangle(final_marked,(s[0]+shift,s[1]),(s[0]+shift+d[0],
+ # s[1]+d[1]),CLR_BLACK,3)
+ for field_block_bubbles in field_block.traverse_bubbles:
+ # All Black or All White case
+ no_outliers = all_q_std_vals[total_q_strip_no] < global_std_thresh
+ # print(total_q_strip_no, field_block_bubbles[0].field_label,
+ # all_q_std_vals[total_q_strip_no], "no_outliers:", no_outliers)
+ per_q_strip_threshold = self.get_local_threshold(
+ all_q_strip_arrs[total_q_strip_no],
+ global_thr,
+ no_outliers,
+ f"Mean Intensity Histogram for {key}.{field_block_bubbles[0].field_label}.{block_q_strip_no}",
+ config.outputs.show_image_level >= 6,
+ )
+ # print(field_block_bubbles[0].field_label,key,block_q_strip_no, "THR: ",
+ # round(per_q_strip_threshold,2))
+ per_omr_threshold_avg += per_q_strip_threshold
+
+ # Note: Little debugging visualization - view the particular Qstrip
+ # if(
+ # 0
+ # # or "q17" in (field_block_bubbles[0].field_label)
+ # # or (field_block_bubbles[0].field_label+str(block_q_strip_no))=="q15"
+ # ):
+ # st, end = qStrip
+ # InteractionUtils.show("QStrip: "+key+"-"+str(block_q_strip_no),
+ # img[st[1] : end[1], st[0]+shift : end[0]+shift],0,config=config)
+
+ # TODO: get rid of total_q_box_no
+ detected_bubbles = []
+ for bubble in field_block_bubbles:
+ bubble_is_marked = (
+ per_q_strip_threshold > all_q_vals[total_q_box_no]
+ )
+ total_q_box_no += 1
+ if bubble_is_marked:
+ detected_bubbles.append(bubble)
+ x, y, field_value = (
+ bubble.x + field_block.shift,
+ bubble.y,
+ bubble.field_value,
+ )
+ cv2.rectangle(
+ final_marked,
+ (int(x + box_w / 12), int(y + box_h / 12)),
+ (
+ int(x + box_w - box_w / 12),
+ int(y + box_h - box_h / 12),
+ ),
+ CLR_DARK_GRAY,
+ 3,
+ )
+
+ cv2.putText(
+ final_marked,
+ str(field_value),
+ (x, y),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ TEXT_SIZE,
+ (20, 20, 10),
+ int(1 + 3.5 * TEXT_SIZE),
+ )
+ else:
+ cv2.rectangle(
+ final_marked,
+ (int(x + box_w / 10), int(y + box_h / 10)),
+ (
+ int(x + box_w - box_w / 10),
+ int(y + box_h - box_h / 10),
+ ),
+ CLR_GRAY,
+ -1,
+ )
+
+ for bubble in detected_bubbles:
+ field_label, field_value = (
+ bubble.field_label,
+ bubble.field_value,
+ )
+ # Only send rolls multi-marked in the directory
+ multi_marked_local = field_label in omr_response
+ omr_response[field_label] = (
+ (omr_response[field_label] + field_value)
+ if multi_marked_local
+ else field_value
+ )
+ # TODO: generalize this into identifier
+ # multi_roll = multi_marked_local and "Roll" in str(q)
+ multi_marked = multi_marked or multi_marked_local
+
+ if len(detected_bubbles) == 0:
+ field_label = field_block_bubbles[0].field_label
+ omr_response[field_label] = field_block.empty_val
+
+ if config.outputs.show_image_level >= 5:
+ if key in all_c_box_vals:
+ q_nums[key].append(f"{key[:2]}_c{str(block_q_strip_no)}")
+ all_c_box_vals[key].append(
+ all_q_strip_arrs[total_q_strip_no]
+ )
+
+ block_q_strip_no += 1
+ total_q_strip_no += 1
+ # /for field_block
+
+ per_omr_threshold_avg /= total_q_strip_no
+ per_omr_threshold_avg = round(per_omr_threshold_avg, 2)
+ # Translucent
+ cv2.addWeighted(
+ final_marked, alpha, transp_layer, 1 - alpha, 0, final_marked
+ )
+ # Box types
+ if config.outputs.show_image_level >= 6:
+ # plt.draw()
+ f, axes = plt.subplots(len(all_c_box_vals), sharey=True)
+ f.canvas.manager.set_window_title(name)
+ ctr = 0
+ type_name = {
+ "int": "Integer",
+ "mcq": "MCQ",
+ "med": "MED",
+ "rol": "Roll",
+ }
+ for k, boxvals in all_c_box_vals.items():
+ axes[ctr].title.set_text(type_name[k] + " Type")
+ axes[ctr].boxplot(boxvals)
+ # thrline=axes[ctr].axhline(per_omr_threshold_avg,color='red',ls='--')
+ # thrline.set_label("Average THR")
+ axes[ctr].set_ylabel("Intensity")
+ axes[ctr].set_xticklabels(q_nums[k])
+ # axes[ctr].legend()
+ ctr += 1
+ # imshow will do the waiting
+ plt.tight_layout(pad=0.5)
+ plt.show()
+
+ if config.outputs.show_image_level >= 3 and final_align is not None:
+ final_align = ImageUtils.resize_util_h(
+ final_align, int(config.dimensions.display_height)
+ )
+ # [final_align.shape[1],0])
+ InteractionUtils.show(
+ "Template Alignment Adjustment", final_align, 0, 0, config=config
+ )
+
+ if config.outputs.save_detections and save_dir is not None:
+ if multi_roll:
+ save_dir = save_dir.joinpath("_MULTI_")
+ image_path = str(save_dir.joinpath(name))
+ ImageUtils.save_img(image_path, final_marked)
+
+ self.append_save_img(2, final_marked)
+
+ if save_dir is not None:
+ for i in range(config.outputs.save_image_level):
+ self.save_image_stacks(i + 1, name, save_dir)
+
+ return omr_response, final_marked, multi_marked, multi_roll
+
+ except Exception as e:
+ raise e
+
+ @staticmethod
+ def draw_template_layout(img, template, shifted=True, draw_qvals=False, border=-1):
+ img = ImageUtils.resize_util(
+ img, template.page_dimensions[0], template.page_dimensions[1]
+ )
+ final_align = img.copy()
+ for field_block in template.field_blocks:
+ s, d = field_block.origin, field_block.dimensions
+ box_w, box_h = field_block.bubble_dimensions
+ shift = field_block.shift
+ if shifted:
+ cv2.rectangle(
+ final_align,
+ (s[0] + shift, s[1]),
+ (s[0] + shift + d[0], s[1] + d[1]),
+ CLR_BLACK,
+ 3,
+ )
+ else:
+ cv2.rectangle(
+ final_align,
+ (s[0], s[1]),
+ (s[0] + d[0], s[1] + d[1]),
+ CLR_BLACK,
+ 3,
+ )
+ for field_block_bubbles in field_block.traverse_bubbles:
+ for pt in field_block_bubbles:
+ x, y = (pt.x + field_block.shift, pt.y) if shifted else (pt.x, pt.y)
+ cv2.rectangle(
+ final_align,
+ (int(x + box_w / 10), int(y + box_h / 10)),
+ (int(x + box_w - box_w / 10), int(y + box_h - box_h / 10)),
+ CLR_GRAY,
+ border,
+ )
+ if draw_qvals:
+ rect = [y, y + box_h, x, x + box_w]
+ cv2.putText(
+ final_align,
+ f"{int(cv2.mean(img[rect[0] : rect[1], rect[2] : rect[3]])[0])}",
+ (rect[2] + 2, rect[0] + (box_h * 2) // 3),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.6,
+ CLR_BLACK,
+ 2,
+ )
+ if shifted:
+ text_in_px = cv2.getTextSize(
+ field_block.name, cv2.FONT_HERSHEY_SIMPLEX, TEXT_SIZE, 4
+ )
+ cv2.putText(
+ final_align,
+ field_block.name,
+ (int(s[0] + d[0] - text_in_px[0][0]), int(s[1] - text_in_px[0][1])),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ TEXT_SIZE,
+ CLR_BLACK,
+ 4,
+ )
+ return final_align
+
+ def get_global_threshold(
+ self,
+ q_vals_orig,
+ plot_title=None,
+ plot_show=True,
+ sort_in_plot=True,
+ looseness=1,
+ ):
+ """
+ Note: Cannot assume qStrip has only-gray or only-white bg
+ (in which case there is only one jump).
+ So there will be either 1 or 2 jumps.
+ 1 Jump :
+ ......
+ ||||||
+ |||||| <-- risky THR
+ |||||| <-- safe THR
+ ....||||||
+ ||||||||||
+
+ 2 Jumps :
+ ......
+ |||||| <-- wrong THR
+ ....||||||
+ |||||||||| <-- safe THR
+ ..||||||||||
+ ||||||||||||
+
+ The abstract "First LARGE GAP" is perfect for this.
+ Current code is considering ONLY TOP 2 jumps(>= MIN_GAP) to be big,
+ gives the smaller one
+
+ """
+ config = self.tuning_config
+ PAGE_TYPE_FOR_THRESHOLD, MIN_JUMP, JUMP_DELTA = map(
+ config.threshold_params.get,
+ [
+ "PAGE_TYPE_FOR_THRESHOLD",
+ "MIN_JUMP",
+ "JUMP_DELTA",
+ ],
+ )
+
+ global_default_threshold = (
+ GLOBAL_PAGE_THRESHOLD_WHITE
+ if PAGE_TYPE_FOR_THRESHOLD == "white"
+ else GLOBAL_PAGE_THRESHOLD_BLACK
+ )
+
+ # Sort the Q bubbleValues
+ # TODO: Change var name of q_vals
+ q_vals = sorted(q_vals_orig)
+ # Find the FIRST LARGE GAP and set it as threshold:
+ ls = (looseness + 1) // 2
+ l = len(q_vals) - ls
+ max1, thr1 = MIN_JUMP, global_default_threshold
+ for i in range(ls, l):
+ jump = q_vals[i + ls] - q_vals[i - ls]
+ if jump > max1:
+ max1 = jump
+ thr1 = q_vals[i - ls] + jump / 2
+
+ # NOTE: thr2 is deprecated, thus is JUMP_DELTA
+ # Make use of the fact that the JUMP_DELTA(Vertical gap ofc) between
+ # values at detected jumps would be atleast 20
+ max2, thr2 = MIN_JUMP, global_default_threshold
+ # Requires atleast 1 gray box to be present (Roll field will ensure this)
+ for i in range(ls, l):
+ jump = q_vals[i + ls] - q_vals[i - ls]
+ new_thr = q_vals[i - ls] + jump / 2
+ if jump > max2 and abs(thr1 - new_thr) > JUMP_DELTA:
+ max2 = jump
+ thr2 = new_thr
+ # global_thr = min(thr1,thr2)
+ global_thr, j_low, j_high = thr1, thr1 - max1 // 2, thr1 + max1 // 2
+
+ # # For normal images
+ # thresholdRead = 116
+ # if(thr1 > thr2 and thr2 > thresholdRead):
+ # print("Note: taking safer thr line.")
+ # global_thr, j_low, j_high = thr2, thr2 - max2//2, thr2 + max2//2
+
+ if plot_title:
+ _, ax = plt.subplots()
+ ax.bar(range(len(q_vals_orig)), q_vals if sort_in_plot else q_vals_orig)
+ ax.set_title(plot_title)
+ thrline = ax.axhline(global_thr, color="green", ls="--", linewidth=5)
+ thrline.set_label("Global Threshold")
+ thrline = ax.axhline(thr2, color="red", ls=":", linewidth=3)
+ thrline.set_label("THR2 Line")
+ # thrline=ax.axhline(j_low,color='red',ls='-.', linewidth=3)
+ # thrline=ax.axhline(j_high,color='red',ls='-.', linewidth=3)
+ # thrline.set_label("Boundary Line")
+ # ax.set_ylabel("Mean Intensity")
+ ax.set_ylabel("Values")
+ ax.set_xlabel("Position")
+ ax.legend()
+ if plot_show:
+ plt.title(plot_title)
+ plt.show()
+
+ return global_thr, j_low, j_high
+
+ def get_local_threshold(
+ self, q_vals, global_thr, no_outliers, plot_title=None, plot_show=True
+ ):
+ """
+ TODO: Update this documentation too-
+ //No more - Assumption : Colwise background color is uniformly gray or white,
+ but not alternating. In this case there is atmost one jump.
+
+ 0 Jump :
+ <-- safe THR?
+ .......
+ ...|||||||
+ |||||||||| <-- safe THR?
+ // How to decide given range is above or below gray?
+ -> global q_vals shall absolutely help here. Just run same function
+ on total q_vals instead of colwise _//
+ How to decide it is this case of 0 jumps
+
+ 1 Jump :
+ ......
+ ||||||
+ |||||| <-- risky THR
+ |||||| <-- safe THR
+ ....||||||
+ ||||||||||
+
+ """
+ config = self.tuning_config
+ # Sort the Q bubbleValues
+ q_vals = sorted(q_vals)
+
+ # Small no of pts cases:
+ # base case: 1 or 2 pts
+ if len(q_vals) < 3:
+ thr1 = (
+ global_thr
+ if np.max(q_vals) - np.min(q_vals) < config.threshold_params.MIN_GAP
+ else np.mean(q_vals)
+ )
+ else:
+ # qmin, qmax, qmean, qstd = round(np.min(q_vals),2), round(np.max(q_vals),2),
+ # round(np.mean(q_vals),2), round(np.std(q_vals),2)
+ # GVals = [round(abs(q-qmean),2) for q in q_vals]
+ # gmean, gstd = round(np.mean(GVals),2), round(np.std(GVals),2)
+ # # DISCRETION: Pretty critical factor in reading response
+ # # Doesn't work well for small number of values.
+ # DISCRETION = 2.7 # 2.59 was closest hit, 3.0 is too far
+ # L2MaxGap = round(max([abs(g-gmean) for g in GVals]),2)
+ # if(L2MaxGap > DISCRETION*gstd):
+ # no_outliers = False
+
+ # # ^Stackoverflow method
+ # print(field_label, no_outliers,"qstd",round(np.std(q_vals),2), "gstd", gstd,
+ # "Gaps in gvals",sorted([round(abs(g-gmean),2) for g in GVals],reverse=True),
+ # '\t',round(DISCRETION*gstd,2), L2MaxGap)
+
+ # else:
+ # Find the LARGEST GAP and set it as threshold: //(FIRST LARGE GAP)
+ l = len(q_vals) - 1
+ max1, thr1 = config.threshold_params.MIN_JUMP, 255
+ for i in range(1, l):
+ jump = q_vals[i + 1] - q_vals[i - 1]
+ if jump > max1:
+ max1 = jump
+ thr1 = q_vals[i - 1] + jump / 2
+ # print(field_label,q_vals,max1)
+
+ confident_jump = (
+ config.threshold_params.MIN_JUMP
+ + config.threshold_params.CONFIDENT_SURPLUS
+ )
+ # If not confident, then only take help of global_thr
+ if max1 < confident_jump:
+ if no_outliers:
+ # All Black or All White case
+ thr1 = global_thr
+ else:
+ # TODO: Low confidence parameters here
+ pass
+
+ # if(thr1 == 255):
+ # print("Warning: threshold is unexpectedly 255! (Outlier Delta issue?)",plot_title)
+
+ # Make a common plot function to show local and global thresholds
+ if plot_show and plot_title is not None:
+ _, ax = plt.subplots()
+ ax.bar(range(len(q_vals)), q_vals)
+ thrline = ax.axhline(thr1, color="green", ls=("-."), linewidth=3)
+ thrline.set_label("Local Threshold")
+ thrline = ax.axhline(global_thr, color="red", ls=":", linewidth=5)
+ thrline.set_label("Global Threshold")
+ ax.set_title(plot_title)
+ ax.set_ylabel("Bubble Mean Intensity")
+ ax.set_xlabel("Bubble Number(sorted)")
+ ax.legend()
+ # TODO append QStrip to this plot-
+ # appendSaveImg(6,getPlotImg())
+ if plot_show:
+ plt.show()
+ return thr1
+
+ def append_save_img(self, key, img):
+ if self.save_image_level >= int(key):
+ self.save_img_list[key].append(img.copy())
+
+ def save_image_stacks(self, key, filename, save_dir):
+ config = self.tuning_config
+ if self.save_image_level >= int(key) and self.save_img_list[key] != []:
+ name = os.path.splitext(filename)[0]
+ result = np.hstack(
+ tuple(
+ [
+ ImageUtils.resize_util_h(img, config.dimensions.display_height)
+ for img in self.save_img_list[key]
+ ]
+ )
+ )
+ result = ImageUtils.resize_util(
+ result,
+ min(
+ len(self.save_img_list[key]) * config.dimensions.display_width // 3,
+ int(config.dimensions.display_width * 2.5),
+ ),
+ )
+ ImageUtils.save_img(f"{save_dir}stack/{name}_{str(key)}_stack.jpg", result)
+
+ def reset_all_save_img(self):
+ for i in range(self.save_image_level):
+ self.save_img_list[i + 1] = []
diff --git a/src/defaults/__init__.py b/src/defaults/__init__.py
new file mode 100644
index 000000000..62ed2e6f5
--- /dev/null
+++ b/src/defaults/__init__.py
@@ -0,0 +1,5 @@
+# https://docs.python.org/3/tutorial/modules.html#:~:text=The%20__init__.py,on%20the%20module%20search%20path.
+# Use all imports relative to root directory
+# (https://chrisyeh96.github.io/2017/08/08/definitive-guide-python-imports.html)
+from src.defaults.config import * # NOQA
+from src.defaults.template import * # NOQA
diff --git a/src/defaults/config.py b/src/defaults/config.py
new file mode 100644
index 000000000..1c6cad3fc
--- /dev/null
+++ b/src/defaults/config.py
@@ -0,0 +1,35 @@
+from dotmap import DotMap
+
+CONFIG_DEFAULTS = DotMap(
+ {
+ "dimensions": {
+ "display_height": 2480,
+ "display_width": 1640,
+ "processing_height": 820,
+ "processing_width": 666,
+ },
+ "threshold_params": {
+ "GAMMA_LOW": 0.7,
+ "MIN_GAP": 30,
+ "MIN_JUMP": 25,
+ "CONFIDENT_SURPLUS": 5,
+ "JUMP_DELTA": 30,
+ "PAGE_TYPE_FOR_THRESHOLD": "white",
+ },
+ "alignment_params": {
+ # Note: 'auto_align' enables automatic template alignment, use if the scans show slight misalignments.
+ "auto_align": False,
+ "match_col": 5,
+ "max_steps": 20,
+ "stride": 1,
+ "thickness": 3,
+ },
+ "outputs": {
+ "show_image_level": 0,
+ "save_image_level": 0,
+ "save_detections": True,
+ "filter_out_multimarked_files": False,
+ },
+ },
+ _dynamic=False,
+)
diff --git a/src/defaults/template.py b/src/defaults/template.py
new file mode 100644
index 000000000..d0a2a8314
--- /dev/null
+++ b/src/defaults/template.py
@@ -0,0 +1,6 @@
+TEMPLATE_DEFAULTS = {
+ "preProcessors": [],
+ "emptyValue": "",
+ "customLabels": {},
+ "outputColumns": [],
+}
diff --git a/src/entry.py b/src/entry.py
new file mode 100644
index 000000000..ed6a11d54
--- /dev/null
+++ b/src/entry.py
@@ -0,0 +1,375 @@
+"""
+
+ OMRChecker
+
+ Author: Udayraj Deshmukh
+ Github: https://github.com/Udayraj123
+
+"""
+import os
+from csv import QUOTE_NONNUMERIC
+from pathlib import Path
+from time import time
+
+import cv2
+import pandas as pd
+from rich.table import Table
+
+from src.constants.common import (
+ CONFIG_FILENAME,
+ ERROR_CODES,
+ EVALUATION_FILENAME,
+ TEMPLATE_FILENAME,
+)
+from src.defaults import CONFIG_DEFAULTS
+from src.evaluation import EvaluationConfig, evaluate_concatenated_response
+from src.logger import console, logger
+from src.template import Template
+from src.utils.file import Paths, setup_dirs_for_paths, setup_outputs_for_template
+from src.utils.image import ImageUtils
+from src.utils.interaction import InteractionUtils, Stats
+from src.utils.parsing import get_concatenated_response, open_config_with_defaults
+
+# Load processors
+STATS = Stats()
+
+
+def entry_point(input_dir, args):
+ if not os.path.exists(input_dir):
+ raise Exception(f"Given input directory does not exist: '{input_dir}'")
+ curr_dir = input_dir
+ return process_dir(input_dir, curr_dir, args)
+
+
+def print_config_summary(
+ curr_dir,
+ omr_files,
+ template,
+ tuning_config,
+ local_config_path,
+ evaluation_config,
+ args,
+):
+ logger.info("")
+ table = Table(title="Current Configurations", show_header=False, show_lines=False)
+ table.add_column("Key", style="cyan", no_wrap=True)
+ table.add_column("Value", style="magenta")
+ table.add_row("Directory Path", f"{curr_dir}")
+ table.add_row("Count of Images", f"{len(omr_files)}")
+ table.add_row("Set Layout Mode ", "ON" if args["setLayout"] else "OFF")
+ pre_processor_names = [pp.__class__.__name__ for pp in template.pre_processors]
+ table.add_row(
+ "Markers Detection",
+ "ON" if "CropOnMarkers" in pre_processor_names else "OFF",
+ )
+ table.add_row("Auto Alignment", f"{tuning_config.alignment_params.auto_align}")
+ table.add_row("Detected Template Path", f"{template}")
+ if local_config_path:
+ table.add_row("Detected Local Config", f"{local_config_path}")
+ if evaluation_config:
+ table.add_row("Detected Evaluation Config", f"{evaluation_config}")
+
+ table.add_row(
+ "Detected pre-processors",
+ ", ".join(pre_processor_names),
+ )
+ console.print(table, justify="center")
+
+
+def process_dir(
+ root_dir,
+ curr_dir,
+ args,
+ template=None,
+ tuning_config=CONFIG_DEFAULTS,
+ evaluation_config=None,
+):
+ # Update local tuning_config (in current recursion stack)
+ local_config_path = curr_dir.joinpath(CONFIG_FILENAME)
+ if os.path.exists(local_config_path):
+ tuning_config = open_config_with_defaults(local_config_path)
+
+ # Update local template (in current recursion stack)
+ local_template_path = curr_dir.joinpath(TEMPLATE_FILENAME)
+ local_template_exists = os.path.exists(local_template_path)
+ if local_template_exists:
+ template = Template(
+ local_template_path,
+ tuning_config,
+ )
+ # Look for subdirectories for processing
+ subdirs = [d for d in curr_dir.iterdir() if d.is_dir()]
+
+ output_dir = Path(args["output_dir"], curr_dir.relative_to(root_dir))
+ paths = Paths(output_dir)
+
+ # look for images in current dir to process
+ exts = ("*.[pP][nN][gG]", "*.[jJ][pP][gG]", "*.[jJ][pP][eE][gG]")
+ omr_files = sorted([f for ext in exts for f in curr_dir.glob(ext)])
+
+ # Exclude images (take union over all pre_processors)
+ excluded_files = []
+ if template:
+ for pp in template.pre_processors:
+ excluded_files.extend(Path(p) for p in pp.exclude_files())
+
+ local_evaluation_path = curr_dir.joinpath(EVALUATION_FILENAME)
+ if not args["setLayout"] and os.path.exists(local_evaluation_path):
+ if not local_template_exists:
+ logger.warning(
+ f"Found an evaluation file without a parent template file: {local_evaluation_path}"
+ )
+ evaluation_config = EvaluationConfig(
+ curr_dir,
+ local_evaluation_path,
+ template,
+ tuning_config,
+ )
+
+ excluded_files.extend(
+ Path(exclude_file) for exclude_file in evaluation_config.get_exclude_files()
+ )
+
+ omr_files = [f for f in omr_files if f not in excluded_files]
+
+ if omr_files:
+ if not template:
+ logger.error(
+ f"Found images, but no template in the directory tree \
+ of '{curr_dir}'. \nPlace {TEMPLATE_FILENAME} in the \
+ appropriate directory."
+ )
+ raise Exception(
+ f"No template file found in the directory tree of {curr_dir}"
+ )
+
+ setup_dirs_for_paths(paths)
+ outputs_namespace = setup_outputs_for_template(paths, template)
+
+ print_config_summary(
+ curr_dir,
+ omr_files,
+ template,
+ tuning_config,
+ local_config_path,
+ evaluation_config,
+ args,
+ )
+ if args["setLayout"]:
+ show_template_layouts(omr_files, template, tuning_config)
+ else:
+ process_files(
+ omr_files,
+ template,
+ tuning_config,
+ evaluation_config,
+ outputs_namespace,
+ )
+
+ elif not subdirs:
+ # Each subdirectory should have images or should be non-leaf
+ logger.info(
+ f"No valid images or sub-folders found in {curr_dir}.\
+ Empty directories not allowed."
+ )
+
+ # recursively process sub-folders
+ for d in subdirs:
+ process_dir(
+ root_dir,
+ d,
+ args,
+ template,
+ tuning_config,
+ evaluation_config,
+ )
+
+
+def show_template_layouts(omr_files, template, tuning_config):
+ for file_path in omr_files:
+ file_name = file_path.name
+ file_path = str(file_path)
+ in_omr = cv2.imread(file_path, cv2.IMREAD_GRAYSCALE)
+ in_omr = template.image_instance_ops.apply_preprocessors(
+ file_path, in_omr, template
+ )
+ template_layout = template.image_instance_ops.draw_template_layout(
+ in_omr, template, shifted=False, border=2
+ )
+ InteractionUtils.show(
+ f"Template Layout: {file_name}", template_layout, 1, 1, config=tuning_config
+ )
+
+
+def process_files(
+ omr_files,
+ template,
+ tuning_config,
+ evaluation_config,
+ outputs_namespace,
+):
+ start_time = int(time())
+ files_counter = 0
+ STATS.files_not_moved = 0
+
+ for file_path in omr_files:
+ files_counter += 1
+ file_name = file_path.name
+
+ in_omr = cv2.imread(str(file_path), cv2.IMREAD_GRAYSCALE)
+
+ logger.info("")
+ logger.info(
+ f"({files_counter}) Opening image: \t'{file_path}'\tResolution: {in_omr.shape}"
+ )
+
+ template.image_instance_ops.reset_all_save_img()
+
+ template.image_instance_ops.append_save_img(1, in_omr)
+
+ in_omr = template.image_instance_ops.apply_preprocessors(
+ file_path, in_omr, template
+ )
+
+ if in_omr is None:
+ # Error OMR case
+ new_file_path = outputs_namespace.paths.errors_dir.joinpath(file_name)
+ outputs_namespace.OUTPUT_SET.append(
+ [file_name] + outputs_namespace.empty_resp
+ )
+ if check_and_move(ERROR_CODES.NO_MARKER_ERR, file_path, new_file_path):
+ err_line = [
+ file_name,
+ file_path,
+ new_file_path,
+ "NA",
+ ] + outputs_namespace.empty_resp
+ pd.DataFrame(err_line, dtype=str).T.to_csv(
+ outputs_namespace.files_obj["Errors"],
+ mode="a",
+ quoting=QUOTE_NONNUMERIC,
+ header=False,
+ index=False,
+ )
+ continue
+
+ # uniquify
+ file_id = str(file_name)
+ save_dir = outputs_namespace.paths.save_marked_dir
+ (
+ response_dict,
+ final_marked,
+ multi_marked,
+ _,
+ ) = template.image_instance_ops.read_omr_response(
+ template, image=in_omr, name=file_id, save_dir=save_dir
+ )
+
+ # TODO: move inner try catch here
+ # concatenate roll nos, set unmarked responses, etc
+ omr_response = get_concatenated_response(response_dict, template)
+
+ if (
+ evaluation_config is None
+ or not evaluation_config.get_should_explain_scoring()
+ ):
+ logger.info(f"Read Response: \n{omr_response}")
+
+ score = 0
+ if evaluation_config is not None:
+ score = evaluate_concatenated_response(
+ omr_response,
+ evaluation_config,
+ file_path,
+ outputs_namespace.paths.evaluation_dir,
+ )
+ logger.info(
+ f"(/{files_counter}) Graded with score: {round(score, 2)}\t for file: '{file_id}'"
+ )
+ else:
+ logger.info(f"(/{files_counter}) Processed file: '{file_id}'")
+
+ if tuning_config.outputs.show_image_level >= 2:
+ InteractionUtils.show(
+ f"Final Marked Bubbles : '{file_id}'",
+ ImageUtils.resize_util_h(
+ final_marked, int(tuning_config.dimensions.display_height * 1.3)
+ ),
+ 1,
+ 1,
+ config=tuning_config,
+ )
+
+ resp_array = []
+ for k in template.output_columns:
+ resp_array.append(omr_response[k])
+
+ outputs_namespace.OUTPUT_SET.append([file_name] + resp_array)
+
+ if multi_marked == 0 or not tuning_config.outputs.filter_out_multimarked_files:
+ STATS.files_not_moved += 1
+ new_file_path = save_dir.joinpath(file_id)
+ # Enter into Results sheet-
+ results_line = [file_name, file_path, new_file_path, score] + resp_array
+ # Write/Append to results_line file(opened in append mode)
+ pd.DataFrame(results_line, dtype=str).T.to_csv(
+ outputs_namespace.files_obj["Results"],
+ mode="a",
+ quoting=QUOTE_NONNUMERIC,
+ header=False,
+ index=False,
+ )
+ else:
+ # multi_marked file
+ logger.info(f"[{files_counter}] Found multi-marked file: '{file_id}'")
+ new_file_path = outputs_namespace.paths.multi_marked_dir.joinpath(file_name)
+ if check_and_move(ERROR_CODES.MULTI_BUBBLE_WARN, file_path, new_file_path):
+ mm_line = [file_name, file_path, new_file_path, "NA"] + resp_array
+ pd.DataFrame(mm_line, dtype=str).T.to_csv(
+ outputs_namespace.files_obj["MultiMarked"],
+ mode="a",
+ quoting=QUOTE_NONNUMERIC,
+ header=False,
+ index=False,
+ )
+ # else:
+ # TODO: Add appropriate record handling here
+ # pass
+
+ print_stats(start_time, files_counter, tuning_config)
+
+
+def check_and_move(error_code, file_path, filepath2):
+ # TODO: fix file movement into error/multimarked/invalid etc again
+ STATS.files_not_moved += 1
+ return True
+
+
+def print_stats(start_time, files_counter, tuning_config):
+ time_checking = max(1, round(time() - start_time, 2))
+ log = logger.info
+ log("")
+ log(f"{'Total file(s) moved': <27}: {STATS.files_moved}")
+ log(f"{'Total file(s) not moved': <27}: {STATS.files_not_moved}")
+ log("--------------------------------")
+ log(
+ f"{'Total file(s) processed': <27}: {files_counter} ({'Sum Tallied!' if files_counter == (STATS.files_moved + STATS.files_not_moved) else 'Not Tallying!'})"
+ )
+
+ if tuning_config.outputs.show_image_level <= 0:
+ log(
+ f"\nFinished Checking {files_counter} file(s) in {round(time_checking, 1)} seconds i.e. ~{round(time_checking / 60, 1)} minute(s)."
+ )
+ log(
+ f"{'OMR Processing Rate': <27}: \t ~ {round(time_checking / files_counter, 2)} seconds/OMR"
+ )
+ log(
+ f"{'OMR Processing Speed': <27}: \t ~ {round((files_counter * 60) / time_checking, 2)} OMRs/minute"
+ )
+ else:
+ log(f"\n{'Total script time': <27}: {time_checking} seconds")
+
+ if tuning_config.outputs.show_image_level <= 1:
+ log(
+ "\nTip: To see some awesome visuals, open config.json and increase 'show_image_level'"
+ )
diff --git a/src/evaluation.py b/src/evaluation.py
new file mode 100644
index 000000000..0b5671695
--- /dev/null
+++ b/src/evaluation.py
@@ -0,0 +1,546 @@
+import ast
+import os
+import re
+from copy import deepcopy
+from csv import QUOTE_NONNUMERIC
+
+import cv2
+import pandas as pd
+from rich.table import Table
+
+from src.logger import console, logger
+from src.schemas.constants import (
+ BONUS_SECTION_PREFIX,
+ DEFAULT_SECTION_KEY,
+ MARKING_VERDICT_TYPES,
+)
+from src.utils.parsing import (
+ get_concatenated_response,
+ open_evaluation_with_validation,
+ parse_fields,
+ parse_float_or_fraction,
+)
+
+
+class AnswerMatcher:
+ def __init__(self, answer_item, section_marking_scheme):
+ self.section_marking_scheme = section_marking_scheme
+ self.answer_item = answer_item
+ self.answer_type = self.validate_and_get_answer_type(answer_item)
+ self.set_defaults_from_scheme(section_marking_scheme)
+
+ @staticmethod
+ def is_a_marking_score(answer_element):
+ # Note: strict type checking is already done at schema validation level,
+ # Here we focus on overall struct type
+ return type(answer_element) == str or type(answer_element) == int
+
+ @staticmethod
+ def is_standard_answer(answer_element):
+ return type(answer_element) == str and len(answer_element) >= 1
+
+ def validate_and_get_answer_type(self, answer_item):
+ if self.is_standard_answer(answer_item):
+ return "standard"
+ elif type(answer_item) == list:
+ if (
+ # Array of answer elements: ['A', 'B', 'AB']
+ len(answer_item) >= 2
+ and all(
+ self.is_standard_answer(answers_or_score)
+ for answers_or_score in answer_item
+ )
+ ):
+ return "multiple-correct"
+ elif (
+ # Array of two-tuples: [['A', 1], ['B', 1], ['C', 3], ['AB', 2]]
+ len(answer_item) >= 1
+ and all(
+ type(answer_and_score) == list and len(answer_and_score) == 2
+ for answer_and_score in answer_item
+ )
+ and all(
+ self.is_standard_answer(allowed_answer)
+ and self.is_a_marking_score(answer_score)
+ for allowed_answer, answer_score in answer_item
+ )
+ ):
+ return "multiple-correct-weighted"
+
+ logger.critical(
+ f"Unable to determine answer type for answer item: {answer_item}"
+ )
+ raise Exception("Unable to determine answer type")
+
+ def set_defaults_from_scheme(self, section_marking_scheme):
+ answer_type = self.answer_type
+ self.empty_val = section_marking_scheme.empty_val
+ answer_item = self.answer_item
+ self.marking = deepcopy(section_marking_scheme.marking)
+ # TODO: reuse part of parse_scheme_marking here -
+ if answer_type == "standard":
+ # no local overrides
+ pass
+ elif answer_type == "multiple-correct":
+ # override marking scheme scores for each allowed answer
+ for allowed_answer in answer_item:
+ self.marking[f"correct-{allowed_answer}"] = self.marking["correct"]
+ elif answer_type == "multiple-correct-weighted":
+ # Note: No override using marking scheme as answer scores are provided in answer_item
+ for allowed_answer, answer_score in answer_item:
+ self.marking[f"correct-{allowed_answer}"] = parse_float_or_fraction(
+ answer_score
+ )
+
+ def get_marking_scheme(self):
+ return self.section_marking_scheme
+
+ def get_section_explanation(self):
+ answer_type = self.answer_type
+ if answer_type in ["standard", "multiple-correct"]:
+ return self.section_marking_scheme.section_key
+ elif answer_type == "multiple-correct-weighted":
+ return f"Custom: {self.marking}"
+
+ def get_verdict_marking(self, marked_answer):
+ answer_type = self.answer_type
+ question_verdict = "incorrect"
+ if answer_type == "standard":
+ question_verdict = self.get_standard_verdict(marked_answer)
+ elif answer_type == "multiple-correct":
+ question_verdict = self.get_multiple_correct_verdict(marked_answer)
+ elif answer_type == "multiple-correct-weighted":
+ question_verdict = self.get_multiple_correct_weighted_verdict(marked_answer)
+ return question_verdict, self.marking[question_verdict]
+
+ def get_standard_verdict(self, marked_answer):
+ allowed_answer = self.answer_item
+ if marked_answer == self.empty_val:
+ return "unmarked"
+ elif marked_answer == allowed_answer:
+ return "correct"
+ else:
+ return "incorrect"
+
+ def get_multiple_correct_verdict(self, marked_answer):
+ allowed_answers = self.answer_item
+ if marked_answer == self.empty_val:
+ return "unmarked"
+ elif marked_answer in allowed_answers:
+ return f"correct-{marked_answer}"
+ else:
+ return "incorrect"
+
+ def get_multiple_correct_weighted_verdict(self, marked_answer):
+ allowed_answers = [
+ allowed_answer for allowed_answer, _answer_score in self.answer_item
+ ]
+ if marked_answer == self.empty_val:
+ return "unmarked"
+ elif marked_answer in allowed_answers:
+ return f"correct-{marked_answer}"
+ else:
+ return "incorrect"
+
+ def __str__(self):
+ return f"{self.answer_item}"
+
+
+class SectionMarkingScheme:
+ def __init__(self, section_key, section_scheme, empty_val):
+ # TODO: get local empty_val from qblock
+ self.empty_val = empty_val
+ self.section_key = section_key
+ # DEFAULT marking scheme follows a shorthand
+ if section_key == DEFAULT_SECTION_KEY:
+ self.questions = None
+ self.marking = self.parse_scheme_marking(section_scheme)
+ else:
+ self.questions = parse_fields(section_key, section_scheme["questions"])
+ self.marking = self.parse_scheme_marking(section_scheme["marking"])
+
+ def __str__(self):
+ return self.section_key
+
+ def parse_scheme_marking(self, marking):
+ parsed_marking = {}
+ for verdict_type in MARKING_VERDICT_TYPES:
+ verdict_marking = parse_float_or_fraction(marking[verdict_type])
+ if (
+ verdict_marking > 0
+ and verdict_type == "incorrect"
+ and not self.section_key.startswith(BONUS_SECTION_PREFIX)
+ ):
+ logger.warning(
+ f"Found positive marks({round(verdict_marking, 2)}) for incorrect answer in the schema '{self.section_key}'. For Bonus sections, add a prefix 'BONUS_' to them."
+ )
+ parsed_marking[verdict_type] = verdict_marking
+
+ return parsed_marking
+
+ def match_answer(self, marked_answer, answer_matcher):
+ question_verdict, verdict_marking = answer_matcher.get_verdict_marking(
+ marked_answer
+ )
+
+ return verdict_marking, question_verdict
+
+
+class EvaluationConfig:
+ """Note: this instance will be reused for multiple omr sheets"""
+
+ def __init__(self, curr_dir, evaluation_path, template, tuning_config):
+ self.path = evaluation_path
+ evaluation_json = open_evaluation_with_validation(evaluation_path)
+ options, marking_schemes, source_type = map(
+ evaluation_json.get, ["options", "marking_schemes", "source_type"]
+ )
+ self.should_explain_scoring = options.get("should_explain_scoring", False)
+ self.has_non_default_section = False
+ self.exclude_files = []
+ self.enable_evaluation_table_to_csv = options.get(
+ "enable_evaluation_table_to_csv", False
+ )
+
+ if source_type == "csv":
+ csv_path = curr_dir.joinpath(options["answer_key_csv_path"])
+ if not os.path.exists(csv_path):
+ logger.warning(f"Answer key csv does not exist at: '{csv_path}'.")
+
+ answer_key_image_path = options.get("answer_key_image_path", None)
+ if os.path.exists(csv_path):
+ # TODO: CSV parsing/validation for each row with a (qNo,