diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 408ab293..6db6fdfd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -30,5 +30,5 @@ If applicable, add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. - -Error Stack trace. Sample images used, etc + - Error Stack trace (using --debug flag). + - Sample images used, etc diff --git a/.gitignore b/.gitignore index a7d4046b..e11fe9f6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ outputs/* # Misc **/.DS_Store +**/__ignore__ **/__pycache__ venv/ OMRChecker.wiki/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2daa2af8..29e9d545 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,57 @@ exclude: "__snapshots__/.*$" default_install_hook_types: [pre-commit, pre-push] repos: + # Convert the pngs to jpgs inside samples folder + - repo: local + hooks: + - id: convert-images + name: Convert png to jpg in samples + entry: scripts/convert_images_hook.py + args: ["--trigger-size", "150"] + files: ^samples.*\.(png|PNG)$ + pass_filenames: true + stages: [commit] + language: python + language_version: python3 + additional_dependencies: [pillow] + always_run: true + fail_fast: true # Wait for user to remove the png files + + # Run resize image before the compress image check + - repo: local + hooks: + - id: resize-images + name: Resize images + entry: scripts/resize_images_hook.py + args: ["--max-width", "1500", "--max-height", "1500", "--trigger-size", "150"] + files: \.(png|jpg|jpeg|PNG|JPG|JPEG)$ + pass_filenames: true + stages: [commit] + language: python + language_version: python3 + additional_dependencies: [pillow] + always_run: true + fail_fast: false + + # Run image compressor before the large files check + - repo: https://github.com/boidolr/pre-commit-images + rev: v1.5.2 + hooks: + - id: optimize-png + - id: optimize-jpg + args: ["--quality", "90"] + - 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'] + args: ["--maxkb=300"] fail_fast: false stages: [commit] - id: pretty-format-json - args: ['--autofix', '--no-sort-keys'] + args: ["--autofix", "--no-sort-keys"] - id: end-of-file-fixer exclude_types: ["csv", "json"] stages: [commit] @@ -34,14 +74,23 @@ repos: hooks: - id: flake8 args: - - "--ignore=E501,W503,E203,E741,F541" # Line too long, Line break occurred before a binary operator, Whitespace before ':' + - "--ignore=E203,E501,E741,E731,F403,F405,F541,W503" + # https://www.flake8rules.com/ + # E203: Whitespace before ':' + # E501: Line too long + # E731: do not assign a lambda expression, use a def + # E741: Do not use variables named 'I', 'O', or 'l' + # F403: unable to detect undefined names + # F405: 'Tuple' may be undefined, or defined from star imports + # F541: f-string without any placeholders + # W503: Line break occurred before a binary operator 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 + entry: python3 -m pytest -rfpsxEX --disable-warnings --verbose -k test_run_omr_marker_mobile language: system pass_filenames: false always_run: true diff --git a/Contributors.md b/Contributors.md index 3f95b245..5be9e852 100644 --- a/Contributors.md +++ b/Contributors.md @@ -20,3 +20,4 @@ - [aayushibansal2001](https://github.com/aayushibansal2001) - [ShamanthVallem](https://github.com/ShamanthVallem) - [rudrapsc](https://github.com/rudrapsc) +- [palash018](https://github.com/palash018) diff --git a/README.md b/README.md index ca796be2..a6c786d3 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # OMR Checker -Read OMR sheets fast and accurately using a scanner 🖨 or your phone 🤳. +Evaluate OMR sheets fast and accurately using a scanner 🖨 or your phone 🤳. ## What is OMR? -OMR stands for Optical Mark Recognition, used to detect and interpret human-marked data on documents. OMR refers to the process of reading and evaluating OMR sheets, commonly used in exams, surveys, and other forms. +OMR stands for Optical Mark Recognition, used to detect and interpret human-marked data on documents. OMR refers to the process of reading and evaluating OMR sheets, commonly used in exams, surveys, and other forms. The OMR sheet scanning is typically done using a scanner, but with OMRChecker it's supported from a mobile camera as well. #### **Quick Links** + - [Installation](#getting-started) - [User Guide](https://github.com/Udayraj123/OMRChecker/wiki) - [Contributor Guide](https://github.com/Udayraj123/OMRChecker/blob/master/CONTRIBUTING.md) @@ -33,9 +34,10 @@ A full-fledged OMR checking software that can read and evaluate OMR sheets scann | Specs | ![Current_Speed](https://img.shields.io/badge/Speed-200+_OMRs/min-blue.svg?style=flat-square) ![Min Resolution](https://img.shields.io/badge/Min_Resolution-640x480-blue.svg?style=flat-square) | | :--------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 💯 **Accurate** | Currently nearly 100% accurate on good quality document scans; and about 90% accurate on mobile images. | -| 💪🏿 **Robust** | Supports low resolution, xeroxed sheets. See [**Robustness**](https://github.com/Udayraj123/OMRChecker/wiki/Robustness) for more. | +| 💪🏿 **Robust** | Supports low resolution, xeroxed sheets as well as colored images. See [**Robustness**](https://github.com/Udayraj123/OMRChecker/wiki/Robustness) for more. | | ⏩ **Fast** | Current processing speed without any optimization is 200 OMRs/minute. | | ✅ **Customizable** | [Easily apply](https://github.com/Udayraj123/OMRChecker/wiki/User-Guide) to custom OMR layouts, surveys, etc. | +| ✅ **Colorful** | Supports Colored Outputs Since April 2024 | | 📊 **Visually Rich** | [Get insights](https://github.com/Udayraj123/OMRChecker/wiki/Rich-Visuals) to configure and debug easily. | | 🎈 **Lightweight** | Very minimal core code size. | | 🏫 **Large Scale** | Tested on a large scale at [Technothlon](https://en.wikipedia.org/wiki/Technothlon). | @@ -248,7 +250,6 @@ Standard Quality Dataset(For ML Based methods) (3 GB) High Quality Dataset(For custom processing) (6 GB) --> - ## FAQ
@@ -328,14 +329,16 @@ Here's a snapshot of the [Android OMR Helper App (archived)](https://github.com/ [![Stargazers over time](https://starchart.cc/Udayraj123/OMRChecker.svg)](https://starchart.cc/Udayraj123/OMRChecker) -*** +--- +

Made with ❤️ by Awesome Contributors

-*** +--- + ### License [![GitHub license](https://img.shields.io/github/license/Udayraj123/OMRChecker.svg)](https://github.com/Udayraj123/OMRChecker/blob/master/LICENSE) @@ -343,8 +346,8 @@ Here's a snapshot of the [Android OMR Helper App (archived)](https://github.com/ For more details see [LICENSE](https://github.com/Udayraj123/OMRChecker/blob/master/LICENSE). ### Donate -Buy Me A Coffee [![paypal](https://www.paypalobjects.com/en_GB/i/btn/btn_donate_LG.gif)](https://www.paypal.me/Udayraj123/500) +Buy Me A Coffee [![paypal](https://www.paypalobjects.com/en_GB/i/btn/btn_donate_LG.gif)](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) diff --git a/main.py b/main.py index dfecbca3..b94dc8c4 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ from pathlib import Path from src.entry import entry_point -from src.logger import logger +from src.utils.logger import logger def parse_args(): @@ -77,9 +77,8 @@ def parse_args(): args = vars(args) if len(unknown) > 0: - logger.warning(f"\nError: Unknown arguments: {unknown}", unknown) argparser.print_help() - exit(11) + raise Exception(f"\nError: Unknown arguments: {unknown}") return args @@ -87,11 +86,19 @@ def entry_point_for_args(args): if args["debug"] is True: # Disable tracebacks sys.tracebacklimit = 0 + # TODO: set log levels for root in args["input_paths"]: - entry_point( - Path(root), - args, - ) + try: + entry_point( + Path(root), + args, + ) + except Exception: + if args["debug"] is True: + logger.critical( + f"OMRChecker crashed. add --debug and run again to see error details" + ) + raise if __name__ == "__main__": diff --git a/requirements.dev.txt b/requirements.dev.txt index 722ab0dd..c22df0d8 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1,7 +1,8 @@ -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 +flake8>=7.0.0 +freezegun>=1.4.0 +pre-commit>=3.7.0 +pytest-mock>=3.14.0 +pytest>=8.1.1 +syrupy>=4.6.1 +Pillow>=10.3.0 diff --git a/requirements.txt b/requirements.txt index 761d2e51..562c5e0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,16 @@ -deepmerge>=1.1.0 +deepmerge>=1.1.1 dotmap>=1.3.30 -jsonschema>=4.17.3 -matplotlib>=3.7.1 -numpy>=1.25.0 -pandas>=2.0.2 -rich>=13.4.2 +jsonschema>=4.21.1 +matplotlib>=3.8.4 +numpy>=1.26.4 +pandas>=2.2.1 +rich>=13.7.1 screeninfo>=0.8.1 +# External deps +scipy>=1.12.0 +shapely>=2.0.3 +einops==0.7.0 +# Note: opencv is excluded from requirements.txt because the installation steps +# may be different as per your OS. Windows/MacOS users can uncomment the following +# opencv-python>=4.9.0.80 +# opencv-contrib-python>=4.9.0.80 diff --git a/samples/1-mobile-camera/MobileCamera/sheet1.jpg b/samples/1-mobile-camera/MobileCamera/sheet1.jpg new file mode 100644 index 00000000..6fa15ea4 Binary files /dev/null and b/samples/1-mobile-camera/MobileCamera/sheet1.jpg differ diff --git a/samples/1-mobile-camera/config.json b/samples/1-mobile-camera/config.json new file mode 100644 index 00000000..df674766 --- /dev/null +++ b/samples/1-mobile-camera/config.json @@ -0,0 +1,6 @@ +{ + "outputs": { + "show_image_level": 0, + "show_colored_outputs": true + } +} diff --git a/samples/sample1/omr_marker.jpg b/samples/1-mobile-camera/omr_marker.jpg similarity index 100% rename from samples/sample1/omr_marker.jpg rename to samples/1-mobile-camera/omr_marker.jpg diff --git a/samples/sample1/template.json b/samples/1-mobile-camera/template.json similarity index 89% rename from samples/sample1/template.json rename to samples/1-mobile-camera/template.json index 46635774..d7356270 100644 --- a/samples/sample1/template.json +++ b/samples/1-mobile-camera/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 1846, 1500 ], @@ -33,9 +33,38 @@ "q9_2" ] }, + "preProcessors": [ + { + "name": "CropPage", + "options": { + "morphKernel": [ + 15, + 15 + ] + } + }, + { + "name": "CropOnMarkers", + "options": { + "type": "FOUR_MARKERS", + "referenceImage": "omr_marker.jpg", + "tuningOptions": { + "marker_rescale_range": [ + 80, + 120 + ] + }, + "markerDimensions": [ + 35, + 35 + ] + } + } + ], "fieldBlocks": { "Medium": { "bubblesGap": 41, + "fieldType": "CUSTOM", "bubbleValues": [ "E", "H" @@ -175,23 +204,5 @@ 1195 ] } - }, - "preProcessors": [ - { - "name": "CropPage", - "options": { - "morphKernel": [ - 10, - 10 - ] - } - }, - { - "name": "CropOnMarkers", - "options": { - "relativePath": "omr_marker.jpg", - "sheetToMarkerWidthRatio": 17 - } - } - ] + } } diff --git a/samples/sample5/README.md b/samples/2-omr-marker/README.md similarity index 74% rename from samples/sample5/README.md rename to samples/2-omr-marker/README.md index b5ed8ccb..5a7d7b25 100644 --- a/samples/sample5/README.md +++ b/samples/2-omr-marker/README.md @@ -3,4 +3,4 @@ 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) +- Using evaluation on custom labels diff --git a/samples/2-omr-marker/ScanBatch1/camscanner-1.jpg b/samples/2-omr-marker/ScanBatch1/camscanner-1.jpg new file mode 100644 index 00000000..68f68c49 Binary files /dev/null and b/samples/2-omr-marker/ScanBatch1/camscanner-1.jpg differ diff --git a/samples/2-omr-marker/ScanBatch2/camscanner-2.jpg b/samples/2-omr-marker/ScanBatch2/camscanner-2.jpg new file mode 100644 index 00000000..29cbc801 Binary files /dev/null and b/samples/2-omr-marker/ScanBatch2/camscanner-2.jpg differ diff --git a/samples/2-omr-marker/config.json b/samples/2-omr-marker/config.json new file mode 100644 index 00000000..f7eb1944 --- /dev/null +++ b/samples/2-omr-marker/config.json @@ -0,0 +1,5 @@ +{ + "outputs": { + "show_image_level": 0 + } +} diff --git a/samples/sample5/evaluation.json b/samples/2-omr-marker/evaluation.json similarity index 97% rename from samples/sample5/evaluation.json rename to samples/2-omr-marker/evaluation.json index 3332fe6a..c885faf9 100644 --- a/samples/sample5/evaluation.json +++ b/samples/2-omr-marker/evaluation.json @@ -30,8 +30,7 @@ "D", "B", "A" - ], - "should_explain_scoring": true + ] }, "marking_schemes": { "DEFAULT": { @@ -89,5 +88,8 @@ "unmarked": 0 } } + }, + "outputs_configuration": { + "should_explain_scoring": true } } diff --git a/samples/sample5/omr_marker.jpg b/samples/2-omr-marker/omr_marker.jpg similarity index 100% rename from samples/sample5/omr_marker.jpg rename to samples/2-omr-marker/omr_marker.jpg diff --git a/samples/sample5/template.json b/samples/2-omr-marker/template.json similarity index 93% rename from samples/sample5/template.json rename to samples/2-omr-marker/template.json index 39f6e83d..8857a796 100644 --- a/samples/sample5/template.json +++ b/samples/2-omr-marker/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 1846, 1500 ], @@ -11,8 +11,12 @@ { "name": "CropOnMarkers", "options": { - "relativePath": "omr_marker.jpg", - "sheetToMarkerWidthRatio": 17 + "type": "FOUR_MARKERS", + "referenceImage": "omr_marker.jpg", + "markerDimensions": [ + 35, + 35 + ] } } ], @@ -44,6 +48,7 @@ }, "fieldBlocks": { "Medium": { + "fieldType": "CUSTOM", "bubbleValues": [ "E", "H" diff --git a/samples/3-answer-key/bonus-marking/IMG_20201116_143512.jpg b/samples/3-answer-key/bonus-marking/IMG_20201116_143512.jpg new file mode 100644 index 00000000..eb2fbd1c Binary files /dev/null and b/samples/3-answer-key/bonus-marking/IMG_20201116_143512.jpg differ diff --git a/samples/3-answer-key/bonus-marking/IMG_20201116_150717658.jpg b/samples/3-answer-key/bonus-marking/IMG_20201116_150717658.jpg new file mode 100644 index 00000000..6bf6d92d Binary files /dev/null and b/samples/3-answer-key/bonus-marking/IMG_20201116_150717658.jpg differ diff --git a/samples/3-answer-key/bonus-marking/IMG_20201116_150750830.jpg b/samples/3-answer-key/bonus-marking/IMG_20201116_150750830.jpg new file mode 100644 index 00000000..e8ced53f Binary files /dev/null and b/samples/3-answer-key/bonus-marking/IMG_20201116_150750830.jpg differ diff --git a/samples/sample4/config.json b/samples/3-answer-key/bonus-marking/config.json similarity index 53% rename from samples/sample4/config.json rename to samples/3-answer-key/bonus-marking/config.json index be3b7be8..0a3b92b7 100644 --- a/samples/sample4/config.json +++ b/samples/3-answer-key/bonus-marking/config.json @@ -1,12 +1,12 @@ { - "dimensions": { - "display_width": 1189, - "display_height": 1682 - }, - "threshold_params": { + "thresholding": { "MIN_JUMP": 30 }, "outputs": { + "display_image_dimensions": [ + 1189, + 1682 + ], "filter_out_multimarked_files": false, "show_image_level": 0 } diff --git a/samples/3-answer-key/bonus-marking/evaluation.json b/samples/3-answer-key/bonus-marking/evaluation.json new file mode 100644 index 00000000..812ee6d3 --- /dev/null +++ b/samples/3-answer-key/bonus-marking/evaluation.json @@ -0,0 +1,76 @@ +{ + "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" + ] + }, + "outputs_configuration": { + "draw_score": { + "enabled": true, + "position": [ + 600, + 650 + ], + "size": 1.5 + }, + "draw_answers_summary": { + "enabled": true, + "position": [ + 300, + 550 + ], + "size": 1.0 + }, + "verdict_colors": { + "correct": "#00ff00", + "incorrect": "#ff0000", + "unmarked": "#0000ff" + }, + "should_explain_scoring": true + }, + "marking_schemes": { + "DEFAULT": { + "correct": "3", + "incorrect": "-1", + "unmarked": "0" + }, + "BONUS_MARKS_ON_ATTEMPT": { + "marking": { + "correct": "3", + "incorrect": "3", + "unmarked": "0" + }, + "questions": [ + "q7" + ] + }, + "BONUS_MARKS_FOR_ALL": { + "marking": { + "correct": "3", + "incorrect": "3", + "unmarked": "3" + }, + "questions": [ + "q8" + ] + } + } +} diff --git a/samples/sample4/template.json b/samples/3-answer-key/bonus-marking/template.json similarity index 85% rename from samples/sample4/template.json rename to samples/3-answer-key/bonus-marking/template.json index 0d615b87..7287a28c 100644 --- a/samples/sample4/template.json +++ b/samples/3-answer-key/bonus-marking/template.json @@ -1,7 +1,7 @@ { - "pageDimensions": [ - 1189, - 1682 + "templateDimensions": [ + 1200, + 1500 ], "bubbleDimensions": [ 30, @@ -32,14 +32,14 @@ "MCQBlock1": { "fieldType": "QTYPE_MCQ4", "origin": [ - 134, - 684 + 138, + 605 ], "fieldLabels": [ "q1..11" ], "bubblesGap": 79, - "labelsGap": 62 + "labelsGap": 55 } } } diff --git a/samples/3-answer-key/using-csv/adrian_omr.png b/samples/3-answer-key/using-csv/adrian_omr.png new file mode 100644 index 00000000..08c618ad Binary files /dev/null and b/samples/3-answer-key/using-csv/adrian_omr.png differ diff --git a/samples/answer-key/using-csv/answer_key.csv b/samples/3-answer-key/using-csv/answer_key.csv similarity index 100% rename from samples/answer-key/using-csv/answer_key.csv rename to samples/3-answer-key/using-csv/answer_key.csv diff --git a/samples/answer-key/using-csv/evaluation.json b/samples/3-answer-key/using-csv/evaluation.json similarity index 72% rename from samples/answer-key/using-csv/evaluation.json rename to samples/3-answer-key/using-csv/evaluation.json index 14a3db25..0ef28865 100644 --- a/samples/answer-key/using-csv/evaluation.json +++ b/samples/3-answer-key/using-csv/evaluation.json @@ -1,8 +1,7 @@ { "source_type": "csv", "options": { - "answer_key_csv_path": "answer_key.csv", - "should_explain_scoring": true + "answer_key_csv_path": "answer_key.csv" }, "marking_schemes": { "DEFAULT": { @@ -10,5 +9,8 @@ "incorrect": "0", "unmarked": "0" } + }, + "outputs_configuration": { + "should_explain_scoring": true } } diff --git a/samples/answer-key/weighted-answers/template.json b/samples/3-answer-key/using-csv/template.json similarity index 94% rename from samples/answer-key/weighted-answers/template.json rename to samples/3-answer-key/using-csv/template.json index 41ec9ffa..b8ad88fe 100644 --- a/samples/answer-key/weighted-answers/template.json +++ b/samples/3-answer-key/using-csv/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 300, 400 ], diff --git a/samples/3-answer-key/using-image/UPSC-mock/angle-1.jpg b/samples/3-answer-key/using-image/UPSC-mock/angle-1.jpg new file mode 100644 index 00000000..5c8e6694 Binary files /dev/null and b/samples/3-answer-key/using-image/UPSC-mock/angle-1.jpg differ diff --git a/samples/3-answer-key/using-image/UPSC-mock/angle-2.jpg b/samples/3-answer-key/using-image/UPSC-mock/angle-2.jpg new file mode 100644 index 00000000..0308b943 Binary files /dev/null and b/samples/3-answer-key/using-image/UPSC-mock/angle-2.jpg differ diff --git a/samples/3-answer-key/using-image/UPSC-mock/angle-3.jpg b/samples/3-answer-key/using-image/UPSC-mock/angle-3.jpg new file mode 100644 index 00000000..9d44d544 Binary files /dev/null and b/samples/3-answer-key/using-image/UPSC-mock/angle-3.jpg differ diff --git a/samples/3-answer-key/using-image/answer_key.jpg b/samples/3-answer-key/using-image/answer_key.jpg new file mode 100644 index 00000000..da099b5c Binary files /dev/null and b/samples/3-answer-key/using-image/answer_key.jpg differ diff --git a/samples/3-answer-key/using-image/config.json b/samples/3-answer-key/using-image/config.json new file mode 100644 index 00000000..48821e88 --- /dev/null +++ b/samples/3-answer-key/using-image/config.json @@ -0,0 +1,9 @@ +{ + "outputs": { + "display_image_dimensions": [ + 1800, + 2400 + ], + "show_image_level": 0 + } +} diff --git a/samples/community/UPSC-mock/evaluation.json b/samples/3-answer-key/using-image/evaluation.json similarity index 81% rename from samples/community/UPSC-mock/evaluation.json rename to samples/3-answer-key/using-image/evaluation.json index 42fbec8b..b53e3b0d 100644 --- a/samples/community/UPSC-mock/evaluation.json +++ b/samples/3-answer-key/using-image/evaluation.json @@ -1,12 +1,11 @@ { - "source_type": "csv", + "source_type": "image_and_csv", "options": { - "answer_key_csv_path": "answer_key.csv", "answer_key_image_path": "answer_key.jpg", + "answer_key_csv_path": "answer_key.csv", "questions_in_order": [ "q1..100" - ], - "should_explain_scoring": true + ] }, "marking_schemes": { "DEFAULT": { @@ -14,5 +13,8 @@ "incorrect": "-2/3", "unmarked": "0" } + }, + "outputs_configuration": { + "should_explain_scoring": true } } diff --git a/samples/community/UPSC-mock/template.json b/samples/3-answer-key/using-image/template.json similarity index 96% rename from samples/community/UPSC-mock/template.json rename to samples/3-answer-key/using-image/template.json index a1ef5c6b..6180c3af 100644 --- a/samples/community/UPSC-mock/template.json +++ b/samples/3-answer-key/using-image/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 1800, 2400 ], @@ -16,6 +16,21 @@ "roll1..10" ] }, + "processingImageShape": [ + 2400, + 1800 + ], + "preProcessors": [ + { + "name": "CropPage", + "options": { + "morphKernel": [ + 10, + 10 + ] + } + } + ], "fieldBlocks": { "bookletNo": { "origin": [ @@ -27,6 +42,7 @@ "fieldLabels": [ "bookletNo" ], + "fieldType": "CUSTOM", "bubbleValues": [ "A", "B", @@ -180,16 +196,5 @@ ], "fieldType": "QTYPE_MCQ4" } - }, - "preProcessors": [ - { - "name": "CropPage", - "options": { - "morphKernel": [ - 10, - 10 - ] - } - } - ] + } } diff --git a/samples/answer-key/weighted-answers/evaluation.json b/samples/3-answer-key/weighted-answers/evaluation.json similarity index 57% rename from samples/answer-key/weighted-answers/evaluation.json rename to samples/3-answer-key/weighted-answers/evaluation.json index c0daefcf..67af8012 100644 --- a/samples/answer-key/weighted-answers/evaluation.json +++ b/samples/3-answer-key/weighted-answers/evaluation.json @@ -5,7 +5,16 @@ "q1..5" ], "answers_in_order": [ - "C", + [ + [ + "A", + 3 + ], + [ + "C", + -2 + ] + ], "E", [ "A", @@ -22,14 +31,26 @@ ] ], "C" - ], - "should_explain_scoring": true + ] }, "marking_schemes": { "DEFAULT": { "correct": "3", "incorrect": "-1", "unmarked": "0" + }, + "CUSTOM_NEGATIVE_MARKING": { + "questions": [ + "q5" + ], + "marking": { + "correct": "3", + "incorrect": "-2", + "unmarked": "0" + } } + }, + "outputs_configuration": { + "should_explain_scoring": true } } diff --git a/samples/answer-key/weighted-answers/images/adrian_omr.png b/samples/3-answer-key/weighted-answers/images/adrian_omr.png similarity index 100% rename from samples/answer-key/weighted-answers/images/adrian_omr.png rename to samples/3-answer-key/weighted-answers/images/adrian_omr.png diff --git a/samples/3-answer-key/weighted-answers/images/adrian_omr_2.png b/samples/3-answer-key/weighted-answers/images/adrian_omr_2.png new file mode 100644 index 00000000..08c618ad Binary files /dev/null and b/samples/3-answer-key/weighted-answers/images/adrian_omr_2.png differ diff --git a/samples/sample2/template.json b/samples/3-answer-key/weighted-answers/template.json similarity index 94% rename from samples/sample2/template.json rename to samples/3-answer-key/weighted-answers/template.json index 41ec9ffa..b8ad88fe 100644 --- a/samples/sample2/template.json +++ b/samples/3-answer-key/weighted-answers/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 300, 400 ], diff --git a/samples/answer-key/using-csv/adrian_omr.png b/samples/answer-key/using-csv/adrian_omr.png deleted file mode 100644 index d8db0994..00000000 Binary files a/samples/answer-key/using-csv/adrian_omr.png and /dev/null differ diff --git a/samples/answer-key/using-csv/template.json b/samples/answer-key/using-csv/template.json deleted file mode 100644 index 41ec9ffa..00000000 --- a/samples/answer-key/using-csv/template.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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/images/adrian_omr_2.png b/samples/answer-key/weighted-answers/images/adrian_omr_2.png deleted file mode 100644 index d8db0994..00000000 Binary files a/samples/answer-key/weighted-answers/images/adrian_omr_2.png and /dev/null differ diff --git a/samples/community/Antibodyy/template.json b/samples/community/Antibodyy/template.json index 7e962bbf..d0ac2cce 100644 --- a/samples/community/Antibodyy/template.json +++ b/samples/community/Antibodyy/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 299, 398 ], @@ -7,6 +7,17 @@ 42, 42 ], + "preProcessors": [ + { + "name": "CropPage", + "options": { + "morphKernel": [ + 10, + 10 + ] + } + } + ], "fieldBlocks": { "MCQBlock1": { "fieldType": "QTYPE_MCQ5", @@ -20,16 +31,5 @@ "q1..6" ] } - }, - "preProcessors": [ - { - "name": "CropPage", - "options": { - "morphKernel": [ - 10, - 10 - ] - } - } - ] + } } diff --git a/samples/community/Sandeep-1507/NEET-2021/omr-1.png b/samples/community/Sandeep-1507/NEET-2021/omr-1.png new file mode 100644 index 00000000..1c449f6e Binary files /dev/null and b/samples/community/Sandeep-1507/NEET-2021/omr-1.png differ diff --git a/samples/community/Sandeep-1507/NEET-2021/omr-2.png b/samples/community/Sandeep-1507/NEET-2021/omr-2.png new file mode 100644 index 00000000..59555910 Binary files /dev/null and b/samples/community/Sandeep-1507/NEET-2021/omr-2.png differ diff --git a/samples/community/Sandeep-1507/NEET-2021/omr-3.png b/samples/community/Sandeep-1507/NEET-2021/omr-3.png new file mode 100644 index 00000000..ccfef00f Binary files /dev/null and b/samples/community/Sandeep-1507/NEET-2021/omr-3.png differ diff --git a/samples/community/Sandeep-1507/config.json b/samples/community/Sandeep-1507/config.json new file mode 100644 index 00000000..b8d0c0ff --- /dev/null +++ b/samples/community/Sandeep-1507/config.json @@ -0,0 +1,11 @@ +{ + "outputs": { + "display_image_dimensions": [ + 1000, + 1300 + ], + "save_detections": true, + "save_image_level": 2, + "show_image_level": 0 + } +} diff --git a/samples/community/Sandeep-1507/omr-1.png b/samples/community/Sandeep-1507/omr-1.png deleted file mode 100644 index 8c40655e..00000000 Binary files a/samples/community/Sandeep-1507/omr-1.png and /dev/null differ diff --git a/samples/community/Sandeep-1507/omr-2.png b/samples/community/Sandeep-1507/omr-2.png deleted file mode 100644 index aabef0a5..00000000 Binary files a/samples/community/Sandeep-1507/omr-2.png and /dev/null differ diff --git a/samples/community/Sandeep-1507/omr-3.png b/samples/community/Sandeep-1507/omr-3.png deleted file mode 100644 index 8f1cb5c8..00000000 Binary files a/samples/community/Sandeep-1507/omr-3.png and /dev/null differ diff --git a/samples/community/Sandeep-1507/template.json b/samples/community/Sandeep-1507/template.json index b35404e2..906d3120 100644 --- a/samples/community/Sandeep-1507/template.json +++ b/samples/community/Sandeep-1507/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 1189, 1682 ], @@ -7,6 +7,10 @@ 15, 15 ], + "processingImageShape": [ + 820, + 666 + ], "preProcessors": [ { "name": "GaussianBlur", diff --git a/samples/community/Shamanth/omr_sheet_01.png b/samples/community/Shamanth/omr_sheet_01.png index ead17dd1..82d9bd49 100644 Binary files a/samples/community/Shamanth/omr_sheet_01.png and b/samples/community/Shamanth/omr_sheet_01.png differ diff --git a/samples/community/Shamanth/template.json b/samples/community/Shamanth/template.json index fb18d975..5f5df589 100644 --- a/samples/community/Shamanth/template.json +++ b/samples/community/Shamanth/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 300, 400 ], @@ -7,6 +7,7 @@ 20, 20 ], + "preProcessors": [], "fieldBlocks": { "MCQBlock1": { "fieldType": "QTYPE_MCQ4", @@ -20,6 +21,5 @@ "bubblesGap": 56, "labelsGap": 46 } - }, - "preProcessors": [] + } } diff --git a/samples/community/UPSC-mock/answer_key.jpg b/samples/community/UPSC-mock/answer_key.jpg deleted file mode 100644 index 7a229c1b..00000000 Binary files a/samples/community/UPSC-mock/answer_key.jpg and /dev/null differ diff --git a/samples/community/UPSC-mock/config.json b/samples/community/UPSC-mock/config.json deleted file mode 100644 index 0edf9d22..00000000 --- a/samples/community/UPSC-mock/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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/scan-angles/angle-1.jpg b/samples/community/UPSC-mock/scan-angles/angle-1.jpg deleted file mode 100644 index 9c75d7be..00000000 Binary files a/samples/community/UPSC-mock/scan-angles/angle-1.jpg and /dev/null differ diff --git a/samples/community/UPSC-mock/scan-angles/angle-2.jpg b/samples/community/UPSC-mock/scan-angles/angle-2.jpg deleted file mode 100644 index edab6060..00000000 Binary files a/samples/community/UPSC-mock/scan-angles/angle-2.jpg and /dev/null differ diff --git a/samples/community/UPSC-mock/scan-angles/angle-3.jpg b/samples/community/UPSC-mock/scan-angles/angle-3.jpg deleted file mode 100644 index f1da4af9..00000000 Binary files a/samples/community/UPSC-mock/scan-angles/angle-3.jpg and /dev/null differ diff --git a/samples/community/UmarFarootAPS/answer_key.csv b/samples/community/UmarFarootAPS/answer_key.csv index b40e8959..398cc14c 100644 --- a/samples/community/UmarFarootAPS/answer_key.csv +++ b/samples/community/UmarFarootAPS/answer_key.csv @@ -1,6 +1,6 @@ q1,C q2,C -q3,"D,E" +q3,"C,D" q4,"A,AB" q5,"[['A', '1'], ['B', '2']]" q6,"['A', 'B']" diff --git a/samples/community/UmarFarootAPS/config.json b/samples/community/UmarFarootAPS/config.json index fde8f5b2..23952194 100644 --- a/samples/community/UmarFarootAPS/config.json +++ b/samples/community/UmarFarootAPS/config.json @@ -1,11 +1,9 @@ { - "dimensions": { - "display_height": 960, - "display_width": 1280, - "processing_height": 1640, - "processing_width": 1332 - }, "outputs": { + "display_image_dimensions": [ + 1280, + 960 + ], "show_image_level": 0 } } diff --git a/samples/community/UmarFarootAPS/evaluation.json b/samples/community/UmarFarootAPS/evaluation.json index 14a3db25..0ef28865 100644 --- a/samples/community/UmarFarootAPS/evaluation.json +++ b/samples/community/UmarFarootAPS/evaluation.json @@ -1,8 +1,7 @@ { "source_type": "csv", "options": { - "answer_key_csv_path": "answer_key.csv", - "should_explain_scoring": true + "answer_key_csv_path": "answer_key.csv" }, "marking_schemes": { "DEFAULT": { @@ -10,5 +9,8 @@ "incorrect": "0", "unmarked": "0" } + }, + "outputs_configuration": { + "should_explain_scoring": true } } diff --git a/samples/community/UmarFarootAPS/scans/scan-type-1.jpg b/samples/community/UmarFarootAPS/scans/scan-type-1.jpg index 0932b240..36b840de 100644 Binary files a/samples/community/UmarFarootAPS/scans/scan-type-1.jpg 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 index 2eef7c32..0e78a7aa 100644 Binary files a/samples/community/UmarFarootAPS/scans/scan-type-2.jpg 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 index f096f518..53c115e3 100644 --- a/samples/community/UmarFarootAPS/template.json +++ b/samples/community/UmarFarootAPS/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 2550, 3300 ], @@ -7,12 +7,20 @@ 32, 32 ], + "processingImageShape": [ + 1640, + 1332 + ], "preProcessors": [ { "name": "CropOnMarkers", "options": { - "relativePath": "omr_marker.jpg", - "sheetToMarkerWidthRatio": 17 + "type": "FOUR_MARKERS", + "referenceImage": "omr_marker.jpg", + "markerDimensions": [ + 40, + 40 + ] } } ], diff --git a/samples/community/ibrahimkilic/template.json b/samples/community/ibrahimkilic/template.json index f0cb9cf3..cdceea24 100644 --- a/samples/community/ibrahimkilic/template.json +++ b/samples/community/ibrahimkilic/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 299, 328 ], @@ -8,12 +8,14 @@ 20 ], "emptyValue": "no", + "preProcessors": [], "fieldBlocks": { "YesNoBlock1": { - "direction": "horizontal", + "fieldType": "CUSTOM", "bubbleValues": [ "yes" ], + "direction": "horizontal", "origin": [ 15, 55 @@ -25,6 +27,5 @@ "q1..5" ] } - }, - "preProcessors": [] + } } diff --git a/samples/experimental/1-timelines-and-dots/README.md b/samples/experimental/1-timelines-and-dots/README.md new file mode 100644 index 00000000..4b2643f8 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/README.md @@ -0,0 +1,31 @@ +## Sample OMRs +# What is a Dot (index point)? +We can use any blob-like unique shape on the OMR sheet to be used as the four index points aka Dots. +These 4 points provided at the 4 corners of the OMR sheet are the most important components of an OMR sheet. The accuracy of evaluation depends on the clarity of these 4 points in the scanned OMR sheet image. + +# What is a Timeline on an OMR sheet? + +In the old pattern, machine read sheets, were additional black marks placed at equal increments running throughout the length of the sheet on either side or on both sides. This strip of black marks is called a timeline which helps the OMR machine to identify the next row of bubbles. ([source](https://www.addmengroup.com/downloads/Addmen-OMR-Sheet-Design-Guide.pdf)) + +OMR software can use the timeline to properly locate the bubbles. So any changes in the timeline markers can generate wrong result. It is usually not larger than the size of bubbles + + + +# When should I use it? + +- If your OMR sheet has this timeline and if adding a custom marker(in four corner points) is not feasible, you can try evaluating your OMR sheet using this method. +- Since the preprocessor converts the dashed line into a filled rectangular blob and uses that for detection, you can use this preprocessor on any rectangular thick lines or thick dots that can be converted to distinguishable blobs. + +# How to use? +We have provided support for 4 configurations: "ONE_LINE_TWO_DOTS", "TWO_DOTS_ONE_LINE", "TWO_LINES", "FOUR_DOTS". +Depending on the configuration of your OMR sheet, choose the one that is applicable. + +Open the samples in this folder to know how to configure each type in detail. + +Also, for further tuning - we support 5 configurations for selecting the points out of the thick blobs: "CENTERS", "INNER_WIDTHS", "INNER_HEIGHTS", "INNER_CORNERS", "OUTER_CORNERS" + +### Drawbacks and Improvements +Currently OMRChecker requires the Dots and Lines to "stand out" from other marks in the sheet. If there's any noise nearby the marker including the print itself, the detection would fail. + + +Track improvements in this functionality [here](https://github.com/users/Udayraj123/projects/2?pane=issue&itemId=57863176). diff --git a/samples/experimental/1-timelines-and-dots/four-dots/config.json b/samples/experimental/1-timelines-and-dots/four-dots/config.json new file mode 100644 index 00000000..7ff11292 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/four-dots/config.json @@ -0,0 +1,9 @@ +{ + "outputs": { + "display_image_dimensions": [ + 2400, + 1800 + ], + "show_image_level": 0 + } +} diff --git a/samples/experimental/1-timelines-and-dots/four-dots/omr-four-dots.jpg b/samples/experimental/1-timelines-and-dots/four-dots/omr-four-dots.jpg new file mode 100644 index 00000000..f411c246 Binary files /dev/null and b/samples/experimental/1-timelines-and-dots/four-dots/omr-four-dots.jpg differ diff --git a/samples/experimental/1-timelines-and-dots/four-dots/template.json b/samples/experimental/1-timelines-and-dots/four-dots/template.json new file mode 100644 index 00000000..119dc240 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/four-dots/template.json @@ -0,0 +1,268 @@ +{ + "templateDimensions": [ + 2012, + 2420 + ], + "bubbleDimensions": [ + 30, + 25 + ], + "customLabels": { + "Subject Code": [ + "subjectCode1", + "subjectCode2" + ], + "Roll": [ + "roll1..10" + ] + }, + "processingImageShape": [ + 1280, + 906 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "type": "FOUR_DOTS", + "tuningOptions": { + "dotBlurKernel": [ + 3, + 3 + ], + "dotThreshold": 120 + }, + "topLeftDot": { + "origin": [ + 38, + 170 + ], + "dimensions": [ + 60, + 60 + ], + "margins": { + "top": 10, + "right": 10, + "bottom": 10, + "left": 10 + } + }, + "topRightDot": { + "origin": [ + 805, + 170 + ], + "dimensions": [ + 60, + 60 + ], + "margins": { + "top": 10, + "right": 10, + "bottom": 10, + "left": 10 + } + }, + "bottomRightDot": { + "origin": [ + 805, + 1092 + ], + "dimensions": [ + 60, + 60 + ], + "margins": { + "top": 10, + "right": 10, + "bottom": 10, + "left": 10 + } + }, + "bottomLeftDot": { + "origin": [ + 35, + 1090 + ], + "dimensions": [ + 60, + 60 + ], + "margins": { + "top": 10, + "right": 10, + "bottom": 10, + "left": 10 + } + } + } + } + ], + "fieldBlocks": { + "bookletNo": { + "origin": [ + 625, + 160 + ], + "bubblesGap": 90, + "labelsGap": 0, + "fieldLabels": [ + "bookletNo" + ], + "fieldType": "CUSTOM", + "bubbleValues": [ + "A", + "B", + "C", + "D" + ], + "direction": "vertical" + }, + "subjectCode": { + "origin": [ + 1022, + 115 + ], + "bubblesGap": 44.5, + "labelsGap": 51, + "fieldLabels": [ + "subjectCode1", + "subjectCode2" + ], + "fieldType": "QTYPE_INT" + }, + "roll": { + "origin": [ + 1385, + 115 + ], + "bubblesGap": 44.5, + "labelsGap": 53, + "fieldLabels": [ + "roll1..10" + ], + "fieldType": "QTYPE_INT" + }, + "q01block": { + "origin": [ + 490, + 673 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q1..10" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q11block": { + "origin": [ + 490, + 1110 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q11..20" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q21block": { + "origin": [ + 490, + 1550 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q21..30" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q31block": { + "origin": [ + 490, + 1990 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q31..40" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q41block": { + "origin": [ + 891, + 673 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q41..50" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q51block": { + "origin": [ + 891, + 1110 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q51..60" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q61block": { + "origin": [ + 891, + 1550 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q61..70" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q71block": { + "origin": [ + 891, + 1990 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q71..80" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q81block": { + "origin": [ + 1282, + 673 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q81..90" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q91block": { + "origin": [ + 1282, + 1110 + ], + "bubblesGap": 79.15, + "labelsGap": 43, + "fieldLabels": [ + "q91..100" + ], + "fieldType": "QTYPE_MCQ4" + } + } +} diff --git a/samples/experimental/1-timelines-and-dots/two-dots-one-line/config.json b/samples/experimental/1-timelines-and-dots/two-dots-one-line/config.json new file mode 100644 index 00000000..bace3082 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/two-dots-one-line/config.json @@ -0,0 +1,9 @@ +{ + "outputs": { + "display_image_dimensions": [ + 1000, + 1300 + ], + "show_image_level": 0 + } +} diff --git a/samples/experimental/1-timelines-and-dots/two-dots-one-line/omr-two-dots-one-line.jpg b/samples/experimental/1-timelines-and-dots/two-dots-one-line/omr-two-dots-one-line.jpg new file mode 100644 index 00000000..f962137d Binary files /dev/null and b/samples/experimental/1-timelines-and-dots/two-dots-one-line/omr-two-dots-one-line.jpg differ diff --git a/samples/experimental/1-timelines-and-dots/two-dots-one-line/template.json b/samples/experimental/1-timelines-and-dots/two-dots-one-line/template.json new file mode 100644 index 00000000..e81a184a --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/two-dots-one-line/template.json @@ -0,0 +1,259 @@ +{ + "templateDimensions": [ + 1189, + 1682 + ], + "bubbleDimensions": [ + 25, + 25 + ], + "processingImageShape": [ + 820, + 666 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ], + "dotThreshold": 120 + }, + "type": "TWO_DOTS_ONE_LINE", + "defaultSelector": "OUTER_CORNERS", + "topLeftDot": { + "origin": [ + 2, + 21 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 2 + } + }, + "bottomLeftDot": { + "origin": [ + 12, + 929 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 12 + } + }, + "rightLine": { + "origin": [ + 760, + 21 + ], + "dimensions": [ + 15, + 1000 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 20 + } + } + } + } + ], + "customLabels": { + "Roll_No": [ + "roll1..8" + ], + "Booklet_No": [ + "booklet1..7" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 14, + 220 + ], + "fieldLabels": [ + "roll1..8" + ], + "bubblesGap": 33.5, + "labelsGap": 28.5 + }, + "Booklet_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 295, + 220 + ], + "fieldLabels": [ + "booklet1..7" + ], + "bubblesGap": 33.5, + "labelsGap": 28.5 + }, + "Language_Codes": { + "fieldType": "CUSTOM", + "bubbleValues": [ + "M", + "N", + "O", + "P" + ], + "direction": "vertical", + "origin": [ + 800, + 260 + ], + "fieldLabels": [ + "language_code_1..2" + ], + "bubblesGap": 66.5, + "labelsGap": 220 + }, + "MCQBlock1a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q1..15" + ], + "bubblesGap": 26.7, + "labelsGap": 33.7, + "origin": [ + 237, + 630 + ] + }, + "MCQBlock1a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q16..30" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 232, + 1145 + ] + }, + "MCQBlock2a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q31..45" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 630 + ] + }, + "MCQBlock2a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q46..60" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 1145 + ] + }, + "MCQBlock3a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q61..75" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 633 + ] + }, + "MCQBlock3a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q76..90" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 1145 + ] + }, + "MCQBlock4a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q91..105" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 633 + ] + }, + "MCQBlock4a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q106..120" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 1145 + ] + }, + "MCQBlock5a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q121..135" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 633 + ] + }, + "MCQBlock5a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q136..150" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 1145 + ] + } + } +} diff --git a/samples/experimental/1-timelines-and-dots/two-lines/config.json b/samples/experimental/1-timelines-and-dots/two-lines/config.json new file mode 100644 index 00000000..7662ffdd --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/two-lines/config.json @@ -0,0 +1,12 @@ +{ + "thresholding": { + "GLOBAL_PAGE_THRESHOLD": 150 + }, + "outputs": { + "display_image_dimensions": [ + 1000, + 1300 + ], + "show_image_level": 0 + } +} diff --git a/samples/experimental/1-timelines-and-dots/two-lines/omr-two-lines.jpg b/samples/experimental/1-timelines-and-dots/two-lines/omr-two-lines.jpg new file mode 100644 index 00000000..034128c3 Binary files /dev/null and b/samples/experimental/1-timelines-and-dots/two-lines/omr-two-lines.jpg differ diff --git a/samples/experimental/1-timelines-and-dots/two-lines/template.json b/samples/experimental/1-timelines-and-dots/two-lines/template.json new file mode 100644 index 00000000..614d3556 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/two-lines/template.json @@ -0,0 +1,230 @@ +{ + "templateDimensions": [ + 1189, + 1682 + ], + "bubbleDimensions": [ + 25, + 25 + ], + "processingImageShape": [ + 820, + 666 + ], + "preProcessors": [ + { + "name": "GaussianBlur", + "options": { + "kSize": [ + 3, + 3 + ], + "sigmaX": 0 + } + }, + { + "name": "CropOnMarkers", + "options": { + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ] + }, + "type": "TWO_LINES", + "defaultSelector": "OUTER_CORNERS", + "leftLine": { + "origin": [ + 40, + 161 + ], + "dimensions": [ + 25, + 940 + ], + "margins": { + "top": 25, + "right": 20, + "bottom": 15, + "left": 20 + } + }, + "rightLine": { + "origin": [ + 760, + 121 + ], + "dimensions": [ + 25, + 980 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 20 + } + } + } + } + ], + "customLabels": { + "Name": [ + "name1..25" + ], + "RollNo": [ + "roll1..3" + ], + "CplNo": [ + "cpl1..3" + ] + }, + "fieldBlocks": { + "NameBlock": { + "fieldType": "CUSTOM", + "bubbleValues": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z" + ], + "direction": "vertical", + "origin": [ + 72, + 83 + ], + "bubbleDimensions": [ + 15, + 15 + ], + "fieldLabels": [ + "name1..25" + ], + "emptyValue": " ", + "bubblesGap": 23.45, + "labelsGap": 27.81 + }, + "RollNo": { + "fieldType": "QTYPE_INT", + "origin": [ + 1037, + 83 + ], + "bubbleDimensions": [ + 20, + 20 + ], + "fieldLabels": [ + "roll1..3" + ], + "bubblesGap": 23.45, + "labelsGap": 27.81 + }, + "CPLNo": { + "fieldType": "QTYPE_INT", + "origin": [ + 820, + 460 + ], + "bubbleDimensions": [ + 20, + 20 + ], + "fieldLabels": [ + "cpl1..5" + ], + "bubblesGap": 23.45, + "labelsGap": 60.81 + }, + "MCQBlock1_20": { + "fieldType": "QTYPE_MCQ4", + "origin": [ + 107, + 780 + ], + "fieldLabels": [ + "q1..20" + ], + "bubblesGap": 38, + "labelsGap": 46.09 + }, + "MCQBlock21_40": { + "fieldType": "QTYPE_MCQ4", + "origin": [ + 325, + 780 + ], + "fieldLabels": [ + "q21..40" + ], + "bubblesGap": 38, + "labelsGap": 46.09 + }, + "MCQBlock41_60": { + "fieldType": "QTYPE_MCQ4", + "origin": [ + 543, + 780 + ], + "fieldLabels": [ + "q41..60" + ], + "bubblesGap": 38, + "labelsGap": 46.09 + }, + "MCQBlock61_80": { + "fieldType": "QTYPE_MCQ4", + "origin": [ + 751, + 780 + ], + "fieldLabels": [ + "q61..80" + ], + "bubblesGap": 38, + "labelsGap": 46.09 + }, + "MCQBlock81_100": { + "fieldType": "QTYPE_MCQ4", + "origin": [ + 969, + 780 + ], + "fieldLabels": [ + "q81..100" + ], + "bubblesGap": 38, + "labelsGap": 46.09 + } + } +} diff --git a/samples/experimental/1-timelines-and-dots/warp-on-points/1-omr-warp.jpg b/samples/experimental/1-timelines-and-dots/warp-on-points/1-omr-warp.jpg new file mode 100644 index 00000000..f962137d Binary files /dev/null and b/samples/experimental/1-timelines-and-dots/warp-on-points/1-omr-warp.jpg differ diff --git a/samples/experimental/1-timelines-and-dots/warp-on-points/2-omr-warp.jpg b/samples/experimental/1-timelines-and-dots/warp-on-points/2-omr-warp.jpg new file mode 100644 index 00000000..3478f7e8 Binary files /dev/null and b/samples/experimental/1-timelines-and-dots/warp-on-points/2-omr-warp.jpg differ diff --git a/samples/experimental/1-timelines-and-dots/warp-on-points/config.json b/samples/experimental/1-timelines-and-dots/warp-on-points/config.json new file mode 100644 index 00000000..bace3082 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/warp-on-points/config.json @@ -0,0 +1,9 @@ +{ + "outputs": { + "display_image_dimensions": [ + 1000, + 1300 + ], + "show_image_level": 0 + } +} diff --git a/samples/experimental/1-timelines-and-dots/warp-on-points/template-accurate-areas.json b/samples/experimental/1-timelines-and-dots/warp-on-points/template-accurate-areas.json new file mode 100644 index 00000000..b66f37f3 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/warp-on-points/template-accurate-areas.json @@ -0,0 +1,260 @@ +{ + "templateDimensions": [ + 1189, + 1682 + ], + "bubbleDimensions": [ + 25, + 25 + ], + "processingImageShape": [ + 820, + 666 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "type": "TWO_DOTS_ONE_LINE", + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ], + "dotThreshold": 120, + "warpMethod": "HOMOGRAPHY" + }, + "defaultSelector": "OUTER_CORNERS", + "topLeftDot": { + "origin": [ + 2, + 21 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 2 + } + }, + "bottomLeftDot": { + "origin": [ + 12, + 929 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 35, + "left": 12 + } + }, + "rightLine": { + "origin": [ + 770, + 41 + ], + "dimensions": [ + 15, + 920 + ], + "margins": { + "top": 35, + "right": 20, + "bottom": 55, + "left": 20 + } + } + } + } + ], + "customLabels": { + "Roll_No": [ + "roll1..8" + ], + "Booklet_No": [ + "booklet1..7" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 14, + 220 + ], + "fieldLabels": [ + "roll1..8" + ], + "bubblesGap": 33.5, + "labelsGap": 28.5 + }, + "Booklet_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 295, + 220 + ], + "fieldLabels": [ + "booklet1..7" + ], + "bubblesGap": 33.5, + "labelsGap": 28.5 + }, + "Language_Codes": { + "fieldType": "CUSTOM", + "bubbleValues": [ + "M", + "N", + "O", + "P" + ], + "direction": "vertical", + "origin": [ + 800, + 260 + ], + "fieldLabels": [ + "language_code_1..2" + ], + "bubblesGap": 66.5, + "labelsGap": 220 + }, + "MCQBlock1a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q1..15" + ], + "bubblesGap": 26.7, + "labelsGap": 33.7, + "origin": [ + 237, + 630 + ] + }, + "MCQBlock1a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q16..30" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 232, + 1145 + ] + }, + "MCQBlock2a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q31..45" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 630 + ] + }, + "MCQBlock2a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q46..60" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 1145 + ] + }, + "MCQBlock3a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q61..75" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 633 + ] + }, + "MCQBlock3a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q76..90" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 1145 + ] + }, + "MCQBlock4a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q91..105" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 633 + ] + }, + "MCQBlock4a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q106..120" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 1145 + ] + }, + "MCQBlock5a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q121..135" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 633 + ] + }, + "MCQBlock5a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q136..150" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 1145 + ] + } + } +} diff --git a/samples/experimental/1-timelines-and-dots/warp-on-points/template-cmap.json b/samples/experimental/1-timelines-and-dots/warp-on-points/template-cmap.json new file mode 100644 index 00000000..1dd53c52 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/warp-on-points/template-cmap.json @@ -0,0 +1,282 @@ +{ + "templateDimensions": [ + 1189, + 1682 + ], + "bubbleDimensions": [ + 25, + 25 + ], + "processingImageShape": [ + 820, + 666 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "type": "TWO_DOTS_ONE_LINE", + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ], + "dotThreshold": 120, + "warpMethod": "REMAP" + }, + "defaultSelector": "OUTER_CORNERS", + "scanAreas": [ + { + "areaTemplate": "topLeftDot", + "areaDescription": { + "label": "AdditionalPoint", + "origin": [ + 48, + 376 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 2 + } + } + } + ], + "topLeftDot": { + "origin": [ + 2, + 21 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 2 + } + }, + "bottomLeftDot": { + "origin": [ + 12, + 929 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 35, + "left": 12 + } + }, + "rightLine": { + "origin": [ + 770, + 41 + ], + "dimensions": [ + 15, + 920 + ], + "margins": { + "top": 35, + "right": 20, + "bottom": 55, + "left": 20 + } + } + } + } + ], + "customLabels": { + "Roll_No": [ + "roll1..8" + ], + "Booklet_No": [ + "booklet1..7" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 7, + 235 + ], + "fieldLabels": [ + "roll1..8" + ], + "bubblesGap": 34.34, + "labelsGap": 28.5 + }, + "Booklet_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 290, + 235 + ], + "fieldLabels": [ + "booklet1..7" + ], + "bubblesGap": 34.34, + "labelsGap": 28.5 + }, + "Language_Codes": { + "fieldType": "CUSTOM", + "bubbleValues": [ + "M", + "N", + "O", + "P" + ], + "direction": "vertical", + "origin": [ + 800, + 267 + ], + "fieldLabels": [ + "language_code_1..2" + ], + "bubblesGap": 67, + "labelsGap": 220 + }, + "MCQBlock1a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q1..15" + ], + "bubblesGap": 26.7, + "labelsGap": 33.7, + "origin": [ + 235, + 644 + ] + }, + "MCQBlock1a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q16..30" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 232, + 1150 + ] + }, + "MCQBlock2a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q31..45" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 644 + ] + }, + "MCQBlock2a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q46..60" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 430, + 1150 + ] + }, + "MCQBlock3a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q61..75" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 640 + ] + }, + "MCQBlock3a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q76..90" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 625, + 1150 + ] + }, + "MCQBlock4a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q91..105" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 640 + ] + }, + "MCQBlock4a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q106..120" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 1150 + ] + }, + "MCQBlock5a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q121..135" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 640 + ] + }, + "MCQBlock5a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q136..150" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 1150 + ] + } + } +} diff --git a/samples/experimental/1-timelines-and-dots/warp-on-points/template-inaccurate-areas.json b/samples/experimental/1-timelines-and-dots/warp-on-points/template-inaccurate-areas.json new file mode 100644 index 00000000..de3d1f6f --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/warp-on-points/template-inaccurate-areas.json @@ -0,0 +1,260 @@ +{ + "templateDimensions": [ + 1189, + 1682 + ], + "bubbleDimensions": [ + 25, + 25 + ], + "processingImageShape": [ + 820, + 666 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ], + "dotThreshold": 120, + "warpMethod": "HOMOGRAPHY" + }, + "type": "TWO_DOTS_ONE_LINE", + "defaultSelector": "OUTER_CORNERS", + "topLeftDot": { + "origin": [ + 2, + 21 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 2 + } + }, + "bottomLeftDot": { + "origin": [ + 12, + 929 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 12 + } + }, + "rightLine": { + "origin": [ + 760, + 21 + ], + "dimensions": [ + 15, + 1000 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 20 + } + } + } + } + ], + "customLabels": { + "Roll_No": [ + "roll1..8" + ], + "Booklet_No": [ + "booklet1..7" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 14, + 220 + ], + "fieldLabels": [ + "roll1..8" + ], + "bubblesGap": 33.5, + "labelsGap": 28.5 + }, + "Booklet_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 295, + 220 + ], + "fieldLabels": [ + "booklet1..7" + ], + "bubblesGap": 33.5, + "labelsGap": 28.5 + }, + "Language_Codes": { + "fieldType": "CUSTOM", + "bubbleValues": [ + "M", + "N", + "O", + "P" + ], + "direction": "vertical", + "origin": [ + 800, + 260 + ], + "fieldLabels": [ + "language_code_1..2" + ], + "bubblesGap": 66.5, + "labelsGap": 220 + }, + "MCQBlock1a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q1..15" + ], + "bubblesGap": 26.7, + "labelsGap": 33.7, + "origin": [ + 237, + 630 + ] + }, + "MCQBlock1a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q16..30" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 232, + 1145 + ] + }, + "MCQBlock2a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q31..45" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 630 + ] + }, + "MCQBlock2a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q46..60" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 1145 + ] + }, + "MCQBlock3a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q61..75" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 633 + ] + }, + "MCQBlock3a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q76..90" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 1145 + ] + }, + "MCQBlock4a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q91..105" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 633 + ] + }, + "MCQBlock4a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q106..120" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 1145 + ] + }, + "MCQBlock5a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q121..135" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 633 + ] + }, + "MCQBlock5a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q136..150" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 1145 + ] + } + } +} diff --git a/samples/experimental/1-timelines-and-dots/warp-on-points/template-warp-stretched-point.json b/samples/experimental/1-timelines-and-dots/warp-on-points/template-warp-stretched-point.json new file mode 100644 index 00000000..c0dcc012 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/warp-on-points/template-warp-stretched-point.json @@ -0,0 +1,282 @@ +{ + "templateDimensions": [ + 1189, + 1682 + ], + "bubbleDimensions": [ + 25, + 25 + ], + "processingImageShape": [ + 820, + 666 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "type": "TWO_DOTS_ONE_LINE", + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ], + "dotThreshold": 120, + "warpMethod": "HOMOGRAPHY" + }, + "defaultSelector": "OUTER_CORNERS", + "scanAreas": [ + { + "areaTemplate": "topLeftDot", + "areaDescription": { + "label": "AdditionalPoint", + "origin": [ + 48, + 450 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 55, + "right": 20, + "bottom": 15, + "left": 2 + } + } + } + ], + "topLeftDot": { + "origin": [ + 2, + 21 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 2 + } + }, + "bottomLeftDot": { + "origin": [ + 12, + 929 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 35, + "left": 12 + } + }, + "rightLine": { + "origin": [ + 770, + 41 + ], + "dimensions": [ + 15, + 920 + ], + "margins": { + "top": 35, + "right": 20, + "bottom": 55, + "left": 20 + } + } + } + } + ], + "customLabels": { + "Roll_No": [ + "roll1..8" + ], + "Booklet_No": [ + "booklet1..7" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 14, + 220 + ], + "fieldLabels": [ + "roll1..8" + ], + "bubblesGap": 33.5, + "labelsGap": 28.5 + }, + "Booklet_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 295, + 220 + ], + "fieldLabels": [ + "booklet1..7" + ], + "bubblesGap": 33.5, + "labelsGap": 28.5 + }, + "Language_Codes": { + "fieldType": "CUSTOM", + "bubbleValues": [ + "M", + "N", + "O", + "P" + ], + "direction": "vertical", + "origin": [ + 800, + 260 + ], + "fieldLabels": [ + "language_code_1..2" + ], + "bubblesGap": 66.5, + "labelsGap": 220 + }, + "MCQBlock1a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q1..15" + ], + "bubblesGap": 26.7, + "labelsGap": 33.7, + "origin": [ + 237, + 630 + ] + }, + "MCQBlock1a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q16..30" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 232, + 1145 + ] + }, + "MCQBlock2a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q31..45" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 630 + ] + }, + "MCQBlock2a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q46..60" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 1145 + ] + }, + "MCQBlock3a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q61..75" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 633 + ] + }, + "MCQBlock3a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q76..90" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 1145 + ] + }, + "MCQBlock4a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q91..105" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 633 + ] + }, + "MCQBlock4a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q106..120" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 1145 + ] + }, + "MCQBlock5a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q121..135" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 633 + ] + }, + "MCQBlock5a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q136..150" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 1145 + ] + } + } +} diff --git a/samples/experimental/1-timelines-and-dots/warp-on-points/template.json b/samples/experimental/1-timelines-and-dots/warp-on-points/template.json new file mode 100644 index 00000000..1dd53c52 --- /dev/null +++ b/samples/experimental/1-timelines-and-dots/warp-on-points/template.json @@ -0,0 +1,282 @@ +{ + "templateDimensions": [ + 1189, + 1682 + ], + "bubbleDimensions": [ + 25, + 25 + ], + "processingImageShape": [ + 820, + 666 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "type": "TWO_DOTS_ONE_LINE", + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ], + "dotThreshold": 120, + "warpMethod": "REMAP" + }, + "defaultSelector": "OUTER_CORNERS", + "scanAreas": [ + { + "areaTemplate": "topLeftDot", + "areaDescription": { + "label": "AdditionalPoint", + "origin": [ + 48, + 376 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 2 + } + } + } + ], + "topLeftDot": { + "origin": [ + 2, + 21 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 15, + "left": 2 + } + }, + "bottomLeftDot": { + "origin": [ + 12, + 929 + ], + "dimensions": [ + 50, + 50 + ], + "margins": { + "top": 15, + "right": 20, + "bottom": 35, + "left": 12 + } + }, + "rightLine": { + "origin": [ + 770, + 41 + ], + "dimensions": [ + 15, + 920 + ], + "margins": { + "top": 35, + "right": 20, + "bottom": 55, + "left": 20 + } + } + } + } + ], + "customLabels": { + "Roll_No": [ + "roll1..8" + ], + "Booklet_No": [ + "booklet1..7" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 7, + 235 + ], + "fieldLabels": [ + "roll1..8" + ], + "bubblesGap": 34.34, + "labelsGap": 28.5 + }, + "Booklet_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 290, + 235 + ], + "fieldLabels": [ + "booklet1..7" + ], + "bubblesGap": 34.34, + "labelsGap": 28.5 + }, + "Language_Codes": { + "fieldType": "CUSTOM", + "bubbleValues": [ + "M", + "N", + "O", + "P" + ], + "direction": "vertical", + "origin": [ + 800, + 267 + ], + "fieldLabels": [ + "language_code_1..2" + ], + "bubblesGap": 67, + "labelsGap": 220 + }, + "MCQBlock1a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q1..15" + ], + "bubblesGap": 26.7, + "labelsGap": 33.7, + "origin": [ + 235, + 644 + ] + }, + "MCQBlock1a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q16..30" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 232, + 1150 + ] + }, + "MCQBlock2a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q31..45" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 435, + 644 + ] + }, + "MCQBlock2a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q46..60" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 430, + 1150 + ] + }, + "MCQBlock3a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q61..75" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 630, + 640 + ] + }, + "MCQBlock3a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q76..90" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 625, + 1150 + ] + }, + "MCQBlock4a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q91..105" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 640 + ] + }, + "MCQBlock4a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q106..120" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 823, + 1150 + ] + }, + "MCQBlock5a1": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q121..135" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 640 + ] + }, + "MCQBlock5a2": { + "fieldType": "QTYPE_MCQ4", + "fieldLabels": [ + "q136..150" + ], + "bubblesGap": 26.7, + "labelsGap": 34.2, + "origin": [ + 1022, + 1150 + ] + } + } +} diff --git a/samples/sample3/README.md b/samples/experimental/2-template-shifts/README.md similarity index 87% rename from samples/sample3/README.md rename to samples/experimental/2-template-shifts/README.md index d764c8b3..c04d7fe9 100644 --- a/samples/sample3/README.md +++ b/samples/experimental/2-template-shifts/README.md @@ -7,8 +7,9 @@ Link to an explainer with a real life example: [Google Drive](https://drive.goog ## 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 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. This can also ### 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. @@ -23,7 +24,7 @@ Below are some examples of the GSM ranges: ## 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. +- 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 diff --git a/samples/experimental/2-template-shifts/colored-thick-sheet/rgb-100-gsm.jpg b/samples/experimental/2-template-shifts/colored-thick-sheet/rgb-100-gsm.jpg new file mode 100644 index 00000000..ba991897 Binary files /dev/null and b/samples/experimental/2-template-shifts/colored-thick-sheet/rgb-100-gsm.jpg differ diff --git a/samples/sample3/colored-thick-sheet/template.json b/samples/experimental/2-template-shifts/colored-thick-sheet/template.json similarity index 98% rename from samples/sample3/colored-thick-sheet/template.json rename to samples/experimental/2-template-shifts/colored-thick-sheet/template.json index 743723d6..c481a61d 100644 --- a/samples/sample3/colored-thick-sheet/template.json +++ b/samples/experimental/2-template-shifts/colored-thick-sheet/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 1800, 2400 ], @@ -7,6 +7,17 @@ 23, 20 ], + "preProcessors": [ + { + "name": "CropPage", + "options": { + "morphKernel": [ + 10, + 10 + ] + } + } + ], "fieldBlocks": { "q01block": { "origin": [ @@ -128,16 +139,5 @@ ], "fieldType": "QTYPE_MCQ4" } - }, - "preProcessors": [ - { - "name": "CropPage", - "options": { - "morphKernel": [ - 10, - 10 - ] - } - } - ] + } } diff --git a/samples/experimental/2-template-shifts/xeroxed-thin-sheet/grayscale-80-gsm.jpg b/samples/experimental/2-template-shifts/xeroxed-thin-sheet/grayscale-80-gsm.jpg new file mode 100644 index 00000000..83ea45b9 Binary files /dev/null and b/samples/experimental/2-template-shifts/xeroxed-thin-sheet/grayscale-80-gsm.jpg differ diff --git a/samples/sample3/xeroxed-thin-sheet/template.json b/samples/experimental/2-template-shifts/xeroxed-thin-sheet/template.json similarity index 98% rename from samples/sample3/xeroxed-thin-sheet/template.json rename to samples/experimental/2-template-shifts/xeroxed-thin-sheet/template.json index 8aa4e41c..9095323d 100644 --- a/samples/sample3/xeroxed-thin-sheet/template.json +++ b/samples/experimental/2-template-shifts/xeroxed-thin-sheet/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 1800, 2400 ], @@ -7,6 +7,17 @@ 23, 20 ], + "preProcessors": [ + { + "name": "CropPage", + "options": { + "morphKernel": [ + 10, + 10 + ] + } + } + ], "fieldBlocks": { "q01block": { "origin": [ @@ -128,16 +139,5 @@ ], "fieldType": "QTYPE_MCQ4" } - }, - "preProcessors": [ - { - "name": "CropPage", - "options": { - "morphKernel": [ - 10, - 10 - ] - } - } - ] + } } diff --git a/samples/experimental/3-feature-based-alignment/config.json b/samples/experimental/3-feature-based-alignment/config.json new file mode 100644 index 00000000..3f984d6b --- /dev/null +++ b/samples/experimental/3-feature-based-alignment/config.json @@ -0,0 +1,9 @@ +{ + "outputs": { + "display_image_dimensions": [ + 2480, + 3508 + ], + "show_image_level": 0 + } +} diff --git a/samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_01.jpg b/samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_01.jpg new file mode 100644 index 00000000..328cc9fb Binary files /dev/null and b/samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_01.jpg differ diff --git a/samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_02.jpg b/samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_02.jpg new file mode 100644 index 00000000..6a2169f0 Binary files /dev/null and b/samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_02.jpg differ diff --git a/samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_03.jpg b/samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_03.jpg new file mode 100644 index 00000000..9752a0b9 Binary files /dev/null and b/samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_03.jpg differ diff --git a/samples/sample6/readme.md b/samples/experimental/3-feature-based-alignment/readme.md similarity index 94% rename from samples/sample6/readme.md rename to samples/experimental/3-feature-based-alignment/readme.md index 2a02ee90..6f1e56f9 100644 --- a/samples/sample6/readme.md +++ b/samples/experimental/3-feature-based-alignment/readme.md @@ -6,7 +6,8 @@ OMR is used to match student roll on final exam scripts. Scripts are scanned usi 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). +Two template files are given in the sample folder, one with feature-based alignment (template_fb_align), the other without (template_no_fb_align). Copy each one's content into template.json file to see their effect. + ## Additional Notes diff --git a/samples/experimental/3-feature-based-alignment/reference.png b/samples/experimental/3-feature-based-alignment/reference.png new file mode 100644 index 00000000..aa2f00de Binary files /dev/null and b/samples/experimental/3-feature-based-alignment/reference.png differ diff --git a/samples/sample6/template.json b/samples/experimental/3-feature-based-alignment/template.json similarity index 82% rename from samples/sample6/template.json rename to samples/experimental/3-feature-based-alignment/template.json index 57605a6c..056b4a70 100644 --- a/samples/sample6/template.json +++ b/samples/experimental/3-feature-based-alignment/template.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 2480, 3508 ], @@ -7,6 +7,10 @@ 42, 42 ], + "processingImageShape": [ + 2339, + 1653 + ], "preProcessors": [ { "name": "Levels", @@ -15,6 +19,14 @@ "high": 0.8 } }, + { + "name": "FeatureBasedAlignment", + "options": { + "reference": "reference.png", + "maxFeatures": 1000, + "2d": true + } + }, { "name": "GaussianBlur", "options": { @@ -45,6 +57,7 @@ "fieldLabels": [ "check_1" ], + "fieldType": "CUSTOM", "bubbleValues": [ "A", "B", @@ -66,6 +79,7 @@ "fieldLabels": [ "check_2" ], + "fieldType": "CUSTOM", "bubbleValues": [ "N", "R", @@ -86,6 +100,7 @@ "fieldLabels": [ "stu" ], + "fieldType": "CUSTOM", "bubbleValues": [ "U", "A", diff --git a/samples/sample6/template_fb_align.json b/samples/experimental/3-feature-based-alignment/template_fb_align.json similarity index 91% rename from samples/sample6/template_fb_align.json rename to samples/experimental/3-feature-based-alignment/template_fb_align.json index 7c0c2397..056b4a70 100644 --- a/samples/sample6/template_fb_align.json +++ b/samples/experimental/3-feature-based-alignment/template_fb_align.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 2480, 3508 ], @@ -7,6 +7,10 @@ 42, 42 ], + "processingImageShape": [ + 2339, + 1653 + ], "preProcessors": [ { "name": "Levels", @@ -53,6 +57,7 @@ "fieldLabels": [ "check_1" ], + "fieldType": "CUSTOM", "bubbleValues": [ "A", "B", @@ -74,6 +79,7 @@ "fieldLabels": [ "check_2" ], + "fieldType": "CUSTOM", "bubbleValues": [ "N", "R", @@ -94,6 +100,7 @@ "fieldLabels": [ "stu" ], + "fieldType": "CUSTOM", "bubbleValues": [ "U", "A", diff --git a/samples/sample6/template_no_fb_align.json b/samples/experimental/3-feature-based-alignment/template_no_fb_align.json similarity index 90% rename from samples/sample6/template_no_fb_align.json rename to samples/experimental/3-feature-based-alignment/template_no_fb_align.json index 57605a6c..3b218ee6 100644 --- a/samples/sample6/template_no_fb_align.json +++ b/samples/experimental/3-feature-based-alignment/template_no_fb_align.json @@ -1,5 +1,5 @@ { - "pageDimensions": [ + "templateDimensions": [ 2480, 3508 ], @@ -7,6 +7,10 @@ 42, 42 ], + "processingImageShape": [ + 2339, + 1653 + ], "preProcessors": [ { "name": "Levels", @@ -45,6 +49,7 @@ "fieldLabels": [ "check_1" ], + "fieldType": "CUSTOM", "bubbleValues": [ "A", "B", @@ -66,6 +71,7 @@ "fieldLabels": [ "check_2" ], + "fieldType": "CUSTOM", "bubbleValues": [ "N", "R", @@ -86,6 +92,7 @@ "fieldLabels": [ "stu" ], + "fieldType": "CUSTOM", "bubbleValues": [ "U", "A", diff --git a/samples/experimental/4-doc-refine/crop-curved/config.json b/samples/experimental/4-doc-refine/crop-curved/config.json new file mode 100644 index 00000000..23cb2dc4 --- /dev/null +++ b/samples/experimental/4-doc-refine/crop-curved/config.json @@ -0,0 +1,10 @@ +{ + "outputs": { + "display_image_dimensions": [ + 1800, + 2400 + ], + "show_image_level": 0, + "show_colored_outputs": true + } +} diff --git a/samples/experimental/4-doc-refine/crop-curved/omr-1.jpg b/samples/experimental/4-doc-refine/crop-curved/omr-1.jpg new file mode 100644 index 00000000..da099b5c Binary files /dev/null and b/samples/experimental/4-doc-refine/crop-curved/omr-1.jpg differ diff --git a/samples/experimental/4-doc-refine/crop-curved/template.json b/samples/experimental/4-doc-refine/crop-curved/template.json new file mode 100644 index 00000000..11598c0a --- /dev/null +++ b/samples/experimental/4-doc-refine/crop-curved/template.json @@ -0,0 +1,203 @@ +{ + "templateDimensions": [ + 1800, + 2400 + ], + "bubbleDimensions": [ + 30, + 25 + ], + "customLabels": { + "Subject Code": [ + "subjectCode1", + "subjectCode2" + ], + "Roll": [ + "roll1..10" + ] + }, + "processingImageShape": [ + 2400, + 1800 + ], + "preProcessors": [ + { + "name": "CropPage", + "options": { + "morphKernel": [ + 10, + 10 + ], + "tuningOptions": { + "warpMethod": "DOC_REFINE" + } + } + } + ], + "fieldBlocks": { + "bookletNo": { + "origin": [ + 595, + 545 + ], + "bubblesGap": 68, + "labelsGap": 0, + "fieldLabels": [ + "bookletNo" + ], + "fieldType": "CUSTOM", + "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" + } + } +} diff --git a/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/config.json b/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/config.json new file mode 100644 index 00000000..65e184cf --- /dev/null +++ b/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/config.json @@ -0,0 +1,15 @@ +{ + "dimensions": { + "display_image_shape": [ + 2480, + 1640 + ], + "processing_image_shape": [ + 820, + 666 + ] + }, + "outputs": { + "show_image_level": 0 + } +} diff --git a/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/scan-1.png b/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/scan-1.png new file mode 100644 index 00000000..af6171c2 Binary files /dev/null and b/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/scan-1.png differ diff --git a/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/scan-2.png b/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/scan-2.png new file mode 100644 index 00000000..59555910 Binary files /dev/null and b/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/scan-2.png differ diff --git a/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/template.json b/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/template.json new file mode 100644 index 00000000..b35404e2 --- /dev/null +++ b/samples/experimental/4-doc-refine/two-lines-curved/NEET-auto-shift/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/experimental/4-doc-refine/two-lines-curved/ProtyardSample/config.json b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/config.json new file mode 100644 index 00000000..f5559bc0 --- /dev/null +++ b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/config.json @@ -0,0 +1,16 @@ +{ + "thresholding": { + "MIN_JUMP": 15, + "MIN_JUMP_SURPLUS_FOR_GLOBAL_FALLBACK": 10, + "GLOBAL_PAGE_THRESHOLD": 150, + "CONFIDENT_JUMP_SURPLUS_FOR_DISPARITY": 25 + }, + "outputs": { + "display_image_dimensions": [ + 3508, + 2480 + ], + "show_image_level": 0, + "save_image_metrics": true + } +} diff --git a/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/sample_sigma.jpg b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/sample_sigma.jpg new file mode 100644 index 00000000..4b8f4095 Binary files /dev/null and b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/sample_sigma.jpg differ diff --git a/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template-docrefine.json b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template-docrefine.json new file mode 100644 index 00000000..849c334d --- /dev/null +++ b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template-docrefine.json @@ -0,0 +1,182 @@ +{ + "processingImageShape": [ + 1654, + 2339 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "warpMethod": "DOC_REFINE", + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ] + }, + "type": "TWO_LINES", + "defaultSelector": "OUTER_CORNERS", + "leftLine": { + "origin": [ + 30, + 21 + ], + "dimensions": [ + 15, + 1090 + ], + "margins": { + "left": 20, + "right": 20, + "top": 15, + "bottom": 15 + } + }, + "rightLine": { + "origin": [ + 750, + 21 + ], + "dimensions": [ + 15, + 1090 + ], + "margins": { + "left": 20, + "right": 20, + "top": 15, + "bottom": 15 + } + } + } + } + ], + "templateDimensions": [ + 1654, + 2339 + ], + "bubbleDimensions": [ + 35, + 35 + ], + "customLabels": { + "Registration_No": [ + "reg1..10" + ], + "Roll_No": [ + "roll1..6" + ], + "Exam_Code": [ + "code1..3" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 107, + 580 + ], + "fieldLabels": [ + "roll1..6" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50.2 + }, + "Student_ID": { + "fieldType": "QTYPE_INT", + "origin": [ + 471, + 580 + ], + "fieldLabels": [ + "reg1..10" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50 + }, + "Exam_ID": { + "fieldType": "QTYPE_INT", + "origin": [ + 1029, + 580 + ], + "fieldLabels": [ + "code1..3" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50 + }, + "q01block": { + "origin": [ + 158, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q1..20" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q02block": { + "origin": [ + 459, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q21..40" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q03block": { + "origin": [ + 760, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q41..60" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q04block": { + "origin": [ + 1060, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q61..80" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q05block": { + "origin": [ + 1363, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q81..100" + ], + "fieldType": "QTYPE_MCQ4" + } + } +} diff --git a/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template-homography.json b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template-homography.json new file mode 100644 index 00000000..5029e5ea --- /dev/null +++ b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template-homography.json @@ -0,0 +1,182 @@ +{ + "processingImageShape": [ + 1654, + 2339 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "warpMethod": "HOMOGRAPHY", + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ] + }, + "type": "TWO_LINES", + "defaultSelector": "OUTER_CORNERS", + "leftLine": { + "origin": [ + 30, + 21 + ], + "dimensions": [ + 15, + 1090 + ], + "margins": { + "left": 20, + "right": 20, + "top": 15, + "bottom": 15 + } + }, + "rightLine": { + "origin": [ + 750, + 21 + ], + "dimensions": [ + 15, + 1090 + ], + "margins": { + "left": 20, + "right": 20, + "top": 15, + "bottom": 15 + } + } + } + } + ], + "templateDimensions": [ + 1654, + 2339 + ], + "bubbleDimensions": [ + 35, + 35 + ], + "customLabels": { + "Registration_No": [ + "reg1..10" + ], + "Roll_No": [ + "roll1..6" + ], + "Exam_Code": [ + "code1..3" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 107, + 580 + ], + "fieldLabels": [ + "roll1..6" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50.2 + }, + "Student_ID": { + "fieldType": "QTYPE_INT", + "origin": [ + 471, + 580 + ], + "fieldLabels": [ + "reg1..10" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50 + }, + "Exam_ID": { + "fieldType": "QTYPE_INT", + "origin": [ + 1029, + 580 + ], + "fieldLabels": [ + "code1..3" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50 + }, + "q01block": { + "origin": [ + 158, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q1..20" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q02block": { + "origin": [ + 459, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q21..40" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q03block": { + "origin": [ + 760, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q41..60" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q04block": { + "origin": [ + 1060, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q61..80" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q05block": { + "origin": [ + 1363, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q81..100" + ], + "fieldType": "QTYPE_MCQ4" + } + } +} diff --git a/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template-remap.json b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template-remap.json new file mode 100644 index 00000000..0c6e004c --- /dev/null +++ b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template-remap.json @@ -0,0 +1,182 @@ +{ + "processingImageShape": [ + 1654, + 2339 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "warpMethod": "REMAP", + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ] + }, + "type": "TWO_LINES", + "defaultSelector": "OUTER_CORNERS", + "leftLine": { + "origin": [ + 30, + 21 + ], + "dimensions": [ + 15, + 1090 + ], + "margins": { + "left": 20, + "right": 20, + "top": 15, + "bottom": 15 + } + }, + "rightLine": { + "origin": [ + 750, + 21 + ], + "dimensions": [ + 15, + 1090 + ], + "margins": { + "left": 20, + "right": 20, + "top": 15, + "bottom": 15 + } + } + } + } + ], + "templateDimensions": [ + 1654, + 2339 + ], + "bubbleDimensions": [ + 35, + 35 + ], + "customLabels": { + "Registration_No": [ + "reg1..10" + ], + "Roll_No": [ + "roll1..6" + ], + "Exam_Code": [ + "code1..3" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 107, + 580 + ], + "fieldLabels": [ + "roll1..6" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50.2 + }, + "Student_ID": { + "fieldType": "QTYPE_INT", + "origin": [ + 471, + 580 + ], + "fieldLabels": [ + "reg1..10" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50 + }, + "Exam_ID": { + "fieldType": "QTYPE_INT", + "origin": [ + 1029, + 580 + ], + "fieldLabels": [ + "code1..3" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50 + }, + "q01block": { + "origin": [ + 158, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q1..20" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q02block": { + "origin": [ + 459, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q21..40" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q03block": { + "origin": [ + 760, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q41..60" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q04block": { + "origin": [ + 1060, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q61..80" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q05block": { + "origin": [ + 1363, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q81..100" + ], + "fieldType": "QTYPE_MCQ4" + } + } +} diff --git a/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template.json b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template.json new file mode 100644 index 00000000..849c334d --- /dev/null +++ b/samples/experimental/4-doc-refine/two-lines-curved/ProtyardSample/template.json @@ -0,0 +1,182 @@ +{ + "processingImageShape": [ + 1654, + 2339 + ], + "preProcessors": [ + { + "name": "CropOnMarkers", + "options": { + "processingImageShape": [ + 1132, + 800 + ], + "tuningOptions": { + "warpMethod": "DOC_REFINE", + "lineKernel": [ + 2, + 10 + ], + "dotKernel": [ + 3, + 3 + ] + }, + "type": "TWO_LINES", + "defaultSelector": "OUTER_CORNERS", + "leftLine": { + "origin": [ + 30, + 21 + ], + "dimensions": [ + 15, + 1090 + ], + "margins": { + "left": 20, + "right": 20, + "top": 15, + "bottom": 15 + } + }, + "rightLine": { + "origin": [ + 750, + 21 + ], + "dimensions": [ + 15, + 1090 + ], + "margins": { + "left": 20, + "right": 20, + "top": 15, + "bottom": 15 + } + } + } + } + ], + "templateDimensions": [ + 1654, + 2339 + ], + "bubbleDimensions": [ + 35, + 35 + ], + "customLabels": { + "Registration_No": [ + "reg1..10" + ], + "Roll_No": [ + "roll1..6" + ], + "Exam_Code": [ + "code1..3" + ] + }, + "fieldBlocks": { + "Roll_No": { + "fieldType": "QTYPE_INT", + "origin": [ + 107, + 580 + ], + "fieldLabels": [ + "roll1..6" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50.2 + }, + "Student_ID": { + "fieldType": "QTYPE_INT", + "origin": [ + 471, + 580 + ], + "fieldLabels": [ + "reg1..10" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50 + }, + "Exam_ID": { + "fieldType": "QTYPE_INT", + "origin": [ + 1029, + 580 + ], + "fieldLabels": [ + "code1..3" + ], + "emptyValue": "no", + "bubblesGap": 52, + "labelsGap": 50 + }, + "q01block": { + "origin": [ + 158, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q1..20" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q02block": { + "origin": [ + 459, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q21..40" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q03block": { + "origin": [ + 760, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q41..60" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q04block": { + "origin": [ + 1060, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q61..80" + ], + "fieldType": "QTYPE_MCQ4" + }, + "q05block": { + "origin": [ + 1363, + 1207 + ], + "bubblesGap": 50.5, + "labelsGap": 51.3, + "fieldLabels": [ + "q81..100" + ], + "fieldType": "QTYPE_MCQ4" + } + } +} diff --git a/samples/sample1/MobileCamera/sheet1.jpg b/samples/sample1/MobileCamera/sheet1.jpg deleted file mode 100644 index 7af758de..00000000 Binary files a/samples/sample1/MobileCamera/sheet1.jpg and /dev/null differ diff --git a/samples/sample2/AdrianSample/adrian_omr.png b/samples/sample2/AdrianSample/adrian_omr.png deleted file mode 100644 index 69a53823..00000000 Binary files a/samples/sample2/AdrianSample/adrian_omr.png and /dev/null differ diff --git a/samples/sample2/AdrianSample/adrian_omr_2.png b/samples/sample2/AdrianSample/adrian_omr_2.png deleted file mode 100644 index d8db0994..00000000 Binary files a/samples/sample2/AdrianSample/adrian_omr_2.png and /dev/null differ diff --git a/samples/sample3/colored-thick-sheet/rgb-100-gsm.jpg b/samples/sample3/colored-thick-sheet/rgb-100-gsm.jpg deleted file mode 100644 index 4a34731a..00000000 Binary files a/samples/sample3/colored-thick-sheet/rgb-100-gsm.jpg and /dev/null differ diff --git a/samples/sample3/xeroxed-thin-sheet/grayscale-80-gsm.jpg b/samples/sample3/xeroxed-thin-sheet/grayscale-80-gsm.jpg deleted file mode 100644 index 3e0e3efa..00000000 Binary files a/samples/sample3/xeroxed-thin-sheet/grayscale-80-gsm.jpg and /dev/null differ diff --git a/samples/sample4/IMG_20201116_143512.jpg b/samples/sample4/IMG_20201116_143512.jpg deleted file mode 100644 index 2f591c7a..00000000 Binary files a/samples/sample4/IMG_20201116_143512.jpg and /dev/null differ diff --git a/samples/sample4/IMG_20201116_150717658.jpg b/samples/sample4/IMG_20201116_150717658.jpg deleted file mode 100644 index 592fe425..00000000 Binary files a/samples/sample4/IMG_20201116_150717658.jpg and /dev/null differ diff --git a/samples/sample4/IMG_20201116_150750830.jpg b/samples/sample4/IMG_20201116_150750830.jpg deleted file mode 100644 index 6846ef31..00000000 Binary files a/samples/sample4/IMG_20201116_150750830.jpg and /dev/null differ diff --git a/samples/sample4/evaluation.json b/samples/sample4/evaluation.json deleted file mode 100644 index ec9b5071..00000000 --- a/samples/sample4/evaluation.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 - }, - "marking_schemes": { - "DEFAULT": { - "correct": "3", - "incorrect": "-1", - "unmarked": "0" - } - } -} diff --git a/samples/sample5/ScanBatch1/camscanner-1.jpg b/samples/sample5/ScanBatch1/camscanner-1.jpg deleted file mode 100644 index 18d585d9..00000000 Binary files a/samples/sample5/ScanBatch1/camscanner-1.jpg and /dev/null differ diff --git a/samples/sample5/ScanBatch2/camscanner-2.jpg b/samples/sample5/ScanBatch2/camscanner-2.jpg deleted file mode 100644 index 2f5491be..00000000 Binary files a/samples/sample5/ScanBatch2/camscanner-2.jpg and /dev/null differ diff --git a/samples/sample6/config.json b/samples/sample6/config.json deleted file mode 100644 index c4979db0..00000000 --- a/samples/sample6/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "dimensions": { - "display_width": 2480, - "display_height": 3508, - "processing_width": 1653, - "processing_height": 2339 - }, - "outputs": { - "show_image_level": 0 - } -} diff --git a/samples/sample6/doc-scans/sample_roll_01.jpg b/samples/sample6/doc-scans/sample_roll_01.jpg deleted file mode 100644 index 25093a37..00000000 Binary files a/samples/sample6/doc-scans/sample_roll_01.jpg and /dev/null differ diff --git a/samples/sample6/doc-scans/sample_roll_02.jpg b/samples/sample6/doc-scans/sample_roll_02.jpg deleted file mode 100644 index a81ee4fc..00000000 Binary files a/samples/sample6/doc-scans/sample_roll_02.jpg and /dev/null differ diff --git a/samples/sample6/doc-scans/sample_roll_03.jpg b/samples/sample6/doc-scans/sample_roll_03.jpg deleted file mode 100644 index 94296f4e..00000000 Binary files a/samples/sample6/doc-scans/sample_roll_03.jpg and /dev/null differ diff --git a/samples/sample6/reference.png b/samples/sample6/reference.png deleted file mode 100644 index 5351267f..00000000 Binary files a/samples/sample6/reference.png and /dev/null differ diff --git a/scripts/convert_images_hook.py b/scripts/convert_images_hook.py new file mode 100755 index 00000000..255dbbc1 --- /dev/null +++ b/scripts/convert_images_hook.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Convert png images within the repository.""" + + +import argparse +import os + +from PIL import Image + + +def get_size_in_kb(path): + return os.path.getsize(path) / 1000 + + +def get_size_reduction(old_size, new_size): + percent = 100 * (new_size - old_size) / old_size + return f"({percent:.2f}%)" + + +def convert_image(image_path): + with Image.open(image_path) as image: + # Note: using hardcoded -4 as we're expected to receive .png or .PNG files only + new_image_path = f"{image_path[:-4]}.jpg" + if not image.mode == "RGB": + image = image.convert("RGB") + image.save(new_image_path, "JPEG", quality=90, optimize=True) + + return new_image_path + + +def convert_images_in_tree(args): + filenames = args.get("filenames", None) + trigger_size = args.get("trigger_size", None) + converted_count = 0 + for image_path in filenames: + old_size = get_size_in_kb(image_path) + if old_size <= trigger_size: + continue + + new_image_path = convert_image(image_path) + new_size = get_size_in_kb(new_image_path) + if new_size <= old_size: + print( + f"Converted png to jpg: {image_path}: {new_size:.2f}KB {get_size_reduction(old_size, new_size)}" + ) + converted_count += 1 + else: + print( + f"Skipping conversion for {image_path} as size is more than before ({new_size:.2f} KB > {old_size:.2f} KB)" + ) + os.remove(new_image_path) + + return converted_count + + +def parse_args(): + # construct the argument parse and parse the arguments + argparser = argparse.ArgumentParser() + + argparser.add_argument( + "--trigger-size", + default=200, + required=True, + type=int, + dest="trigger_size", + help="Specify minimum file size to trigger the hook.", + ) + argparser.add_argument("filenames", nargs="*", help="Files to optimize.") + + ( + args, + unknown, + ) = argparser.parse_known_args() + + args = vars(args) + + if len(unknown) > 0: + argparser.print_help() + raise Exception(f"\nError: Unknown arguments: {unknown}") + return args + + +if __name__ == "__main__": + args = parse_args() + + converted_count = convert_images_in_tree(args) + trigger_size = args["trigger_size"] + if converted_count > 0: + print( + f"Note: {converted_count} png images above {trigger_size}KB were converted to jpg.\nPlease manually remove the png files and add your commit again." + ) + exit(1) + else: + # print("All sample images are jpgs. Commit accepted.") + exit(0) diff --git a/scripts/resize_images_hook.py b/scripts/resize_images_hook.py new file mode 100755 index 00000000..5b287536 --- /dev/null +++ b/scripts/resize_images_hook.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Resize images within the repository.""" + + +import argparse +import os +import shutil + +from PIL import Image + + +def get_size_in_kb(path): + return os.path.getsize(path) / 1000 + + +def get_size_reduction(old_size, new_size): + percent = 100 * (new_size - old_size) / old_size + return f"({percent:.2f}%)" + + +def resize_util(image, u_width=None, u_height=None): + w, h = image.size[:2] + if u_height is None: + u_height = int(h * u_width / w) + if u_width is None: + u_width = int(w * u_height / h) + + if u_height == h and u_width == w: + # No need to resize + return image + return image.resize((int(u_width), int(u_height)), Image.LANCZOS) + + +def resize_image_and_save(image_path, max_width, max_height): + without_extension, extension = os.path.splitext(image_path) + temp_image_path = f"{without_extension}-tmp{extension}" + with Image.open(image_path) as image: + old_image_size = image.size[:2] + w, h = old_image_size + resized = False + + if h > max_height: + image = resize_util(image, u_height=max_height) + resized = True + + if w > max_width: + image = resize_util(image, u_width=max_width) + w, h = image.size[:2] + resized = True + + if resized: + image.save(temp_image_path) + return True, temp_image_path, old_image_size, image.size + + return False, temp_image_path, old_image_size, image.size + + +def resize_images_in_tree(args): + max_width = args.get("max_width", None) + max_height = args.get("max_height", None) + trigger_size = args.get("trigger_size", None) + filenames = args.get("filenames", None) + resized_count = 0 + for image_path in filenames: + old_size = get_size_in_kb(image_path) + if old_size <= trigger_size: + continue + ( + resized_and_saved, + temp_image_path, + old_image_size, + new_image_size, + ) = resize_image_and_save(image_path, max_width, max_height) + if resized_and_saved: + new_size = get_size_in_kb(temp_image_path) + if new_size >= old_size: + print( + f"Skipping resize for {image_path} as size is more than before ({new_size:.2f} KB > {old_size:.2f} KB)" + ) + os.remove(temp_image_path) + else: + shutil.move(temp_image_path, image_path) + print( + f"Resized: {image_path} {old_image_size} -> {new_image_size} with file size {new_size:.2f}KB {get_size_reduction(old_size, new_size)}" + ) + resized_count += 1 + return resized_count + + +def parse_args(): + # construct the argument parse and parse the arguments + argparser = argparse.ArgumentParser() + + argparser.add_argument("filenames", nargs="*", help="Files to optimize.") + + argparser.add_argument( + "--trigger-size", + default=200, + required=True, + type=int, + dest="trigger_size", + help="Specify minimum file size to trigger the hook.", + ) + + argparser.add_argument( + "--max-width", + default=1000, + required=True, + type=int, + dest="max_width", + help="Specify maximum width of the resized images.", + ) + argparser.add_argument( + "--max-height", + default=1000, + required=False, + type=int, + dest="max_height", + help="Specify maximum height of the resized images.", + ) + + ( + args, + unknown, + ) = argparser.parse_known_args() + + args = vars(args) + + if len(unknown) > 0: + argparser.print_help() + raise Exception(f"\nError: Unknown arguments: {unknown}") + + return args + + +if __name__ == "__main__": + args = parse_args() + resized_count = resize_images_in_tree(args) + if resized_count > 0: + print( + f"Note: {resized_count} images were resized. Please check, add and commit again." + ) + exit(1) + else: + # print("All images are of the appropriate size. Commit accepted.") + exit(0) diff --git a/src/__init__.py b/src/__init__.py index 7fc528b4..d6439c89 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,5 +1,7 @@ # https://docs.python.org/3/tutorial/modules.html#:~:text=The%20__init__.py,on%20the%20module%20search%20path. -from src.logger import logger + +# Note: This import is added to the root __init__.py to adjust for perceived loading time +from src.utils.logger import logger # It takes a few seconds for the imports logger.info(f"Loading OMRChecker modules...") diff --git a/src/algorithm/__init__.py b/src/algorithm/__init__.py new file mode 100644 index 00000000..81434d7e --- /dev/null +++ b/src/algorithm/__init__.py @@ -0,0 +1 @@ +# https://docs.python.org/3/tutorial/modules.html#:~:text=The%20__init__.py,on%20the%20module%20search%20path. diff --git a/src/algorithm/core.py b/src/algorithm/core.py new file mode 100644 index 00000000..9049e041 --- /dev/null +++ b/src/algorithm/core.py @@ -0,0 +1,1192 @@ +""" + + OMRChecker + + Author: Udayraj Deshmukh + Github: https://github.com/Udayraj123 + +""" + +import math +import os +import random +import re +from collections import defaultdict +from copy import copy as shallowcopy +from copy import deepcopy +from typing import Any + +import cv2 +import numpy as np +from matplotlib import colormaps, pyplot + +from src.algorithm.detection import BubbleMeanValue, FieldStdMeanValue +from src.algorithm.evaluation import get_evaluation_symbol +from src.schemas.constants import Verdict +from src.utils.constants import ( + BONUS_SYMBOL, + CLR_BLACK, + CLR_GRAY, + CLR_WHITE, + MARKED_TEMPLATE_TRANSPARENCY, + TEXT_SIZE, +) +from src.utils.image import ImageUtils +from src.utils.interaction import InteractionUtils +from src.utils.logger import logger + + +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, gray_image, colored_image, original_template + ): + config = self.tuning_config + + # Copy template for this instance op + template = shallowcopy(original_template) + + # Make deepcopy for only parts that are mutated by Processor + template.field_blocks = deepcopy(template.field_blocks) + + # resize to conform to common preprocessor input requirements + gray_image = ImageUtils.resize_to_shape( + gray_image, template.processing_image_shape + ) + if config.outputs.show_colored_outputs: + colored_image = ImageUtils.resize_to_shape( + colored_image, template.processing_image_shape + ) + + # run pre_processors in sequence + for pre_processor in template.pre_processors: + ( + out_omr, + colored_image, + next_template, + ) = pre_processor.resize_and_apply_filter( + gray_image, colored_image, template, file_path + ) + gray_image = out_omr + template = next_template + + if template.output_image_shape: + # resize to output requirements + gray_image = ImageUtils.resize_to_shape( + gray_image, template.output_image_shape + ) + if config.outputs.show_colored_outputs: + colored_image = ImageUtils.resize_to_shape( + colored_image, template.output_image_shape + ) + + return gray_image, colored_image, template + + def read_omr_response(self, image, template, file_path): + config = self.tuning_config + + img = image.copy() + # origDim = img.shape[:2] + img = ImageUtils.resize_to_dimensions(img, template.template_dimensions) + if img.max() > img.min(): + img = ImageUtils.normalize(img) + + # Move them to data class if needed + 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": []} + + # Get mean bubbleValues n other stats + ( + global_bubble_means_and_refs, + field_number_to_field_bubble_means, + global_field_bubble_means_stds, + ) = ( + [], + [], + [], + ) + for field_block in template.field_blocks: + # TODO: support for if field_block.field_type == "BARCODE": + + field_bubble_means_stds = [] + box_w, box_h = field_block.bubble_dimensions + for field in field_block.fields: + field_bubbles = field.field_bubbles + field_bubble_means = [] + for unit_bubble in field_bubbles: + x, y = unit_bubble.get_shifted_position(field_block.shifts) + rect = [y, y + box_h, x, x + box_w] + mean_value = cv2.mean(img[rect[0] : rect[1], rect[2] : rect[3]])[0] + field_bubble_means.append( + BubbleMeanValue(mean_value, unit_bubble) + # TODO: cross/check mark detection support (#167) + # detectCross(img, rect) ? 0 : 255 + ) + + # TODO: move std calculation inside the class + field_std = round( + np.std([item.mean_value for item in field_bubble_means]), 2 + ) + field_bubble_means_stds.append( + FieldStdMeanValue(field_std, field_block) + ) + + field_number_to_field_bubble_means.append(field_bubble_means) + global_bubble_means_and_refs.extend(field_bubble_means) + global_field_bubble_means_stds.extend(field_bubble_means_stds) + + ( + GLOBAL_PAGE_THRESHOLD, + MIN_JUMP, + JUMP_DELTA, + GLOBAL_THRESHOLD_MARGIN, + MIN_JUMP_SURPLUS_FOR_GLOBAL_FALLBACK, + CONFIDENT_JUMP_SURPLUS_FOR_DISPARITY, + ) = map( + config.thresholding.get, + [ + "GLOBAL_PAGE_THRESHOLD", + "MIN_JUMP", + "JUMP_DELTA", + "GLOBAL_THRESHOLD_MARGIN", + "MIN_JUMP_SURPLUS_FOR_GLOBAL_FALLBACK", + "CONFIDENT_JUMP_SURPLUS_FOR_DISPARITY", + ], + ) + # TODO: see if this is needed, then take from config.json + MIN_JUMP_STD = 15 + JUMP_DELTA_STD = 5 + global_default_std_threshold = 10 + global_std_thresh, _, _ = self.get_global_threshold( + global_field_bubble_means_stds, + global_default_std_threshold, + MIN_JUMP=MIN_JUMP_STD, + JUMP_DELTA=JUMP_DELTA_STD, + plot_title="Q-wise Std-dev Plot", + plot_show=config.outputs.show_image_level >= 6, + sort_in_plot=True, + ) + + # Note: Plotting takes Significant times here --> Change Plotting args + # to support show_image_level + global_threshold_for_template, j_low, j_high = self.get_global_threshold( + global_bubble_means_and_refs, # , looseness=4 + GLOBAL_PAGE_THRESHOLD, + plot_title=f"Mean Intensity Barplot: {file_path}", + MIN_JUMP=MIN_JUMP, + JUMP_DELTA=JUMP_DELTA, + plot_show=config.outputs.show_image_level >= 6, + sort_in_plot=True, + looseness=4, + ) + global_max_jump = j_high - j_low + + logger.info( + f"Thresholding:\t global_threshold_for_template: {round(global_threshold_for_template, 2)} \tglobal_std_THR: {round(global_std_thresh, 2)}\t{'(Looks like a Xeroxed OMR)' if (global_threshold_for_template == 255) else ''}" + ) + + per_omr_threshold_avg, absolute_field_number = 0, 0 + global_field_confidence_metrics = [] + for field_block in template.field_blocks: + block_field_number = 1 + key = field_block.name[:3] + box_w, box_h = field_block.bubble_dimensions + + for field in field_block.fields: + field_bubbles = field.field_bubbles + # All Black or All White case + no_outliers = ( + # TODO: rename mean_value in parent class to suit better + global_field_bubble_means_stds[absolute_field_number].mean_value + < global_std_thresh + ) + # print(absolute_field_number, field.field_label, + # global_field_bubble_means_stds[absolute_field_number].mean_value, "no_outliers:", no_outliers) + + field_bubble_means = field_number_to_field_bubble_means[ + absolute_field_number + ] + + ( + local_threshold_for_field, + local_max_jump, + ) = self.get_local_threshold( + field_bubble_means, + global_threshold_for_template, + no_outliers, + plot_title=f"Mean Intensity Barplot for {key}.{field.field_label}.block{block_field_number}", + plot_show=config.outputs.show_image_level >= 7, + ) + # TODO: move get_local_threshold into FieldDetection + field.local_threshold = local_threshold_for_field + # print(field.field_label,key,block_field_number, "THR: ", + # round(local_threshold_for_field,2)) + per_omr_threshold_avg += local_threshold_for_field + + # TODO: @staticmethod + def apply_field_detection( + field, + field_bubble_means, + local_threshold_for_field, + global_threshold_for_template, + ): + # TODO: see if deepclone is really needed given parent's instance + # field_bubble_means = [ + # deepcopy(bubble) for bubble in field_bubble_means + # ] + + bubbles_in_doubt = { + "by_disparity": [], + "by_jump": [], + "global_higher": [], + "global_lower": [], + } + + for bubble in field_bubble_means: + global_bubble_is_marked = ( + global_threshold_for_template > bubble.mean_value + ) + local_bubble_is_marked = ( + local_threshold_for_field > bubble.mean_value + ) + # TODO: refactor this mutation to a more appropriate place + bubble.is_marked = local_bubble_is_marked + # 1. Disparity in global/local threshold output + if global_bubble_is_marked != local_bubble_is_marked: + bubbles_in_doubt["by_disparity"].append(bubble) + + # 5. High confidence if the gap is very large compared to MIN_JUMP + is_global_jump_confident = ( + global_max_jump + > MIN_JUMP + CONFIDENT_JUMP_SURPLUS_FOR_DISPARITY + ) + is_local_jump_confident = ( + local_max_jump > MIN_JUMP + CONFIDENT_JUMP_SURPLUS_FOR_DISPARITY + ) + + # TODO: FieldDetection.bubbles = field_bubble_means + thresholds_string = f"global={round(global_threshold_for_template,2)} local={round(local_threshold_for_field,2)} global_margin={GLOBAL_THRESHOLD_MARGIN}" + jumps_string = f"global_max_jump={round(global_max_jump,2)} local_max_jump={round(local_max_jump,2)} MIN_JUMP={MIN_JUMP} SURPLUS={CONFIDENT_JUMP_SURPLUS_FOR_DISPARITY}" + if len(bubbles_in_doubt["by_disparity"]) > 0: + logger.warning( + f"found disparity in field: {field.field_label}", + list(map(str, bubbles_in_doubt["by_disparity"])), + thresholds_string, + ) + # 5.2 if the gap is very large compared to MIN_JUMP, but still there is disparity + if is_global_jump_confident: + logger.warning( + f"is_global_jump_confident but still has disparity", + jumps_string, + ) + elif is_local_jump_confident: + logger.warning( + f"is_local_jump_confident but still has disparity", + jumps_string, + ) + else: + # Note: debug logs are disabled by default + logger.debug( + f"party_matched for field: {field.field_label}", + thresholds_string, + ) + + # 5.1 High confidence if the gap is very large compared to MIN_JUMP + if is_local_jump_confident: + # Higher weightage for confidence + logger.debug( + f"is_local_jump_confident => increased confidence", + jumps_string, + ) + # No output disparity, but - + # 2.1 global threshold is "too close" to lower bubbles + bubbles_in_doubt["global_lower"] = [ + bubble + for bubble in field_bubble_means + if GLOBAL_THRESHOLD_MARGIN + > max( + GLOBAL_THRESHOLD_MARGIN, + global_threshold_for_template - bubble.mean_value, + ) + ] + + if len(bubbles_in_doubt["global_lower"]) > 0: + logger.warning( + 'bubbles_in_doubt["global_lower"]', + list(map(str, bubbles_in_doubt["global_lower"])), + ) + # 2.2 global threshold is "too close" to higher bubbles + bubbles_in_doubt["global_higher"] = [ + bubble + for bubble in field_bubble_means + if GLOBAL_THRESHOLD_MARGIN + > max( + GLOBAL_THRESHOLD_MARGIN, + bubble.mean_value - global_threshold_for_template, + ) + ] + + if len(bubbles_in_doubt["global_higher"]) > 0: + logger.warning( + 'bubbles_in_doubt["global_higher"]', + list(map(str, bubbles_in_doubt["global_higher"])), + ) + + # 3. local jump outliers are close to the configured min_jump but below it. + # Note: This factor indicates presence of cases like partially filled bubbles, + # mis-aligned box boundaries or some form of unintentional marking over the bubble + if len(field_bubble_means) > 1: + # TODO: move util + def get_jumps_in_bubble_means(field_bubble_means): + # get sorted array + sorted_field_bubble_means = sorted( + field_bubble_means, + ) + # get jumps + jumps_in_bubble_means = [] + previous_bubble = sorted_field_bubble_means[0] + previous_mean = previous_bubble.mean_value + for i in range(1, len(sorted_field_bubble_means)): + bubble = sorted_field_bubble_means[i] + current_mean = bubble.mean_value + jumps_in_bubble_means.append( + [ + round(current_mean - previous_mean, 2), + previous_bubble, + ] + ) + previous_bubble = bubble + previous_mean = current_mean + return jumps_in_bubble_means + + jumps_in_bubble_means = get_jumps_in_bubble_means( + field_bubble_means + ) + bubbles_in_doubt["by_jump"] = [ + bubble + for jump, bubble in jumps_in_bubble_means + if MIN_JUMP_SURPLUS_FOR_GLOBAL_FALLBACK + > max( + MIN_JUMP_SURPLUS_FOR_GLOBAL_FALLBACK, + MIN_JUMP - jump, + ) + ] + + if len(bubbles_in_doubt["by_jump"]) > 0: + logger.warning( + 'bubbles_in_doubt["by_jump"]', + list(map(str, bubbles_in_doubt["by_jump"])), + ) + logger.warning( + list(map(str, jumps_in_bubble_means)), + ) + + # TODO: aggregate the bubble metrics into the Field objects + # collect_bubbles_in_doubt(bubbles_in_doubt["by_disparity"], bubbles_in_doubt["global_higher"], bubbles_in_doubt["global_lower"], bubbles_in_doubt["by_jump"]) + confidence_metrics = { + "bubbles_in_doubt": bubbles_in_doubt, + "is_global_jump_confident": is_global_jump_confident, + "is_local_jump_confident": is_local_jump_confident, + "local_max_jump": local_max_jump, + "field_label": field.field_label, + } + return field_bubble_means, confidence_metrics + + field_bubble_means, confidence_metrics = apply_field_detection( + field, + field_bubble_means, + local_threshold_for_field, + global_threshold_for_template, + ) + global_field_confidence_metrics.append(confidence_metrics) + + detected_bubbles = [ + bubble_detection + for bubble_detection in field_bubble_means + if bubble_detection.is_marked + ] + for bubble_detection in detected_bubbles: + bubble = bubble_detection.item_reference + field_label, field_value = ( + bubble.field_label, + bubble.field_value, + ) + multi_marked_local = field_label in omr_response + # Apply concatenation + omr_response[field_label] = ( + (omr_response[field_label] + field_value) + if multi_marked_local + else field_value + ) + # TODO: generalize this into rolls -> identifier + # Only send rolls multi-marked in the directory () + # multi_roll = multi_marked_local and "Roll" in str(q) + + multi_marked = multi_marked or multi_marked_local + + # Empty value logic + if len(detected_bubbles) == 0: + field_label = field.field_label + omr_response[field_label] = field_block.empty_val + + # TODO: fix after all_c_box_vals is refactored + # if config.outputs.show_image_level >= 5: + # if key in all_c_box_vals: + # q_nums[key].append(f"{key[:2]}_c{str(block_field_number)}") + # all_c_box_vals[key].append(field_number_to_field_bubble_means[absolute_field_number]) + + block_field_number += 1 + absolute_field_number += 1 + # /for field_block + + # TODO: aggregate with weightages + # overall_confidence = self.get_confidence_metrics(fields_confidence) + # underconfident_fields = filter(lambda x: x.confidence < 0.8, fields_confidence) + + # TODO: Make the plot for underconfident_fields + # logger.info(name, overall_confidence, underconfident_fields) + + per_omr_threshold_avg /= absolute_field_number + per_omr_threshold_avg = round(per_omr_threshold_avg, 2) + + # TODO: refactor all_c_box_vals + # Box types + # if config.outputs.show_image_level >= 5: + # # pyplot.draw() + # f, axes = pyplot.subplots(len(all_c_box_vals), sharey=True) + # f.canvas.manager.set_window_title( + # f"Bubble Intensity by question type for {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 + # pyplot.tight_layout(pad=0.5) + # pyplot.show() + + return ( + omr_response, + multi_marked, + multi_roll, + field_number_to_field_bubble_means, + global_threshold_for_template, + global_field_confidence_metrics, + ) + + def draw_template_layout( + self, gray_image, colored_image, template, *args, **kwargs + ): + config = self.tuning_config + final_marked = self.draw_template_layout_util( + gray_image, "GRAYSCALE", template, *args, **kwargs + ) + + colored_final_marked = colored_image + if config.outputs.show_colored_outputs: + save_marked_dir = kwargs.get("save_marked_dir", None) + kwargs["save_marked_dir"] = ( + save_marked_dir.joinpath("colored") + if save_marked_dir is not None + else None + ) + colored_final_marked = self.draw_template_layout_util( + colored_final_marked, + "COLORED", + template, + *args, + **kwargs, + ) + + InteractionUtils.show( + "Final Marked Bubbles", + final_marked, + 0, + resize_to_height=True, + config=config, + ) + InteractionUtils.show( + "Final Marked Bubbles (Colored)", + colored_final_marked, + 1, + resize_to_height=True, + config=config, + ) + else: + InteractionUtils.show( + "Final Marked Bubbles", + final_marked, + 1, + resize_to_height=True, + config=config, + ) + + return final_marked, colored_final_marked + + def draw_template_layout_util( + self, + image, + image_type, + template, + file_id=None, + field_number_to_field_bubble_means=None, + save_marked_dir=None, + evaluation_meta=None, + evaluation_config=None, + shifted=False, + border=-1, + ): + config = self.tuning_config + + marked_image = ImageUtils.resize_to_dimensions( + image, template.template_dimensions + ) + transparent_layer = marked_image.copy() + should_draw_field_block_rectangles = field_number_to_field_bubble_means is None + should_draw_marked_bubbles = field_number_to_field_bubble_means is not None + should_draw_question_verdicts = ( + should_draw_marked_bubbles and evaluation_meta is not None + ) + should_save_detections = ( + # TODO: support colored images + image_type == "GRAYSCALE" + and config.outputs.save_detections + and save_marked_dir is not None + ) + + if should_draw_field_block_rectangles: + marked_image = self.draw_field_blocks_layout( + marked_image, template, shifted, shouldCopy=False, border=border + ) + return marked_image + + # TODO: move indent into smaller function + if should_draw_marked_bubbles: + marked_image = self.draw_marked_bubbles_with_evaluation_meta( + marked_image, + image_type, + template, + evaluation_meta, + evaluation_config, + field_number_to_field_bubble_means, + ) + + if should_save_detections: + # TODO: migrate support for multi_marked bucket based on identifier config + # if multi_roll: + # save_marked_dir = save_marked_dir.joinpath("_MULTI_") + image_path = str(save_marked_dir.joinpath(file_id)) + ImageUtils.save_img(image_path, marked_image) + + # if config.outputs.show_colored_outputs: + # TODO: add colored counterparts + + if should_draw_question_verdicts: + marked_image = self.draw_evaluation_summary( + marked_image, evaluation_meta, evaluation_config + ) + + # Prepare save images + if should_save_detections: + self.append_save_image(2, marked_image) + + # Translucent + cv2.addWeighted( + marked_image, + MARKED_TEMPLATE_TRANSPARENCY, + transparent_layer, + 1 - MARKED_TEMPLATE_TRANSPARENCY, + 0, + marked_image, + ) + + if should_save_detections: + for i in range(config.outputs.save_image_level): + self.save_image_stacks(i + 1, file_id, save_marked_dir) + + return marked_image + + def draw_field_blocks_layout( + self, image, template, shifted=True, shouldCopy=True, thickness=3, border=3 + ): + marked_image = image.copy() if shouldCopy else image + for field_block in template.field_blocks: + field_block_name, origin, dimensions, bubble_dimensions = map( + lambda attr: getattr(field_block, attr), + [ + "name", + "origin", + "dimensions", + "bubble_dimensions", + ], + ) + block_position = field_block.get_shifted_origin() if shifted else origin + + # Field block bounding rectangle + ImageUtils.draw_box( + marked_image, + block_position, + dimensions, + color=CLR_BLACK, + style="BOX_HOLLOW", + thickness_factor=0, + border=border, + ) + + for field in field_block.fields: + field_bubbles = field.field_bubbles + for unit_bubble in field_bubbles: + shifted_position = unit_bubble.get_shifted_position( + field_block.shifts + ) + ImageUtils.draw_box( + marked_image, + shifted_position, + bubble_dimensions, + thickness_factor=1 / 10, + border=border, + ) + + if shifted: + text_position = lambda size_x, size_y: ( + int(block_position[0] + dimensions[0] - size_x), + int(block_position[1] - size_y), + ) + text = f"({field_block.shifts}){field_block_name}" + ImageUtils.draw_text(marked_image, text, text_position, thickness) + + return marked_image + + def draw_marked_bubbles_with_evaluation_meta( + self, + marked_image, + # TODO: make cases for colors for image_type == "COLORED" and box shapes for image_type == "GRAYSCALE" + image_type, + template, + evaluation_meta, + evaluation_config, + field_number_to_field_bubble_means, + ): + should_draw_question_verdicts = evaluation_meta is not None + absolute_field_number = 0 + for field_block in template.field_blocks: + for field in field_block.fields: + field_label = field.field_label + field_bubble_means = field_number_to_field_bubble_means[ + absolute_field_number + ] + absolute_field_number += 1 + + question_has_verdict = ( + should_draw_question_verdicts + and field_label in evaluation_meta["questions_meta"] + ) + + # linked_custom_labels = [custom_label if (field_label in field_labels) else None for (custom_label, field_labels) in template.custom_labels.items()] + # is_part_of_custom_label = len(linked_custom_labels) > 0 + # TODO: replicate verdict: question_has_verdict = len([if field_label in questions_meta else None for field_label in linked_custom_labels]) + + if question_has_verdict: + question_meta = evaluation_meta["questions_meta"][field_label] + # Draw answer key items + self.draw_field_with_question_meta( + marked_image, field_bubble_means, field_block, question_meta + ) + else: + self.draw_field_bubbles_and_detections( + marked_image, field_bubble_means, field_block + ) + + return marked_image + + def draw_field_bubbles_and_detections( + self, marked_image, field_bubble_means, field_block + ): + bubble_dimensions = tuple(field_block.bubble_dimensions) + for bubble_detection in field_bubble_means: + bubble = bubble_detection.item_reference + shifted_position = tuple(bubble.get_shifted_position(field_block.shifts)) + field_value = str(bubble.field_value) + + if bubble_detection.is_marked: + ImageUtils.draw_box( + marked_image, + shifted_position, + bubble_dimensions, + color=CLR_GRAY, + style="BOX_FILLED", + # TODO: pass verdict_color here and insert symbol mapping here ( +, -, *) + thickness_factor=1 / 12, + ) + ImageUtils.draw_text( + marked_image, + field_value, + shifted_position, + text_size=TEXT_SIZE, + color=(20, 20, 10), + thickness=int(1 + 3.5 * TEXT_SIZE), + ) + else: + ImageUtils.draw_box( + marked_image, + shifted_position, + bubble_dimensions, + style="BOX_HOLLOW", + thickness_factor=1 / 10, + ) + + def draw_field_with_question_meta( + self, marked_image, field_bubble_means, field_block, question_meta + ): + bubble_dimensions = tuple(field_block.bubble_dimensions) + for bubble_detection in field_bubble_means: + bubble = bubble_detection.item_reference + shifted_position = tuple(bubble.get_shifted_position(field_block.shifts)) + field_value = str(bubble.field_value) + + # TODO: answer_type == AnswerType.MULTIPLE_CORRECT_WEIGHTED etc + + # TODO: support for custom_labels verdicts too! + if ( + # Convert answer_item to string to handle array of answers + # TODO: think of edge cases for this + field_value + in str(question_meta["answer_item"]) + ) or question_meta["bonus_type"]: + ImageUtils.draw_box( + marked_image, + shifted_position, + bubble_dimensions, + CLR_BLACK, + style="BOX_HOLLOW", + thickness_factor=0, + ) + if question_meta["bonus_type"] == "BONUS_ON_ATTEMPT": + if question_meta["question_schema_verdict"] == Verdict.UNMARKED: + position, position_diagonal = ImageUtils.draw_box( + marked_image, + shifted_position, + bubble_dimensions, + color=CLR_WHITE, + style="BOX_FILLED", + # TODO: pass verdict_color here and insert symbol mapping here ( +, -, *) + thickness_factor=1 / 12, + ) + + ImageUtils.draw_symbol( + marked_image, + BONUS_SYMBOL, + position, + position_diagonal, + ) + if question_meta["bonus_type"] == "BONUS_FOR_ALL": + position, position_diagonal = ImageUtils.draw_box( + marked_image, + shifted_position, + bubble_dimensions, + color=CLR_WHITE, + style="BOX_FILLED", + # TODO: pass verdict_color here and insert symbol mapping here ( +, -, *) + thickness_factor=1 / 12, + ) + + ImageUtils.draw_symbol( + marked_image, + "+", + position, + position_diagonal, + ) + + # TODO: take config for CROSS_TICKS vs BUBBLE_BOUNDARY and call appropriate util + if ( + bubble_detection.is_marked + or question_meta["bonus_type"] == "BONUS_FOR_ALL" + ): + position, position_diagonal = ImageUtils.draw_box( + marked_image, + shifted_position, + bubble_dimensions, + color=CLR_WHITE, + style="BOX_FILLED", + # TODO: pass verdict_color here and insert symbol mapping here ( +, -, *) + thickness_factor=1 / 12, + ) + + symbol = get_evaluation_symbol(question_meta) + ImageUtils.draw_symbol( + marked_image, + symbol, + position, + position_diagonal, + ) + # if(is_field_correct): + # # TODO: green + # pass + + # if(is_field_incorrect): + # # TODO: red + # pass + + # TODO: add condition only for marked_image and not for evaluation_image (new variable) + if bubble_detection.is_marked: + ImageUtils.draw_text( + marked_image, + field_value, + shifted_position, + text_size=TEXT_SIZE, + color=(20, 20, 10), + thickness=int(1 + 3.5 * TEXT_SIZE), + ) + + else: + ImageUtils.draw_box( + marked_image, + shifted_position, + bubble_dimensions, + style="BOX_HOLLOW", + thickness_factor=1 / 10, + ) + + def draw_evaluation_summary(self, marked_image, evaluation_meta, evaluation_config): + if evaluation_config.draw_answers_summary["enabled"]: + self.draw_answers_summary( + marked_image, evaluation_config, evaluation_meta["score"] + ) + + if evaluation_config.draw_score["enabled"]: + self.draw_score(marked_image, evaluation_config, evaluation_meta["score"]) + return marked_image + + def draw_answers_summary(self, marked_image, evaluation_config, score): + ( + formatted_answers_summary, + position, + size, + thickness, + ) = evaluation_config.get_formatted_answers_summary() + ImageUtils.draw_text( + marked_image, + formatted_answers_summary, + position, + text_size=size, + thickness=thickness, + ) + + def draw_score(self, marked_image, evaluation_config, score): + ( + formatted_score, + position, + size, + thickness, + ) = evaluation_config.get_formatted_score(score) + ImageUtils.draw_text( + marked_image, formatted_score, position, text_size=size, thickness=thickness + ) + + def get_global_threshold( + self, + bubble_means_and_refs, + global_default_threshold, + MIN_JUMP, + JUMP_DELTA, + plot_title, + plot_show, + 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_TWO_BUBBLES) to be big, + gives the smaller one (looseness factor) + + """ + # Sort the Q bubbleValues + sorted_bubble_means_and_refs = sorted( + bubble_means_and_refs, + ) + sorted_bubble_means = [item.mean_value for item in sorted_bubble_means_and_refs] + + # Find the FIRST LARGE GAP and set it as threshold: + ls = (looseness + 1) // 2 + l = len(sorted_bubble_means) - ls + max1, thr1 = MIN_JUMP, global_default_threshold + for i in range(ls, l): + jump = sorted_bubble_means[i + ls] - sorted_bubble_means[i - ls] + if jump > max1: + max1 = jump + thr1 = sorted_bubble_means[i - ls] + jump / 2 + + global_threshold_for_template, j_low, j_high = ( + thr1, + thr1 - max1 // 2, + thr1 + max1 // 2, + ) + + # NOTE: thr2 is deprecated, thus is JUMP_DELTA (used only in the plotting) + # TODO: make use of outliers using percentile logic and report the benchmarks + # 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 = sorted_bubble_means[i + ls] - sorted_bubble_means[i - ls] + new_thr = sorted_bubble_means[i - ls] + jump / 2 + if jump > max2 and abs(thr1 - new_thr) > JUMP_DELTA: + max2 = jump + thr2 = new_thr + # global_threshold_for_template = min(thr1,thr2) + + # TODO: maybe use plot_create flag when using plots in append_save_image + if plot_show: + _, ax = pyplot.subplots() + # TODO: move into individual utils + plot_means_and_refs = ( + sorted_bubble_means_and_refs if sort_in_plot else bubble_means_and_refs + ) + plot_values = [x.mean_value for x in plot_means_and_refs] + original_bin_names = [ + x.item_reference.plot_bin_name for x in plot_means_and_refs + ] + plot_labels = [x.item_reference_name for x in plot_means_and_refs] + + # TODO: move into individual utils + sorted_unique_bin_names, unique_label_indices = np.unique( + original_bin_names, return_inverse=True + ) + + plot_color_sampler = colormaps["Spectral"].resampled( + len(sorted_unique_bin_names) + ) + + shuffled_color_indices = random.sample( + list(unique_label_indices), len(unique_label_indices) + ) + # logger.info(list(zip(original_bin_names, shuffled_color_indices))) + plot_colors = plot_color_sampler( + [shuffled_color_indices[i] for i in unique_label_indices] + ) + # plot_colors = plot_color_sampler(unique_label_indices) + bar_container = ax.bar( + range(len(plot_means_and_refs)), + plot_values, + color=plot_colors, + label=plot_labels, + ) + + # TODO: move into individual utils + low = min(plot_values) + high = max(plot_values) + margin_factor = 0.1 + pyplot.ylim( + [ + math.ceil(low - margin_factor * (high - low)), + math.ceil(high + margin_factor * (high - low)), + ] + ) + + # Show field labels + ax.bar_label(bar_container, labels=plot_labels) + handles, labels = ax.get_legend_handles_labels() + # Naturally sorted unique legend labels https://stackoverflow.com/a/27512450/6242649 + ax.legend( + *zip( + *sorted( + [ + (h, l) + for i, (h, l) in enumerate(zip(handles, labels)) + if l not in labels[:i] + ], + key=lambda s: [ + int(t) if t.isdigit() else t.lower() + for t in re.split("(\\d+)", s[1]) + ], + ) + ) + ) + ax.set_title(plot_title) + ax.axhline( + global_threshold_for_template, color="green", ls="--", linewidth=5 + ).set_label("Global Threshold") + ax.axhline(thr2, color="red", ls=":", linewidth=3).set_label("THR2 Line") + # ax.axhline(j_low,color='red',ls='-.', linewidth=3) + # ax.axhline(j_high,color='red',ls='-.', linewidth=3).set_label("Boundary Line") + # ax.set_ylabel("Mean Intensity") + ax.set_ylabel("Values") + ax.set_xlabel("Position") + + pyplot.title(plot_title) + pyplot.show() + + return global_threshold_for_template, j_low, j_high + + def get_local_threshold( + self, + bubble_means_and_refs, + global_threshold_for_template, + no_outliers, + plot_title, + plot_show, + ): + """ + 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 bubble_means_list shall absolutely help here. Just run same function + on total bubble_means_list 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 + sorted_bubble_means_and_refs = sorted( + bubble_means_and_refs, + ) + sorted_bubble_means = [item.mean_value for item in sorted_bubble_means_and_refs] + # Small no of pts cases: + # base case: 1 or 2 pts + if len(sorted_bubble_means) < 3: + max1, thr1 = config.thresholding.MIN_JUMP, ( + global_threshold_for_template + if np.max(sorted_bubble_means) - np.min(sorted_bubble_means) + < config.thresholding.MIN_GAP_TWO_BUBBLES + else np.mean(sorted_bubble_means) + ) + else: + l = len(sorted_bubble_means) - 1 + max1, thr1 = config.thresholding.MIN_JUMP, 255 + for i in range(1, l): + jump = sorted_bubble_means[i + 1] - sorted_bubble_means[i - 1] + if jump > max1: + max1 = jump + thr1 = sorted_bubble_means[i - 1] + jump / 2 + # print(field_label,sorted_bubble_means,max1) + + confident_jump = ( + config.thresholding.MIN_JUMP + + config.thresholding.MIN_JUMP_SURPLUS_FOR_GLOBAL_FALLBACK + ) + + # TODO: seek improvement here because of the empty cases failing here(boundary walls) + # Can see erosion make a lot of sense here? + # If not confident, then only take help of global_threshold_for_template + if max1 < confident_jump: + # Threshold hack: local can never be 255 + if no_outliers or thr1 == 255: + # All Black or All White case + thr1 = global_threshold_for_template + else: + # TODO: Low confidence parameters here + pass + + # TODO: Make a common plot util to show local and global thresholds + if plot_show: + # TODO: add plot labels via the util + _, ax = pyplot.subplots() + ax.bar(range(len(sorted_bubble_means)), sorted_bubble_means) + thrline = ax.axhline(thr1, color="green", ls=("-."), linewidth=3) + thrline.set_label("Local Threshold") + thrline = ax.axhline( + global_threshold_for_template, 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() + pyplot.show() + return thr1, max1 + + def append_save_image(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_marked_dir): + config = self.tuning_config + if self.save_image_level >= int(key) and self.save_img_list[key] != []: + display_height, display_width = config.outputs.display_image_dimensions + name = os.path.splitext(filename)[0] + result = np.hstack( + tuple( + [ + ImageUtils.resize_util(img, u_height=display_height) + for img in self.save_img_list[key] + ] + ) + ) + result = ImageUtils.resize_util( + result, + min( + len(self.save_img_list[key]) * display_width // 3, + int(display_width * 2.5), + ), + ) + ImageUtils.save_img( + f"{save_marked_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/algorithm/detection.py b/src/algorithm/detection.py new file mode 100644 index 00000000..0369cfb3 --- /dev/null +++ b/src/algorithm/detection.py @@ -0,0 +1,86 @@ +""" + + OMRChecker + + Author: Udayraj Deshmukh + Github: https://github.com/Udayraj123 + +""" + +import functools + +from src.utils.parsing import default_dump + + +@functools.total_ordering +class MeanValueItem: + def __init__(self, mean_value, item_reference): + self.mean_value = mean_value + self.item_reference = item_reference + self.item_reference_name = item_reference.name + + def __str__(self): + return f"{self.item_reference} : {round(self.mean_value, 2)}" + + def _is_valid_operand(self, other): + return hasattr(other, "mean_value") and hasattr(other, "item_reference") + + def __eq__(self, other): + if not self._is_valid_operand(other): + return NotImplementedError + return self.mean_value == other.mean_value + + def __lt__(self, other): + if not self._is_valid_operand(other): + return NotImplementedError + return self.mean_value < other.mean_value + + +# TODO: merge with FieldDetection +class BubbleMeanValue(MeanValueItem): + def __init__(self, mean_value, unit_bubble): + super().__init__(mean_value, unit_bubble) + # TODO: move this into FieldDetection/give reference to it + self.is_marked = None + + def to_json(self): + # TODO: mini util for this loop + return { + key: default_dump(getattr(self, key)) + for key in [ + "is_marked", + # "shifted_position": unit_bubble.item_reference.get_shifted_position(field_block.shifts), + "item_reference_name", + "mean_value", + ] + } + + def __str__(self): + return f"{self.item_reference} : {round(self.mean_value, 2)} {'*' if self.is_marked else ''}" + + +# TODO: see if this one can be merged in above +class FieldStdMeanValue(MeanValueItem): + def __init__(self, mean_value, item_reference): + super().__init__(mean_value, item_reference) + + def to_json(self): + return { + key: default_dump(getattr(self, key)) + for key in [ + "item_reference_name", + "mean_value", + ] + } + + +# TODO: use this or merge it +class FieldDetection: + def __init__(self, field, confidence): + self.field = field + self.confidence = confidence + # TODO: use local_threshold from here + # self.local_threshold = None + + +# TODO: move the detection utils in this file. diff --git a/src/evaluation.py b/src/algorithm/evaluation.py similarity index 57% rename from src/evaluation.py rename to src/algorithm/evaluation.py index 67cdb40c..b0630086 100644 --- a/src/evaluation.py +++ b/src/algorithm/evaluation.py @@ -1,21 +1,35 @@ +""" + + OMRChecker + + Author: Udayraj Deshmukh + Github: https://github.com/Udayraj123 + +""" + import ast import os import re from copy import deepcopy -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, + SCHEMA_VERDICTS_IN_ORDER, + VERDICT_TO_SCHEMA_VERDICT, + VERDICTS_IN_ORDER, + AnswerType, + SchemaVerdict, + Verdict, ) +from src.utils.image import ImageUtils +from src.utils.logger import console, logger from src.utils.parsing import ( get_concatenated_response, - open_evaluation_with_validation, + open_evaluation_with_defaults, parse_fields, parse_float_or_fraction, ) @@ -24,9 +38,9 @@ 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) + self.answer_type = self.get_answer_type(answer_item) + self.parse_and_set_answer_item(answer_item) + self.set_local_marking_defaults(section_marking_scheme) @staticmethod def is_a_marking_score(answer_element): @@ -38,9 +52,9 @@ def is_a_marking_score(answer_element): 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): + def get_answer_type(self, answer_item): if self.is_standard_answer(answer_item): - return "standard" + return AnswerType.STANDARD elif type(answer_item) == list: if ( # Array of answer elements: ['A', 'B', 'AB'] @@ -50,7 +64,7 @@ def validate_and_get_answer_type(self, answer_item): for answers_or_score in answer_item ) ): - return "multiple-correct" + return AnswerType.MULTIPLE_CORRECT elif ( # Array of two-tuples: [['A', 1], ['B', 1], ['C', 3], ['AB', 2]] len(answer_item) >= 1 @@ -64,82 +78,95 @@ def validate_and_get_answer_type(self, answer_item): for allowed_answer, answer_score in answer_item ) ): - return "multiple-correct-weighted" + return AnswerType.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): + def parse_and_set_answer_item(self, answer_item): + answer_type = self.answer_type + if answer_type == AnswerType.MULTIPLE_CORRECT_WEIGHTED: + # parse answer scores for weighted answers + self.answer_item = [ + [allowed_answer, parse_float_or_fraction(answer_score)] + for allowed_answer, answer_score in answer_item + ] + else: + self.answer_item = answer_item + + def set_local_marking_defaults(self, section_marking_scheme): answer_type = self.answer_type self.empty_val = section_marking_scheme.empty_val - answer_item = self.answer_item + # Make a copy of section marking locally self.marking = deepcopy(section_marking_scheme.marking) - # TODO: reuse part of parse_scheme_marking here - - if answer_type == "standard": - # no local overrides + if answer_type == AnswerType.STANDARD: + # no local overrides needed pass - elif answer_type == "multiple-correct": + elif answer_type == AnswerType.MULTIPLE_CORRECT: + allowed_answers = self.answer_item # 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 - ) + for allowed_answer in allowed_answers: + self.marking[f"{Verdict.ANSWER_MATCH}-{allowed_answer}"] = self.marking[ + Verdict.ANSWER_MATCH + ] + elif answer_type == AnswerType.MULTIPLE_CORRECT_WEIGHTED: + for allowed_answer, parsed_answer_score in self.answer_item: + self.marking[ + f"{Verdict.ANSWER_MATCH}-{allowed_answer}" + ] = parsed_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"]: + if answer_type in [AnswerType.STANDARD, AnswerType.MULTIPLE_CORRECT]: return self.section_marking_scheme.section_key - elif answer_type == "multiple-correct-weighted": - return f"Custom: {self.marking}" + elif answer_type == AnswerType.MULTIPLE_CORRECT_WEIGHTED: + return f"Custom Weights: {self.marking}" def get_verdict_marking(self, marked_answer): answer_type = self.answer_type - question_verdict = "incorrect" - if answer_type == "standard": + question_verdict = Verdict.NO_ANSWER_MATCH + if answer_type == AnswerType.STANDARD: question_verdict = self.get_standard_verdict(marked_answer) - elif answer_type == "multiple-correct": + elif answer_type == AnswerType.MULTIPLE_CORRECT: question_verdict = self.get_multiple_correct_verdict(marked_answer) - elif answer_type == "multiple-correct-weighted": + elif answer_type == AnswerType.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" + return Verdict.UNMARKED elif marked_answer == allowed_answer: - return "correct" + return Verdict.ANSWER_MATCH else: - return "incorrect" + return Verdict.NO_ANSWER_MATCH def get_multiple_correct_verdict(self, marked_answer): allowed_answers = self.answer_item if marked_answer == self.empty_val: - return "unmarked" + return Verdict.UNMARKED elif marked_answer in allowed_answers: - return f"correct-{marked_answer}" + # ANSWER-MATCH-BC + return f"{Verdict.ANSWER_MATCH}-{marked_answer}" else: - return "incorrect" + return Verdict.NO_ANSWER_MATCH 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" + return Verdict.UNMARKED elif marked_answer in allowed_answers: - return f"correct-{marked_answer}" + return f"{Verdict.ANSWER_MATCH}-{marked_answer}" else: - return "incorrect" + return Verdict.NO_ANSWER_MATCH def __str__(self): return f"{self.answer_item}" @@ -153,27 +180,34 @@ def __init__(self, section_key, section_scheme, empty_val): # DEFAULT marking scheme follows a shorthand if section_key == DEFAULT_SECTION_KEY: self.questions = None - self.marking = self.parse_scheme_marking(section_scheme) + self.marking = self.parse_verdict_marking_from_scheme(section_scheme) else: self.questions = parse_fields(section_key, section_scheme["questions"]) - self.marking = self.parse_scheme_marking(section_scheme["marking"]) + self.marking = self.parse_verdict_marking_from_scheme( + section_scheme["marking"] + ) def __str__(self): return self.section_key - def parse_scheme_marking(self, marking): + def parse_verdict_marking_from_scheme(self, section_scheme): parsed_marking = {} - for verdict_type in MARKING_VERDICT_TYPES: - verdict_marking = parse_float_or_fraction(marking[verdict_type]) + for verdict in VERDICTS_IN_ORDER: + schema_verdict = VERDICT_TO_SCHEMA_VERDICT[verdict] + schema_verdict_marking = parse_float_or_fraction( + section_scheme[schema_verdict] + ) if ( - verdict_marking > 0 - and verdict_type == "incorrect" + schema_verdict_marking > 0 + and schema_verdict == SchemaVerdict.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." + f"Found positive marks({round(schema_verdict_marking, 2)}) for incorrect answer in the schema '{self.section_key}'. For Bonus sections, prefer adding a prefix 'BONUS_' to the scheme name." ) - parsed_marking[verdict_type] = verdict_marking + + # translated marking e.g. parsed[MATCHED] -> section_scheme['correct'] + parsed_marking[verdict] = schema_verdict_marking return parsed_marking @@ -184,21 +218,59 @@ def match_answer(self, marked_answer, answer_matcher): return verdict_marking, question_verdict + def get_bonus_type(self): + if self.marking[Verdict.NO_ANSWER_MATCH] <= 0: + return None + elif self.marking[Verdict.UNMARKED] > 0: + return "BONUS_FOR_ALL" + elif self.marking[Verdict.UNMARKED] == 0: + return "BONUS_ON_ATTEMPT" + else: + return None + 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"] + evaluation_json = open_evaluation_with_defaults(evaluation_path) + + ( + options, + outputs_configuration, + marking_schemes, + source_type, + ) = map( + evaluation_json.get, + [ + "options", + "outputs_configuration", + "marking_schemes", + "source_type", + ], ) - self.should_explain_scoring = options.get("should_explain_scoring", False) - self.has_non_default_section = False + + ( + self.should_explain_scoring, + self.draw_score, + self.draw_answers_summary, + self.verdict_colors, + ) = map( + outputs_configuration.get, + [ + "should_explain_scoring", + "draw_score", + "draw_answers_summary", + "verdict_colors", + ], + ) + + self.has_custom_marking = False self.exclude_files = [] - if source_type == "csv": + # TODO: separate handlers for these two type + if source_type == "image_and_csv" or 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}'.") @@ -210,42 +282,47 @@ def __init__(self, curr_dir, evaluation_path, template, tuning_config): csv_path, header=None, names=["question", "answer"], - converters={"question": str, "answer": self.parse_answer_column}, + converters={ + "question": lambda question: question.strip(), + "answer": self.parse_answer_column, + }, ) self.questions_in_order = answer_key["question"].to_list() answers_in_order = answer_key["answer"].to_list() elif not answer_key_image_path: - raise Exception(f"Answer key csv not found at '{csv_path}'") + raise Exception( + f"Answer key csv not found at '{csv_path}' and answer key image not provided to generate the csv" + ) else: + # Attempt answer key image to generate the csv image_path = str(curr_dir.joinpath(answer_key_image_path)) if not os.path.exists(image_path): raise Exception(f"Answer key image not found at '{image_path}'") - # self.exclude_files.append(image_path) + self.exclude_files.append(image_path) - logger.debug( + logger.info( f"Attempting to generate answer key from image: '{image_path}'" ) # TODO: use a common function for below changes? - in_omr = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) - in_omr = template.image_instance_ops.apply_preprocessors( - image_path, in_omr, template + gray_image, _colored_image = ImageUtils.read_image_util( + image_path, tuning_config + ) + ( + gray_image, + _colored_image, + template, + ) = template.image_instance_ops.apply_preprocessors( + image_path, gray_image, _colored_image, template ) - if in_omr is None: + if gray_image is None: raise Exception( f"Could not read answer key from image {image_path}" ) - ( - response_dict, - _final_marked, - _multi_marked, - _multi_roll, - ) = template.image_instance_ops.read_omr_response( - template, - image=in_omr, - name=image_path, - save_dir=None, + + (response_dict, *_) = template.image_instance_ops.read_omr_response( + gray_image, template, image_path ) omr_response = get_concatenated_response(response_dict, template) @@ -265,7 +342,7 @@ def __init__(self, curr_dir, evaluation_path, template, tuning_config): ] if len(empty_answered_questions) > 0: logger.error( - f"Found empty answers for questions: {empty_answered_questions}, empty value used: '{empty_val}'" + f"Found empty answers for the questions: {empty_answered_questions}, empty value used: '{empty_val}'" ) raise Exception( f"Found empty answers in file '{image_path}'. Please check your template again in the --setLayout mode." @@ -291,19 +368,7 @@ def __init__(self, curr_dir, evaluation_path, template, tuning_config): self.validate_questions(answers_in_order) - self.section_marking_schemes, self.question_to_scheme = {}, {} - for section_key, section_scheme in marking_schemes.items(): - section_marking_scheme = SectionMarkingScheme( - section_key, section_scheme, template.global_empty_val - ) - if section_key != DEFAULT_SECTION_KEY: - self.section_marking_schemes[section_key] = section_marking_scheme - for q in section_marking_scheme.questions: - # TODO: check the answer key for custom scheme here? - self.question_to_scheme[q] = section_marking_scheme - self.has_non_default_section = True - else: - self.default_marking_scheme = section_marking_scheme + self.set_marking_schemes(marking_schemes, template) self.validate_marking_schemes() @@ -311,59 +376,13 @@ def __init__(self, curr_dir, evaluation_path, template, tuning_config): answers_in_order ) self.validate_answers(answers_in_order, tuning_config) - - def __str__(self): - return str(self.path) - - # Externally called methods have higher abstraction level. - def prepare_and_validate_omr_response(self, omr_response): - self.reset_explanation_table() - - omr_response_questions = set(omr_response.keys()) - all_questions = set(self.questions_in_order) - missing_questions = sorted(all_questions.difference(omr_response_questions)) - if len(missing_questions) > 0: - logger.critical(f"Missing OMR response for: {missing_questions}") - raise Exception( - f"Some questions are missing in the OMR response for the given answer key" - ) - - prefixed_omr_response_questions = set( - [k for k in omr_response.keys() if k.startswith("q")] - ) - missing_prefixed_questions = sorted( - prefixed_omr_response_questions.difference(all_questions) - ) - if len(missing_prefixed_questions) > 0: - logger.warning( - f"No answer given for potential questions in OMR response: {missing_prefixed_questions}" - ) - - def match_answer_for_question(self, current_score, question, marked_answer): - answer_matcher = self.question_to_answer_matcher[question] - question_verdict, delta = answer_matcher.get_verdict_marking(marked_answer) - self.conditionally_add_explanation( - answer_matcher, - delta, - marked_answer, - question_verdict, - question, - current_score, - ) - return delta - - def conditionally_print_explanation(self): - if self.should_explain_scoring: - console.print(self.explanation_table, justify="center") - - def get_should_explain_scoring(self): - return self.should_explain_scoring - - def get_exclude_files(self): - return self.exclude_files + self.reset_evaluation() + self.validate_format_strings() @staticmethod def parse_answer_column(answer_column): + # Remove all whitespaces + answer_column = answer_column.replace(" ", "") if answer_column[0] == "[": # multiple-correct-weighted or multiple-correct parsed_answer = ast.literal_eval(answer_column) @@ -378,32 +397,15 @@ def parse_answer_column(answer_column): def parse_questions_in_order(self, questions_in_order): return parse_fields("questions_in_order", questions_in_order) - def validate_answers(self, answers_in_order, tuning_config): - answer_matcher_map = self.question_to_answer_matcher - if tuning_config.outputs.filter_out_multimarked_files: - multi_marked_answer = False - for question, answer_item in zip(self.questions_in_order, answers_in_order): - answer_type = answer_matcher_map[question].answer_type - if answer_type == "standard": - if len(answer_item) > 1: - multi_marked_answer = True - if answer_type == "multiple-correct": - for single_answer in answer_item: - if len(single_answer) > 1: - multi_marked_answer = True - break - if answer_type == "multiple-correct-weighted": - for single_answer, _answer_score in answer_item: - if len(single_answer) > 1: - multi_marked_answer = True - - if multi_marked_answer: - raise Exception( - f"Provided answer key contains multiple correct answer(s), but config.filter_out_multimarked_files is True. Scoring will get skipped." - ) - def validate_questions(self, answers_in_order): questions_in_order = self.questions_in_order + + # for question, answer in zip(questions_in_order, answers_in_order): + # if question in template.custom_label: + # # TODO: get all bubble values for the custom label + # if len(answer) != len(firstBubbleValues): + # logger.warning(f"The question {question} is a custom label and its answer does not have same length as the custom label") + len_questions_in_order, len_answers_in_order = len(questions_in_order), len( answers_in_order ) @@ -415,6 +417,20 @@ def validate_questions(self, answers_in_order): f"Unequal lengths for questions_in_order and answers_in_order ({len_questions_in_order} != {len_answers_in_order})" ) + def set_marking_schemes(self, marking_schemes, template): + self.section_marking_schemes, self.question_to_scheme = {}, {} + for section_key, section_scheme in marking_schemes.items(): + section_marking_scheme = SectionMarkingScheme( + section_key, section_scheme, template.global_empty_val + ) + if section_key != DEFAULT_SECTION_KEY: + self.section_marking_schemes[section_key] = section_marking_scheme + for q in section_marking_scheme.questions: + self.question_to_scheme[q] = section_marking_scheme + self.has_custom_marking = True + else: + self.default_marking_scheme = section_marking_scheme + def validate_marking_schemes(self): section_marking_schemes = self.section_marking_schemes section_questions = set() @@ -442,44 +458,114 @@ def parse_answers_and_map_questions(self, answers_in_order): section_marking_scheme = self.get_marking_scheme_for_question(question) answer_matcher = AnswerMatcher(answer_item, section_marking_scheme) question_to_answer_matcher[question] = answer_matcher - if ( - answer_matcher.answer_type == "multiple-correct-weighted" - and section_marking_scheme.section_key != DEFAULT_SECTION_KEY - ): - logger.warning( - f"The custom scheme '{section_marking_scheme}' will not apply to question '{question}' as it will use the given answer weights f{answer_item}" - ) + if answer_matcher.answer_type == AnswerType.MULTIPLE_CORRECT_WEIGHTED: + self.has_custom_marking = True + if section_marking_scheme.section_key != DEFAULT_SECTION_KEY: + logger.warning( + f"The custom scheme '{section_marking_scheme}' contains the question '{question}' but the given answer weights will be used instead: f{answer_item}" + ) return question_to_answer_matcher - # Then unfolding lower abstraction levels - def reset_explanation_table(self): - self.explanation_table = None - self.prepare_explanation_table() - - def prepare_explanation_table(self): - # TODO: provide a way to export this as csv/pdf - if not self.should_explain_scoring: - return - table = Table(title="Evaluation Explanation Table", show_lines=True) - table.add_column("Question") - table.add_column("Marked") - table.add_column("Answer(s)") - table.add_column("Verdict") - table.add_column("Delta") - table.add_column("Score") - # TODO: Add max and min score in explanation (row-wise and total) - if self.has_non_default_section: - table.add_column("Section") - self.explanation_table = table - def get_marking_scheme_for_question(self, question): return self.question_to_scheme.get(question, self.default_marking_scheme) + def validate_answers(self, answers_in_order, tuning_config): + answer_matcher_map = self.question_to_answer_matcher + if tuning_config.outputs.filter_out_multimarked_files: + contains_multi_marked_answer = False + for question, answer_item in zip(self.questions_in_order, answers_in_order): + answer_type = answer_matcher_map[question].answer_type + if answer_type == AnswerType.STANDARD: + if len(answer_item) > 1: + contains_multi_marked_answer = True + if answer_type == AnswerType.MULTIPLE_CORRECT: + for single_answer in answer_item: + if len(single_answer) > 1: + contains_multi_marked_answer = True + break + if answer_type == AnswerType.MULTIPLE_CORRECT_WEIGHTED: + for single_answer, _answer_score in answer_item: + if len(single_answer) > 1: + contains_multi_marked_answer = True + + if contains_multi_marked_answer: + raise Exception( + f"Provided answer key contains multiple correct answer(s), but config.filter_out_multimarked_files is True. Scoring will get skipped." + ) + + def validate_format_strings(self): + answers_summary_format_string = self.draw_answers_summary[ + "answers_summary_format_string" + ] + try: + answers_summary_format_string.format(**self.schema_verdict_counts) + except: # NOQA + raise Exception( + f"The format string should contain only allowed variables {SCHEMA_VERDICTS_IN_ORDER}. answers_summary_format_string={answers_summary_format_string}" + ) + + score_format_string = self.draw_score["score_format_string"] + try: + score_format_string.format(score=0) + except: # NOQA + raise Exception( + f"The format string should contain only allowed variables ['score']. score_format_string={score_format_string}" + ) + + def __str__(self): + return str(self.path) + + def get_exclude_files(self): + return self.exclude_files + + # Externally called methods have higher abstraction level. + def prepare_and_validate_omr_response(self, omr_response): + self.reset_evaluation() + + omr_response_questions = set(omr_response.keys()) + all_questions = set(self.questions_in_order) + missing_questions = sorted(all_questions.difference(omr_response_questions)) + if len(missing_questions) > 0: + logger.critical(f"Missing OMR response for: {missing_questions}") + raise Exception( + f"Some questions are missing in the OMR response for the given answer key" + ) + + prefixed_omr_response_questions = set( + [k for k in omr_response.keys() if k.startswith("q")] + ) + missing_prefixed_questions = sorted( + prefixed_omr_response_questions.difference(all_questions) + ) + if len(missing_prefixed_questions) > 0: + logger.warning( + f"No answer given for potential questions in OMR response: {missing_prefixed_questions}" + ) + + def match_answer_for_question(self, current_score, question, marked_answer): + answer_matcher = self.question_to_answer_matcher[question] + question_verdict, delta = answer_matcher.get_verdict_marking(marked_answer) + question_schema_verdict = self.get_schema_verdict( + answer_matcher, question_verdict, delta + ) + self.schema_verdict_counts[question_schema_verdict] += 1 + self.conditionally_add_explanation( + answer_matcher, + delta, + marked_answer, + question_schema_verdict, + question_verdict, + question, + current_score, + ) + return delta, question_verdict, answer_matcher, question_schema_verdict + def conditionally_add_explanation( self, answer_matcher, delta, marked_answer, + question_schema_verdict, question_verdict, question, current_score, @@ -493,12 +579,12 @@ def conditionally_add_explanation( question, marked_answer, str(answer_matcher), - str.title(question_verdict), + f"{question_schema_verdict.title()} ({question_verdict.title()})", str(round(delta, 2)), str(round(next_score, 2)), ( answer_matcher.get_section_explanation() - if self.has_non_default_section + if self.has_custom_marking else None ), ] @@ -506,17 +592,115 @@ def conditionally_add_explanation( ] self.explanation_table.add_row(*row) + def get_schema_verdict(self, answer_matcher, question_verdict, delta): + # Note: Negative custom weights should be considered as incorrect schema verdict(special case) + if ( + delta < 0 + and answer_matcher.answer_type == AnswerType.MULTIPLE_CORRECT_WEIGHTED + ): + return SchemaVerdict.INCORRECT + else: + for verdict in VERDICTS_IN_ORDER: + # using startswith to handle cases like matched-A + if question_verdict.startswith(verdict): + schema_verdict = VERDICT_TO_SCHEMA_VERDICT[verdict] + return schema_verdict + + def conditionally_print_explanation(self): + if self.should_explain_scoring: + console.print(self.explanation_table, justify="center") + + def get_should_explain_scoring(self): + return self.should_explain_scoring + + def get_formatted_answers_summary(self, answers_summary_format_string=None): + if answers_summary_format_string is None: + answers_summary_format_string = self.draw_answers_summary[ + "answers_summary_format_string" + ] + answers_format = answers_summary_format_string.format( + **self.schema_verdict_counts + ) + position = self.draw_answers_summary["position"] + size = self.draw_answers_summary["size"] + thickness = int(self.draw_answers_summary["size"] * 2) + return answers_format, position, size, thickness + + def get_formatted_score(self, score): + score_format = self.draw_score["score_format_string"].format(score=score) + position = self.draw_score["position"] + size = self.draw_score["size"] + thickness = int(self.draw_score["size"] * 2) + return score_format, position, size, thickness + + def reset_evaluation(self): + self.explanation_table = None + self.schema_verdict_counts = { + schema_verdict: 0 for schema_verdict in SCHEMA_VERDICTS_IN_ORDER + } + self.prepare_explanation_table() + + def prepare_explanation_table(self): + # TODO: provide a way to export this as csv/pdf + if not self.should_explain_scoring: + return + table = Table(title="Evaluation Explanation Table", show_lines=True) + table.add_column("Question") + table.add_column("Marked") + table.add_column("Answer(s)") + table.add_column("Verdict") + table.add_column("Delta") + table.add_column("Score") + # TODO: Add max and min score in explanation (row-wise and total) + if self.has_custom_marking: + table.add_column("Marking Scheme") + self.explanation_table = table + + +def get_evaluation_symbol(question_meta): + if question_meta["bonus_type"] is not None: + return "+" + if question_meta["delta"] > 0: + return "+" + if question_meta["delta"] < 0: + return "-" + else: + return "o" + def evaluate_concatenated_response(concatenated_response, evaluation_config): evaluation_config.prepare_and_validate_omr_response(concatenated_response) current_score = 0.0 + questions_meta = {} for question in evaluation_config.questions_in_order: marked_answer = concatenated_response[question] - delta = evaluation_config.match_answer_for_question( + ( + delta, + question_verdict, + answer_matcher, + question_schema_verdict, + ) = evaluation_config.match_answer_for_question( current_score, question, marked_answer ) + marking_scheme = evaluation_config.get_marking_scheme_for_question(question) + bonus_type = marking_scheme.get_bonus_type() current_score += delta + questions_meta[question] = { + "question_verdict": question_verdict, + "marked_answer": marked_answer, + "delta": delta, + "current_score": current_score, + "answer_item": answer_matcher.answer_item, + "answer_type": answer_matcher.answer_type, + "bonus_type": bonus_type, + "question_schema_verdict": question_schema_verdict, + } evaluation_config.conditionally_print_explanation() - - return current_score + evaluation_meta = { + "score": current_score, + "questions_meta": questions_meta, + # "schema_verdict_counts": evaluation_config.schema_verdict_counts, + "formatted_answers_summary": evaluation_config.get_formatted_answers_summary(), + } + return current_score, evaluation_meta diff --git a/src/template.py b/src/algorithm/template.py similarity index 68% rename from src/template.py rename to src/algorithm/template.py index 0acc4672..a3101a22 100644 --- a/src/template.py +++ b/src/algorithm/template.py @@ -6,12 +6,14 @@ Github: https://github.com/Udayraj123 """ -from src.constants import FIELD_TYPES -from src.core import ImageInstanceOps -from src.logger import logger + +from src.algorithm.core import ImageInstanceOps from src.processors.manager import PROCESSOR_MANAGER +from src.utils.constants import BUILTIN_FIELD_TYPES +from src.utils.logger import logger from src.utils.parsing import ( custom_sort_output_columns, + default_dump, open_template_with_defaults, parse_fields, ) @@ -30,8 +32,10 @@ def __init__(self, template_path, tuning_config): pre_processors_object, self.bubble_dimensions, self.global_empty_val, + self.template_dimensions, self.options, - self.page_dimensions, + self.processing_image_shape, + self.output_image_shape, ) = map( json_object.get, [ @@ -41,8 +45,11 @@ def __init__(self, template_path, tuning_config): "preProcessors", "bubbleDimensions", "emptyValue", + "templateDimensions", "options", - "pageDimensions", + "processingImageShape", + "outputImageShape", + # TODO: support for "sortFiles" key ], ) @@ -64,15 +71,19 @@ def __init__(self, template_path, tuning_config): def parse_output_columns(self, output_columns_array): self.output_columns = parse_fields(f"Output Columns", output_columns_array) + # TODO: make a child class TemplateLayout to keep template only about the layouts & json data? def setup_pre_processors(self, pre_processors_object, relative_dir): # load image pre_processors self.pre_processors = [] for pre_processor in pre_processors_object: - ProcessorClass = PROCESSOR_MANAGER.processors[pre_processor["name"]] - pre_processor_instance = ProcessorClass( + ImageTemplateProcessorClass = PROCESSOR_MANAGER.processors[ + pre_processor["name"] + ] + pre_processor_instance = ImageTemplateProcessorClass( options=pre_processor["options"], relative_dir=relative_dir, image_instance_ops=self.image_instance_ops, + default_processing_image_shape=self.processing_image_shape, ) self.pre_processors.append(pre_processor_instance) @@ -80,6 +91,7 @@ def setup_field_blocks(self, field_blocks_object): # Add field_blocks self.field_blocks = [] self.all_parsed_labels = set() + # TODO: add support for parsing "conditionalSets" with their matcher for block_name, field_block_object in field_blocks_object.items(): self.parse_and_add_field_block(block_name, field_block_object) @@ -151,17 +163,19 @@ def validate_template_columns(self, non_custom_columns, all_custom_columns): def parse_and_add_field_block(self, block_name, field_block_object): field_block_object = self.pre_fill_field_block(field_block_object) block_instance = FieldBlock(block_name, field_block_object) + # TODO: support custom field types like Barcode and OCR self.field_blocks.append(block_instance) self.validate_parsed_labels(field_block_object["fieldLabels"], block_instance) def pre_fill_field_block(self, field_block_object): - if "fieldType" in field_block_object: + field_type = field_block_object["fieldType"] + # TODO: support for if field_type == "BARCODE": + + if field_type in BUILTIN_FIELD_TYPES: field_block_object = { **field_block_object, - **FIELD_TYPES[field_block_object["fieldType"]], + **BUILTIN_FIELD_TYPES[field_type], } - else: - field_block_object = {**field_block_object, "fieldType": "__CUSTOM__"} return { "direction": "vertical", @@ -177,16 +191,17 @@ def validate_parsed_labels(self, field_labels, block_instance): ) field_labels_set = set(parsed_field_labels) if not self.all_parsed_labels.isdisjoint(field_labels_set): + overlap = field_labels_set.intersection(self.all_parsed_labels) # Note: in case of two fields pointing to same column, use a custom column instead of same field labels. logger.critical( f"An overlap found between field string: {field_labels} in block '{block_name}' and existing labels: {self.all_parsed_labels}" ) raise Exception( - f"The field strings for field block {block_name} overlap with other existing fields" + f"The field strings for field block {block_name} overlap with other existing fields: {overlap}" ) self.all_parsed_labels.update(field_labels_set) - page_width, page_height = self.page_dimensions + page_width, page_height = self.template_dimensions block_width, block_height = block_instance.dimensions [block_start_x, block_start_y] = block_instance.origin @@ -202,18 +217,55 @@ def validate_parsed_labels(self, field_labels, block_instance): or block_start_y < 0 ): raise Exception( - f"Overflowing field block '{block_name}' with origin {block_instance.origin} and dimensions {block_instance.dimensions} in template with dimensions {self.page_dimensions}" + f"Overflowing field block '{block_name}' with origin {block_instance.origin} and dimensions {block_instance.dimensions} in template with dimensions {self.template_dimensions}" ) def __str__(self): return str(self.path) + # Make the class serializable + def to_json(self): + return { + key: default_dump(getattr(self, key)) + for key in [ + "template_dimensions", + "field_blocks", + # Not needed as local props are overridden - + # "bubble_dimensions", + # 'options', + # "global_empty_val", + ] + } + class FieldBlock: def __init__(self, block_name, field_block_object): self.name = block_name - self.shift = 0 + # TODO: Move plot_bin_name into child class + self.plot_bin_name = block_name self.setup_field_block(field_block_object) + self.shifts = [0, 0] + + # Need this at runtime as we have allowed mutation of template via pre-processors + def get_shifted_origin(self): + origin, shifts = self.origin, self.shifts + return [origin[0] + shifts[0], origin[1] + shifts[1]] + + # Make the class serializable + def to_json(self): + return { + key: default_dump(getattr(self, key)) + for key in [ + "bubble_dimensions", + "dimensions", + "empty_val", + "fields", + "name", + "origin", + # "shifted_origin", + # "plot_bin_name", + ] + } def setup_field_block(self, field_block_object): # case mapping @@ -246,6 +298,8 @@ def setup_field_block(self, field_block_object): ) self.origin = origin self.bubble_dimensions = bubble_dimensions + # TODO: support barcode, ocr, etc custom field types + self.field_type = field_type self.calculate_block_dimensions( bubble_dimensions, bubble_values, @@ -292,22 +346,60 @@ def generate_bubble_grid( labels_gap, ): _h, _v = (1, 0) if (direction == "vertical") else (0, 1) - self.traverse_bubbles = [] + self.fields = [] # Generate the bubble grid lead_point = [float(self.origin[0]), float(self.origin[1])] for field_label in self.parsed_field_labels: bubble_point = lead_point.copy() field_bubbles = [] - for bubble_value in bubble_values: + for bubble_index, bubble_value in enumerate(bubble_values): field_bubbles.append( - Bubble(bubble_point.copy(), field_label, field_type, bubble_value) + FieldBubble( + bubble_point.copy(), + # TODO: move field_label into field_label_ref + field_label, + field_type, + bubble_value, + bubble_index, + ) ) bubble_point[_h] += bubbles_gap - self.traverse_bubbles.append(field_bubbles) + self.fields.append(Field(field_label, field_type, field_bubbles, direction)) lead_point[_v] += labels_gap -class Bubble: +class Field: + """ + Container for a Field on the OMR i.e. a group of FieldBubbles with a collective field_label + + """ + + def __init__(self, field_label, field_type, field_bubbles, direction): + self.field_label = field_label + self.field_type = field_type + self.field_bubbles = field_bubbles + self.direction = direction + # TODO: move local_threshold into child detection class + self.local_threshold = None + + def __str__(self): + return self.field_label + + # Make the class serializable + def to_json(self): + return { + key: default_dump(getattr(self, key)) + for key in [ + "field_label", + "field_type", + "direction", + "field_bubbles", + "local_threshold", + ] + } + + +class FieldBubble: """ Container for a Point Box on the OMR @@ -316,12 +408,35 @@ class Bubble: It can also correspond to a single digit of integer type Q (eg q5d1) """ - def __init__(self, pt, field_label, field_type, field_value): + def __init__(self, pt, field_label, field_type, field_value, bubble_index): + self.name = f"{field_label}_{field_value}" + self.plot_bin_name = field_label self.x = round(pt[0]) self.y = round(pt[1]) self.field_label = field_label self.field_type = field_type self.field_value = field_value + self.bubble_index = bubble_index def __str__(self): - return str([self.x, self.y]) + return self.name # f"{self.field_label}: [{self.x}, {self.y}]" + + def get_shifted_position(self, shifts): + return [self.x + shifts[0], self.y + shifts[1]] + + # Make the class serializable + def to_json(self): + return { + key: default_dump(getattr(self, key)) + for key in [ + "field_label", + "field_value", + # for item_reference_name + "name", + "x", + "y", + # "plot_bin_name", + # "field_type", + # "bubble_index", + ] + } diff --git a/src/core.py b/src/core.py deleted file mode 100644 index 7196f52c..00000000 --- a/src/core.py +++ /dev/null @@ -1,721 +0,0 @@ -import os -from collections import defaultdict -from typing import Any - -import cv2 -import matplotlib.pyplot as plt -import numpy as np - -import src.constants as constants -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]), - # constants.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), - ), - constants.CLR_DARK_GRAY, - 3, - ) - - cv2.putText( - final_marked, - str(field_value), - (x, y), - cv2.FONT_HERSHEY_SIMPLEX, - constants.TEXT_SIZE, - (20, 20, 10), - int(1 + 3.5 * constants.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), - ), - constants.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 >= 5: - # 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]), - constants.CLR_BLACK, - 3, - ) - else: - cv2.rectangle( - final_align, - (s[0], s[1]), - (s[0] + d[0], s[1] + d[1]), - constants.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)), - constants.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, - constants.CLR_BLACK, - 2, - ) - if shifted: - text_in_px = cv2.getTextSize( - field_block.name, cv2.FONT_HERSHEY_SIMPLEX, constants.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, - constants.TEXT_SIZE, - constants.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 = ( - constants.GLOBAL_PAGE_THRESHOLD_WHITE - if PAGE_TYPE_FOR_THRESHOLD == "white" - else constants.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/config.py b/src/defaults/config.py deleted file mode 100644 index 1c6cad3f..00000000 --- a/src/defaults/config.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index d0a2a831..00000000 --- a/src/defaults/template.py +++ /dev/null @@ -1,6 +0,0 @@ -TEMPLATE_DEFAULTS = { - "preProcessors": [], - "emptyValue": "", - "customLabels": {}, - "outputColumns": [], -} diff --git a/src/entry.py b/src/entry.py index 298bb235..4039819c 100644 --- a/src/entry.py +++ b/src/entry.py @@ -6,23 +6,25 @@ Github: https://github.com/Udayraj123 """ + +import json 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 import constants -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.algorithm.evaluation import EvaluationConfig, evaluate_concatenated_response +from src.algorithm.template import Template +from src.schemas.constants import DEFAULT_ANSWERS_SUMMARY_FORMAT_STRING +from src.schemas.defaults import CONFIG_DEFAULTS +from src.utils import constants 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.logger import console, logger from src.utils.parsing import get_concatenated_response, open_config_with_defaults # Load processors @@ -36,6 +38,47 @@ def entry_point(input_dir, args): return process_dir(input_dir, curr_dir, args) +def export_omr_metrics( + outputs_namespace, + file_name, + image, + final_marked, + colored_final_marked, + template, + field_number_to_field_bubble_means, + global_threshold_for_template, + global_field_confidence_metrics, + evaluation_meta, +): + global_bubble_means_and_refs = [] + for field_bubble_means in field_number_to_field_bubble_means: + global_bubble_means_and_refs.extend(field_bubble_means) + # sorted_global_bubble_means_and_refs = sorted(global_bubble_means_and_refs) + + image_metrics_path = outputs_namespace.paths.image_metrics_dir.joinpath( + f"{os.path.splitext(file_name)[0]}.js" + ) + with open( + image_metrics_path, + "w", + ) as f: + json_string = json.dumps( + { + "global_threshold_for_template": global_threshold_for_template, + "template": template, + "evaluation_meta": ( + evaluation_meta if evaluation_meta is not None else {} + ), + "global_bubble_means_and_refs": global_bubble_means_and_refs, + "global_field_confidence_metrics": global_field_confidence_metrics, + }, + default=lambda x: x.to_json(), + indent=4, + ) + f.write(f"export default {json_string}") + logger.info(f"Exported image metrics to: {image_metrics_path}") + + def print_config_summary( curr_dir, omr_files, @@ -51,12 +94,12 @@ def print_config_summary( 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("Debug Mode ", "ON" if args["debug"] else "OFF") table.add_row("Set Layout Mode ", "ON" if args["setLayout"] else "OFF") table.add_row( "Markers Detection", "ON" if "CropOnMarkers" in template.pre_processors 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}") @@ -67,6 +110,8 @@ def print_config_summary( "Detected pre-processors", f"{[pp.__class__.__name__ for pp in template.pre_processors]}", ) + table.add_row("Processing Image Shape", f"{template.processing_image_shape}") + console.print(table, justify="center") @@ -108,6 +153,7 @@ def process_dir( excluded_files.extend(Path(p) for p in pp.exclude_files()) local_evaluation_path = curr_dir.joinpath(constants.EVALUATION_FILENAME) + # Note: if setLayout is passed, there's no need to load evaluation file if not args["setLayout"] and os.path.exists(local_evaluation_path): if not local_template_exists: logger.warning( @@ -133,6 +179,7 @@ def process_dir( of '{curr_dir}'. \nPlace {constants.TEMPLATE_FILENAME} in the \ appropriate directory." ) + # TODO: restore support for --default-template flag raise Exception( f"No template file found in the directory tree of {curr_dir}" ) @@ -183,12 +230,21 @@ 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 + gray_image, colored_image = ImageUtils.read_image_util(file_path, tuning_config) + ( + gray_image, + colored_image, + template, + ) = template.image_instance_ops.apply_preprocessors( + file_path, gray_image, colored_image, template + ) + gray_layout, colored_layout = template.image_instance_ops.draw_template_layout( + gray_image, colored_image, template, shifted=False, border=2 ) - template_layout = template.image_instance_ops.draw_template_layout( - in_omr, template, shifted=False, border=2 + template_layout = ( + colored_layout + if tuning_config.outputs.show_colored_outputs + else gray_layout ) InteractionUtils.show( f"Template Layout: {file_name}", template_layout, 1, 1, config=tuning_config @@ -205,27 +261,36 @@ def process_files( start_time = int(time()) files_counter = 0 STATS.files_not_moved = 0 - + logger.set_log_levels(tuning_config.outputs.show_logs_by_type) for file_path in omr_files: files_counter += 1 file_name = file_path.name + file_id = str(file_name) - in_omr = cv2.imread(str(file_path), cv2.IMREAD_GRAYSCALE) + gray_image, colored_image = ImageUtils.read_image_util( + str(file_path), tuning_config + ) logger.info("") logger.info( - f"({files_counter}) Opening image: \t'{file_path}'\tResolution: {in_omr.shape}" + f"({files_counter}) Opening image: \t'{file_path}'\tResolution: {gray_image.shape}" ) template.image_instance_ops.reset_all_save_img() - template.image_instance_ops.append_save_img(1, in_omr) + template.image_instance_ops.append_save_image(1, gray_image) - in_omr = template.image_instance_ops.apply_preprocessors( - file_path, in_omr, template + # TODO: use try catch here and store paths to error files + # Note: the returned template is a copy + ( + gray_image, + colored_image, + template, + ) = template.image_instance_ops.apply_preprocessors( + file_path, gray_image, colored_image, template ) - if in_omr is None: + if gray_image is None: # Error OMR case new_file_path = outputs_namespace.paths.errors_dir.joinpath(file_name) outputs_namespace.OUTPUT_SET.append( @@ -249,16 +314,15 @@ def process_files( ) continue - # uniquify - file_id = str(file_name) - save_dir = outputs_namespace.paths.save_marked_dir ( response_dict, - final_marked, multi_marked, _, + field_number_to_field_bubble_means, + global_threshold_for_template, + global_field_confidence_metrics, ) = template.image_instance_ops.read_omr_response( - template, image=in_omr, name=file_id, save_dir=save_dir + gray_image, template, file_path ) # TODO: move inner try catch here @@ -271,26 +335,52 @@ def process_files( ): logger.info(f"Read Response: \n{omr_response}") - score = 0 + score, evaluation_meta = 0, None if evaluation_config is not None: - score = evaluate_concatenated_response(omr_response, evaluation_config) + score, evaluation_meta = evaluate_concatenated_response( + omr_response, evaluation_config + ) + default_answers_summary = evaluation_config.get_formatted_answers_summary( + DEFAULT_ANSWERS_SUMMARY_FORMAT_STRING + ) logger.info( - f"(/{files_counter}) Graded with score: {round(score, 2)}\t for file: '{file_id}'" + f"(/{files_counter}) Graded with score: {round(score, 2)}\t {default_answers_summary} \t 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, + # Save output images + save_marked_dir = outputs_namespace.paths.save_marked_dir + ( + final_marked, + colored_final_marked, + ) = template.image_instance_ops.draw_template_layout( + gray_image, + colored_image, + template, + file_id, + field_number_to_field_bubble_means, + save_marked_dir=save_marked_dir, + evaluation_meta=evaluation_meta, + evaluation_config=evaluation_config, + ) + + # Save output metrics + if tuning_config.outputs.save_image_metrics: + export_omr_metrics( + outputs_namespace, + file_name, + gray_image, + final_marked, + colored_final_marked, + template, + field_number_to_field_bubble_means, + global_threshold_for_template, + global_field_confidence_metrics, + evaluation_meta, ) + # Save output results resp_array = [] for k in template.output_columns: resp_array.append(omr_response[k]) @@ -299,7 +389,7 @@ def process_files( 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) + new_file_path = save_marked_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) @@ -329,6 +419,8 @@ def process_files( # TODO: Add appropriate record handling here # pass + logger.reset_log_levels() + print_stats(start_time, files_counter, tuning_config) diff --git a/src/processors/CropOnMarkers.py b/src/processors/CropOnMarkers.py index 4867e038..eb1478ee 100644 --- a/src/processors/CropOnMarkers.py +++ b/src/processors/CropOnMarkers.py @@ -1,233 +1,26 @@ -import os +from src.processors.interfaces.ImageTemplatePreprocessor import ( + ImageTemplatePreprocessor, +) +from src.processors.internal.CropOnCustomMarkers import CropOnCustomMarkers +from src.processors.internal.CropOnDotLines import CropOnDotLines -import cv2 -import numpy as np -from src.logger import logger -from src.processors.interfaces.ImagePreprocessor import ImagePreprocessor -from src.utils.image import ImageUtils -from src.utils.interaction import InteractionUtils +class CropOnMarkers(ImageTemplatePreprocessor): + __is_internal_preprocessor__ = False - -class CropOnMarkers(ImagePreprocessor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - config = self.tuning_config - marker_ops = self.options - self.threshold_circles = [] - # img_utils = ImageUtils() - - # options with defaults - self.marker_path = os.path.join( - self.relative_dir, marker_ops.get("relativePath", "omr_marker.jpg") - ) - self.min_matching_threshold = marker_ops.get("min_matching_threshold", 0.3) - self.max_matching_variation = marker_ops.get("max_matching_variation", 0.41) - self.marker_rescale_range = tuple( - int(r) for r in marker_ops.get("marker_rescale_range", (35, 100)) - ) - self.marker_rescale_steps = int(marker_ops.get("marker_rescale_steps", 10)) - self.apply_erode_subtract = marker_ops.get("apply_erode_subtract", True) - self.marker = self.load_marker(marker_ops, config) - def __str__(self): - return self.marker_path + if self.options["type"] == "FOUR_MARKERS": + self.instance = CropOnCustomMarkers(*args, **kwargs) + else: + self.instance = CropOnDotLines(*args, **kwargs) def exclude_files(self): - return [self.marker_path] - - def apply_filter(self, image, file_path): - config = self.tuning_config - image_instance_ops = self.image_instance_ops - image_eroded_sub = ImageUtils.normalize_util( - image - if self.apply_erode_subtract - else (image - cv2.erode(image, kernel=np.ones((5, 5)), iterations=5)) - ) - # Quads on warped image - quads = {} - h1, w1 = image_eroded_sub.shape[:2] - midh, midw = h1 // 3, w1 // 2 - origins = [[0, 0], [midw, 0], [0, midh], [midw, midh]] - quads[0] = image_eroded_sub[0:midh, 0:midw] - quads[1] = image_eroded_sub[0:midh, midw:w1] - quads[2] = image_eroded_sub[midh:h1, 0:midw] - quads[3] = image_eroded_sub[midh:h1, midw:w1] - - # Draw Quadlines - image_eroded_sub[:, midw : midw + 2] = 255 - image_eroded_sub[midh : midh + 2, :] = 255 - - best_scale, all_max_t = self.getBestMatch(image_eroded_sub) - if best_scale is None: - if config.outputs.show_image_level >= 1: - InteractionUtils.show("Quads", image_eroded_sub, config=config) - return None - - optimal_marker = ImageUtils.resize_util_h( - self.marker, u_height=int(self.marker.shape[0] * best_scale) - ) - _h, w = optimal_marker.shape[:2] - centres = [] - sum_t, max_t = 0, 0 - quarter_match_log = "Matching Marker: " - for k in range(0, 4): - res = cv2.matchTemplate(quads[k], optimal_marker, cv2.TM_CCOEFF_NORMED) - max_t = res.max() - quarter_match_log += f"Quarter{str(k + 1)}: {str(round(max_t, 3))}\t" - if ( - max_t < self.min_matching_threshold - or abs(all_max_t - max_t) >= self.max_matching_variation - ): - logger.error( - file_path, - "\nError: No circle found in Quad", - k + 1, - "\n\t min_matching_threshold", - self.min_matching_threshold, - "\t max_matching_variation", - self.max_matching_variation, - "\t max_t", - max_t, - "\t all_max_t", - all_max_t, - ) - if config.outputs.show_image_level >= 1: - InteractionUtils.show( - f"No markers: {file_path}", - image_eroded_sub, - 0, - config=config, - ) - InteractionUtils.show( - f"res_Q{str(k + 1)} ({str(max_t)})", - res, - 1, - config=config, - ) - return None - - pt = np.argwhere(res == max_t)[0] - pt = [pt[1], pt[0]] - pt[0] += origins[k][0] - pt[1] += origins[k][1] - # print(">>",pt) - image = cv2.rectangle( - image, tuple(pt), (pt[0] + w, pt[1] + _h), (150, 150, 150), 2 - ) - # display: - image_eroded_sub = cv2.rectangle( - image_eroded_sub, - tuple(pt), - (pt[0] + w, pt[1] + _h), - (50, 50, 50) if self.apply_erode_subtract else (155, 155, 155), - 4, - ) - centres.append([pt[0] + w / 2, pt[1] + _h / 2]) - sum_t += max_t - - logger.info(quarter_match_log) - logger.info(f"Optimal Scale: {best_scale}") - # analysis data - self.threshold_circles.append(sum_t / 4) - - image = ImageUtils.four_point_transform(image, np.array(centres)) - # appendSaveImg(1,image_eroded_sub) - # appendSaveImg(1,image_norm) + return self.instance.exclude_files() - image_instance_ops.append_save_img(2, image_eroded_sub) - # Debugging image - - # res = cv2.matchTemplate(image_eroded_sub,optimal_marker,cv2.TM_CCOEFF_NORMED) - # res[ : , midw:midw+2] = 255 - # res[ midh:midh+2, : ] = 255 - # show("Markers Matching",res) - if config.outputs.show_image_level >= 2 and config.outputs.show_image_level < 4: - image_eroded_sub = ImageUtils.resize_util_h( - image_eroded_sub, image.shape[0] - ) - image_eroded_sub[:, -5:] = 0 - h_stack = np.hstack((image_eroded_sub, image)) - InteractionUtils.show( - f"Warped: {file_path}", - ImageUtils.resize_util( - h_stack, int(config.dimensions.display_width * 1.6) - ), - 0, - 0, - [0, 0], - config=config, - ) - # iterations : Tuned to 2. - # image_eroded_sub = image_norm - cv2.erode(image_norm, kernel=np.ones((5,5)),iterations=2) - return image - - def load_marker(self, marker_ops, config): - if not os.path.exists(self.marker_path): - logger.error( - "Marker not found at path provided in template:", - self.marker_path, - ) - exit(31) - - marker = cv2.imread(self.marker_path, cv2.IMREAD_GRAYSCALE) - - if "sheetToMarkerWidthRatio" in marker_ops: - marker = ImageUtils.resize_util( - marker, - config.dimensions.processing_width - / int(marker_ops["sheetToMarkerWidthRatio"]), - ) - marker = cv2.GaussianBlur(marker, (5, 5), 0) - marker = cv2.normalize( - marker, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX - ) - - if self.apply_erode_subtract: - marker -= cv2.erode(marker, kernel=np.ones((5, 5)), iterations=5) - - return marker - - # Resizing the marker within scaleRange at rate of descent_per_step to - # find the best match. - def getBestMatch(self, image_eroded_sub): - config = self.tuning_config - descent_per_step = ( - self.marker_rescale_range[1] - self.marker_rescale_range[0] - ) // self.marker_rescale_steps - _h, _w = self.marker.shape[:2] - res, best_scale = None, None - all_max_t = 0 - - for r0 in np.arange( - self.marker_rescale_range[1], - self.marker_rescale_range[0], - -1 * descent_per_step, - ): # reverse order - s = float(r0 * 1 / 100) - if s == 0.0: - continue - rescaled_marker = ImageUtils.resize_util_h( - self.marker, u_height=int(_h * s) - ) - # res is the black image with white dots - res = cv2.matchTemplate( - image_eroded_sub, rescaled_marker, cv2.TM_CCOEFF_NORMED - ) - - max_t = res.max() - if all_max_t < max_t: - # print('Scale: '+str(s)+', Circle Match: '+str(round(max_t*100,2))+'%') - best_scale, all_max_t = s, max_t - - if all_max_t < self.min_matching_threshold: - logger.warning( - "\tTemplate matching too low! Consider rechecking preProcessors applied before this." - ) - if config.outputs.show_image_level >= 1: - InteractionUtils.show("res", res, 1, 0, config=config) + def __str__(self): + return self.instance.__str__() - if best_scale is None: - logger.warning( - "No matchings for given scaleRange:", self.marker_rescale_range - ) - return best_scale, all_max_t + def apply_filter(self, *args, **kwargs): + return self.instance.apply_filter(*args, **kwargs) diff --git a/src/processors/CropPage.py b/src/processors/CropPage.py index 25629e41..1b608adb 100644 --- a/src/processors/CropPage.py +++ b/src/processors/CropPage.py @@ -1,112 +1,150 @@ """ https://www.pyimagesearch.com/2015/04/06/zero-parameter-automatic-canny-edge-detection-with-python-and-opencv/ """ + import cv2 import numpy as np -from src.logger import logger -from src.processors.interfaces.ImagePreprocessor import ImagePreprocessor +from src.processors.constants import EDGE_TYPES_IN_ORDER, WarpMethod +from src.processors.internal.WarpOnPointsCommon import WarpOnPointsCommon +from src.utils.constants import CLR_WHITE from src.utils.image import ImageUtils from src.utils.interaction import InteractionUtils +from src.utils.logger import logger +from src.utils.math import MathUtils MIN_PAGE_AREA = 80000 -def normalize(image): - return cv2.normalize(image, 0, 255, norm_type=cv2.NORM_MINMAX) - - -def check_max_cosine(approx): - # assumes 4 pts present - max_cosine = 0 - min_cosine = 1.5 - for i in range(2, 5): - cosine = abs(angle(approx[i % 4], approx[i - 2], approx[i - 1])) - max_cosine = max(cosine, max_cosine) - min_cosine = min(cosine, min_cosine) - - if max_cosine >= 0.35: - logger.warning("Quadrilateral is not a rectangle.") - return False - return True - +class CropPage(WarpOnPointsCommon): + __is_internal_preprocessor__ = False -def validate_rect(approx): - return len(approx) == 4 and check_max_cosine(approx.reshape(4, 2)) + def validate_and_remap_options_schema(self, options): + tuning_options = options.get("tuningOptions", {}) + parsed_options = { + "enableCropping": True, + "tuningOptions": { + "warpMethod": tuning_options.get( + "warpMethod", WarpMethod.PERSPECTIVE_TRANSFORM + ) + }, + } + return parsed_options -def angle(p_1, p_2, p_0): - dx1 = float(p_1[0] - p_0[0]) - dy1 = float(p_1[1] - p_0[1]) - dx2 = float(p_2[0] - p_0[0]) - dy2 = float(p_2[1] - p_0[1]) - return (dx1 * dx2 + dy1 * dy2) / np.sqrt( - (dx1 * dx1 + dy1 * dy1) * (dx2 * dx2 + dy2 * dy2) + 1e-10 - ) - - -class CropPage(ImagePreprocessor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - cropping_ops = self.options - self.morph_kernel = tuple( - int(x) for x in cropping_ops.get("morphKernel", [10, 10]) + options = self.options + self.morph_kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, tuple(options.get("morphKernel", (10, 10))) ) - def apply_filter(self, image, file_path): - image = normalize(cv2.GaussianBlur(image, (3, 3), 0)) - - # Resize should be done with another preprocessor is needed - sheet = self.find_page(image, file_path) - if len(sheet) == 0: - logger.error( - f"\tError: Paper boundary not found for: '{file_path}'\nHave you accidentally included CropPage preprocessor?" - ) - return None - - logger.info(f"Found page corners: \t {sheet.tolist()}") - - # Warp layer 1 - image = ImageUtils.four_point_transform(image, sheet) - - # Return preprocessed image - return image - - def find_page(self, image, file_path): + def __str__(self): + return f"CropPage" + + def prepare_image(self, image): + return ImageUtils.normalize(image) + + def extract_control_destination_points(self, image, colored_image, file_path): + options = self.options + sheet, page_contour = self.find_page_contour_and_corners(image, file_path) + ( + ordered_page_corners, + edge_contours_map, + ) = ImageUtils.split_patch_contour_on_corners(sheet, page_contour) + + logger.info(f"Found page corners: \t {ordered_page_corners}") + ( + destination_page_corners, + _, + ) = ImageUtils.get_cropped_rectangle_destination_points(ordered_page_corners) + + if self.warp_method in [ + WarpMethod.DOC_REFINE, + WarpMethod.PERSPECTIVE_TRANSFORM, + ]: + return ordered_page_corners, destination_page_corners, edge_contours_map + else: + # TODO: remove this if REMAP method is removed, see if homography REALLY needs page contour points + max_points_per_edge = options.get("maxPointsPerEdge", None) + + control_points, destination_points = [], [] + for edge_type in EDGE_TYPES_IN_ORDER: + destination_line = MathUtils.select_edge_from_rectangle( + destination_page_corners, edge_type + ) + # Extrapolates the destination_line to get approximate destination points + ( + edge_control_points, + edge_destination_points, + ) = ImageUtils.get_control_destination_points_from_contour( + edge_contours_map[edge_type], destination_line, max_points_per_edge + ) + # Note: edge-wise duplicates would get added here + # TODO: see if we can avoid duplicates at source itself + control_points += edge_control_points + destination_points += edge_destination_points + + return control_points, destination_points, edge_contours_map + + def find_page_contour_and_corners(self, image, file_path): config = self.tuning_config - image = normalize(image) - _ret, image = cv2.threshold(image, 200, 255, cv2.THRESH_TRUNC) - image = normalize(image) - - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, self.morph_kernel) + image = ImageUtils.normalize(image) # Close the small holes, i.e. Complete the edges on canny image - closed = cv2.morphologyEx(image, cv2.MORPH_CLOSE, kernel) + closed = cv2.morphologyEx(image, cv2.MORPH_CLOSE, self.morph_kernel) - edge = cv2.Canny(closed, 185, 55) + # TODO: self.append_save_image(2, closed) - if config.outputs.show_image_level >= 5: - InteractionUtils.show("edge", edge, config=config) + # TODO: parametrize these tuning params + canny_edge = cv2.Canny(closed, 185, 55) + + # TODO: self.append_save_image(3, canny_edge) # findContours returns outer boundaries in CW and inner ones, ACW. - cnts = ImageUtils.grab_contours( - cv2.findContours(edge, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + all_contours = ImageUtils.grab_contours( + cv2.findContours( + canny_edge, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE + ) # , cv2.CHAIN_APPROX_NONE) ) # convexHull to resolve disordered curves due to noise - cnts = [cv2.convexHull(c) for c in cnts] - cnts = sorted(cnts, key=cv2.contourArea, reverse=True)[:5] + all_contours = [ + cv2.convexHull(bounding_contour) for bounding_contour in all_contours + ] + all_contours = sorted(all_contours, key=cv2.contourArea, reverse=True)[:5] sheet = [] - for c in cnts: - if cv2.contourArea(c) < MIN_PAGE_AREA: + page_contour = None + for bounding_contour in all_contours: + if cv2.contourArea(bounding_contour) < MIN_PAGE_AREA: continue - peri = cv2.arcLength(c, True) - approx = cv2.approxPolyDP(c, epsilon=0.025 * peri, closed=True) - if validate_rect(approx): + peri = cv2.arcLength(bounding_contour, True) + approx = cv2.approxPolyDP( + bounding_contour, epsilon=0.025 * peri, closed=True + ) + if MathUtils.validate_rect(approx): sheet = np.reshape(approx, (4, -1)) - cv2.drawContours(image, [approx], -1, (0, 255, 0), 2) - cv2.drawContours(edge, [approx], -1, (255, 255, 255), 10) + page_contour = np.vstack(bounding_contour).squeeze() + ImageUtils.draw_contour( + canny_edge, approx, color=CLR_WHITE, thickness=10 + ) + ImageUtils.draw_contour( + self.debug_image, approx, color=CLR_WHITE, thickness=10 + ) + + # TODO: self.append_save_image(2, canny_edge) break - return sheet + if config.outputs.show_image_level >= 6: + hstack = ImageUtils.get_padded_hstack([image, closed, canny_edge]) + + InteractionUtils.show("Page edges detection", hstack) + + if page_contour is None: + logger.error(f"Error: Paper boundary not found for: '{file_path}'") + logger.warning( + f"Have you accidentally included CropPage preprocessor?\nIf no, increase the processing dimensions from config. Current image size used: {image.shape[:2]}" + ) + raise Exception("Paper boundary not found") + return sheet, page_contour diff --git a/src/processors/FeatureBasedAlignment.py b/src/processors/FeatureBasedAlignment.py index be872924..72802de2 100644 --- a/src/processors/FeatureBasedAlignment.py +++ b/src/processors/FeatureBasedAlignment.py @@ -2,32 +2,43 @@ Image based feature alignment Credits: https://www.learnopencv.com/image-alignment-feature-based-using-opencv-c-python/ """ + import cv2 import numpy as np -from src.processors.interfaces.ImagePreprocessor import ImagePreprocessor +from src.processors.interfaces.ImageTemplatePreprocessor import ( + ImageTemplatePreprocessor, +) from src.utils.image import ImageUtils from src.utils.interaction import InteractionUtils +# TODO: support WarpOnPointsCommon? + -class FeatureBasedAlignment(ImagePreprocessor): +class FeatureBasedAlignment(ImageTemplatePreprocessor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) options = self.options - config = self.tuning_config # process reference image self.ref_path = self.relative_dir.joinpath(options["reference"]) ref_img = cv2.imread(str(self.ref_path), cv2.IMREAD_GRAYSCALE) - self.ref_img = ImageUtils.resize_util( - ref_img, - config.dimensions.processing_width, - config.dimensions.processing_height, - ) + self.ref_img = ImageUtils.resize_to_shape(ref_img, self.processing_image_shape) # get options with defaults self.max_features = int(options.get("maxFeatures", 500)) self.good_match_percent = options.get("goodMatchPercent", 0.15) - self.transform_2_d = options.get("2d", False) + + matcher_type = options.get( + "matcherType", "DESCRIPTOR_MATCHER_BRUTEFORCE_HAMMING" + ) + if matcher_type == "NORM_HAMMING": + self.matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + elif matcher_type == "DESCRIPTOR_MATCHER_BRUTEFORCE_HAMMING": + self.matcher = cv2.DescriptorMatcher_create( + cv2.DESCRIPTOR_MATCHER_BRUTEFORCE_HAMMING + ) + + self.transform_2_d = options.get("2d", True) # Extract keypoints and description of source image self.orb = cv2.ORB_create(self.max_features) self.to_keypoints, self.to_descriptors = self.orb.detectAndCompute( @@ -40,7 +51,7 @@ def __str__(self): def exclude_files(self): return [self.ref_path] - def apply_filter(self, image, _file_path): + def apply_filter(self, image, colored_image, _template, _file_path): config = self.tuning_config # Convert images to grayscale # im1Gray = cv2.cvtColor(im1, cv2.COLOR_BGR2GRAY) @@ -48,18 +59,14 @@ def apply_filter(self, image, _file_path): image = cv2.normalize(image, 0, 255, norm_type=cv2.NORM_MINMAX) - # Detect ORB features and compute descriptors. + # Detect Oriented Fast and Rotated Brief (ORB) features and compute descriptors. from_keypoints, from_descriptors = self.orb.detectAndCompute(image, None) # Match features. - matcher = cv2.DescriptorMatcher_create( - cv2.DESCRIPTOR_MATCHER_BRUTEFORCE_HAMMING - ) - - # create BFMatcher object (alternate matcher) - # matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) - matches = np.array(matcher.match(from_descriptors, self.to_descriptors, None)) + matches = np.array( + self.matcher.match(from_descriptors, self.to_descriptors, None) + ) # Sort matches by score matches = sorted(matches, key=lambda x: x.distance, reverse=False) @@ -73,7 +80,12 @@ def apply_filter(self, image, _file_path): im_matches = cv2.drawMatches( image, from_keypoints, self.ref_img, self.to_keypoints, matches, None ) - InteractionUtils.show("Aligning", im_matches, resize=True, config=config) + im_matches = ImageUtils.resize_util( + im_matches, u_height=config.outputs.display_image_dimensions[1] + ) + InteractionUtils.show( + "Aligning", im_matches, resize_to_height=True, config=config + ) # Extract location of good matches points1 = np.zeros((len(matches), 2), dtype=np.float32) @@ -84,11 +96,23 @@ def apply_filter(self, image, _file_path): points2[i, :] = self.to_keypoints[match.trainIdx].pt # Find homography - height, width = self.ref_img.shape + height, width = self.ref_img.shape[:2] if self.transform_2_d: + # Note: estimateAffinePartial2D might save on computation as we expect no noise in the data m, _inliers = cv2.estimateAffine2D(points1, points2) - return cv2.warpAffine(image, m, (width, height)) + # 2D == in image plane: + + warped_image = cv2.warpAffine(image, m, (width, height)) + + if config.outputs.show_colored_outputs: + colored_image = cv2.warpAffine(colored_image, m, (width, height)) + else: + # Use homography + h, _mask = cv2.findHomography(points1, points2, cv2.RANSAC) + # 3D == perspective from out of plane: + warped_image = cv2.warpPerspective(image, h, (width, height)) + + if config.outputs.show_colored_outputs: + colored_image = cv2.warpPerspective(colored_image, h, (width, height)) - # Use homography - h, _mask = cv2.findHomography(points1, points2, cv2.RANSAC) - return cv2.warpPerspective(image, h, (width, height)) + return warped_image, colored_image, _template diff --git a/src/processors/GaussianBlur.py b/src/processors/GaussianBlur.py new file mode 100644 index 00000000..cb047aa6 --- /dev/null +++ b/src/processors/GaussianBlur.py @@ -0,0 +1,20 @@ +import cv2 + +from src.processors.interfaces.ImageTemplatePreprocessor import ( + ImageTemplatePreprocessor, +) + + +class GaussianBlur(ImageTemplatePreprocessor): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + options = self.options + self.kSize = tuple(int(x) for x in options.get("kSize", (3, 3))) + self.sigmaX = int(options.get("sigmaX", 0)) + + def apply_filter(self, image, _colored_image, _template, _file_path): + return ( + cv2.GaussianBlur(image, self.kSize, self.sigmaX), + _colored_image, + _template, + ) diff --git a/src/processors/Levels.py b/src/processors/Levels.py new file mode 100644 index 00000000..680adca4 --- /dev/null +++ b/src/processors/Levels.py @@ -0,0 +1,35 @@ +import cv2 +import numpy as np + +from src.processors.interfaces.ImageTemplatePreprocessor import ( + ImageTemplatePreprocessor, +) + + +class Levels(ImageTemplatePreprocessor): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + options = self.options + + def output_level(value, low, high, gamma): + if value <= low: + return 0 + if value >= high: + return 255 + inv_gamma = 1.0 / gamma + return (((value - low) / (high - low)) ** inv_gamma) * 255 + + self.gamma = np.array( + [ + output_level( + i, + int(255 * options.get("low", 0)), + int(255 * options.get("high", 1)), + options.get("gamma", 1.0), + ) + for i in np.arange(0, 256) + ] + ).astype("uint8") + + def apply_filter(self, image, _colored_image, _template, _file_path): + return cv2.LUT(image, self.gamma), _colored_image, _template diff --git a/src/processors/MedianBlur.py b/src/processors/MedianBlur.py new file mode 100644 index 00000000..769613a4 --- /dev/null +++ b/src/processors/MedianBlur.py @@ -0,0 +1,15 @@ +import cv2 + +from src.processors.interfaces.ImageTemplatePreprocessor import ( + ImageTemplatePreprocessor, +) + + +class MedianBlur(ImageTemplatePreprocessor): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + options = self.options + self.kSize = int(options.get("kSize", 5)) + + def apply_filter(self, image, _colored_image, _template, _file_path): + return cv2.medianBlur(image, self.kSize), _colored_image, _template diff --git a/src/processors/builtins.py b/src/processors/builtins.py deleted file mode 100644 index faa6288f..00000000 --- a/src/processors/builtins.py +++ /dev/null @@ -1,54 +0,0 @@ -import cv2 -import numpy as np - -from src.processors.interfaces.ImagePreprocessor import ImagePreprocessor - - -class Levels(ImagePreprocessor): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - options = self.options - - def output_level(value, low, high, gamma): - if value <= low: - return 0 - if value >= high: - return 255 - inv_gamma = 1.0 / gamma - return (((value - low) / (high - low)) ** inv_gamma) * 255 - - self.gamma = np.array( - [ - output_level( - i, - int(255 * options.get("low", 0)), - int(255 * options.get("high", 1)), - options.get("gamma", 1.0), - ) - for i in np.arange(0, 256) - ] - ).astype("uint8") - - def apply_filter(self, image, _file_path): - return cv2.LUT(image, self.gamma) - - -class MedianBlur(ImagePreprocessor): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - options = self.options - self.kSize = int(options.get("kSize", 5)) - - def apply_filter(self, image, _file_path): - return cv2.medianBlur(image, self.kSize) - - -class GaussianBlur(ImagePreprocessor): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - options = self.options - self.kSize = tuple(int(x) for x in options.get("kSize", (3, 3))) - self.sigmaX = int(options.get("sigmaX", 0)) - - def apply_filter(self, image, _file_path): - return cv2.GaussianBlur(image, self.kSize, self.sigmaX) diff --git a/src/processors/constants.py b/src/processors/constants.py new file mode 100644 index 00000000..370866f8 --- /dev/null +++ b/src/processors/constants.py @@ -0,0 +1,158 @@ +from dotmap import DotMap + +EdgeType = DotMap( + { + "TOP": "TOP", + "RIGHT": "RIGHT", + "BOTTOM": "BOTTOM", + "LEFT": "LEFT", + }, + _dynamic=False, +) + +EDGE_TYPES_IN_ORDER = [EdgeType.TOP, EdgeType.RIGHT, EdgeType.BOTTOM, EdgeType.LEFT] + + +AreaTemplate = DotMap( + { + "topLeftDot": "topLeftDot", + "topRightDot": "topRightDot", + "bottomRightDot": "bottomRightDot", + "bottomLeftDot": "bottomLeftDot", + "topLeftMarker": "topLeftMarker", + "topRightMarker": "topRightMarker", + "bottomRightMarker": "bottomRightMarker", + "bottomLeftMarker": "bottomLeftMarker", + "topLine": "topLine", + "leftLine": "leftLine", + "bottomLine": "bottomLine", + "rightLine": "rightLine", + }, + _dynamic=False, +) + +DOT_AREA_TYPES_IN_ORDER = [ + AreaTemplate.topLeftDot, + AreaTemplate.topRightDot, + AreaTemplate.bottomRightDot, + AreaTemplate.bottomLeftDot, +] + +MARKER_AREA_TYPES_IN_ORDER = [ + AreaTemplate.topLeftMarker, + AreaTemplate.topRightMarker, + AreaTemplate.bottomRightMarker, + AreaTemplate.bottomLeftMarker, +] + +LINE_AREA_TYPES_IN_ORDER = [ + AreaTemplate.topLine, + AreaTemplate.rightLine, + AreaTemplate.bottomLine, + AreaTemplate.leftLine, +] + +TARGET_EDGE_FOR_LINE = { + AreaTemplate.topLine: EdgeType.TOP, + AreaTemplate.rightLine: EdgeType.RIGHT, + AreaTemplate.bottomLine: EdgeType.BOTTOM, + AreaTemplate.leftLine: EdgeType.LEFT, +} + +# This defines the precedence for composing ordered points in the edge_contours_map +TARGET_ENDPOINTS_FOR_EDGES = { + EdgeType.TOP: [ + [AreaTemplate.topLeftDot, 0], + [AreaTemplate.topLeftMarker, 0], + [AreaTemplate.leftLine, -1], + [AreaTemplate.topLine, "ALL"], + [AreaTemplate.rightLine, 0], + [AreaTemplate.topRightDot, 0], + [AreaTemplate.topRightMarker, 0], + ], + EdgeType.RIGHT: [ + [AreaTemplate.topRightDot, 0], + [AreaTemplate.topRightMarker, 0], + [AreaTemplate.topLine, -1], + [AreaTemplate.rightLine, "ALL"], + [AreaTemplate.bottomLine, 0], + [AreaTemplate.bottomRightDot, 0], + [AreaTemplate.bottomRightMarker, 0], + ], + EdgeType.LEFT: [ + [AreaTemplate.bottomLeftDot, 0], + [AreaTemplate.bottomLeftMarker, 0], + [AreaTemplate.bottomLine, -1], + [AreaTemplate.leftLine, "ALL"], + [AreaTemplate.topLine, 0], + [AreaTemplate.topLeftDot, 0], + [AreaTemplate.topLeftMarker, 0], + ], + EdgeType.BOTTOM: [ + [AreaTemplate.bottomRightDot, 0], + [AreaTemplate.bottomRightMarker, 0], + [AreaTemplate.rightLine, -1], + [AreaTemplate.bottomLine, "ALL"], + [AreaTemplate.leftLine, 0], + [AreaTemplate.bottomLeftDot, 0], + [AreaTemplate.bottomLeftMarker, 0], + ], +} + + +ScannerType = DotMap( + { + "PATCH_DOT": "PATCH_DOT", + "PATCH_LINE": "PATCH_LINE", + "TEMPLATE_MATCH": "TEMPLATE_MATCH", + }, + _dynamic=False, +) + +SCANNER_TYPES_IN_ORDER = [ + ScannerType.PATCH_DOT, + ScannerType.PATCH_LINE, + ScannerType.TEMPLATE_MATCH, +] + + +SelectorType = DotMap( + { + "SELECT_TOP_LEFT": "SELECT_TOP_LEFT", + "SELECT_TOP_RIGHT": "SELECT_TOP_RIGHT", + "SELECT_BOTTOM_RIGHT": "SELECT_BOTTOM_RIGHT", + "SELECT_BOTTOM_LEFT": "SELECT_BOTTOM_LEFT", + "SELECT_CENTER": "SELECT_CENTER", + "LINE_INNER_EDGE": "LINE_INNER_EDGE", + "LINE_OUTER_EDGE": "LINE_OUTER_EDGE", + }, + _dynamic=False, +) +SELECTOR_TYPES_IN_ORDER = [ + SelectorType.SELECT_TOP_LEFT, + SelectorType.SELECT_TOP_RIGHT, + SelectorType.SELECT_BOTTOM_RIGHT, + SelectorType.SELECT_BOTTOM_LEFT, + SelectorType.SELECT_CENTER, + SelectorType.LINE_INNER_EDGE, + SelectorType.LINE_OUTER_EDGE, +] +WarpMethodFlags = DotMap( + { + "INTER_LINEAR": "INTER_LINEAR", + "INTER_CUBIC": "INTER_CUBIC", + "INTER_NEAREST": "INTER_NEAREST", + }, + _dynamic=False, +) + +WarpMethod = DotMap( + { + "PERSPECTIVE_TRANSFORM": "PERSPECTIVE_TRANSFORM", + "HOMOGRAPHY": "HOMOGRAPHY", + "REMAP_GRIDDATA": "REMAP_GRIDDATA", + "DOC_REFINE": "DOC_REFINE", + "WARP_AFFINE": "WARP_AFFINE", + }, + _dynamic=False, +) diff --git a/src/processors/helpers/mapping.py b/src/processors/helpers/mapping.py new file mode 100644 index 00000000..dcdfc4c9 --- /dev/null +++ b/src/processors/helpers/mapping.py @@ -0,0 +1,260 @@ +from typing import * + +import cv2 +import numpy as np +import pandas as pd + +# import torch +# import torch.nn.functional as F +# from einops import rearrange +from scipy.interpolate import RegularGridInterpolator +from shapely.geometry import ( + LineString, + MultiLineString, + MultiPoint, + Point, + Polygon, + box, +) +from shapely.ops import linemerge, nearest_points, snap, split + + +def create_identity_meshgrid(resolution: Union[int, Tuple], with_margin: bool = False): + if isinstance(resolution, int): + resolution = (resolution, resolution) + + margin_0 = 0.5 / resolution[0] if with_margin else 0 + margin_1 = 0.5 / resolution[1] if with_margin else 0 + return np.mgrid[ + margin_0 : 1 - margin_0 : complex(0, resolution[0]), + margin_1 : 1 - margin_1 : complex(0, resolution[1]), + ].transpose(1, 2, 0) + + +def scale_map(input_map: np.ndarray, output_shape: Union[int, Tuple[int, int]]): + try: + output_shape = (int(output_shape), int(output_shape)) + except (ValueError, TypeError): + output_shape = tuple(int(v) for v in output_shape) + + H, W, C = input_map.shape + if H == output_shape[0] and W == output_shape[1]: + return input_map.copy() + + if np.any(np.isnan(input_map)): + print( + "WARNING: scaling maps containing nan values will result in unsteady borders!" + ) + + # The range of x and y coordinates to get a cross product + x = np.linspace(0, 1, W) + y = np.linspace(0, 1, H) + # Fit the interpolator grid to input_map + interp = RegularGridInterpolator((y, x), input_map, method="linear") + + # xi = values to evaluate at + xi = create_identity_meshgrid(output_shape, with_margin=False).reshape(-1, 2) + + return interp(xi).reshape(*output_shape, C) + + +# def apply_map( +# image: np.ndarray, +# bm: np.ndarray, +# resolution: Union[None, int, Tuple[int, int]] = None, +# ): +# if resolution is not None: +# bm = scale_map(bm, resolution) + +# input_dtype = image.dtype +# img = rearrange(image, "h w c -> 1 c h w") + +# img = torch.from_numpy(img).double() + +# bm = torch.from_numpy(bm).unsqueeze(0).double() +# # normalise bm to [-1, 1] +# bm = (bm * 2) - 1 +# bm = torch.roll(bm, shifts=1, dims=-1) + +# res = F.grid_sample(input=img, grid=bm, align_corners=True, padding_mode="border") +# res = rearrange(res[0], "c h w -> h w c") + +# res = res.numpy().astype(input_dtype) +# return res + + +def _create_backward_map( + segments: Dict[str, LineString], resolution: int # Tuple[int, int] +) -> np.ndarray: + # map geometric objects to normalized space + coord_map = { + (0, 0): segments["top"].coords[0], + (1, 0): segments["top"].coords[-1], + (0, 1): segments["bottom"].coords[0], + (1, 1): segments["bottom"].coords[-1], + } + dst = np.array(list(coord_map.keys())).astype("float32") + src = np.array(list(coord_map.values())).astype("float32") + M = cv2.getPerspectiveTransform(src=src, dst=dst) + + polygon = Polygon(linemerge(segments.values())) + pts = np.array(polygon.exterior.coords).reshape(-1, 1, 2) + pts = cv2.perspectiveTransform(pts, M).squeeze() + + p_norm = Polygon(pts) + + def transform_line(line): + line_points = np.array(line.coords).reshape(-1, 1, 2) + line_points = cv2.perspectiveTransform(line_points, M).squeeze() + return LineString(line_points) + + h_norm = [transform_line(segments[name]) for name in ["top", "bottom"]] + v_norm = [transform_line(segments[name]) for name in ["left", "right"]] + p_h_norm = MultiLineString(h_norm) + p_v_norm = MultiLineString(v_norm) + + (minx, miny, maxx, maxy) = p_norm.buffer(1).bounds + + backward_map = np.zeros((resolution, resolution, 2)) + grid_step_points = np.linspace(0, 1, resolution) + edge_cases = {0: 0, resolution - 1: -1} + + # calculate y - values by interpolating x values + for y_grid_index, grid_step_point in enumerate(grid_step_points): + if y_grid_index in edge_cases: + pos = edge_cases[y_grid_index] + intersections = [Point(v_norm[0].coords[pos]), Point(v_norm[1].coords[pos])] + else: + divider = LineString([(minx, grid_step_point), (maxx, grid_step_point)]) + intersections = list(p_v_norm.intersection(divider).geoms) + + if len(intersections) > 2: + intersections = [ + min(intersections, key=lambda p: p.x), + max(intersections, key=lambda p: p.x), + ] + + assert len(intersections) == 2, "Unexpected number of intersections!" + + # stretching 'x' intersections to the resolution grid size + stretched_x_line = np.linspace( + intersections[0].x, intersections[1].x, resolution + ) + backward_map[y_grid_index, 0:resolution, 0] = stretched_x_line + + # calculate x - values by interpolating y values + for x_grid_index, grid_step_point in enumerate(grid_step_points): + if x_grid_index in edge_cases: + pos = edge_cases[x_grid_index] + intersections = [Point(h_norm[0].coords[pos]), Point(h_norm[1].coords[pos])] + else: + divider = LineString([(grid_step_point, miny), (grid_step_point, maxy)]) + intersections = list(p_h_norm.intersection(divider).geoms) + + if len(intersections) > 2: + intersections = [ + min(intersections, key=lambda p: p.y), + max(intersections, key=lambda p: p.y), + ] + + assert len(intersections) == 2, "Unexpected number of intersections!" + + # stretching 'y' intersections to the resolution grid size + stretched_y_line = np.linspace( + intersections[0].y, intersections[1].y, resolution + ) + backward_map[0:resolution, x_grid_index, 1] = stretched_y_line + + # transform grid back to original space + + # (resolution, resolution, 2) -> (resolution * resolution, 2) + backward_map_points = backward_map.reshape(-1, 1, 2) + # The values represent curve's points + ordered_rich_curve_points = cv2.perspectiveTransform( + backward_map_points, np.linalg.inv(M) + ).squeeze() + # (resolution * resolution, 2) -> (resolution, resolution, 2) + backward_map = ordered_rich_curve_points.reshape(resolution, resolution, 2) + + # flip x and y coordinate: first y, then x + backward_map = np.roll(backward_map, shift=1, axis=-1) + + return backward_map + + +def _snap_corners(polygon: Polygon, corners: List[Point]) -> List[Point]: + corners = [nearest_points(polygon, point)[0] for point in corners] + assert len(corners) == 4 + return corners + + +def _split_polygon(polygon: Polygon, corners: List[Point]) -> List[LineString]: + boundary = snap(polygon.boundary, MultiPoint(corners), 0.0000001) + segments = split(boundary, MultiPoint(corners)).geoms + assert len(segments) in [4, 5] + + if len(segments) == 4: + return segments + + return [ + linemerge([segments[0], segments[4]]), + segments[1], + segments[2], + segments[3], + ] + + +def _classify_segments( + segments: Dict[str, LineString], corners: List[Point] +) -> Dict[str, LineString]: + bbox = box(*MultiLineString(segments).bounds) + bbox_points = np.array(bbox.exterior.coords[:-1]) + + # classify bbox nodes + df = pd.DataFrame(data=bbox_points, columns=["bbox_x", "bbox_y"]) + name_x = (df.bbox_x == df.bbox_x.min()).replace({True: "left", False: "right"}) + name_y = (df.bbox_y == df.bbox_y.min()).replace({True: "top", False: "bottom"}) + df["name"] = name_y + "-" + name_x + assert len(df.name.unique()) == 4 + + # find bbox node to corner node association + approx_points = np.array([c.xy for c in corners]).squeeze() + assert approx_points.shape == (4, 2) + + assignments = [ + np.roll(np.array(range(4))[::step], shift=i) + for i in range(4) + for step in [1, -1] + ] + + costs = [ + np.linalg.norm(bbox_points - approx_points[assignment], axis=-1).sum() + for assignment in assignments + ] + + min_assignment = min(zip(costs, assignments))[1] + df["corner_x"] = approx_points[min_assignment][:, 0] + df["corner_y"] = approx_points[min_assignment][:, 1] + + # retrieve correct segment and fix direction if necessary + segment_endpoints = {frozenset([s.coords[0], s.coords[-1]]): s for s in segments} + + def get_directed_segment(start_name, end_name): + start = df[df.name == start_name] + start = (float(start.corner_x), float(start.corner_y)) + + end = df[df.name == end_name] + end = (float(end.corner_x), float(end.corner_y)) + + segment = segment_endpoints[frozenset([start, end])] + if start != segment.coords[0]: + segment = LineString(reversed(segment.coords)) + + return segment + + return { + "top": get_directed_segment("top-left", "top-right"), + "bottom": get_directed_segment("bottom-left", "bottom-right"), + "left": get_directed_segment("top-left", "bottom-left"), + "right": get_directed_segment("top-right", "bottom-right"), + } diff --git a/src/processors/helpers/rectify.py b/src/processors/helpers/rectify.py new file mode 100644 index 00000000..6ec12f17 --- /dev/null +++ b/src/processors/helpers/rectify.py @@ -0,0 +1,272 @@ +from typing import * + +import cv2 +import numpy as np +from rich.table import Table +from shapely.geometry import LineString, MultiLineString, Point, Polygon +from shapely.ops import linemerge + +from src.processors.constants import EDGE_TYPES_IN_ORDER +from src.utils.logger import console, logger +from src.utils.math import MathUtils + +# from .mapping import scale_map + + +def rectify( + *, + edge_contours_map: Dict[str, List[Tuple[int, int]]], + enable_cropping: bool, + # output_shape: Tuple[int, int], + # resolution_map: int = 512, +) -> np.ndarray: + """ + Rectifies an image based on the given mask. Corners can be provided to speed up the rectification. + + Args: + image (np.ndarray): The input image as a NumPy array. Shape: (height, width, channels). + mask (np.ndarray): The mask indicating the region of interest as a NumPy array. Shape: (height, width). + output_shape (Tuple[int, int]): The desired output shape of the rectified image. First element: height, second element: width. + corners (Optional[List[Tuple[int, int]]], optional): The corners of the region of interest. List of points with first x, then y coordinate. Has to contain exactly 4 points. Defaults to None. + resolution_map (int, optional): The resolution of the mapping grid. Higher grid size increases computation time but generates better results. Defaults to 512. + + Returns: + np.ndarray: The rectified image as a NumPy array. + """ + + segments = { + edge_type.lower(): LineString(edge_contours_map[edge_type]) + for edge_type in EDGE_TYPES_IN_ORDER + } + + # logger.info("segments", segments) + + # given_segments = colored_image.copy() + # for name, segment in segments.items(): + # min_x, min_y, max_x, max_y = segment.bounds + # ImageUtils.draw_text(given_segments, name, [min_x - 10, min_y + 10]) + # ImageUtils.draw_contour(given_segments, list(segment.coords)) + # InteractionUtils.show("given_segments", given_segments) + + output_shape = _get_output_shape_for_segments(edge_contours_map, enable_cropping) + + scaled_map = _create_backward_output_map(segments, output_shape, enable_cropping) + return scaled_map + # resolution_map = 10 # temp + # backward_map = _create_backward_map(segments, resolution_map) + # logger.info("backward_map.shape", backward_map.shape) + # scaled_map = scale_map(backward_map, output_shape) + + # return warped_colored_image + + +def _get_output_shape_for_segments( + edge_contours_map: Dict[str, List[Tuple[int, int]]], enable_cropping: bool +): + max_width = max( + [ + MathUtils.distance(edge_contours_map[name][0], edge_contours_map[name][-1]) + for name in ["TOP", "BOTTOM"] + ] + ) + max_height = max( + [ + MathUtils.distance(edge_contours_map[name][0], edge_contours_map[name][-1]) + for name in ["LEFT", "RIGHT"] + ] + ) + return int(max_height), int(max_width) + + +def transform_line(line, M): + line_points = np.array(line.coords).reshape(-1, 1, 2) + line_points = cv2.perspectiveTransform(line_points, M).squeeze() + return LineString(line_points) + + +def _create_backward_output_map( + segments: Dict[str, LineString], + output_shape: Tuple[int, int], + enable_cropping: bool, +) -> np.ndarray: + resolution_h, resolution_w = output_shape + transformed_backward_points_map = np.zeros((resolution_h, resolution_w, 2)) + + # TODO: support for in-place transforms when enable_cropping is False + # map geometric objects to normalized space + coord_map = { + (0, 0): segments["top"].coords[0], + (resolution_w, 0): segments["top"].coords[-1], + (0, resolution_h): segments["bottom"].coords[-1], # Clockwise order of points + (resolution_w, resolution_h): segments["bottom"].coords[0], + } + # Get page corners tranform matrix + dst = np.array(list(coord_map.keys())).astype("float32") + src = np.array(list(coord_map.values())).astype("float32") + print_points_mapping_table(f"Corners Transform", src, dst) + + # TODO: can get this matrix from homography as well! (for inplace alignment) + M = cv2.getPerspectiveTransform(src=src, dst=dst) + + transformed_segments = { + name: transform_line(segments[name], M) for name in segments.keys() + } + + logger.info("output_shape", output_shape) + combined_segments = Polygon(linemerge(segments.values())) + + # Reshape to match with perspectiveTransform() expected input + # (x, 2) -> (x, 1, 2) + combined_segments_points = np.array(combined_segments.exterior.coords) + combined_segments_points_for_transform = combined_segments_points.reshape(-1, 1, 2) + cropped_combined_segments_points = cv2.perspectiveTransform( + combined_segments_points_for_transform, M + ).squeeze() + + cropped_combined_segments = Polygon(cropped_combined_segments_points) + ( + cropped_page_min_x, + cropped_page_min_y, + cropped_page_max_x, + cropped_page_max_y, + ) = cropped_combined_segments.buffer(1).bounds + + control_points = [ + [int(point[0]), int(point[1])] for point in combined_segments_points + ] + destination_points = [ + [int(point[0]), int(point[1])] for point in cropped_combined_segments_points + ] + print_points_mapping_table( + f"Contours Approx Transform", control_points, destination_points + ) + + logger.info( + f"Approx Transformed Bounds: ({cropped_page_min_x:.2f},{cropped_page_min_y:.2f}) -> ({cropped_page_max_x:.2f},{cropped_page_max_y:.2f})", + ) + + transformed_box_with_vertical_curves = MultiLineString( + [transformed_segments["left"], transformed_segments["right"]] + ) + + # TODO: >> see if RegularGridInterpolator can be used for making this efficient! + grid_step_points_h = list(range(0, resolution_h)) # np.linspace(0, 1, resolution_h) + edge_cases = {0: 0, resolution_h - 1: -1} + + # calculate y - values by interpolating x values + for y_grid_index, grid_step_point in enumerate(grid_step_points_h): + # logger.info(f"{grid_step_point}/{len(grid_step_points_h)}") + if y_grid_index in edge_cases: + pos = edge_cases[y_grid_index] + intersections = [ + # Clockwise order adjustment + Point(transformed_segments["left"].coords[-1 if pos == 0 else 0]), + Point(transformed_segments["right"].coords[pos]), + ] + else: + scan_line_x = LineString( + [ + (cropped_page_min_x, grid_step_point), + (cropped_page_max_x, grid_step_point), + ] + ) + # logger.info(transformed_box_with_vertical_curves, scan_line_x) + intersections = list( + transformed_box_with_vertical_curves.intersection(scan_line_x).geoms + ) + + if len(intersections) > 2: + # TODO later: here we can find non-corner anchor points to align too! + logger.warning(grid_step_point, intersections) + + intersections = [ + min(intersections, key=lambda p: p.x), + max(intersections, key=lambda p: p.x), + ] + + # TODO: maybe do this using perspectiveTransform on the whole line-strip (based on resolution? + # TODO: optimization - O(h) -> O(len(contour)) can do per boundary point in a y-sorted fashion + + # TODO: support for maxDisplacements/max_displacements (dx, dy) and check (resolution_w - intersections[1].x) < dx and + # stretch_start = min( max_displacements[0], intersections[0].x) + # stretch_end = resolution_w - min(max_displacements[1], resolution_w - intersections[1].x) + + # stretching 'x' intersections to the resolution grid size + stretched_x_line = np.linspace( + intersections[0].x, intersections[1].x, resolution_w + ) + transformed_backward_points_map[ + y_grid_index, 0:resolution_w, 0 + ] = stretched_x_line + + transformed_box_with_horizontal_curves = MultiLineString( + [transformed_segments["top"], transformed_segments["bottom"]] + ) + grid_step_points_w = list(range(0, resolution_w)) # np.linspace(0, 1, resolution_w) + edge_cases = {0: 0, resolution_w - 1: -1} + # calculate x - values by interpolating y values + for x_grid_index, grid_step_point in enumerate(grid_step_points_w): + # logger.info(f"{x_grid_index}/{len(grid_step_points_w)}") + if x_grid_index in edge_cases: + pos = edge_cases[x_grid_index] + intersections = [ + Point(transformed_segments["top"].coords[pos]), + # Clockwise order adjustment + Point(transformed_segments["bottom"].coords[-1 if pos == 0 else 0]), + ] + else: + scan_line_y = LineString( + [ + (grid_step_point, cropped_page_min_y), + (grid_step_point, cropped_page_max_y), + ] + ) + intersections = list( + transformed_box_with_horizontal_curves.intersection(scan_line_y).geoms + ) + + # if len(intersections) > 2: + intersections = [ + min(intersections, key=lambda p: p.y), + max(intersections, key=lambda p: p.y), + ] + + # stretching 'y' intersections to the resolution grid size + stretched_y_line = np.linspace( + intersections[0].y, intersections[1].y, resolution_h + ) + transformed_backward_points_map[ + 0:resolution_h, x_grid_index, 1 + ] = stretched_y_line + + # transform grid back to original space + # (resolution_h, resolution_w, 2) -> (resolution_h * resolution_w, 2) + backward_map_points = transformed_backward_points_map.reshape(-1, 1, 2) + # The values represent curve's points + ordered_rich_curve_points = cv2.perspectiveTransform( + backward_map_points, np.linalg.inv(M) + ).squeeze() + # (resolution_h * resolution_w, 2) -> (resolution_h, resolution_w, 2) + backward_points_map = ordered_rich_curve_points.reshape( + resolution_h, resolution_w, 2 + ) + + # flip x and y coordinate: first y, then x + # backward_points_map = np.roll(backward_points_map, shift=1, axis=-1) + + logger.info("backward_points_map.shape", backward_points_map.shape) + backward_points_map = np.float32(backward_points_map) + return backward_points_map + + +def print_points_mapping_table(title, control_points, destination_points): + table = Table( + title=title, + show_header=True, + show_lines=False, + ) + table.add_column("Control", style="cyan", no_wrap=True) + table.add_column("Destination", style="magenta") + for c, d in zip(control_points, destination_points): + table.add_row(str(c), str(d)) + console.print(table, justify="center") diff --git a/src/processors/interfaces/ImagePreprocessor.py b/src/processors/interfaces/ImagePreprocessor.py deleted file mode 100644 index a5cf09a4..00000000 --- a/src/processors/interfaces/ImagePreprocessor.py +++ /dev/null @@ -1,18 +0,0 @@ -# Use all imports relative to root directory -from src.processors.manager import Processor - - -class ImagePreprocessor(Processor): - """Base class for an extension that applies some preprocessing to the input image""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def apply_filter(self, image, filename): - """Apply filter to the image and returns modified image""" - raise NotImplementedError - - @staticmethod - def exclude_files(): - """Returns a list of file paths that should be excluded from processing""" - return [] diff --git a/src/processors/interfaces/ImageTemplatePreprocessor.py b/src/processors/interfaces/ImageTemplatePreprocessor.py new file mode 100644 index 00000000..970fe53a --- /dev/null +++ b/src/processors/interfaces/ImageTemplatePreprocessor.py @@ -0,0 +1,53 @@ +# Use all imports relative to root directory +import os + +from src.processors.internal.Processor import Processor +from src.utils.image import ImageUtils + + +class ImageTemplatePreprocessor(Processor): + """Base class for an extension that applies some preprocessing to the input image""" + + def __init__( + self, options, relative_dir, image_instance_ops, default_processing_image_shape + ): + super().__init__( + options, + relative_dir, + ) + self.append_save_image = image_instance_ops.append_save_image + self.tuning_config = image_instance_ops.tuning_config + # Note: we're taking this at preProcessor level because it represents + # the need of a preProcessor's coordinate system(e.g. area selectors) + self.processing_image_shape = options.get( + "processingImageShape", default_processing_image_shape + ) + + def get_relative_path(self, path): + return os.path.join(self.relative_dir, path) + + def apply_filter(self, _image, _colored_image, _template, _file_path): + """Apply filter to the image and returns modified image""" + raise NotImplementedError + + def resize_and_apply_filter(self, in_image, colored_image, _template, _file_path): + config = self.tuning_config + + in_image = ImageUtils.resize_to_shape(in_image, self.processing_image_shape) + + if config.outputs.show_colored_outputs: + colored_image = ImageUtils.resize_to_shape( + colored_image, + self.processing_image_shape, + ) + + out_image, colored_image, _template = self.apply_filter( + in_image, colored_image, _template, _file_path + ) + + return out_image, colored_image, _template + + @staticmethod + def exclude_files(): + """Returns a list of file paths that should be excluded from processing""" + return [] diff --git a/src/processors/internal/CropOnCustomMarkers.py b/src/processors/internal/CropOnCustomMarkers.py new file mode 100644 index 00000000..7b9886ea --- /dev/null +++ b/src/processors/internal/CropOnCustomMarkers.py @@ -0,0 +1,434 @@ +import os + +import cv2 +import numpy as np + +from src.processors.constants import ( + MARKER_AREA_TYPES_IN_ORDER, + AreaTemplate, + ScannerType, + WarpMethod, +) +from src.processors.internal.CropOnPatchesCommon import CropOnPatchesCommon +from src.utils.image import ImageUtils +from src.utils.interaction import InteractionUtils +from src.utils.logger import logger +from src.utils.math import MathUtils +from src.utils.parsing import OVERRIDE_MERGER + + +# TODO: add support for showing patch area centers during setLayout option?! +class CropOnCustomMarkers(CropOnPatchesCommon): + __is_internal_preprocessor__ = True + scan_area_templates_for_layout = { + "FOUR_MARKERS": MARKER_AREA_TYPES_IN_ORDER, + } + default_scan_area_descriptions = { + **{ + area_template: { + "scannerType": ScannerType.TEMPLATE_MATCH, + "selector": "SELECT_CENTER", + "maxPoints": 2, # for cropping + # Note: all 4 margins are a required property for a patch area + } + for area_template in MARKER_AREA_TYPES_IN_ORDER + }, + "CUSTOM": {}, + } + + default_points_selector_map = { + "CENTERS": { + AreaTemplate.topLeftMarker: "SELECT_CENTER", + AreaTemplate.topRightMarker: "SELECT_CENTER", + AreaTemplate.bottomRightMarker: "SELECT_CENTER", + AreaTemplate.bottomLeftMarker: "SELECT_CENTER", + }, + "INNER_WIDTHS": { + AreaTemplate.topLeftMarker: "SELECT_TOP_RIGHT", + AreaTemplate.topRightMarker: "SELECT_TOP_LEFT", + AreaTemplate.bottomRightMarker: "SELECT_BOTTOM_LEFT", + AreaTemplate.bottomLeftMarker: "SELECT_BOTTOM_RIGHT", + }, + "INNER_HEIGHTS": { + AreaTemplate.topLeftMarker: "SELECT_BOTTOM_LEFT", + AreaTemplate.topRightMarker: "SELECT_BOTTOM_RIGHT", + AreaTemplate.bottomRightMarker: "SELECT_TOP_RIGHT", + AreaTemplate.bottomLeftMarker: "SELECT_TOP_LEFT", + }, + "INNER_CORNERS": { + AreaTemplate.topLeftMarker: "SELECT_BOTTOM_RIGHT", + AreaTemplate.topRightMarker: "SELECT_BOTTOM_LEFT", + AreaTemplate.bottomRightMarker: "SELECT_TOP_LEFT", + AreaTemplate.bottomLeftMarker: "SELECT_TOP_RIGHT", + }, + "OUTER_CORNERS": { + AreaTemplate.topLeftMarker: "SELECT_TOP_LEFT", + AreaTemplate.topRightMarker: "SELECT_TOP_RIGHT", + AreaTemplate.bottomRightMarker: "SELECT_BOTTOM_RIGHT", + AreaTemplate.bottomLeftMarker: "SELECT_BOTTOM_LEFT", + }, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + tuning_options = self.tuning_options + self.threshold_circles = [] + + # TODO: dedicated marker scanArea override support needed for these? + self.min_matching_threshold = tuning_options.get("min_matching_threshold", 0.3) + self.marker_rescale_range = tuple( + tuning_options.get("marker_rescale_range", (85, 115)) + ) + self.marker_rescale_steps = int(tuning_options.get("marker_rescale_steps", 5)) + self.apply_erode_subtract = tuning_options.get("apply_erode_subtract", True) + + self.init_resized_markers() + + def validate_and_remap_options_schema(self, options): + reference_image_path, layout_type = options["referenceImage"], options["type"] + tuning_options = options.get("tuningOptions", {}) + # Note: options["tuningOptions"] is accessible in self.tuning_options at Processor level + parsed_options = { + "pointsLayout": layout_type, + "enableCropping": True, + "tuningOptions": { + "warpMethod": tuning_options.get( + "warpMethod", WarpMethod.PERSPECTIVE_TRANSFORM + ) + }, + } + + # TODO: add default values for provided scanAreas? + # Allow non-marker scanAreas here too? + defaultDimensions = options.get("markerDimensions", None) + # inject scanAreas (Note: override merge with defaults will happen in parent class) + parsed_scan_areas = [] + for area_template in self.scan_area_templates_for_layout[layout_type]: + local_description = options.get(area_template, {}) + # .pop() will delete the customOptions key from the description if it exists + local_custom_options = local_description.pop("customOptions", {}) + parsed_scan_areas.append( + { + "areaTemplate": area_template, + "areaDescription": local_description, + "customOptions": { + "referenceImage": reference_image_path, + "markerDimensions": defaultDimensions, + **local_custom_options, + }, + } + ) + parsed_options["scanAreas"] = parsed_scan_areas + return parsed_options + + def validate_scan_areas(self): + super().validate_scan_areas() + # Additional marker related validations + for scan_area in self.scan_areas: + area_template, area_description, custom_options = map( + scan_area.get, ["areaTemplate", "areaDescription", "customOptions"] + ) + area_label = area_description["label"] + if area_template in self.scan_area_templates_for_layout["FOUR_MARKERS"]: + if "referenceImage" not in custom_options: + raise Exception( + f"referenceImage not provided for custom marker area {area_label}" + ) + reference_image_path = self.get_relative_path( + custom_options["referenceImage"] + ) + + if not os.path.exists(reference_image_path): + raise Exception( + f"Marker reference image not found for {area_label} at path provided: {reference_image_path}" + ) + + def init_resized_markers(self): + self.loaded_reference_images = {} + self.marker_for_area_label = {} + for scan_area in self.scan_areas: + area_description, custom_options = map( + scan_area.get, ["areaDescription", "customOptions"] + ) + area_label, scanner_type = ( + area_description["label"], + area_description["scannerType"], + ) + + if scanner_type != ScannerType.TEMPLATE_MATCH: + continue + reference_image_path = self.get_relative_path( + custom_options["referenceImage"] + ) + if reference_image_path in self.loaded_reference_images: + reference_image = self.loaded_reference_images[reference_image_path] + else: + # TODO: add colored support later based on image_type passed at parent level + reference_image = cv2.imread(reference_image_path, cv2.IMREAD_GRAYSCALE) + self.loaded_reference_images[reference_image_path] = reference_image + reference_area = custom_options.get( + "referenceArea", self.get_default_scan_area_for_image(reference_image) + ) + logger.debug("area_label=", area_label, custom_options) + + extracted_marker = self.extract_marker_from_reference( + reference_image, reference_area, custom_options + ) + + self.marker_for_area_label[area_label] = extracted_marker + + def extract_marker_from_reference( + self, reference_image, reference_area, custom_options + ): + options = self.options + origin, dimensions = reference_area["origin"], reference_area["dimensions"] + x, y = origin + w, h = dimensions + marker = reference_image[y : y + h, x : x + w] + + marker_dimensions = custom_options.get( + "markerDimensions", options.get("markerDimensions", None) + ) + if marker_dimensions is not None: + marker = ImageUtils.resize_to_dimensions(marker, marker_dimensions) + + blur_kernel = custom_options.get("markerBlurKernel", (5, 5)) + marker = cv2.GaussianBlur(marker, blur_kernel, 0) + + marker = cv2.normalize( + marker, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX + ) + + if self.apply_erode_subtract: + marker -= cv2.erode(marker, kernel=np.ones((5, 5)), iterations=5) + + return marker + + @staticmethod + def get_default_scan_area_for_image(image): + h, w = image.shape[:2] + return { + "origin": [1, 1], + "dimensions": [w - 1, h - 1], + } + + def get_runtime_area_description_with_defaults(self, image, scan_area): + area_template, area_description = ( + scan_area["areaTemplate"], + scan_area["areaDescription"], + ) + + # Note: currently user input would be restricted to only markers at once (no combination of markers and dots) + # TODO: >> handle a instance of this class from parent using scannerType for applicable ones! + # Check for area_template + if area_template not in self.scan_area_templates_for_layout["FOUR_MARKERS"]: + return area_description + + area_label, origin, dimensions = map( + area_description.get, ["label", "origin", "dimensions"] + ) + if origin is None or dimensions is None: + image_shape = image.shape[:2] + marker_shape = self.marker_for_area_label[area_label].shape[:2] + quadrant_description = self.get_quadrant_area_description( + area_template, image_shape, marker_shape + ) + area_description = OVERRIDE_MERGER.merge( + quadrant_description, area_description + ) + # Check for area_description["scannerType"] + + # Note: this runtime template is supposedly always valid + return area_description + + def find_dot_corners_from_options(self, image, area_description, file_path): + config = self.tuning_config + + # Note we expect fill_runtime_defaults_from_area_templates to be called from parent + + absolute_corners = self.find_marker_corners_in_patch( + area_description, image, file_path + ) + + if config.outputs.show_image_level >= 1: + ImageUtils.draw_contour(self.debug_image, absolute_corners) + + return absolute_corners + + def get_quadrant_area_description(self, patch_type, image_shape, marker_shape): + h, w = image_shape + half_height, half_width = h // 2, w // 2 + marker_h, marker_w = marker_shape + + if patch_type == AreaTemplate.topLeftMarker: + area_start, area_end = [1, 1], [half_width, half_height] + elif patch_type == AreaTemplate.topRightMarker: + area_start, area_end = [half_width, 1], [w, half_height] + elif patch_type == AreaTemplate.bottomRightMarker: + area_start, area_end = [half_width, half_height], [w, h] + elif patch_type == AreaTemplate.bottomLeftMarker: + area_start, area_end = [1, half_height], [half_width, h] + else: + raise Exception(f"Unexpected quadrant patch_type {patch_type}") + + origin = [ + (area_start[0] + area_end[0] - marker_w) // 2, + (area_start[1] + area_end[1] - marker_h) // 2, + ] + + margin_horizontal = (area_end[0] - area_start[0] - marker_w) / 2 - 1 + margin_vertical = (area_end[1] - area_start[1] - marker_h) / 2 - 1 + + return { + "origin": origin, + "dimensions": [marker_w, marker_h], + "margins": { + "top": margin_vertical, + "right": margin_horizontal, + "bottom": margin_vertical, + "left": margin_horizontal, + }, + "selector": "SELECT_CENTER", + "scannerType": "TEMPLATE_MARKER", + } + + def find_marker_corners_in_patch(self, area_description, image, file_path): + area_label = area_description["label"] + + patch_area, area_start = self.compute_scan_area_util(image, area_description) + # Note: now best match is being computed separately inside each patch_area + (marker_position, optimal_marker) = self.get_best_match(area_label, patch_area) + + if marker_position is None: + return None + + h, w = optimal_marker.shape[:2] + x, y = marker_position + ordered_patch_corners = MathUtils.get_rectangle_points(x, y, w, h) + + absolute_corners = MathUtils.shift_points_from_origin( + area_start, ordered_patch_corners + ) + return absolute_corners + + # Resizing the marker within scaleRange at rate of descent_per_step to + # find the best match. + def get_best_match(self, area_label, patch_area): + config = self.tuning_config + descent_per_step = ( + self.marker_rescale_range[1] - self.marker_rescale_range[0] + ) // self.marker_rescale_steps + marker = self.marker_for_area_label[area_label] + marker_height, marker_width = marker.shape[:2] + marker_position, optimal_match_result, optimal_scale, optimal_marker = ( + None, + None, + None, + None, + ) + optimal_match_max = 0 + + for r0 in np.arange( + self.marker_rescale_range[1], + self.marker_rescale_range[0], + -1 * descent_per_step, + ): + scale = float(r0 * 1 / 100) + if scale <= 0.0: + continue + rescaled_marker = ImageUtils.resize_util( + marker, + u_width=int(marker_width * scale), + u_height=int(marker_height * scale), + ) + + # res is the black image with white dots + match_result = cv2.matchTemplate( + patch_area, rescaled_marker, cv2.TM_CCOEFF_NORMED + ) + + match_max = match_result.max() + if optimal_match_max < match_max: + # print('Scale: '+str(scale)+', Circle Match: '+str(round(match_max*100,2))+'%') + ( + optimal_scale, + optimal_marker, + optimal_match_max, + optimal_match_result, + ) = ( + scale, + rescaled_marker, + match_max, + match_result, + ) + + if optimal_scale is None: + logger.warning( + f"No matchings for {area_label} for given scaleRange:", + self.marker_rescale_range, + ) + if config.outputs.show_image_level >= 1: + # Note: We need images of dtype float for displaying optimal_match_result. + self.debug_hstack += [ + patch_area / 255, + optimal_marker / 255, + optimal_match_result, + ] + self.debug_vstack.append(self.debug_hstack) + self.debug_hstack = [] + + is_not_matching = optimal_match_max < self.min_matching_threshold + if is_not_matching: + logger.error( + f"Error: No marker found in patch {area_label}, (match_max={optimal_match_max:.2f} < {self.min_matching_threshold:.2f}) for {area_label}! Recheck tuningOptions or output of previous preProcessor." + ) + logger.info( + f"Sizes: optimal_marker:{optimal_marker.shape[:2]}, patch_area: {patch_area.shape[:2]}" + ) + + if config.outputs.show_image_level >= 1: + hstack = ImageUtils.get_padded_hstack( + [patch_area / 255, optimal_marker / 255, optimal_match_result] + ) + InteractionUtils.show( + f"No Markers res_{area_label} ({str(optimal_match_max)})", + hstack, + pause=True, + ) + raise Exception(f"Error: No marker found in patch {area_label}") + else: + y, x = np.argwhere(optimal_match_result == optimal_match_max)[0] + marker_position = [x, y] + + logger.info( + f"{area_label}:\toptimal_match_max={str(round(optimal_match_max, 2))}\t optimal_scale={optimal_scale}\t" + ) + + if config.outputs.show_image_level >= 5 or ( + is_not_matching and config.outputs.show_image_level >= 1 + ): + hstack = ImageUtils.get_padded_hstack( + [ + self.debug_image / 255, + rescaled_marker / 255, + optimal_match_result, + ] + ) + InteractionUtils.show( + f"Template Marker Matching: {area_label} ({optimal_match_max:.2f}/{self.min_matching_threshold:.2f})", + hstack, + pause=is_not_matching, + ) + + return marker_position, optimal_marker + + def exclude_files(self): + return self.loaded_reference_images.keys() + + def prepare_image(self, image): + # TODO: remove apply_erode_subtract? + return ImageUtils.normalize( + image + if self.apply_erode_subtract + else (image - cv2.erode(image, kernel=np.ones((5, 5)), iterations=5)) + ) diff --git a/src/processors/internal/CropOnDotLines.py b/src/processors/internal/CropOnDotLines.py new file mode 100644 index 00000000..37cc667e --- /dev/null +++ b/src/processors/internal/CropOnDotLines.py @@ -0,0 +1,433 @@ +import cv2 +import numpy as np + +from src.processors.constants import ( + DOT_AREA_TYPES_IN_ORDER, + EDGE_TYPES_IN_ORDER, + LINE_AREA_TYPES_IN_ORDER, + TARGET_EDGE_FOR_LINE, + AreaTemplate, + EdgeType, + ScannerType, + WarpMethod, +) +from src.processors.internal.CropOnPatchesCommon import CropOnPatchesCommon +from src.utils.image import ImageUtils +from src.utils.interaction import InteractionUtils +from src.utils.math import MathUtils + +# from rich.table import Table +# from src.utils.logger import console + + +class CropOnDotLines(CropOnPatchesCommon): + __is_internal_preprocessor__ = True + + scan_area_templates_for_layout = { + "ONE_LINE_TWO_DOTS": [ + AreaTemplate.topRightDot, + AreaTemplate.bottomRightDot, + AreaTemplate.leftLine, + ], + "TWO_DOTS_ONE_LINE": [ + AreaTemplate.rightLine, + AreaTemplate.topLeftDot, + AreaTemplate.bottomLeftDot, + ], + "TWO_LINES": [ + AreaTemplate.leftLine, + AreaTemplate.rightLine, + ], + "TWO_LINES_HORIZONTAL": [ + AreaTemplate.topLine, + AreaTemplate.bottomLine, + ], + "FOUR_DOTS": DOT_AREA_TYPES_IN_ORDER, + } + + default_scan_area_descriptions = { + **{ + area_template: { + "scannerType": ScannerType.PATCH_DOT, + "selector": "SELECT_CENTER", + "maxPoints": 2, # for cropping + } + for area_template in DOT_AREA_TYPES_IN_ORDER + }, + **{ + area_template: { + "scannerType": ScannerType.PATCH_LINE, + "selector": "LINE_OUTER_EDGE", + } + for area_template in LINE_AREA_TYPES_IN_ORDER + }, + "CUSTOM": {}, + } + + default_points_selector_map = { + "CENTERS": { + AreaTemplate.topLeftDot: "SELECT_CENTER", + AreaTemplate.topRightDot: "SELECT_CENTER", + AreaTemplate.bottomRightDot: "SELECT_CENTER", + AreaTemplate.bottomLeftDot: "SELECT_CENTER", + AreaTemplate.leftLine: "LINE_OUTER_EDGE", + AreaTemplate.rightLine: "LINE_OUTER_EDGE", + }, + "INNER_WIDTHS": { + AreaTemplate.topLeftDot: "SELECT_TOP_RIGHT", + AreaTemplate.topRightDot: "SELECT_TOP_LEFT", + AreaTemplate.bottomRightDot: "SELECT_BOTTOM_LEFT", + AreaTemplate.bottomLeftDot: "SELECT_BOTTOM_RIGHT", + AreaTemplate.leftLine: "LINE_INNER_EDGE", + AreaTemplate.rightLine: "LINE_INNER_EDGE", + }, + "INNER_HEIGHTS": { + AreaTemplate.topLeftDot: "SELECT_BOTTOM_LEFT", + AreaTemplate.topRightDot: "SELECT_BOTTOM_RIGHT", + AreaTemplate.bottomRightDot: "SELECT_TOP_RIGHT", + AreaTemplate.bottomLeftDot: "SELECT_TOP_LEFT", + AreaTemplate.leftLine: "LINE_OUTER_EDGE", + AreaTemplate.rightLine: "LINE_OUTER_EDGE", + }, + "INNER_CORNERS": { + AreaTemplate.topLeftDot: "SELECT_BOTTOM_RIGHT", + AreaTemplate.topRightDot: "SELECT_BOTTOM_LEFT", + AreaTemplate.bottomRightDot: "SELECT_TOP_LEFT", + AreaTemplate.bottomLeftDot: "SELECT_TOP_RIGHT", + AreaTemplate.leftLine: "LINE_INNER_EDGE", + AreaTemplate.rightLine: "LINE_INNER_EDGE", + }, + "OUTER_CORNERS": { + AreaTemplate.topLeftDot: "SELECT_TOP_LEFT", + AreaTemplate.topRightDot: "SELECT_TOP_RIGHT", + AreaTemplate.bottomRightDot: "SELECT_BOTTOM_RIGHT", + AreaTemplate.bottomLeftDot: "SELECT_BOTTOM_LEFT", + AreaTemplate.leftLine: "LINE_OUTER_EDGE", + AreaTemplate.rightLine: "LINE_OUTER_EDGE", + }, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + tuning_options = self.tuning_options + self.line_kernel_morph = cv2.getStructuringElement( + cv2.MORPH_RECT, tuple(tuning_options.get("lineKernel", [2, 10])) + ) + self.dot_kernel_morph = cv2.getStructuringElement( + cv2.MORPH_RECT, tuple(tuning_options.get("dotKernel", [5, 5])) + ) + + def validate_scan_areas(self): + # TODO: more validations here at child class level (customOptions etc) + return super().validate_scan_areas() + + def validate_and_remap_options_schema(self, options): + layout_type = options["type"] + tuning_options = options.get("tuningOptions", {}) + parsed_options = { + "pointsLayout": layout_type, + "enableCropping": True, + "tuningOptions": { + "warpMethod": tuning_options.get( + "warpMethod", WarpMethod.PERSPECTIVE_TRANSFORM + ) + }, + } + + # TODO: add default values for provided options["scanAreas"]? like get "maxPoints" from options["lineMaxPoints"] + # inject scanAreas + parsed_options["scanAreas"] = [ + # Note: currently at CropOnMarkers/CropOnDotLines level, 'scanAreas' is extended provided areas + *options.get("scanAreas", []), + *[ + { + "areaTemplate": area_template, + "areaDescription": options.get(area_template, {}), + "customOptions": { + # TODO: get customOptions here + }, + } + for area_template in self.scan_area_templates_for_layout[layout_type] + ], + ] + return parsed_options + + edge_selector_map = { + AreaTemplate.topLine: { + "LINE_INNER_EDGE": EdgeType.BOTTOM, + "LINE_OUTER_EDGE": EdgeType.TOP, + }, + AreaTemplate.leftLine: { + "LINE_INNER_EDGE": EdgeType.RIGHT, + "LINE_OUTER_EDGE": EdgeType.LEFT, + }, + AreaTemplate.bottomLine: { + "LINE_INNER_EDGE": EdgeType.TOP, + "LINE_OUTER_EDGE": EdgeType.BOTTOM, + }, + AreaTemplate.rightLine: { + "LINE_INNER_EDGE": EdgeType.LEFT, + "LINE_OUTER_EDGE": EdgeType.RIGHT, + }, + } + + @staticmethod + def select_edge_from_scan_area(area_description, edge_type): + destination_rectangle = MathUtils.get_rectangle_points_from_box( + area_description["origin"], area_description["dimensions"] + ) + + destination_line = MathUtils.select_edge_from_rectangle( + destination_rectangle, edge_type + ) + return destination_line + + def find_and_select_points_from_line( + self, image, area_template, area_description, _file_path + ): + area_label = area_description["label"] + points_selector = area_description.get( + "selector", self.default_points_selector.get(area_label, None) + ) + + line_edge_contours_map = self.find_line_corners_and_contours( + image, area_description + ) + + selected_edge_type = self.edge_selector_map[area_label][points_selector] + target_edge_type = TARGET_EDGE_FOR_LINE[area_template] + selected_contour = line_edge_contours_map[selected_edge_type] + destination_line = self.select_edge_from_scan_area( + area_description, selected_edge_type + ) + # Ensure clockwise order after extraction + if selected_edge_type != target_edge_type: + selected_contour.reverse() + destination_line.reverse() + + max_points = area_description.get("maxPoints", None) + + # Extrapolates the destination_line to get approximate destination points + ( + control_points, + destination_points, + ) = ImageUtils.get_control_destination_points_from_contour( + selected_contour, destination_line, max_points + ) + # Temp + # table = Table( + # title=f"{area_label}: {destination_line}", + # show_header=True, + # show_lines=False, + # ) + # table.add_column("Control", style="cyan", no_wrap=True) + # table.add_column("Destination", style="magenta") + # for c, d in zip(control_points, destination_points): + # table.add_row(str(c), str(d)) + # console.print(table, justify="center") + + return control_points, destination_points, selected_contour + + def find_line_corners_and_contours(self, image, area_description): + area_label = area_description["label"] + config = self.tuning_config + tuning_options = self.tuning_options + area, area_start = self.compute_scan_area_util(image, area_description) + + # Make boxes darker (less gamma) + darker_image = ImageUtils.adjust_gamma(area, config.thresholding.GAMMA_LOW) + + # Lines are expected to be fairly dark + line_threshold = tuning_options.get("lineThreshold", 180) + + _, thresholded = cv2.threshold( + darker_image, line_threshold, 255, cv2.THRESH_TRUNC + ) + normalised = ImageUtils.normalize(thresholded) + + # add white padding + kernel_height, kernel_width = self.line_kernel_morph.shape[:2] + white, pad_range = ImageUtils.pad_image_from_center( + normalised, kernel_width, kernel_height, 255 + ) + + # Threshold-Normalize after morph + white padding + _, white_thresholded = cv2.threshold( + white, line_threshold, 255, cv2.THRESH_TRUNC + ) + white_normalised = ImageUtils.normalize(white_thresholded) + + # Open : erode then dilate + line_morphed = cv2.morphologyEx( + white_normalised, cv2.MORPH_OPEN, self.line_kernel_morph, iterations=3 + ) + + # remove white padding + line_morphed = line_morphed[ + pad_range[0] : pad_range[1], pad_range[2] : pad_range[3] + ] + + if config.outputs.show_image_level >= 5: + self.debug_hstack += [ + darker_image, + normalised, + white_thresholded, + line_morphed, + ] + elif config.outputs.show_image_level == 4: + InteractionUtils.show( + f"morph_opened_{area_label}", line_morphed, pause=False + ) + + ( + _, + edge_contours_map, + ) = self.find_morph_corners_and_contours_map( + area_start, line_morphed, area_description + ) + + if edge_contours_map is None: + raise Exception( + f"No line match found at origin: {area_description['origin']} with dimensions: { area_description['dimensions']}" + ) + return edge_contours_map + + def find_dot_corners_from_options(self, image, area_description, _file_path): + config = self.tuning_config + tuning_options = self.tuning_options + area_label = area_description["label"] + + area, area_start = self.compute_scan_area_util(image, area_description) + + # TODO: simple colored thresholding to clear out noise? + + dot_blur_kernel = tuning_options.get("dotBlurKernel", None) + if dot_blur_kernel: + area = cv2.GaussianBlur(area, dot_blur_kernel, 0) + + # Open : erode then dilate + morph_c = cv2.morphologyEx( + area, cv2.MORPH_OPEN, self.dot_kernel_morph, iterations=3 + ) + + # TODO: try pyrDown to 64 values and find the outlier for black threshold? + # Dots are expected to be fairly dark + dot_threshold = tuning_options.get("dotThreshold", 150) + _, thresholded = cv2.threshold(morph_c, dot_threshold, 255, cv2.THRESH_TRUNC) + normalised = ImageUtils.normalize(thresholded) + + if config.outputs.show_image_level >= 5: + self.debug_hstack += [area, morph_c, thresholded, normalised] + elif config.outputs.show_image_level == 4: + InteractionUtils.show( + f"threshold_normalised: {area_label}", normalised, pause=False + ) + + corners, _ = self.find_morph_corners_and_contours_map( + area_start, normalised, area_description + ) + if corners is None: + hstack = ImageUtils.get_padded_hstack([self.debug_image, area, thresholded]) + InteractionUtils.show( + f"No patch/dot debug hstack", + ImageUtils.get_padded_hstack(self.debug_hstack), + pause=0, + ) + InteractionUtils.show(f"No patch/dot found:", hstack, pause=1) + raise Exception( + f"No patch/dot found at origin: {area_description['origin']} with dimensions: { area_description['dimensions']}" + ) + + return corners + + # TODO: >> create a ScanArea class and move some methods there + def find_morph_corners_and_contours_map(self, area_start, area, area_description): + scanner_type, area_label = ( + area_description["scannerType"], + area_description["label"], + ) + config = self.tuning_config + canny_edges = cv2.Canny(area, 185, 55) + + if config.outputs.show_image_level >= 5: + self.debug_hstack.append(canny_edges) + + # Should mostly return a single contour in the area + all_contours = ImageUtils.grab_contours( + cv2.findContours( + canny_edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE + ) # cv2.CHAIN_APPROX_NONE) + ) + # Note: skipping convexHull here because we want to preserve the curves + + if len(all_contours) == 0: + return None, None + + ordered_patch_corners, edge_contours_map = None, None + largest_contour = sorted(all_contours, key=cv2.contourArea, reverse=True)[0] + if config.outputs.show_image_level >= 5: + h, w = canny_edges.shape[:2] + contour_overlay = 255 * np.ones((h, w), np.uint8) + ImageUtils.draw_contour(contour_overlay, largest_contour) + self.debug_hstack.append(contour_overlay) + + # Convert to list of 2d points + bounding_contour = np.vstack(largest_contour).squeeze() + + # TODO: >> see if bounding_hull is still needed + bounding_hull = cv2.convexHull(bounding_contour) + + if scanner_type == ScannerType.PATCH_DOT: + # Bounding rectangle will not be rotated + x, y, w, h = cv2.boundingRect(bounding_hull) + patch_corners = MathUtils.get_rectangle_points(x, y, w, h) + ( + ordered_patch_corners, + edge_contours_map, + ) = ImageUtils.split_patch_contour_on_corners( + patch_corners, bounding_contour + ) + elif scanner_type == ScannerType.PATCH_LINE: + # Rotated rectangle can correct slight rotations better + rotated_rect = cv2.minAreaRect(bounding_hull) + # TODO: less confidence if angle = rotated_rect[2] is too skew + rotated_rect_points = cv2.boxPoints(rotated_rect) + patch_corners = np.intp(rotated_rect_points) + ( + ordered_patch_corners, + edge_contours_map, + ) = ImageUtils.split_patch_contour_on_corners( + patch_corners, bounding_contour + ) + else: + raise Exception(f"Unsupported scanner type: {scanner_type}") + + # TODO: less confidence if given dimensions differ from matched block size (also give a warning) + if config.outputs.show_image_level >= 5: + if ordered_patch_corners is not None: + corners_contour_overlay = canny_edges.copy() + ImageUtils.draw_contour(corners_contour_overlay, ordered_patch_corners) + self.debug_hstack.append(corners_contour_overlay) + + InteractionUtils.show( + f"Debug Largest Patch: {area_label}", + ImageUtils.get_padded_hstack(self.debug_hstack), + 0, + resize_to_height=True, + config=config, + ) + self.debug_vstack.append(self.debug_hstack) + self.debug_hstack = [] + + absolute_corners = MathUtils.shift_points_from_origin( + area_start, ordered_patch_corners + ) + + shifted_edge_contours_map = { + edge_type: MathUtils.shift_points_from_origin( + area_start, edge_contours_map[edge_type] + ) + for edge_type in EDGE_TYPES_IN_ORDER + } + + return absolute_corners, shifted_edge_contours_map diff --git a/src/processors/internal/CropOnPatchesCommon.py b/src/processors/internal/CropOnPatchesCommon.py new file mode 100644 index 00000000..d2baba13 --- /dev/null +++ b/src/processors/internal/CropOnPatchesCommon.py @@ -0,0 +1,324 @@ +from copy import deepcopy + +import cv2 +import numpy as np + +from src.processors.constants import ( + EDGE_TYPES_IN_ORDER, + TARGET_ENDPOINTS_FOR_EDGES, + EdgeType, + ScannerType, + WarpMethod, +) +from src.processors.internal.WarpOnPointsCommon import WarpOnPointsCommon +from src.utils.constants import CLR_DARK_GREEN +from src.utils.image import ImageUtils +from src.utils.logger import logger +from src.utils.math import MathUtils +from src.utils.parsing import OVERRIDE_MERGER + + +class CropOnPatchesCommon(WarpOnPointsCommon): + __is_internal_preprocessor__ = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.parse_and_apply_scan_area_templates_and_defaults() + self.validate_scan_areas() + self.validate_points_layouts() + + options = self.options + # Default to select centers for roi + self.default_points_selector = self.default_points_selector_map[ + options.get("defaultSelector", "CENTERS") + ] + + def exclude_files(self): + return [] + + def __str__(self): + return f"CropOnMarkers[\"{self.options['type']}\"]" + + def prepare_image(self, image): + return image + + def parse_and_apply_scan_area_templates_and_defaults(self): + options = self.options + scan_areas = options["scanAreas"] + scan_areas_with_defaults = [] + for scan_area in scan_areas: + area_template, area_description, custom_options = ( + scan_area["areaTemplate"], + scan_area.get("areaDescription", {}), + scan_area.get("customOptions", {}), + ) + area_description["label"] = area_description.get("label", area_template) + scan_areas_with_defaults += [ + { + "areaTemplate": area_template, + "areaDescription": OVERRIDE_MERGER.merge( + deepcopy(self.default_scan_area_descriptions[area_template]), + area_description, + ), + "customOptions": custom_options, + } + ] + self.scan_areas = scan_areas_with_defaults + # logger.debug(self.scan_areas) + + def validate_scan_areas(self): + seen_labels = set() + repeat_labels = set() + for scan_area in self.scan_areas: + area_label = scan_area["areaDescription"]["label"] + if area_label in seen_labels: + repeat_labels.add(area_label) + seen_labels.add(area_label) + if len(repeat_labels) > 0: + raise Exception(f"Found repeated labels in scanAreas: {repeat_labels}") + + # TODO: check if this needs to move into child for working properly (accessing self attributes declared in child in parent's constructor) + def validate_points_layouts(self): + options = self.options + points_layout = options["pointsLayout"] + if ( + points_layout not in self.scan_area_templates_for_layout + and points_layout != "CUSTOM" + ): + raise Exception( + f"Invalid pointsLayout provided: {points_layout} for {self}" + ) + + expected_templates = set(self.scan_area_templates_for_layout[points_layout]) + provided_templates = set( + [scan_area["areaTemplate"] for scan_area in self.scan_areas] + ) + not_provided_area_templates = expected_templates.difference(provided_templates) + + if len(not_provided_area_templates) > 0: + logger.error(f"not_provided_area_templates={not_provided_area_templates}") + raise Exception( + f"Missing a few scanAreaTemplates for the pointsLayout {points_layout}" + ) + + def extract_control_destination_points(self, image, _colored_image, file_path): + config = self.tuning_config + + ( + control_points, + destination_points, + ) = ( + [], + [], + ) + + # TODO: use shapely and corner points to split easily? + + area_template_points = {} + page_corners, destination_page_corners = [], [] + for scan_area in self.scan_areas: + area_template = scan_area["areaTemplate"] + area_description = self.get_runtime_area_description_with_defaults( + image, scan_area + ) + scanner_type = area_description["scannerType"] + # Note: area_description is computed at runtime(e.g. for CropOnCustomMarkers with default quadrants) + if ( + scanner_type == ScannerType.PATCH_DOT + or scanner_type == ScannerType.TEMPLATE_MATCH + ): + dot_point, destination_point = self.find_and_select_point_from_dot( + image, area_description, file_path + ) + area_control_points, area_destination_points = [dot_point], [ + destination_point + ] + area_template_points[area_template] = area_control_points + + page_corners.append(dot_point) + destination_page_corners.append(destination_point) + + elif scanner_type == ScannerType.PATCH_LINE: + ( + area_control_points, + area_destination_points, + selected_contour, + ) = self.find_and_select_points_from_line( + image, area_template, area_description, file_path + ) + area_template_points[area_template] = selected_contour + page_corners += [area_control_points[0], area_control_points[-1]] + destination_page_corners += [ + area_destination_points[0], + area_destination_points[-1], + ] + # TODO: support DASHED_LINE here later + control_points += area_control_points + destination_points += area_destination_points + + if config.outputs.show_image_level >= 4: + self.draw_area_contours_and_anchor_shifts( + area_control_points, area_destination_points + ) + + # Fill edge contours + edge_contours_map = self.get_edge_contours_map_from_area_points( + area_template_points + ) + + if self.warp_method in [ + WarpMethod.PERSPECTIVE_TRANSFORM, + ]: + ordered_page_corners, ordered_indices = MathUtils.order_four_points( + page_corners, dtype="float32" + ) + destination_page_corners = [ + destination_page_corners[i] for i in ordered_indices + ] + return ordered_page_corners, destination_page_corners, edge_contours_map + + # TODO: sort edge_contours_map manually? + return control_points, destination_points, edge_contours_map + + def get_runtime_area_description_with_defaults(self, image, scan_area): + return scan_area["areaDescription"] + + def get_edge_contours_map_from_area_points(self, area_template_points): + edge_contours_map = { + EdgeType.TOP: [], + EdgeType.RIGHT: [], + EdgeType.BOTTOM: [], + EdgeType.LEFT: [], + } + + for edge_type in EDGE_TYPES_IN_ORDER: + for area_template, contour_point_index in TARGET_ENDPOINTS_FOR_EDGES[ + edge_type + ]: + if area_template in area_template_points: + area_points = area_template_points[area_template] + if contour_point_index == "ALL": + edge_contours_map[edge_type] += area_points + else: + edge_contours_map[edge_type].append( + area_points[contour_point_index] + ) + return edge_contours_map + + def draw_area_contours_and_anchor_shifts( + self, area_control_points, area_destination_points + ): + if len(area_control_points) > 1: + if len(area_control_points) == 2: + # Draw line if it's just two points + ImageUtils.draw_contour(self.debug_image, area_control_points) + else: + # Draw convex hull of the found control points + ImageUtils.draw_contour( + self.debug_image, + cv2.convexHull(np.intp(area_control_points)), + ) + + # Helper for alignment + ImageUtils.draw_arrows( + self.debug_image, + area_control_points, + area_destination_points, + tip_length=0.4, + ) + for control_point in area_control_points: + # Show current detections too + ImageUtils.draw_box( + self.debug_image, + control_point, + # TODO: change this based on image shape + [20, 20], + color=CLR_DARK_GREEN, + border=1, + centered=True, + ) + + def find_and_select_point_from_dot(self, image, area_description, file_path): + area_label = area_description["label"] + points_selector = area_description.get( + "selector", self.default_points_selector.get(area_label, None) + ) + + dot_rect = self.find_dot_corners_from_options( + image, area_description, file_path + ) + + dot_point = self.select_point_from_rectangle(dot_rect, points_selector) + + if dot_point is None: + raise Exception(f"No dot found for area {area_label}") + + destination_rect = self.compute_scan_area_destination_rect(area_description) + destination_point = self.select_point_from_rectangle( + destination_rect, points_selector + ) + + return dot_point, destination_point + + @staticmethod + def select_point_from_rectangle(rectangle, points_selector): + tl, tr, br, bl = rectangle + if points_selector == "SELECT_TOP_LEFT": + return tl + if points_selector == "SELECT_TOP_RIGHT": + return tr + if points_selector == "SELECT_BOTTOM_RIGHT": + return br + if points_selector == "SELECT_BOTTOM_LEFT": + return bl + if points_selector == "SELECT_CENTER": + return [ + (tl[0] + br[0]) // 2, + (tl[1] + br[1]) // 2, + ] + return None + + def compute_scan_area_destination_rect(self, area_description): + x, y = area_description["origin"] + w, h = area_description["dimensions"] + return np.intp(MathUtils.get_rectangle_points(x, y, w, h)) + + def compute_scan_area_util(self, image, area_description): + area_label = area_description["label"] + # parse arguments + h, w = image.shape[:2] + origin, dimensions, margins = map( + area_description.get, ["origin", "dimensions", "margins"] + ) + + # compute area and clip to image dimensions + area_start = [ + int(origin[0] - margins["left"]), + int(origin[1] - margins["top"]), + ] + area_end = [ + int(origin[0] + margins["right"] + dimensions[0]), + int(origin[1] + margins["bottom"] + dimensions[1]), + ] + + if area_start[0] < 0 or area_start[1] < 0 or area_end[0] > w or area_end[1] > h: + logger.warning( + f"Clipping label {area_label} with scan rectangle: {[area_start, area_end]} to image boundary." + ) + + area_start = [max(0, area_start[0]), max(0, area_start[1])] + area_end = [min(w, area_end[0]), min(h, area_end[1])] + + # Extract image area + area = image[area_start[1] : area_end[1], area_start[0] : area_end[0]] + + config = self.tuning_config + if config.outputs.show_image_level >= 1: + ImageUtils.draw_box_diagonal( + self.debug_image, + area_start, + area_end, + color=CLR_DARK_GREEN, + border=2, + ) + return area, np.array(area_start) diff --git a/src/processors/internal/Processor.py b/src/processors/internal/Processor.py new file mode 100644 index 00000000..96332b21 --- /dev/null +++ b/src/processors/internal/Processor.py @@ -0,0 +1,15 @@ +class Processor: + """Base class that each processor must inherit from.""" + + def __init__( + self, + options, + relative_dir, + ): + self.options = options + self.tuning_options = options.get("tuningOptions", {}) + self.relative_dir = relative_dir + self.description = "UNKNOWN" + + def __str__(self): + return self.__module__ diff --git a/src/processors/internal/WarpOnPointsCommon.py b/src/processors/internal/WarpOnPointsCommon.py new file mode 100644 index 00000000..2583fb84 --- /dev/null +++ b/src/processors/internal/WarpOnPointsCommon.py @@ -0,0 +1,349 @@ +import cv2 +import numpy as np +from scipy.interpolate import griddata + +from src.processors.constants import WarpMethod, WarpMethodFlags +from src.processors.helpers.rectify import rectify +from src.processors.interfaces.ImageTemplatePreprocessor import ( + ImageTemplatePreprocessor, +) +from src.utils.image import ImageUtils +from src.utils.interaction import InteractionUtils +from src.utils.logger import logger +from src.utils.math import MathUtils +from src.utils.parsing import OVERRIDE_MERGER + + +# Internal Processor for separation of code +class WarpOnPointsCommon(ImageTemplatePreprocessor): + __is_internal_preprocessor__ = True + + warp_method_flags_map = { + WarpMethodFlags.INTER_LINEAR: cv2.INTER_LINEAR, + WarpMethodFlags.INTER_CUBIC: cv2.INTER_CUBIC, + WarpMethodFlags.INTER_NEAREST: cv2.INTER_NEAREST, + } + + def validate_and_remap_options_schema(self): + raise Exception(f"Not implemented") + + def __init__( + self, options, relative_dir, image_instance_ops, default_processing_image_shape + ): + # TODO: need to fix this (self attributes will be overridden by parent and may cause inconsistency) + self.tuning_config = image_instance_ops.tuning_config + + parsed_options = self.validate_and_remap_options_schema(options) + # Processor tuningOptions defaults + parsed_options = OVERRIDE_MERGER.merge( + { + "tuningOptions": options.get("tuningOptions", {}), + }, + parsed_options, + ) + + super().__init__( + parsed_options, + relative_dir, + image_instance_ops, + default_processing_image_shape, + ) + options = self.options + tuning_options = self.tuning_options + self.enable_cropping = options.get("enableCropping", False) + + self.warp_method = tuning_options.get( + "warpMethod", + ( + WarpMethod.PERSPECTIVE_TRANSFORM + if self.enable_cropping + else WarpMethod.HOMOGRAPHY + ), + ) + self.warp_method_flag = self.warp_method_flags_map.get( + tuning_options.get("warpMethodFlag", "INTER_LINEAR") + ) + + def exclude_files(self): + return [] + + def prepare_image(self, image): + return image + + def apply_filter(self, image, colored_image, _template, file_path): + config = self.tuning_config + self.debug_image = image.copy() + self.debug_hstack = [] + self.debug_vstack = [] + + image = self.prepare_image(image) + + ( + control_points, + destination_points, + edge_contours_map, + ) = self.extract_control_destination_points(image, colored_image, file_path) + + ( + parsed_control_points, + parsed_destination_points, + warped_dimensions, + ) = self.parse_control_destination_points_for_image( + image, control_points, destination_points + ) + + logger.info( + f"Cropping Enabled: {self.enable_cropping}\n parsed_control_points={parsed_control_points} \n parsed_destination_points={parsed_destination_points} \n warped_dimensions={warped_dimensions}" + ) + + if self.warp_method == WarpMethod.PERSPECTIVE_TRANSFORM: + ( + transform_matrix, + warped_dimensions, + parsed_destination_points, + ) = self.get_perspective_transform_matrix( + parsed_control_points, # parsed_destination_points + ) + warped_image, warped_colored_image = self.warp_perspective( + image, colored_image, transform_matrix, warped_dimensions + ) + + # elif TODO: support for warpAffine as well for non cropped Alignment!! + elif self.warp_method == WarpMethod.HOMOGRAPHY: + transform_matrix, _matches_mask = self.get_homography_matrix( + parsed_control_points, parsed_destination_points + ) + warped_image, warped_colored_image = self.warp_perspective( + image, colored_image, transform_matrix, warped_dimensions + ) + elif self.warp_method == WarpMethod.REMAP_GRIDDATA: + warped_image, warped_colored_image = self.remap_with_griddata( + image, + colored_image, + parsed_control_points, + parsed_destination_points, + ) + elif self.warp_method == WarpMethod.DOC_REFINE: + warped_image, warped_colored_image = self.remap_with_doc_refine_rectify( + image, colored_image, edge_contours_map + ) + + if config.outputs.show_image_level >= 4: + title_prefix = "Warped Image" + if self.enable_cropping: + title_prefix = "Cropped Image" + # Draw the convex hull of all control points + ImageUtils.draw_contour( + self.debug_image, cv2.convexHull(parsed_control_points) + ) + if config.outputs.show_image_level >= 5: + InteractionUtils.show("Anchor Points", self.debug_image, pause=False) + + matched_lines = ImageUtils.draw_matches( + image, + parsed_control_points, + warped_image, + parsed_destination_points, + ) + + InteractionUtils.show( + f"{title_prefix} with Match Lines: {file_path}", + matched_lines, + pause=True, + resize_to_height=True, + config=config, + ) + + return warped_image, warped_colored_image, _template + + def get_perspective_transform_matrix( + self, + parsed_control_points, # _parsed_destination_points + ): + if len(parsed_control_points) > 4: + logger.critical(f"Too many parsed_control_points={parsed_control_points}") + raise Exception( + f"Expected 4 control points for perspective transform, found {len(parsed_control_points)}. If you want to use a different method, pass it in tuningOptions['warpMethod']" + ) + # TODO: order the points from outside in parsing itself + parsed_control_points, ordered_indices = MathUtils.order_four_points( + parsed_control_points, dtype="float32" + ) + # TODO: fix use _parsed_destination_points and make it work? + # parsed_destination_points = _parsed_destination_points[ordered_indices] + ( + parsed_destination_points, + warped_dimensions, + ) = ImageUtils.get_cropped_rectangle_destination_points(parsed_control_points) + + transform_matrix = cv2.getPerspectiveTransform( + parsed_control_points, parsed_destination_points + ) + return transform_matrix, warped_dimensions, parsed_destination_points + + def get_homography_matrix(self, parsed_control_points, parsed_destination_points): + # Note: the robust methods cv2.RANSAC or cv2.LMEDS are not used as they will + # take a subset of the destination points(inliers) which is not desired for our use-case + + # Getting the homography. + homography, matches_mask = cv2.findHomography( + parsed_control_points, + parsed_destination_points, + method=0, + # method=cv2.RANSAC, + # Note: ransacReprojThreshold is literally the the pixel distance in our coordinates + # ransacReprojThreshold=3, + ) + # TODO: check if float32 is really needed for the matrix + transform_matrix = np.float32(homography) + return transform_matrix, matches_mask + + def warp_perspective( + self, image, colored_image, transform_matrix, warped_dimensions + ): + config = self.tuning_config + + # Crop the image + warped_image = cv2.warpPerspective( + image, transform_matrix, warped_dimensions, flags=self.warp_method_flag + ) + + # TODO: Save intuitive meta data + # self.append_save_image(3,warped_image) + warped_colored_image = None + if config.outputs.show_colored_outputs: + warped_colored_image = cv2.warpPerspective( + colored_image, transform_matrix, warped_dimensions + ) + + # self.append_save_image(1,warped_image) + + return warped_image, warped_colored_image + + def parse_control_destination_points_for_image( + self, image, control_points, destination_points + ): + parsed_control_points, parsed_destination_points = [], [] + + # de-dupe + control_points_set = set() + for control_point, destination_point in zip(control_points, destination_points): + control_point_tuple = tuple(control_point) + if control_point_tuple not in control_points_set: + control_points_set.add(control_point_tuple) + parsed_control_points.append(control_point) + parsed_destination_points.append(destination_point) + + # TODO: add support for isOptionalFeaturePoint(maybe filter the 'None' control points!) + # TODO: do an ordering of the points + + h, w = image.shape[:2] + warped_dimensions = (w, h) + if self.enable_cropping: + # TODO: exclude the 'excludeFromCropping' destination points(and corresponding control points, after using for alignments) + # TODO: use a small class for each point? + + # get bounding box on the destination points (with validation?) + ( + destination_box, + rectangle_dimensions, + ) = MathUtils.get_bounding_box_of_points(parsed_destination_points) + # TODO: Give a warning if the destination_points do not form a convex polygon! + warped_dimensions = rectangle_dimensions + + # Cropping means the bounding destination points need to become the bounding box! + # >> Rest of the points need to scale according to that grid!? + + # TODO: find a way to get the bounding box to control points mapping + # parsed_destination_points = destination_box[[1,2,0,3]] + + # Shift the destination points to enable the cropping + from_origin = -1 * destination_box[0] + parsed_destination_points = MathUtils.shift_points_from_origin( + from_origin, parsed_destination_points + ) + # Note: control points remain the same (wrt image shape!) + + # Note: the inner elements may already be floats returned by scan area detections + parsed_control_points = np.float32(parsed_control_points) + parsed_destination_points = np.float32(parsed_destination_points) + + return ( + parsed_control_points, + parsed_destination_points, + warped_dimensions, + ) + + def remap_with_griddata( + self, image, colored_image, parsed_control_points, parsed_destination_points + ): + config = self.tuning_config + + assert image.shape[:2] == colored_image.shape[:2] + + if self.enable_cropping: + # TODO: >> get this more reliably - use minAreaRect instead? + _, (w, h) = MathUtils.get_bounding_box_of_points(parsed_destination_points) + else: + h, w = image.shape[:2] + + # https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.griddata.html + # For us: D=2, n=len(parsed_control_points) + # meshgrid == all integer coordinates of destination image ( [0, h] x [0, w]) + grid_y, grid_x = np.mgrid[0 : h - 1 : complex(h), 0 : w - 1 : complex(w)] + + # We make use of griddata's ability to map n-d data points in a continuous function + grid_z = griddata( + # Input points + points=parsed_destination_points, + # Expected values + values=parsed_control_points, + # Points at which to interpolate data (inside convex hull of the points) + xi=(grid_x, grid_y), + method="cubic", + ) + grid_z = grid_z.astype("float32") + + warped_image = cv2.remap( + image, map1=grid_z, map2=None, interpolation=cv2.INTER_CUBIC + ) + warped_colored_image = None + if config.outputs.show_colored_outputs: + warped_colored_image = cv2.remap( + colored_image, + map1=grid_z, + map2=None, + interpolation=cv2.INTER_CUBIC, + ) + + return warped_image, warped_colored_image + + def remap_with_doc_refine_rectify(self, image, colored_image, edge_contours_map): + config = self.tuning_config + + # TODO: adapt this contract in the remap framework (use griddata vs scanline interchangeably?) + scaled_map = rectify( + edge_contours_map=edge_contours_map, enable_cropping=self.enable_cropping + ) + + # logger.info("scaled_map parts", scaled_map[::120, ::120, :]) + # logger.info("rectified_image", rectified_image) + logger.info("scaled_map.shape", scaled_map.shape) + + warped_image = cv2.remap( + image, + map1=scaled_map, + map2=None, + interpolation=cv2.INTER_NEAREST, # cv2.INTER_CUBIC + ) + InteractionUtils.show("warped_image", warped_image, 0) + + warped_colored_image = None + if config.outputs.show_colored_outputs: + warped_colored_image = cv2.remap( + colored_image, map1=scaled_map, map2=None, interpolation=cv2.INTER_CUBIC + ) + logger.info("warped_colored_image.shape", warped_colored_image.shape) + InteractionUtils.show("warped_colored_image", warped_colored_image, 0) + + return warped_image, warped_colored_image diff --git a/src/processors/manager.py b/src/processors/manager.py index c41e0713..60d6e669 100644 --- a/src/processors/manager.py +++ b/src/processors/manager.py @@ -1,80 +1,24 @@ -""" -Processor/Extension framework -Adapated from https://github.com/gdiepen/python_processor_example -""" -import inspect -import pkgutil - -from src.logger import logger - - -class Processor: - """Base class that each processor must inherit from.""" - - def __init__( - self, - options=None, - relative_dir=None, - image_instance_ops=None, - ): - self.options = options - self.relative_dir = relative_dir - self.image_instance_ops = image_instance_ops - self.tuning_config = image_instance_ops.tuning_config - self.description = "UNKNOWN" - - -class ProcessorManager: - """Upon creation, this class will read the processors package for modules - that contain a class definition that is inheriting from the Processor class - """ - - def __init__(self, processors_dir="src.processors"): - """Constructor that initiates the reading of all available processors - when an instance of the ProcessorCollection object is created - """ - self.processors_dir = processors_dir - self.reload_processors() - - @staticmethod - def get_name_filter(processor_name): - def filter_function(member): - return inspect.isclass(member) and member.__module__ == processor_name - - return filter_function - - def reload_processors(self): - """Reset the list of all processors and initiate the walk over the main - provided processor package to load all available processors - """ - self.processors = {} - self.seen_paths = [] - - logger.info(f'Loading processors from "{self.processors_dir}"...') - self.walk_package(self.processors_dir) - - def walk_package(self, package): - """walk the supplied package to retrieve all processors""" - imported_package = __import__(package, fromlist=["blah"]) - loaded_packages = [] - for _, processor_name, ispkg in pkgutil.walk_packages( - imported_package.__path__, imported_package.__name__ + "." - ): - if not ispkg and processor_name != __name__: - processor_module = __import__(processor_name, fromlist=["blah"]) - # https://stackoverflow.com/a/46206754/6242649 - clsmembers = inspect.getmembers( - processor_module, - ProcessorManager.get_name_filter(processor_name), - ) - for _, c in clsmembers: - # Only add classes that are a sub class of Processor, but NOT Processor itself - if issubclass(c, Processor) & (c is not Processor): - self.processors[c.__name__] = c - loaded_packages.append(c.__name__) - - logger.info(f"Loaded processors: {loaded_packages}") - - -# Singleton export -PROCESSOR_MANAGER = ProcessorManager() +from dotmap import DotMap + +from src.processors.CropOnMarkers import CropOnMarkers +from src.processors.CropPage import CropPage +from src.processors.FeatureBasedAlignment import FeatureBasedAlignment +from src.processors.GaussianBlur import GaussianBlur +from src.processors.Levels import Levels +from src.processors.MedianBlur import MedianBlur + +# Note: we're now hard coding the processors mapping to support working export of PyInstaller +PROCESSOR_MANAGER = DotMap( + { + "processors": { + "CropOnMarkers": CropOnMarkers, + # TODO: "WarpOnPoints": WarpOnPointsCommon, + "CropPage": CropPage, + "FeatureBasedAlignment": FeatureBasedAlignment, + "GaussianBlur": GaussianBlur, + "Levels": Levels, + "MedianBlur": MedianBlur, + } + }, + _dynamic=False, +) diff --git a/src/schemas/config_schema.py b/src/schemas/config_schema.py index 6eb815f8..c5f18589 100644 --- a/src/schemas/config_schema.py +++ b/src/schemas/config_schema.py @@ -1,3 +1,5 @@ +from src.schemas.constants import two_positive_integers + CONFIG_SCHEMA = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/Udayraj123/OMRChecker/tree/master/src/schemas/config-schema.json", @@ -6,51 +8,110 @@ "type": "object", "additionalProperties": False, "properties": { - "dimensions": { + "thresholding": { + "description": "The values used in the core algorithm of OMRChecker", "type": "object", "additionalProperties": False, "properties": { - "display_height": {"type": "integer"}, - "display_width": {"type": "integer"}, - "processing_height": {"type": "integer"}, - "processing_width": {"type": "integer"}, - }, - }, - "threshold_params": { - "type": "object", - "additionalProperties": False, - "properties": { - "GAMMA_LOW": {"type": "number", "minimum": 0, "maximum": 1}, - "MIN_GAP": {"type": "integer", "minimum": 10, "maximum": 100}, - "MIN_JUMP": {"type": "integer", "minimum": 10, "maximum": 100}, - "CONFIDENT_SURPLUS": {"type": "integer", "minimum": 0, "maximum": 20}, - "JUMP_DELTA": {"type": "integer", "minimum": 10, "maximum": 100}, - "PAGE_TYPE_FOR_THRESHOLD": { - "enum": ["white", "black"], - "type": "string", + # TODO: rename these variables for better usability + "MIN_GAP_TWO_BUBBLES": { + "description": "Minimum difference between all mean values of the bubbles. Used for local thresholding of 2 or 1 bubbles", + "type": "integer", + "minimum": 10, + "maximum": 100, + }, + "MIN_JUMP": { + "description": "Minimum difference between consecutive elements to be consider as a jump in a sorted array of mean values of the bubbles", + "type": "integer", + "minimum": 10, + "maximum": 100, + }, + "MIN_JUMP_SURPLUS_FOR_GLOBAL_FALLBACK": { + "description": "This value is added to jump value, underconfident bubbles fallback to global_threshold_for_template", + "type": "integer", + "minimum": 0, + "maximum": 20, + }, + "GLOBAL_THRESHOLD_MARGIN": { + "description": 'This value determines if the calculated global threshold is "too close" to lower bubbles in confidence metrics ', + "type": "integer", + "minimum": 0, + "maximum": 20, + }, + "JUMP_DELTA": { + "description": "Note: JUMP_DELTA is deprecated, used only in plots currently to determine a stricter threshold", + "type": "integer", + "minimum": 10, + "maximum": 100, + }, + "CONFIDENT_JUMP_SURPLUS_FOR_DISPARITY": { + "description": "This value is added to jump value to distinguish safe detections vs underconfident detections", + "type": "integer", + "minimum": 0, + "maximum": 100, + }, + "GLOBAL_PAGE_THRESHOLD": { + "description": "This option decides the starting value to use before applying local outlier threshold", + "type": "integer", + "minimum": 0, + "maximum": 255, + }, + "GAMMA_LOW": { + "description": "Used in the CropOnDotLines processor to create a darker image for enhanced line detection (darker boxes)", + "type": "number", + "minimum": 0, + "maximum": 1, }, - }, - }, - "alignment_params": { - "type": "object", - "additionalProperties": False, - "properties": { - "auto_align": {"type": "boolean"}, - "match_col": {"type": "integer", "minimum": 0, "maximum": 10}, - "max_steps": {"type": "integer", "minimum": 1, "maximum": 100}, - "stride": {"type": "integer", "minimum": 1, "maximum": 10}, - "thickness": {"type": "integer", "minimum": 1, "maximum": 10}, }, }, "outputs": { + "description": "The configuration related to the outputs generated by OMRChecker", "type": "object", "additionalProperties": False, "properties": { - "show_image_level": {"type": "integer", "minimum": 0, "maximum": 6}, - "save_image_level": {"type": "integer", "minimum": 0, "maximum": 6}, - "save_detections": {"type": "boolean"}, - # This option moves multimarked files into a separate folder for manual checking, skipping evaluation - "filter_out_multimarked_files": {"type": "boolean"}, + "display_image_dimensions": { + **two_positive_integers, + "description": "The dimensions (width, height) for images displayed during the execution", + }, + "show_logs_by_type": { + "description": "The toggles for enabling logs per level", + "type": "object", + "properties": { + "critical": {"type": "boolean"}, + "error": {"type": "boolean"}, + "warning": {"type": "boolean"}, + "info": {"type": "boolean"}, + "debug": {"type": "boolean"}, + }, + }, + "show_image_level": { + "description": "The toggle level for showing debug images (higher means more debug images)", + "type": "integer", + "minimum": 0, + "maximum": 6, + }, + "save_image_level": { + "description": "The toggle level for saving debug images (higher means more debug images)", + "type": "integer", + "minimum": 0, + "maximum": 6, + }, + "show_colored_outputs": { + "description": "This option shows colored outputs while taking a small toll on the processing speeds", + "type": "boolean", + }, + "save_detections": { + "description": "This option saves the detection outputs while taking a small toll on the processing speeds", + "type": "boolean", + }, + "save_image_metrics": { + "description": "This option exports the confidence metrics etc related to the images. These can be later used for deeper analysis/visualizations", + "type": "boolean", + }, + "filter_out_multimarked_files": { + "description": "This option moves files having multi-marked responses into a separate folder for manual checking, skipping evaluation", + "type": "boolean", + }, }, }, }, diff --git a/src/schemas/constants.py b/src/schemas/constants.py index ccaa6f38..96c838ac 100644 --- a/src/schemas/constants.py +++ b/src/schemas/constants.py @@ -1,8 +1,69 @@ +from dotmap import DotMap + DEFAULT_SECTION_KEY = "DEFAULT" BONUS_SECTION_PREFIX = "BONUS" -MARKING_VERDICT_TYPES = ["correct", "incorrect", "unmarked"] +Verdict = DotMap( + { + "ANSWER_MATCH": "answer-match", + "NO_ANSWER_MATCH": "no-answer-match", + "UNMARKED": "unmarked", + }, + _dynamic=False, +) + +VERDICTS_IN_ORDER = [ + Verdict.ANSWER_MATCH, + Verdict.NO_ANSWER_MATCH, + Verdict.UNMARKED, +] + + +SchemaVerdict = DotMap( + { + "CORRECT": "correct", + "INCORRECT": "incorrect", + "UNMARKED": "unmarked", + }, + _dynamic=False, +) + +SCHEMA_VERDICTS_IN_ORDER = [ + SchemaVerdict.CORRECT, + SchemaVerdict.INCORRECT, + SchemaVerdict.UNMARKED, +] + +DEFAULT_SCORE_FORMAT_STRING = "Score: {score}" +DEFAULT_ANSWERS_SUMMARY_FORMAT_STRING = " ".join( + [ + f"{schema_verdict.title()}: {{{schema_verdict}}}" + for schema_verdict in SCHEMA_VERDICTS_IN_ORDER + ] +) + +VERDICT_TO_SCHEMA_VERDICT = { + Verdict.ANSWER_MATCH: SchemaVerdict.CORRECT, + Verdict.NO_ANSWER_MATCH: SchemaVerdict.INCORRECT, + Verdict.UNMARKED: SchemaVerdict.UNMARKED, +} + +AnswerType = DotMap( + { + # Standard answer type allows single correct answers. They can have multiple characters(multi-marked) as well. + # Useful for any standard response e.g. 'A', '01', '99', 'AB', etc + "STANDARD": "standard", + # Multiple correct answer type covers multiple correct answers + # Useful for ambiguous/bonus questions e.g. ['A', 'B'], ['1', '01'], ['A', 'B', 'AB'], etc + "MULTIPLE_CORRECT": "multiple-correct", + # Multiple correct weighted answer covers multiple answers with weights + # Useful for partial marking e.g. [['A', 2], ['B', 0.5], ['AB', 2.5]], [['1', 0.5], ['01', 1]], etc + "MULTIPLE_CORRECT_WEIGHTED": "multiple-correct-weighted", + }, + _dynamic=False, +) + ARRAY_OF_STRINGS = { "type": "array", @@ -15,3 +76,30 @@ } FIELD_STRING_REGEX_GROUPS = r"([^\.\d]+)(\d+)\.{2,3}(\d+)" + + +positive_number = {"type": "number", "minimum": 0} +positive_integer = {"type": "integer", "minimum": 0} +two_positive_integers = { + "type": "array", + "prefixItems": [ + positive_integer, + positive_integer, + ], + "maxItems": 2, + "minItems": 2, +} +two_positive_numbers = { + "type": "array", + "prefixItems": [ + positive_number, + positive_number, + ], + "maxItems": 2, + "minItems": 2, +} +zero_to_one_number = { + "type": "number", + "minimum": 0, + "maximum": 1, +} diff --git a/src/defaults/__init__.py b/src/schemas/defaults/__init__.py similarity index 60% rename from src/defaults/__init__.py rename to src/schemas/defaults/__init__.py index 62ed2e6f..fd39684f 100644 --- a/src/defaults/__init__.py +++ b/src/schemas/defaults/__init__.py @@ -1,5 +1,6 @@ # 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 +from src.schemas.defaults.config import * # NOQA +from src.schemas.defaults.evaluation import * # NOQA +from src.schemas.defaults.template import * # NOQA diff --git a/src/schemas/defaults/config.py b/src/schemas/defaults/config.py new file mode 100644 index 00000000..e3631668 --- /dev/null +++ b/src/schemas/defaults/config.py @@ -0,0 +1,34 @@ +from dotmap import DotMap + +CONFIG_DEFAULTS = DotMap( + { + "thresholding": { + "GAMMA_LOW": 0.7, + "MIN_GAP_TWO_BUBBLES": 30, + "MIN_JUMP": 25, + "CONFIDENT_JUMP_SURPLUS_FOR_DISPARITY": 25, + "MIN_JUMP_SURPLUS_FOR_GLOBAL_FALLBACK": 5, + "GLOBAL_THRESHOLD_MARGIN": 10, + "JUMP_DELTA": 30, + # Note: tune this value to avoid empty bubble detections + "GLOBAL_PAGE_THRESHOLD": 200, + }, + "outputs": { + "display_image_dimensions": [720, 1080], + "show_image_level": 0, + "save_image_level": 0, + "show_logs_by_type": { + "critical": True, + "error": True, + "warning": True, + "info": True, + "debug": False, + }, + "save_detections": True, + "show_colored_outputs": True, + "save_image_metrics": False, + "filter_out_multimarked_files": False, + }, + }, + _dynamic=False, +) diff --git a/src/schemas/defaults/evaluation.py b/src/schemas/defaults/evaluation.py new file mode 100644 index 00000000..6970f6a4 --- /dev/null +++ b/src/schemas/defaults/evaluation.py @@ -0,0 +1,30 @@ +from src.schemas.constants import ( + DEFAULT_ANSWERS_SUMMARY_FORMAT_STRING, + DEFAULT_SCORE_FORMAT_STRING, +) + +EVALUATION_CONFIG_DEFAULTS = { + "options": {}, + "marking_schemes": {}, + "conditionalSets": [], + "outputs_configuration": { + "should_explain_scoring": False, + "draw_score": { + "enabled": False, + "position": [200, 200], + "score_format_string": DEFAULT_SCORE_FORMAT_STRING, + "size": 1.5, + }, + "draw_answers_summary": { + "enabled": False, + "position": [200, 600], + "answers_summary_format_string": DEFAULT_ANSWERS_SUMMARY_FORMAT_STRING, + "size": 1.0, + }, + "verdict_colors": { + "correct": "#00ff00", + "incorrect": "#ff0000", + "unmarked": "#0000ff", + }, + }, +} diff --git a/src/schemas/defaults/template.py b/src/schemas/defaults/template.py new file mode 100644 index 00000000..dee96863 --- /dev/null +++ b/src/schemas/defaults/template.py @@ -0,0 +1,12 @@ +TEMPLATE_DEFAULTS = { + "customLabels": {}, + "emptyValue": "", + "fieldBlocks": {}, + "conditionalSets": [], + "outputColumns": [], + "preProcessors": [], + "processingImageShape": [820, 666], + "sortFiles": { + "enabled": False, + }, +} diff --git a/src/schemas/evaluation_schema.py b/src/schemas/evaluation_schema.py index 8de1af76..4ffbc9b8 100644 --- a/src/schemas/evaluation_schema.py +++ b/src/schemas/evaluation_schema.py @@ -2,146 +2,303 @@ ARRAY_OF_STRINGS, DEFAULT_SECTION_KEY, FIELD_STRING_TYPE, + SCHEMA_VERDICTS_IN_ORDER, + two_positive_integers, ) marking_score_regex = "-?(\\d+)(/(\\d+))?" marking_score = { "oneOf": [ - {"type": "string", "pattern": marking_score_regex}, - {"type": "number"}, + { + "description": "The marking score as a string. We can pass natural fractions as well", + "type": "string", + "pattern": marking_score_regex, + }, + { + "description": "The marking score as a number. It can be negative as well", + "type": "number", + }, ] } marking_object_properties = { - "additionalProperties": False, - "required": ["correct", "incorrect", "unmarked"], + "description": "The marking object describes verdict-wise score deltas", + "required": SCHEMA_VERDICTS_IN_ORDER, "type": "object", + "additionalProperties": False, "properties": { - # TODO: can support streak marking if we allow array of marking_scores here - "correct": marking_score, - "incorrect": marking_score, - "unmarked": marking_score, + schema_verdict: marking_score for schema_verdict in SCHEMA_VERDICTS_IN_ORDER }, } - -EVALUATION_SCHEMA = { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Udayraj123/OMRChecker/tree/master/src/schemas/evaluation-schema.json", - "title": "Evaluation Schema", - "description": "OMRChecker evaluation schema i.e. the marking scheme", +image_and_csv_options = { + "description": "The options needed if source type is image and csv", + "required": ["answer_key_csv_path"], + "dependentRequired": { + "answer_key_image_path": [ + "answer_key_csv_path", + "questions_in_order", + ] + }, "type": "object", - "additionalProperties": True, - "required": ["source_type", "options", "marking_schemes"], + "additionalProperties": False, "properties": { + "answer_key_csv_path": { + "description": "The path to the answer key csv relative to the evalution.json file", + "type": "string", + }, + "answer_key_image_path": { + "description": "The path to the answer key image relative to the evalution.json file", + "type": "string", + }, + "questions_in_order": { + **ARRAY_OF_STRINGS, + "description": "An array of fields to treat as questions when the answer key image is provided", + }, + }, +} + +common_evaluation_schema_properties = { + "source_type": {"type": "string", "enum": ["csv", "image_and_csv", "custom"]}, + "options": {"type": "object"}, + "marking_schemes": { + "type": "object", + "required": [DEFAULT_SECTION_KEY], + "patternProperties": { + f"^{DEFAULT_SECTION_KEY}$": marking_object_properties, + f"^(?!{DEFAULT_SECTION_KEY}$).*": { + "description": "A section that defines custom marking for a subset of the questions", + "additionalProperties": False, + "required": ["marking", "questions"], + "type": "object", + "properties": { + "questions": { + "oneOf": [ + FIELD_STRING_TYPE, + { + "type": "array", + "items": FIELD_STRING_TYPE, + }, + ] + }, + "marking": marking_object_properties, + }, + }, + }, + }, + "outputs_configuration": { + "description": "The configuration for outputs produced from the evaluation", + "type": "object", + "required": [], "additionalProperties": False, - "source_type": {"type": "string", "enum": ["csv", "custom"]}, - "options": {"type": "object"}, - "marking_schemes": { - "type": "object", - "required": [DEFAULT_SECTION_KEY], - "patternProperties": { - f"^{DEFAULT_SECTION_KEY}$": marking_object_properties, - f"^(?!{DEFAULT_SECTION_KEY}$).*": { + "properties": { + "should_explain_scoring": { + "description": "Whether to print the table explaining question-wise verdicts", + "type": "boolean", + }, + "draw_score": { + "description": "The configuration for drawing the final score", + "type": "object", + "required": [ + "enabled", + ], + "additionalProperties": False, + "properties": { + "enabled": { + "description": "The toggle for enabling the configuration", + "type": "boolean", + }, + "position": { + "description": "The position of the score box", + **two_positive_integers, + }, + "score_format_string": { + "description": "The format string to compose the score string. Supported variables - {score}", + "type": "string", + }, + "size": { + "description": "The font size for the score box", + "type": "number", + }, + }, + "allOf": [ + { + "if": {"properties": {"enabled": {"const": True}}}, + "then": { + "required": ["position", "score_format_string"], + }, + } + ], + }, + "draw_answers_summary": { + "description": "The configuration for drawing the answers summary", + "type": "object", + "required": [ + "enabled", + ], + "additionalProperties": False, + "properties": { + "enabled": { + "description": "The toggle for enabling the configuration", + "type": "boolean", + }, + "position": { + "description": "The position of the answers summary box", + **two_positive_integers, + }, + "answers_summary_format_string": { + "description": "The format string to compose the answer summary. Supported variables - {correct}, {incorrect}, {unmarked} ", + "type": "string", + }, + "size": { + "description": "The font size for the answers summary box", + "type": "number", + }, + }, + "allOf": [ + { + "if": {"properties": {"enabled": {"const": True}}}, + "then": { + "required": [ + "position", + "answers_summary_format_string", + ], + }, + } + ], + }, + "verdict_colors": { + "description": "The mapping from schema verdicts to the corresponding colors", + "type": "object", + "additionalProperties": False, + "properties": { + "correct": {"type": "string"}, + "incorrect": {"type": "string"}, + "unmarked": {"type": "string"}, + }, + }, + }, + }, +} +common_evaluation_schema_conditions = [ + { + "if": {"properties": {"source_type": {"const": "csv"}}}, + "then": {"properties": {"options": image_and_csv_options}}, + }, + { + "if": {"properties": {"source_type": {"const": "image_and_csv"}}}, + "then": {"properties": {"options": image_and_csv_options}}, + }, + { + "if": {"properties": {"source_type": {"const": "custom"}}}, + "then": { + "properties": { + "options": { "additionalProperties": False, - "required": ["marking", "questions"], + "required": ["answers_in_order", "questions_in_order"], "type": "object", "properties": { - "questions": { + "questions_in_order": { + **ARRAY_OF_STRINGS, + "description": "An array of fields to treat as questions specified in an order to apply evaluation", + }, + "answers_in_order": { "oneOf": [ - FIELD_STRING_TYPE, { + "description": "An array of answers in the same order as provided array of questions", "type": "array", - "items": FIELD_STRING_TYPE, + "items": { + "oneOf": [ + # Standard answer type allows single correct answers. They can have multiple characters(multi-marked) as well. + # Useful for any standard response e.g. 'A', '01', '99', 'AB', etc + {"type": "string"}, + # Multiple correct answer type covers multiple correct answers + # Useful for ambiguous/bonus questions e.g. ['A', 'B'], ['1', '01'], ['A', 'B', 'AB'], etc + { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + }, + # Multiple correct weighted answer covers multiple answers with weights + # Useful for partial marking e.g. [['A', 2], ['B', 0.5], ['AB', 2.5]], [['1', 0.5], ['01', 1]], etc + { + "type": "array", + "items": { + "type": "array", + "items": False, + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + {"type": "string"}, + marking_score, + ], + }, + }, + ], + }, }, ] }, - "marking": marking_object_properties, }, - }, - }, + } + } }, }, - "allOf": [ - { - "if": {"properties": {"source_type": {"const": "csv"}}}, - "then": { +] + +EVALUATION_SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Udayraj123/OMRChecker/tree/master/src/schemas/evaluation-schema.json", + "title": "Evaluation Schema", + "description": "The OMRChecker evaluation schema", + "type": "object", + "required": ["source_type", "options", "marking_schemes"], + "additionalProperties": False, + "properties": { + "additionalProperties": False, + **common_evaluation_schema_properties, + "conditionalSets": { + "description": "An array of answer sets with their conditions. These will override the default values in case of any conflict", + "type": "array", + "items": { + "description": "Each item represents a conditional evaluation schema to apply for the given matcher", + "type": "object", + "required": ["name", "matcher", "evaluation"], + "additionalProperties": False, "properties": { - "options": { - "additionalProperties": False, - "required": ["answer_key_csv_path"], - "dependentRequired": { - "answer_key_image_path": [ - "answer_key_csv_path", - "questions_in_order", - ] - }, + "name": {"type": "string"}, + "matcher": { + "description": "Mapping response fields from default layout to the set name", "type": "object", + "required": ["formatString", "matchRegex"], + "additionalProperties": False, "properties": { - "should_explain_scoring": {"type": "boolean"}, - "answer_key_csv_path": {"type": "string"}, - "answer_key_image_path": {"type": "string"}, - "questions_in_order": ARRAY_OF_STRINGS, + "formatString": { + "description": "Format string composed of the response variables to apply the regex on e.g. '{roll}-{barcode}'", + "type": "string", + }, + # Example: match last four characters ".*-SET1" + "matchRegex": { + "description": "Mapping to use on the composed field string", + "type": "string", + "format": "regex", + }, }, - } - } - }, - }, - { - "if": {"properties": {"source_type": {"const": "custom"}}}, - "then": { - "properties": { - "options": { - "additionalProperties": False, - "required": ["answers_in_order", "questions_in_order"], + }, + "evaluation": { + # Note: even outputs_configuration is going to be different as per the set, allowing custom colors for different sets! + "description": "The custom evaluation schema to apply if given matcher is satisfied", "type": "object", + "required": ["source_type", "options", "marking_schemes"], + "additionalProperties": False, "properties": { - "should_explain_scoring": {"type": "boolean"}, - "answers_in_order": { - "oneOf": [ - { - "type": "array", - "items": { - "oneOf": [ - # "standard": single correct, multi-marked single-correct - # Example: "q1" --> '67' - {"type": "string"}, - # "multiple-correct": multiple-correct (for ambiguous/bonus questions) - # Example: "q1" --> [ 'A', 'B' ] - { - "type": "array", - "items": {"type": "string"}, - "minItems": 2, - }, - # "multiple-correct-weighted": array of answer-wise weights (marking scheme not applicable) - # Example 1: "q1" --> [['A', 1], ['B', 2], ['C', 3]] or - # Example 2: "q2" --> [['A', 1], ['B', 1], ['AB', 2]] - { - "type": "array", - "items": { - "type": "array", - "items": False, - "minItems": 2, - "maxItems": 2, - "prefixItems": [ - {"type": "string"}, - marking_score, - ], - }, - }, - # Multiple-correct with custom marking scheme - # ["A", ["1", "2", "3"]], - # [["A", "B", "AB"], ["1", "2", "3"]] - ], - }, - }, - ] - }, - "questions_in_order": ARRAY_OF_STRINGS, + **common_evaluation_schema_properties, }, - } - } + "allOf": [*common_evaluation_schema_conditions], + }, + }, }, }, - ], + }, + "allOf": [*common_evaluation_schema_conditions], } diff --git a/src/schemas/template_schema.py b/src/schemas/template_schema.py index 451d499d..34932522 100644 --- a/src/schemas/template_schema.py +++ b/src/schemas/template_schema.py @@ -1,32 +1,398 @@ -from src.constants import FIELD_TYPES -from src.schemas.constants import ARRAY_OF_STRINGS, FIELD_STRING_TYPE +from src.processors.constants import ( + MARKER_AREA_TYPES_IN_ORDER, + SCANNER_TYPES_IN_ORDER, + SELECTOR_TYPES_IN_ORDER, + AreaTemplate, + WarpMethod, + WarpMethodFlags, +) +from src.schemas.constants import ( + ARRAY_OF_STRINGS, + FIELD_STRING_TYPE, + positive_integer, + positive_number, + two_positive_integers, + two_positive_numbers, + zero_to_one_number, +) +from src.utils.constants import BUILTIN_FIELD_TYPES -positive_number = {"type": "number", "minimum": 0} -positive_integer = {"type": "integer", "minimum": 0} -two_positive_integers = { - "type": "array", - "prefixItems": [ - positive_integer, - positive_integer, - ], - "maxItems": 2, - "minItems": 2, +margins_schema = { + "description": "The margins to use around a box", + "type": "object", + "required": ["top", "right", "bottom", "left"], + "additionalProperties": False, + "properties": { + "top": positive_integer, + "right": positive_integer, + "bottom": positive_integer, + "left": positive_integer, + }, +} +# TODO: deprecate in favor of scan_area_description +box_area_description = { + "description": "The description of a box area on the image", + "type": "object", + "required": ["origin", "dimensions", "margins"], + "additionalProperties": False, + "properties": { + "origin": two_positive_integers, + "dimensions": two_positive_integers, + "margins": margins_schema, + }, +} + +scan_area_template = { + "description": "The ready-made template to use to prefill description of a scanArea", + "type": "string", + "enum": ["CUSTOM", *AreaTemplate.values()], } -two_positive_numbers = { + +custom_marker_options = { + "markerDimensions": { + **two_positive_integers, + "description": "The dimensions of the omr marker", + }, + "referenceImage": { + "description": "The relative path to reference image of the omr marker", + "type": "string", + }, +} + +crop_on_marker_custom_options_schema = { + "description": "Custom options for the scannerType TEMPLATE_MATCH", + "type": "object", + "additionalProperties": False, + "properties": {**custom_marker_options}, +} + +# TODO: actually this one needs to be specific to processor.type +common_custom_options_schema = { + "description": "Custom options based on the scannerType", + "type": "object", + # TODO: add conditional properties here like maxPoints and excludeFromCropping here based on scannerType + # expand conditionally: crop_on_marker_custom_options_schema +} + + +point_selector_patch_area_description = { + **box_area_description, + "description": "The detailed description of a patch area with an optional point selector", + "additionalProperties": False, + "properties": { + **box_area_description["properties"], + "selector": { + "type": "string", + "enum": [*SELECTOR_TYPES_IN_ORDER], + }, + }, +} + + +marker_area_description = { + **point_selector_patch_area_description, + "description": "The detailed description of a patch area for a custom marker", + "additionalProperties": False, + "properties": { + **point_selector_patch_area_description["properties"], + "selector": { + "type": "string", + "enum": [*SELECTOR_TYPES_IN_ORDER], + }, + "customOptions": crop_on_marker_custom_options_schema, + }, +} + + +scan_area_description = { + **point_selector_patch_area_description, + "description": "The detailed description of a scanArea's coordinates and purpose", + # TODO: "required": [...], + "additionalProperties": False, + "properties": { + **point_selector_patch_area_description["properties"], + "label": { + "description": "The label to use for the scanArea", + "type": "string", + }, + "selectorMargins": { + **margins_schema, + "description": "The margins around the scanArea's box at provided origin", + }, + "scannerType": { + "description": "The scanner type to use in the given scan area", + "type": "string", + "enum": SCANNER_TYPES_IN_ORDER, + }, + "maxPoints": { + **positive_integer, + "description": "The maximum points to pick from the given scanArea", + }, + }, +} +scan_areas_object = { + "description": "The schema of a scanArea", "type": "array", - "prefixItems": [ - positive_number, - positive_number, - ], - "maxItems": 2, - "minItems": 2, + "items": { + "type": "object", + "required": ["areaTemplate"], + "additionalProperties": False, + "properties": { + "areaTemplate": scan_area_template, + "areaDescription": scan_area_description, + "customOptions": common_custom_options_schema, + }, + }, +} + +default_points_selector_types = [ + "CENTERS", + "INNER_WIDTHS", + "INNER_HEIGHTS", + "INNER_CORNERS", + "OUTER_CORNERS", +] + +# TODO: deprecate crop_on_marker_types +crop_on_marker_types = [ + "FOUR_MARKERS", + "ONE_LINE_TWO_DOTS", + "TWO_DOTS_ONE_LINE", + "TWO_LINES", + # TODO: support for "TWO_LINES_HORIZONTAL" + "FOUR_DOTS", +] + +points_layout_types = [ + *crop_on_marker_types, + "CUSTOM", +] + +# if_required_attrs help in suppressing redundant errors from 'allOf' +pre_processor_if_required_attrs = { + "required": ["name", "options"], +} +crop_on_markers_options_if_required_attrs = { + "required": ["type"], +} +warp_on_points_options_if_required_attrs = { + "required": ["scanAreas"], +} +pre_processor_options_available_keys = {"processingImageShape": True} + +crop_on_markers_tuning_options_available_keys = { + "dotThreshold": True, + "dotKernel": True, + "lineThreshold": True, + "dotBlurKernel": True, + "lineKernel": True, + "apply_erode_subtract": True, + "marker_rescale_range": True, + "marker_rescale_steps": True, + "min_matching_threshold": True, +} +crop_on_markers_options_available_keys = { + **pre_processor_options_available_keys, + "scanAreas": True, + "defaultSelector": True, + "tuningOptions": True, + "type": True, +} + +warp_on_points_tuning_options = { + "warpMethod": { + "type": "string", + "enum": [*WarpMethod.values()], + }, + "warpMethodFlag": { + "type": "string", + "enum": [*WarpMethodFlags.values()], + }, +} + + +warp_on_points_options_available_keys = { + **pre_processor_options_available_keys, + "enableCropping": True, + "defaultSelector": True, + "scanAreas": True, + "tuningOptions": True, +} + +crop_on_dot_lines_tuning_options = { + "description": "Custom tuning options for the CropOnDotLines pre-processor", + "type": "object", + "additionalProperties": False, + "properties": { + **crop_on_markers_tuning_options_available_keys, + **warp_on_points_tuning_options, + "dotBlurKernel": { + **two_positive_integers, + "description": "The size of the kernel to use for blurring in each dot's scanArea", + }, + "dotKernel": { + **two_positive_integers, + "description": "The size of the morph kernel to use for smudging each dot", + }, + "lineKernel": { + **two_positive_integers, + "description": "The size of the morph kernel to use for smudging each line", + }, + "dotThreshold": { + **positive_number, + "description": "The threshold to apply for clearing out the noise near a dot after smudging", + }, + "lineThreshold": { + **positive_number, + "description": "The threshold to apply for clearing out the noise near a line after smudging", + }, + }, } -zero_to_one_number = { - "type": "number", - "minimum": 0, - "maximum": 1, +crop_on_four_markers_tuning_options = { + "description": "Custom tuning options for the CropOnCustomMarkers pre-processor", + "type": "object", + "additionalProperties": False, + "properties": { + **crop_on_markers_tuning_options_available_keys, + **warp_on_points_tuning_options, + "apply_erode_subtract": { + "description": "A boolean to enable erosion for (sometimes) better marker detection", + "type": "boolean", + }, + "marker_rescale_range": { + **two_positive_integers, + "description": "The range of rescaling in percentage", + }, + "marker_rescale_steps": { + **positive_integer, + "description": "The number of rescaling steps", + }, + "min_matching_threshold": { + "description": "The threshold for template matching", + "type": "number", + }, + }, } +common_field_block_properties = { + # Common properties here + "emptyValue": { + "description": "The custom empty bubble value to use in this field block", + "type": "string", + }, + "fieldType": { + "description": "The field type to use from a list of ready-made types as well as the custom type", + "type": "string", + "enum": [*list(BUILTIN_FIELD_TYPES.keys()), "CUSTOM", "BARCODE"], + }, +} +traditional_field_block_properties = { + **common_field_block_properties, + "bubbleDimensions": { + **two_positive_numbers, + "description": "The custom dimensions for the bubbles in the current field block: [width, height]", + }, + "bubblesGap": { + **positive_number, + "description": "The gap between two bubbles(top-left to top-left) in the current field block", + }, + "bubbleValues": { + **ARRAY_OF_STRINGS, + "description": "The ordered array of values to use for given bubbles per field in this field block", + }, + "direction": { + "description": "The direction of expanding the bubbles layout in this field block", + "type": "string", + "enum": ["horizontal", "vertical"], + }, + "fieldLabels": { + "description": "The ordered array of labels to use for given fields in this field block", + "type": "array", + "items": FIELD_STRING_TYPE, + }, + "labelsGap": { + **positive_number, + "description": "The gap between two labels(top-left to top-left) in the current field block", + }, + "origin": { + **two_positive_integers, + "description": "The top left point of the first bubble in this field block", + }, +} + +many_field_blocks_description = { + "description": "Each fieldBlock denotes a small group of adjacent fields", + "type": "object", + "patternProperties": { + "^.*$": { + "description": "The key is a unique name for the field block", + "type": "object", + "required": [ + "origin", + "bubblesGap", + "labelsGap", + "fieldLabels", + ], + "allOf": [ + { + "if": { + "properties": { + "fieldType": { + "enum": [ + *list(BUILTIN_FIELD_TYPES.keys()), + ] + } + }, + }, + "then": { + "required": ["fieldType"], + "additionalProperties": False, + "properties": traditional_field_block_properties, + }, + }, + { + "if": { + "properties": {"fieldType": {"const": "CUSTOM"}}, + }, + "then": { + "required": [ + "bubbleValues", + "direction", + "fieldType", + ], + "additionalProperties": False, + "properties": traditional_field_block_properties, + }, + }, + { + "if": { + "properties": {"fieldType": {"const": "BARCODE"}}, + }, + "then": { + # TODO: move barcode specific properties into this if-else + "required": [ + "scanArea", + "fieldType", + "fieldLabel", + # TODO: "failIfNotFound" + # "emptyValue", + ], + "additionalProperties": False, + "properties": { + **common_field_block_properties, + "scanArea": box_area_description, + "fieldLabel": {"type": "string"}, + }, + }, + }, + # TODO: support for PHOTO_BLOB, OCR custom fields here + ], + "properties": common_field_block_properties, + } + }, +} + + TEMPLATE_SCHEMA = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/Udayraj123/OMRChecker/tree/master/src/schemas/template-schema.json", @@ -35,7 +401,7 @@ "type": "object", "required": [ "bubbleDimensions", - "pageDimensions", + "templateDimensions", "preProcessors", "fieldBlocks", ], @@ -43,7 +409,7 @@ "properties": { "bubbleDimensions": { **two_positive_integers, - "description": "The dimensions of the overlay bubble area: [width, height]", + "description": "The default dimensions for the bubbles in the template overlay: [width, height]", }, "customLabels": { "description": "The customLabels contain fields that need to be joined together before generating the results sheet", @@ -52,25 +418,42 @@ "^.*$": {"type": "array", "items": FIELD_STRING_TYPE} }, }, + "emptyValue": { + "description": "The value to be used in case of empty bubble detected at global level.", + "type": "string", + }, "outputColumns": { + "description": "The ordered list of columns to be contained in the output csv(default order: alphabetical)", "type": "array", "items": FIELD_STRING_TYPE, - "description": "The ordered list of columns to be contained in the output csv(default order: alphabetical)", }, - "pageDimensions": { + "templateDimensions": { **two_positive_integers, "description": "The dimensions(width, height) to which the page will be resized to before applying template", }, + "processingImageShape": { + **two_positive_integers, + "description": "Shape of the processing image after all the pre-processors are applied: [height, width]", + }, + "outputImageShape": { + **two_positive_integers, + "description": "Shape of the final output image: [height, width]", + }, "preProcessors": { "description": "Custom configuration values to use in the template's directory", "type": "array", "items": { "type": "object", + **pre_processor_if_required_attrs, + "additionalProperties": True, "properties": { + # Common properties to be used here "name": { + "description": "The name of the pre-processor to use", "type": "string", "enum": [ "CropOnMarkers", + # TODO: "WarpOnPoints", "CropPage", "FeatureBasedAlignment", "GaussianBlur", @@ -78,44 +461,86 @@ "MedianBlur", ], }, + "options": { + "description": "The options to pass to the pre-processor", + "type": "object", + "additionalProperties": True, + "properties": { + # Note: common properties across all preprocessors items can stay here + "processingImageShape": { + **two_positive_integers, + "description": "Shape of the processing image for the current pre-processors: [height, width]", + }, + }, + }, }, - "required": ["name", "options"], "allOf": [ { - "if": {"properties": {"name": {"const": "CropOnMarkers"}}}, + "if": { + "properties": {"name": {"const": "CropPage"}}, + **pre_processor_if_required_attrs, + }, "then": { "properties": { "options": { + "description": "Options for the CropPage pre-processor", "type": "object", "additionalProperties": False, "properties": { - "apply_erode_subtract": {"type": "boolean"}, - "marker_rescale_range": two_positive_numbers, - "marker_rescale_steps": {"type": "number"}, - "max_matching_variation": {"type": "number"}, - "min_matching_threshold": {"type": "number"}, - "relativePath": {"type": "string"}, - "sheetToMarkerWidthRatio": {"type": "number"}, + **pre_processor_options_available_keys, + # TODO: support DOC_REFINE warpMethod + "tuningOptions": True, + "morphKernel": { + **two_positive_integers, + "description": "The size of the morph kernel used for smudging the page", + }, + # TODO: support for maxPointsPerEdge + "maxPointsPerEdge": { + **two_positive_integers, + "description": "Max number of control points to use in one edge", + }, }, - "required": ["relativePath"], - } + }, } }, }, { "if": { - "properties": {"name": {"const": "FeatureBasedAlignment"}} + "properties": {"name": {"const": "FeatureBasedAlignment"}}, + **pre_processor_if_required_attrs, }, "then": { "properties": { "options": { + "description": "Options for the FeatureBasedAlignment pre-processor", "type": "object", "additionalProperties": False, "properties": { - "2d": {"type": "boolean"}, - "goodMatchPercent": {"type": "number"}, - "maxFeatures": {"type": "integer"}, - "reference": {"type": "string"}, + **pre_processor_options_available_keys, + "2d": { + "description": "Uses warpAffine if True, otherwise uses warpPerspective", + "type": "boolean", + }, + "goodMatchPercent": { + "description": "Threshold for the match percentage", + "type": "number", + }, + "maxFeatures": { + "description": "Maximum number of matched features to consider", + "type": "integer", + }, + "reference": { + "description": "Relative path to the reference image", + "type": "string", + }, + "matcherType": { + "description": "Type of the matcher to use", + "type": "string", + "enum": [ + "DESCRIPTOR_MATCHER_BRUTEFORCE_HAMMING", + "NORM_HAMMING", + ], + }, }, "required": ["reference"], } @@ -123,58 +548,277 @@ }, }, { - "if": {"properties": {"name": {"const": "Levels"}}}, + "if": { + "properties": {"name": {"const": "GaussianBlur"}}, + **pre_processor_if_required_attrs, + }, "then": { "properties": { "options": { + "description": "Options for the GaussianBlur pre-processor", "type": "object", "additionalProperties": False, "properties": { - "gamma": zero_to_one_number, - "high": zero_to_one_number, - "low": zero_to_one_number, + **pre_processor_options_available_keys, + "kSize": { + **two_positive_integers, + "description": "Size of the kernel", + }, + "sigmaX": { + "description": "Value of sigmaX in fraction", + "type": "number", + }, }, } } }, }, { - "if": {"properties": {"name": {"const": "MedianBlur"}}}, + "if": { + "properties": {"name": {"const": "Levels"}}, + **pre_processor_if_required_attrs, + }, "then": { "properties": { "options": { "type": "object", "additionalProperties": False, - "properties": {"kSize": {"type": "integer"}}, + "properties": { + **pre_processor_options_available_keys, + "gamma": { + **zero_to_one_number, + "description": "The value for gamma parameter", + }, + "high": { + **zero_to_one_number, + "description": "The value for high parameter", + }, + "low": { + **zero_to_one_number, + "description": "The value for low parameter", + }, + }, } } }, }, { - "if": {"properties": {"name": {"const": "GaussianBlur"}}}, + "if": { + "properties": {"name": {"const": "MedianBlur"}}, + **pre_processor_if_required_attrs, + }, "then": { "properties": { "options": { + "description": "Options for the MedianBlur pre-processor", "type": "object", "additionalProperties": False, "properties": { - "kSize": two_positive_integers, - "sigmaX": {"type": "number"}, + **pre_processor_options_available_keys, + "kSize": {"type": "integer"}, }, } } }, }, { - "if": {"properties": {"name": {"const": "CropPage"}}}, + "if": { + "properties": {"name": {"const": "WarpOnPoints"}}, + **pre_processor_if_required_attrs, + }, "then": { "properties": { "options": { + "description": "Options for the WarpOnPoints pre-processor", + **warp_on_points_options_if_required_attrs, "type": "object", + "required": [], "additionalProperties": False, "properties": { - "morphKernel": two_positive_integers + **warp_on_points_options_available_keys, + "pointsLayout": { + "description": "The type of layout of the scanAreas for finding anchor points", + "type": "string", + "enum": points_layout_types, + }, + "defaultSelector": { + "description": "The default points selector for the given scanAreas", + "type": "string", + "enum": default_points_selector_types, + }, + "enableCropping": { + "description": "Whether to crop the image to a bounding box of the given anchor points", + "type": "boolean", + }, + "tuningOptions": { + "description": "Custom tuning options for the WarpOnPoints pre-processor", + "type": "object", + "required": [], + "additionalProperties": False, + "properties": { + **crop_on_dot_lines_tuning_options[ + "properties" + ], + }, + }, + "scanAreas": scan_areas_object, + }, + } + } + }, + }, + { + "if": { + "properties": {"name": {"const": "CropOnMarkers"}}, + **pre_processor_if_required_attrs, + }, + "then": { + "properties": { + "options": { + "description": "Options for the CropOnMarkers pre-processor", + "type": "object", + # Note: "required" key is retrieved from crop_on_markers_options_if_required_attrs + **crop_on_markers_options_if_required_attrs, + "additionalProperties": True, + "properties": { + # Note: the keys need to match with crop_on_markers_options_available_keys + **crop_on_markers_options_available_keys, + "scanAreas": scan_areas_object, + "defaultSelector": { + "description": "The default points selector for the given scanAreas", + "type": "string", + "enum": default_points_selector_types, + }, + "type": { + "description": "The type of the Cropping instance to use", + "type": "string", + "enum": crop_on_marker_types, + }, }, + "allOf": [ + { + "if": { + **crop_on_markers_options_if_required_attrs, + "properties": { + "type": {"const": "FOUR_MARKERS"} + }, + }, + "then": { + "required": [ + "referenceImage", + "markerDimensions", + # *MARKER_AREA_TYPES_IN_ORDER, + ], + "additionalProperties": False, + "properties": { + **crop_on_markers_options_available_keys, + **custom_marker_options, + **{ + area_template: marker_area_description + for area_template in MARKER_AREA_TYPES_IN_ORDER + }, + "tuningOptions": crop_on_four_markers_tuning_options, + }, + }, + }, + { + "if": { + **crop_on_markers_options_if_required_attrs, + "properties": { + "type": { + "const": "ONE_LINE_TWO_DOTS" + } + }, + }, + "then": { + # TODO: check that "topLeftDot": False, etc here is not passable + "required": [ + "leftLine", + "topRightDot", + "bottomRightDot", + ], + "additionalProperties": False, + "properties": { + **crop_on_markers_options_available_keys, + "tuningOptions": crop_on_dot_lines_tuning_options, + "leftLine": point_selector_patch_area_description, + "topRightDot": point_selector_patch_area_description, + "bottomRightDot": point_selector_patch_area_description, + }, + }, + }, + { + "if": { + **crop_on_markers_options_if_required_attrs, + "properties": { + "type": { + "const": "TWO_DOTS_ONE_LINE" + } + }, + }, + "then": { + "required": [ + "rightLine", + "topLeftDot", + "bottomLeftDot", + ], + "additionalProperties": False, + "properties": { + **crop_on_markers_options_available_keys, + "tuningOptions": crop_on_dot_lines_tuning_options, + "rightLine": point_selector_patch_area_description, + "topLeftDot": point_selector_patch_area_description, + "bottomLeftDot": point_selector_patch_area_description, + }, + }, + }, + { + "if": { + **crop_on_markers_options_if_required_attrs, + "properties": { + "type": {"const": "TWO_LINES"} + }, + }, + "then": { + "required": [ + "leftLine", + "rightLine", + ], + "additionalProperties": False, + "properties": { + **crop_on_markers_options_available_keys, + "tuningOptions": crop_on_dot_lines_tuning_options, + "leftLine": point_selector_patch_area_description, + "rightLine": point_selector_patch_area_description, + }, + }, + }, + { + "if": { + **crop_on_markers_options_if_required_attrs, + "properties": { + "type": {"const": "FOUR_DOTS"} + }, + }, + "then": { + "required": [ + "topRightDot", + "bottomRightDot", + "topLeftDot", + "bottomLeftDot", + ], + "additionalProperties": False, + "properties": { + **crop_on_markers_options_available_keys, + "tuningOptions": crop_on_dot_lines_tuning_options, + "topRightDot": point_selector_patch_area_description, + "bottomRightDot": point_selector_patch_area_description, + "topLeftDot": point_selector_patch_area_description, + "bottomLeftDot": point_selector_patch_area_description, + }, + }, + }, + ], } } }, @@ -183,44 +827,107 @@ }, }, "fieldBlocks": { - "description": "The fieldBlocks denote small groups of adjacent fields", + **many_field_blocks_description, + "description": "The default field block to apply and read before applying any matcher on the fields response.", + }, + "conditionalSets": { + "description": "An array of field block sets with their conditions. These will override the default values in case of any conflict", + "type": "array", + "items": { + "description": "Each item represents a conditional layout of field blocks", + "type": "object", + "required": ["name", "matcher", "fieldBlocks"], + "additionalProperties": False, + "properties": { + "name": {"type": "string"}, + "matcher": { + "description": "Mapping response fields from default layout to the set name", + "type": "object", + "required": ["formatString", "matchRegex"], + "additionalProperties": False, + "properties": { + "formatString": { + "description": "Format string composed of the response variables to apply the regex on e.g. '{roll}-{barcode}'", + "type": "string", + }, + # Example: match last four characters ".*-SET1" + "matchRegex": { + "description": "Mapping to use on the composed field string", + "type": "string", + "format": "regex", + }, + }, + }, + "fieldBlocks": { + **many_field_blocks_description, + "description": "The custom field blocks layout to apply if given matcher is satisfied", + }, + }, + }, + }, + "sortFiles": { + "description": "Configuration to sort images/files based on field responses, QR codes, barcodes, etc and a regex mapping", "type": "object", - "patternProperties": { - "^.*$": { - "type": "object", - "required": [ - "origin", - "bubblesGap", - "labelsGap", - "fieldLabels", - ], - "oneOf": [ - {"required": ["fieldType"]}, - {"required": ["bubbleValues", "direction"]}, + "allOf": [ + {"required": ["enabled"]}, + { + "if": { + "properties": {"enabled": {"const": True}}, + }, + "then": { + "required": [ + "enabled", + "sortMode", + "outputDirectory", + "fileMappings", + ] + }, + }, + ], + "additionalProperties": False, + "properties": { + "enabled": { + "description": "Whether to enable sorting. Note that file copies/movements are irreversible once enabled", + "type": "boolean", + }, + "sortMode": { + "description": "Whether to copy files or move files", + "type": "string", + "enum": [ + "COPY", + "MOVE", ], + }, + # TODO: ignore outputDirectory when resolved during template reading + "outputDirectory": { + "description": "Relative path of the directory to use to sort the files", + "type": "string", + }, + "fileMapping": { + "description": "A mapping from regex to the relative file path to use", + "type": "object", + "required": ["formatString"], + "additionalProperties": False, "properties": { - "bubbleDimensions": two_positive_numbers, - "bubblesGap": positive_number, - "bubbleValues": ARRAY_OF_STRINGS, - "direction": { + # "{barcode}", "{roll}-{barcode}" + "formatString": { + "description": "Format string composed of the response variables to apply the regex on e.g. '{roll}-{barcode}'", + "type": "string", + }, + # Example: extract first four characters "(\w{4}).*" + "extractRegex": { + "description": "Mapping to use on the composed field string", "type": "string", - "enum": ["horizontal", "vertical"], + "format": "regex", }, - "emptyValue": {"type": "string"}, - "fieldLabels": {"type": "array", "items": FIELD_STRING_TYPE}, - "labelsGap": positive_number, - "origin": two_positive_integers, - "fieldType": { + # TODO: support for "abortIfNotMatched"? + "capturedString": { + "description": "The captured groups string to use for replacement", "type": "string", - "enum": list(FIELD_TYPES.keys()), }, }, - } + }, }, }, - "emptyValue": { - "description": "The value to be used in case of empty bubble detected at global level.", - "type": "string", - }, }, } diff --git a/src/tests/__snapshots__/test_all_samples.ambr b/src/tests/__snapshots__/test_all_samples.ambr index 3049cd3c..352eb9d7 100644 --- a/src/tests/__snapshots__/test_all_samples.ambr +++ b/src/tests/__snapshots__/test_all_samples.ambr @@ -11,7 +11,26 @@ ''', 'Results/Results_05AM.csv': ''' "file_id","input_path","output_path","score","q1","q2","q3","q4","q5" - "adrian_omr.png","samples/answer-key/using-csv/adrian_omr.png","outputs/answer-key/using-csv/CheckedOMRs/adrian_omr.png","5.0","C","E","A","B","B" + "adrian_omr.png","samples/3-answer-key/using-csv/adrian_omr.png","outputs/3-answer-key/using-csv/CheckedOMRs/adrian_omr.png","5.0","C","E","A","B","B" + + ''', + }) +# --- +# name: test_run_answer_key_using_image + dict({ + 'UPSC-mock/Manual/ErrorFiles.csv': ''' + "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + + ''', + 'UPSC-mock/Manual/MultiMarkedFiles.csv': ''' + "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + + ''', + 'UPSC-mock/Results/Results_05AM.csv': ''' + "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + "angle-1.jpg","samples/3-answer-key/using-image/UPSC-mock/angle-1.jpg","outputs/3-answer-key/using-image/UPSC-mock/CheckedOMRs/angle-1.jpg","70.66666666666669","","","","D","D","A","","C","C","B","","A","C","C","D","A","D","A","C","A","D","B","D","D","C","D","D","D","D","","B","A","D","D","C","","B","","C","D","","","A","","A","C","C","B","C","A","A","C","","C","","D","B","C","","B","C","D","","","C","C","","C","A","B","C","","","","","D","D","C","D","A","","","B","","B","D","C","C","","D","","D","C","D","A","","A","","","A","C","B","A" + "angle-2.jpg","samples/3-answer-key/using-image/UPSC-mock/angle-2.jpg","outputs/3-answer-key/using-image/UPSC-mock/CheckedOMRs/angle-2.jpg","70.66666666666669","","","","D","D","A","","C","C","B","","A","C","C","D","A","D","A","C","A","D","B","D","D","C","D","D","D","D","","B","A","D","D","C","","B","","C","D","","","A","","A","C","C","B","C","A","A","C","","C","","D","B","C","","B","C","D","","","C","C","","C","A","B","C","","","","","D","D","C","D","A","","","B","","B","D","C","C","","D","","D","C","D","A","","A","","","A","C","B","A" + "angle-3.jpg","samples/3-answer-key/using-image/UPSC-mock/angle-3.jpg","outputs/3-answer-key/using-image/UPSC-mock/CheckedOMRs/angle-3.jpg","70.66666666666669","","","","D","D","A","","C","C","B","","A","C","C","D","A","D","A","C","A","D","B","D","D","C","D","D","D","D","","B","A","D","D","C","","B","","C","D","","","A","","A","C","C","B","C","A","A","C","","C","","D","B","C","","B","C","D","","","C","C","","C","A","B","C","","","","","D","D","C","D","A","","","B","","B","D","C","C","","D","","D","C","D","A","","A","","","A","C","B","A" ''', }) @@ -28,8 +47,27 @@ ''', 'images/Results/Results_05AM.csv': ''' "file_id","input_path","output_path","score","q1","q2","q3","q4","q5" - "adrian_omr.png","samples/answer-key/weighted-answers/images/adrian_omr.png","outputs/answer-key/weighted-answers/images/CheckedOMRs/adrian_omr.png","5.5","B","E","A","C","B" - "adrian_omr_2.png","samples/answer-key/weighted-answers/images/adrian_omr_2.png","outputs/answer-key/weighted-answers/images/CheckedOMRs/adrian_omr_2.png","10.0","C","E","A","B","B" + "adrian_omr.png","samples/3-answer-key/weighted-answers/images/adrian_omr.png","outputs/3-answer-key/weighted-answers/images/CheckedOMRs/adrian_omr.png","4.5","B","E","A","C","B" + "adrian_omr_2.png","samples/3-answer-key/weighted-answers/images/adrian_omr_2.png","outputs/3-answer-key/weighted-answers/images/CheckedOMRs/adrian_omr_2.png","4.0","C","E","A","B","B" + + ''', + }) +# --- +# name: test_run_bonus_marking + dict({ + 'Manual/ErrorFiles.csv': ''' + "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11" + + ''', + 'Manual/MultiMarkedFiles.csv': ''' + "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11" + + ''', + 'Results/Results_05AM.csv': ''' + "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11" + "IMG_20201116_143512.jpg","samples/3-answer-key/bonus-marking/IMG_20201116_143512.jpg","outputs/3-answer-key/bonus-marking/CheckedOMRs/IMG_20201116_143512.jpg","33.0","B","D","C","B","D","C","BC","A","C","D","C" + "IMG_20201116_150717658.jpg","samples/3-answer-key/bonus-marking/IMG_20201116_150717658.jpg","outputs/3-answer-key/bonus-marking/CheckedOMRs/IMG_20201116_150717658.jpg","33.0","B","D","C","B","D","C","BC","A","C","D","C" + "IMG_20201116_150750830.jpg","samples/3-answer-key/bonus-marking/IMG_20201116_150750830.jpg","outputs/3-answer-key/bonus-marking/CheckedOMRs/IMG_20201116_150750830.jpg","6.0","A","","D","C","AC","A","D","B","C","D","D" ''', }) @@ -53,19 +91,19 @@ # --- # name: test_run_community_Sandeep_1507 dict({ - 'Manual/ErrorFiles.csv': ''' + 'NEET-2021/Manual/ErrorFiles.csv': ''' "file_id","input_path","output_path","score","Booklet_No","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100","q101","q102","q103","q104","q105","q106","q107","q108","q109","q110","q111","q112","q113","q114","q115","q116","q117","q118","q119","q120","q121","q122","q123","q124","q125","q126","q127","q128","q129","q130","q131","q132","q133","q134","q135","q136","q137","q138","q139","q140","q141","q142","q143","q144","q145","q146","q147","q148","q149","q150","q151","q152","q153","q154","q155","q156","q157","q158","q159","q160","q161","q162","q163","q164","q165","q166","q167","q168","q169","q170","q171","q172","q173","q174","q175","q176","q177","q178","q179","q180","q181","q182","q183","q184","q185","q186","q187","q188","q189","q190","q191","q192","q193","q194","q195","q196","q197","q198","q199","q200" ''', - 'Manual/MultiMarkedFiles.csv': ''' + 'NEET-2021/Manual/MultiMarkedFiles.csv': ''' "file_id","input_path","output_path","score","Booklet_No","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100","q101","q102","q103","q104","q105","q106","q107","q108","q109","q110","q111","q112","q113","q114","q115","q116","q117","q118","q119","q120","q121","q122","q123","q124","q125","q126","q127","q128","q129","q130","q131","q132","q133","q134","q135","q136","q137","q138","q139","q140","q141","q142","q143","q144","q145","q146","q147","q148","q149","q150","q151","q152","q153","q154","q155","q156","q157","q158","q159","q160","q161","q162","q163","q164","q165","q166","q167","q168","q169","q170","q171","q172","q173","q174","q175","q176","q177","q178","q179","q180","q181","q182","q183","q184","q185","q186","q187","q188","q189","q190","q191","q192","q193","q194","q195","q196","q197","q198","q199","q200" ''', - 'Results/Results_05AM.csv': ''' + 'NEET-2021/Results/Results_05AM.csv': ''' "file_id","input_path","output_path","score","Booklet_No","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100","q101","q102","q103","q104","q105","q106","q107","q108","q109","q110","q111","q112","q113","q114","q115","q116","q117","q118","q119","q120","q121","q122","q123","q124","q125","q126","q127","q128","q129","q130","q131","q132","q133","q134","q135","q136","q137","q138","q139","q140","q141","q142","q143","q144","q145","q146","q147","q148","q149","q150","q151","q152","q153","q154","q155","q156","q157","q158","q159","q160","q161","q162","q163","q164","q165","q166","q167","q168","q169","q170","q171","q172","q173","q174","q175","q176","q177","q178","q179","q180","q181","q182","q183","q184","q185","q186","q187","q188","q189","q190","q191","q192","q193","q194","q195","q196","q197","q198","q199","q200" - "omr-1.png","samples/community/Sandeep-1507/omr-1.png","outputs/community/Sandeep-1507/CheckedOMRs/omr-1.png","0","0190880","D","C","B","A","A","B","C","D","D","C","B","A","D","A","B","C","D","","B","D","C","A","C","C","B","A","D","A","AC","C","B","D","C","B","A","B","B","D","D","A","C","B","D","A","C","B","D","B","D","A","A","B","C","D","C","B","A","D","D","A","B","C","D","C","B","A","B","C","D","A","B","C","D","B","A","C","D","C","B","A","D","B","D","A","A","B","A","C","B","D","C","D","B","A","C","C","B","D","B","C","B","A","D","C","B","A","B","C","D","A","A","A","B","B","A","B","C","D","A","A","D","C","B","A","","A","B","C","D","D","D","B","B","C","C","D","C","C","D","D","C","C","B","B","A","A","D","D","B","A","D","C","B","A","A","D","D","B","B","A","A","B","C","D","D","C","B","A","B","D","A","C","C","C","A","A","B","B","D","D","A","A","B","C","D","B","D","A","B","C","D","AD","C","D","B","C","A","B","C","D" - "omr-2.png","samples/community/Sandeep-1507/omr-2.png","outputs/community/Sandeep-1507/CheckedOMRs/omr-2.png","0","0no22nonono","A","B","B","A","D","C","B","D","C","D","D","D","B","B","D","D","D","B","C","C","A","A","B","A","D","A","A","B","A","C","A","C","D","D","D","","","C","C","B","B","B","","D","","C","D","","D","B","A","D","B","A","C","A","C","A","C","B","A","D","C","B","C","B","C","D","B","B","D","C","C","D","D","A","D","A","D","C","B","D","C","A","C","","C","B","B","","A","A","D","","B","A","","C","A","D","D","C","C","A","C","A","C","D","A","A","A","D","D","B","C","B","B","B","D","A","C","D","D","A","A","A","C","D","C","C","B","D","A","A","C","B","","D","A","C","C","C","","","","A","C","","D","A","B","A","A","C","A","D","B","B","A","D","A","B","C","A","C","D","D","D","C","A","C","A","C","D","A","A","A","D","A","B","A","B","C","B","A","","B","C","D","D","","","D","C","C","C","","C","A","" - "omr-3.png","samples/community/Sandeep-1507/omr-3.png","outputs/community/Sandeep-1507/CheckedOMRs/omr-3.png","0","0nononono73","B","A","C","D","A","D","D","A","C","A","A","B","C","A","A","C","A","B","A","D","C","C","A","D","D","C","C","C","A","C","C","B","B","D","D","C","","","C","B","","","D","A","A","A","A","","A","C","C","C","D","C","","A","B","C","D","B","C","C","C","D","A","B","B","B","D","D","B","B","C","D","B","D","A","B","A","B","C","A","C","A","C","D","","","A","B","","B","C","D","A","D","D","","","C","D","B","B","A","A","D","D","B","A","B","B","C","C","D","D","C","A","D","C","D","C","C","B","C","D","C","D","A","B","D","C","B","D","B","B","","D","","B","D","B","B","C","A","D","","C","","C","","B","C","A","B","B","D","D","D","B","A","D","D","A","D","D","C","B","B","D","C","B","A","C","D","A","D","D","A","C","A","B","D","C","C","C","A","D","","","B","B","","C","C","B","B","C","","","B" + "omr-1.png","samples/community/Sandeep-1507/NEET-2021/omr-1.png","outputs/community/Sandeep-1507/NEET-2021/CheckedOMRs/omr-1.png","0","0190880","D","C","B","A","A","B","C","D","D","C","B","A","D","A","B","C","D","","B","D","C","A","C","C","B","A","D","A","AC","C","B","D","C","B","A","B","B","D","D","A","C","B","D","A","C","B","D","B","D","A","A","B","C","D","C","B","A","D","D","A","B","C","D","C","B","A","B","C","D","A","B","C","D","B","A","C","D","C","B","A","D","B","D","A","A","B","A","C","B","D","C","D","B","A","C","C","B","D","B","C","B","A","D","C","B","A","B","C","D","A","A","A","B","B","A","B","C","D","A","A","D","C","B","A","","A","B","C","D","D","D","B","B","C","C","D","C","C","D","D","C","C","B","B","A","A","D","D","B","A","D","C","B","A","A","D","D","B","B","A","A","B","C","D","D","C","B","A","B","D","A","C","C","C","A","A","B","B","D","D","A","A","B","C","D","B","D","A","B","C","D","AD","C","D","B","C","A","B","C","D" + "omr-2.png","samples/community/Sandeep-1507/NEET-2021/omr-2.png","outputs/community/Sandeep-1507/NEET-2021/CheckedOMRs/omr-2.png","0","0nononono73","B","A","C","D","A","D","D","A","C","A","A","B","C","A","A","C","A","B","A","D","C","C","A","D","D","C","C","C","A","C","C","B","B","D","D","C","","","C","B","","","D","A","A","A","A","","A","C","C","C","D","C","","A","B","C","D","B","C","C","C","D","A","B","B","B","D","D","B","B","C","D","B","D","A","B","A","B","C","A","C","A","C","D","","","A","B","","B","C","D","A","D","D","","","C","D","B","B","A","A","D","D","B","A","B","B","C","C","D","D","C","A","D","C","D","C","C","B","C","D","C","D","A","B","D","C","B","D","B","B","","D","","B","D","B","B","C","A","D","","C","","C","","B","C","A","B","B","D","D","D","B","A","D","D","A","D","D","C","B","B","D","C","B","A","C","D","A","D","D","A","C","A","B","D","C","C","C","A","D","","","B","B","","C","C","B","B","C","","","B" + "omr-3.png","samples/community/Sandeep-1507/NEET-2021/omr-3.png","outputs/community/Sandeep-1507/NEET-2021/CheckedOMRs/omr-3.png","0","0no22nonono","A","B","B","A","D","C","B","D","C","D","D","D","B","B","D","D","D","B","C","C","A","A","B","A","D","A","A","B","A","C","A","C","D","D","D","","","C","C","B","B","B","","D","","C","D","","D","B","A","D","B","A","C","A","C","A","C","B","A","D","C","B","C","B","C","D","B","B","D","C","C","D","D","A","D","A","D","C","B","D","C","A","C","","C","B","B","","A","A","D","","B","A","","C","A","D","D","C","C","A","C","A","C","D","A","A","A","D","D","B","C","B","B","B","D","A","C","D","D","A","A","A","C","D","C","C","B","D","A","A","C","B","","D","A","C","C","C","","","","A","C","","D","A","B","A","A","C","A","D","B","B","A","D","A","B","C","A","C","D","D","D","C","A","C","A","C","D","A","A","A","D","A","B","A","B","C","B","A","","B","C","D","D","","","D","C","C","C","","C","A","" ''', }) @@ -87,38 +125,6 @@ ''', }) # --- -# name: test_run_community_UPSC_mock - dict({ - 'Manual/ErrorFiles.csv': ''' - "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - - ''', - 'Manual/MultiMarkedFiles.csv': ''' - "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - - ''', - 'Results/Results_05AM.csv': ''' - "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - "answer_key.jpg","samples/community/UPSC-mock/answer_key.jpg","outputs/community/UPSC-mock/CheckedOMRs/answer_key.jpg","200.0","","","","C","D","A","C","C","C","B","A","C","C","B","D","B","D","C","C","B","D","B","D","C","C","C","B","D","D","D","B","A","D","D","C","A","B","C","A","D","A","A","A","D","D","B","A","B","C","B","A","C","D","C","D","A","B","C","A","C","C","C","D","B","C","C","C","C","A","D","A","D","A","D","C","C","D","C","D","A","A","C","B","C","D","C","A","B","C","B","D","A","A","C","A","B","D","C","D","A","C","B","A" - - ''', - 'scan-angles/Manual/ErrorFiles.csv': ''' - "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - - ''', - 'scan-angles/Manual/MultiMarkedFiles.csv': ''' - "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - - ''', - 'scan-angles/Results/Results_05AM.csv': ''' - "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - "angle-1.jpg","samples/community/UPSC-mock/scan-angles/angle-1.jpg","outputs/community/UPSC-mock/scan-angles/CheckedOMRs/angle-1.jpg","70.66666666666669","","","","D","D","A","","C","C","B","","A","C","C","D","A","D","A","C","A","D","B","D","D","C","D","D","D","D","","B","A","D","D","C","","B","","C","D","","","A","","A","C","C","B","C","A","A","C","","C","","D","B","C","","B","C","D","","","C","C","","C","A","B","C","","","","","D","D","C","D","A","","","B","","B","D","C","C","","D","","D","C","D","A","","A","","","A","C","B","A" - "angle-2.jpg","samples/community/UPSC-mock/scan-angles/angle-2.jpg","outputs/community/UPSC-mock/scan-angles/CheckedOMRs/angle-2.jpg","70.66666666666669","","","","D","D","A","","C","C","B","","A","C","C","D","A","D","A","C","A","D","B","D","D","C","D","D","D","D","","B","A","D","D","C","","B","","C","D","","","A","","A","C","C","B","C","A","A","C","","C","","D","B","C","","B","C","D","","","C","C","","C","A","B","C","","","","","D","D","C","D","A","","","B","","B","D","C","C","","D","","D","C","D","A","","A","","","A","C","B","A" - "angle-3.jpg","samples/community/UPSC-mock/scan-angles/angle-3.jpg","outputs/community/UPSC-mock/scan-angles/CheckedOMRs/angle-3.jpg","70.66666666666669","","","","D","D","A","","C","C","B","","A","C","C","D","A","D","A","C","A","D","B","D","D","C","D","D","D","D","","B","A","D","D","C","","B","","C","D","","","A","","A","C","C","B","C","A","A","C","","C","","D","B","C","","B","C","D","","","C","C","","C","A","B","C","","","","","D","D","C","D","A","","","B","","B","D","C","C","","D","","D","C","D","A","","A","","","A","C","B","A" - - ''', - }) -# --- # name: test_run_community_UmarFarootAPS dict({ 'scans/Manual/ErrorFiles.csv': ''' @@ -132,7 +138,7 @@ 'scans/Results/Results_05AM.csv': ''' "file_id","input_path","output_path","score","Roll_no","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100","q101","q102","q103","q104","q105","q106","q107","q108","q109","q110","q111","q112","q113","q114","q115","q116","q117","q118","q119","q120","q121","q122","q123","q124","q125","q126","q127","q128","q129","q130","q131","q132","q133","q134","q135","q136","q137","q138","q139","q140","q141","q142","q143","q144","q145","q146","q147","q148","q149","q150","q151","q152","q153","q154","q155","q156","q157","q158","q159","q160","q161","q162","q163","q164","q165","q166","q167","q168","q169","q170","q171","q172","q173","q174","q175","q176","q177","q178","q179","q180","q181","q182","q183","q184","q185","q186","q187","q188","q189","q190","q191","q192","q193","q194","q195","q196","q197","q198","q199","q200" "scan-type-1.jpg","samples/community/UmarFarootAPS/scans/scan-type-1.jpg","outputs/community/UmarFarootAPS/scans/CheckedOMRs/scan-type-1.jpg","49.0","2468","A","C","B","C","A","D","B","C","B","D","C","A","C","D","B","C","A","B","C","A","C","B","D","C","A","B","D","C","A","C","B","D","B","A","C","D","B","C","A","C","D","A","C","D","A","B","D","C","A","C","D","B","C","A","C","D","B","C","D","A","B","C","B","C","D","B","D","A","C","B","D","A","B","C","B","A","C","D","B","A","C","B","C","B","A","D","B","A","C","D","B","D","B","C","B","D","A","C","B","C","B","C","D","B","C","A","B","C","A","D","C","B","D","B","A","B","C","D","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","B","A","C","B","A","C","A","B","C","B","C","B","A","C","A","C","B","B","C","B","A","C","A","B","A","B","A","B","C","D","B","C","A","C","D","C","A","C","B","A","C","A","B","C","B","D","A","B","C","D","C","B","B","C","A","B","C","B" - "scan-type-2.jpg","samples/community/UmarFarootAPS/scans/scan-type-2.jpg","outputs/community/UmarFarootAPS/scans/CheckedOMRs/scan-type-2.jpg","20.0","0234","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","A","D","","","AD","","","","A","D","","","","","","","D","A","","D","","A","","D","","","","A","","","C","","","D","","","A","","","","D","","C","","A","","C","","D","B","B","","","A","","D","","","","D","","","","","A","D","","","B","","","D","","","A","","","D","","","","","","D","","","","A","D","","","A","","B","","D","","","","C","C","D","D","A","","D","","A","D","","","D","","B","D","","","D","","D","B","","","","D","","A","","","","D","","B","","","","","","D","","","A","","","A","","D","","","D" + "scan-type-2.jpg","samples/community/UmarFarootAPS/scans/scan-type-2.jpg","outputs/community/UmarFarootAPS/scans/CheckedOMRs/scan-type-2.jpg","21.0","0234","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","C","D","C","B","A","B","A","D","","","AD","","","","A","D","","","","","","","D","A","","D","","A","","D","","","","A","","","C","","","D","","","A","","","","D","","C","","A","","C","","D","B","B","","","A","","D","","","","D","","","","","A","D","","","B","","","D","","","A","","","D","","","B","","","D","","","","A","D","","","A","","B","","D","","","","C","C","D","D","A","","D","","A","D","","","D","","B","D","","","D","","D","B","","","","D","","A","","","","D","","B","","","","","","D","","","A","","","A","","D","","","D" ''', }) @@ -154,91 +160,60 @@ ''', }) # --- -# name: test_run_sample1 +# name: test_run_crop_four_dots dict({ - 'MobileCamera/Manual/ErrorFiles.csv': ''' - "file_id","input_path","output_path","score","Roll","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20" - - ''', - 'MobileCamera/Manual/MultiMarkedFiles.csv': ''' - "file_id","input_path","output_path","score","Roll","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20" - - ''', - 'MobileCamera/Results/Results_05AM.csv': ''' - "file_id","input_path","output_path","score","Roll","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20" - "sheet1.jpg","samples/sample1/MobileCamera/sheet1.jpg","outputs/sample1/MobileCamera/CheckedOMRs/sheet1.jpg","0","E503110026","B","","D","B","6","11","20","7","16","B","D","C","D","A","D","B","A","C","C","D" - - ''', - }) -# --- -# name: test_run_sample2 - dict({ - 'AdrianSample/Manual/ErrorFiles.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5" + 'Manual/ErrorFiles.csv': ''' + "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" ''', - 'AdrianSample/Manual/MultiMarkedFiles.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5" + 'Manual/MultiMarkedFiles.csv': ''' + "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" ''', - 'AdrianSample/Results/Results_05AM.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5" - "adrian_omr.png","samples/sample2/AdrianSample/adrian_omr.png","outputs/sample2/AdrianSample/CheckedOMRs/adrian_omr.png","0","B","E","A","C","B" - "adrian_omr_2.png","samples/sample2/AdrianSample/adrian_omr_2.png","outputs/sample2/AdrianSample/CheckedOMRs/adrian_omr_2.png","0","C","E","A","B","B" + 'Results/Results_05AM.csv': ''' + "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + "omr-four-dots.jpg","samples/experimental/1-timelines-and-dots/four-dots/omr-four-dots.jpg","outputs/experimental/1-timelines-and-dots/four-dots/CheckedOMRs/omr-four-dots.jpg","0","1000001064","06","A","C","C","A","B","","D","C","D","A","A","D","","B","A","B","A","C","B","","","","D","C","D","C","C","","C","D","B","","B","B","","","B","A","B","A","A","B","D","D","C","B","C","C","D","B","D","","A","A","C","B","B","D","C","B","A","C","A","C","C","B","B","D","C","B","A","","C","D","B","D","C","A","C","C","B","C","B","C","D","A","B","C","B","B","","","","","D","D","B","","","D","" ''', }) # --- -# name: test_run_sample3 +# name: test_run_crop_two_dots_one_line dict({ - 'colored-thick-sheet/Manual/ErrorFiles.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - - ''', - 'colored-thick-sheet/Manual/MultiMarkedFiles.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - - ''', - 'colored-thick-sheet/Results/Results_05AM.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - "rgb-100-gsm.jpg","samples/sample3/colored-thick-sheet/rgb-100-gsm.jpg","outputs/sample3/colored-thick-sheet/CheckedOMRs/rgb-100-gsm.jpg","0","D","D","A","","C","C","B","","A","C","C","D","A","D","A","C","A","D","B","D","D","C","D","D","D","D","","B","A","D","D","C","","B","","C","D","","","A","","A","C","C","B","C","A","A","C","","C","","D","B","C","","B","C","D","","","C","C","","C","A","B","C","","","","","D","D","C","D","A","","","B","","B","D","C","C","","D","","D","C","D","A","","A","","","A","C","B","A" - - ''', - 'xeroxed-thin-sheet/Manual/ErrorFiles.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + 'Manual/ErrorFiles.csv': ''' + "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" ''', - 'xeroxed-thin-sheet/Manual/MultiMarkedFiles.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + 'Manual/MultiMarkedFiles.csv': ''' + "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" ''', - 'xeroxed-thin-sheet/Results/Results_05AM.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" - "grayscale-80-gsm.jpg","samples/sample3/xeroxed-thin-sheet/grayscale-80-gsm.jpg","outputs/sample3/xeroxed-thin-sheet/CheckedOMRs/grayscale-80-gsm.jpg","0","C","D","A","C","C","C","B","A","C","C","B","D","B","D","C","C","B","D","B","D","C","C","C","B","D","D","D","B","A","D","D","C","A","B","C","A","D","A","A","A","D","D","B","A","B","C","B","A","C","D","C","D","A","B","C","A","C","C","C","D","B","C","C","C","C","A","D","A","D","A","D","C","C","D","C","D","A","A","C","B","C","D","C","A","B","C","B","D","A","A","C","A","B","D","C","D","A","C","B","A" + 'Results/Results_05AM.csv': ''' + "file_id","input_path","output_path","score","Roll","Subject Code","bookletNo","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + "omr-four-dots.jpg","samples/experimental/1-timelines-and-dots/four-dots/omr-four-dots.jpg","outputs/experimental/1-timelines-and-dots/four-dots/CheckedOMRs/omr-four-dots.jpg","0","1000001064","06","A","C","C","A","B","","D","C","D","A","A","D","","B","A","B","A","C","B","","","","D","C","D","C","C","","C","D","B","","B","B","","","B","A","B","A","A","B","D","D","C","B","C","C","D","B","D","","A","A","C","B","B","D","C","B","A","C","A","C","C","B","B","D","C","B","A","","C","D","B","D","C","A","C","C","B","C","B","C","D","A","B","C","B","B","","","","","D","D","B","","","D","" ''', }) # --- -# name: test_run_sample4 +# name: test_run_feature_based_alignment dict({ - 'Manual/ErrorFiles.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11" + 'doc-scans/Manual/ErrorFiles.csv': ''' + "file_id","input_path","output_path","score","Roll" ''', - 'Manual/MultiMarkedFiles.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11" + 'doc-scans/Manual/MultiMarkedFiles.csv': ''' + "file_id","input_path","output_path","score","Roll" ''', - 'Results/Results_05AM.csv': ''' - "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11" - "IMG_20201116_143512.jpg","samples/sample4/IMG_20201116_143512.jpg","outputs/sample4/CheckedOMRs/IMG_20201116_143512.jpg","33.0","B","D","C","B","D","C","BC","A","C","D","C" - "IMG_20201116_150717658.jpg","samples/sample4/IMG_20201116_150717658.jpg","outputs/sample4/CheckedOMRs/IMG_20201116_150717658.jpg","33.0","B","D","C","B","D","C","BC","A","C","D","C" - "IMG_20201116_150750830.jpg","samples/sample4/IMG_20201116_150750830.jpg","outputs/sample4/CheckedOMRs/IMG_20201116_150750830.jpg","-2.0","A","","D","C","AC","A","D","B","C","D","D" + 'doc-scans/Results/Results_05AM.csv': ''' + "file_id","input_path","output_path","score","Roll" + "sample_roll_01.jpg","samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_01.jpg","outputs/experimental/3-feature-based-alignment/doc-scans/CheckedOMRs/sample_roll_01.jpg","0","A0188877Y" + "sample_roll_02.jpg","samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_02.jpg","outputs/experimental/3-feature-based-alignment/doc-scans/CheckedOMRs/sample_roll_02.jpg","0","A0203959W" + "sample_roll_03.jpg","samples/experimental/3-feature-based-alignment/doc-scans/sample_roll_03.jpg","outputs/experimental/3-feature-based-alignment/doc-scans/CheckedOMRs/sample_roll_03.jpg","0","A0204729A" ''', }) # --- -# name: test_run_sample5 +# name: test_run_omr_marker dict({ 'ScanBatch1/Manual/ErrorFiles.csv': ''' "file_id","input_path","output_path","score","Roll","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22" @@ -250,7 +225,7 @@ ''', 'ScanBatch1/Results/Results_05AM.csv': ''' "file_id","input_path","output_path","score","Roll","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22" - "camscanner-1.jpg","samples/sample5/ScanBatch1/camscanner-1.jpg","outputs/sample5/ScanBatch1/CheckedOMRs/camscanner-1.jpg","-4.0","E204420102","D","C","A","C","B","08","52","21","85","36","B","C","A","A","D","C","C","AD","A","A","D","" + "camscanner-1.jpg","samples/2-omr-marker/ScanBatch1/camscanner-1.jpg","outputs/2-omr-marker/ScanBatch1/CheckedOMRs/camscanner-1.jpg","-4.0","E204420102","D","C","A","C","B","08","52","21","85","36","B","C","A","A","D","C","C","AD","A","A","D","" ''', 'ScanBatch2/Manual/ErrorFiles.csv': ''' @@ -263,39 +238,71 @@ ''', 'ScanBatch2/Results/Results_05AM.csv': ''' "file_id","input_path","output_path","score","Roll","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22" - "camscanner-2.jpg","samples/sample5/ScanBatch2/camscanner-2.jpg","outputs/sample5/ScanBatch2/CheckedOMRs/camscanner-2.jpg","55.0","E204420109","C","C","B","C","C","01","19","10","10","18","D","A","D","D","D","C","C","C","C","D","B","A" + "camscanner-2.jpg","samples/2-omr-marker/ScanBatch2/camscanner-2.jpg","outputs/2-omr-marker/ScanBatch2/CheckedOMRs/camscanner-2.jpg","55.0","E204420109","C","C","B","C","C","01","19","10","10","18","D","A","D","D","D","C","C","C","C","D","B","A" ''', }) # --- -# name: test_run_sample6 +# name: test_run_omr_marker_mobile dict({ - 'Manual/ErrorFiles.csv': ''' - "file_id","input_path","output_path","score","Roll" + 'MobileCamera/Manual/ErrorFiles.csv': ''' + "file_id","input_path","output_path","score","Roll","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20" ''', - 'Manual/MultiMarkedFiles.csv': ''' - "file_id","input_path","output_path","score","Roll" + 'MobileCamera/Manual/MultiMarkedFiles.csv': ''' + "file_id","input_path","output_path","score","Roll","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20" ''', - 'Results/Results_05AM.csv': ''' - "file_id","input_path","output_path","score","Roll" - "reference.png","samples/sample6/reference.png","outputs/sample6/CheckedOMRs/reference.png","0","A" + 'MobileCamera/Results/Results_05AM.csv': ''' + "file_id","input_path","output_path","score","Roll","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20" + "sheet1.jpg","samples/1-mobile-camera/MobileCamera/sheet1.jpg","outputs/1-mobile-camera/MobileCamera/CheckedOMRs/sheet1.jpg","0","E503110026","B","","D","B","6","11","20","7","16","B","D","C","D","A","D","B","A","C","C","D" ''', - 'doc-scans/Manual/ErrorFiles.csv': ''' - "file_id","input_path","output_path","score","Roll" + }) +# --- +# name: test_run_template_shifts + dict({ + 'colored-thick-sheet/Manual/ErrorFiles.csv': ''' + "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" ''', - 'doc-scans/Manual/MultiMarkedFiles.csv': ''' - "file_id","input_path","output_path","score","Roll" + 'colored-thick-sheet/Manual/MultiMarkedFiles.csv': ''' + "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" ''', - 'doc-scans/Results/Results_05AM.csv': ''' - "file_id","input_path","output_path","score","Roll" - "sample_roll_01.jpg","samples/sample6/doc-scans/sample_roll_01.jpg","outputs/sample6/doc-scans/CheckedOMRs/sample_roll_01.jpg","0","A0188877Y" - "sample_roll_02.jpg","samples/sample6/doc-scans/sample_roll_02.jpg","outputs/sample6/doc-scans/CheckedOMRs/sample_roll_02.jpg","0","A0203959W" - "sample_roll_03.jpg","samples/sample6/doc-scans/sample_roll_03.jpg","outputs/sample6/doc-scans/CheckedOMRs/sample_roll_03.jpg","0","A0204729A" + 'colored-thick-sheet/Results/Results_05AM.csv': ''' + "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + "rgb-100-gsm.jpg","samples/experimental/2-template-shifts/colored-thick-sheet/rgb-100-gsm.jpg","outputs/experimental/2-template-shifts/colored-thick-sheet/CheckedOMRs/rgb-100-gsm.jpg","0","D","D","A","","C","C","B","","A","C","C","D","A","D","A","C","A","D","B","D","D","C","D","D","D","D","","B","A","D","D","C","","B","","C","D","","","A","","A","C","C","B","C","A","A","C","","C","","D","B","C","","B","C","D","","","C","C","","C","A","B","C","","","","","D","D","C","D","A","","","B","","B","D","C","C","","D","","D","C","D","A","","A","","","A","C","B","A" + + ''', + 'xeroxed-thin-sheet/Manual/ErrorFiles.csv': ''' + "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + + ''', + 'xeroxed-thin-sheet/Manual/MultiMarkedFiles.csv': ''' + "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + + ''', + 'xeroxed-thin-sheet/Results/Results_05AM.csv': ''' + "file_id","input_path","output_path","score","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + "grayscale-80-gsm.jpg","samples/experimental/2-template-shifts/xeroxed-thin-sheet/grayscale-80-gsm.jpg","outputs/experimental/2-template-shifts/xeroxed-thin-sheet/CheckedOMRs/grayscale-80-gsm.jpg","0","C","D","A","C","C","C","B","A","C","C","B","D","B","D","C","C","B","D","B","D","C","C","C","B","D","D","D","B","A","D","D","C","A","B","C","A","D","A","A","A","D","D","B","A","B","C","B","A","C","D","C","D","A","B","C","A","C","C","C","D","B","C","C","C","C","A","D","A","D","A","D","C","C","D","C","D","A","A","C","B","C","D","C","A","B","C","B","D","A","A","C","A","B","D","C","D","A","C","B","A" + + ''', + }) +# --- +# name: test_run_two_lines + dict({ + 'Manual/ErrorFiles.csv': ''' + "file_id","input_path","output_path","score","CplNo","Name","RollNo","cpl4","cpl5","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + + ''', + 'Manual/MultiMarkedFiles.csv': ''' + "file_id","input_path","output_path","score","CplNo","Name","RollNo","cpl4","cpl5","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + + ''', + 'Results/Results_05AM.csv': ''' + "file_id","input_path","output_path","score","CplNo","Name","RollNo","cpl4","cpl5","q1","q2","q3","q4","q5","q6","q7","q8","q9","q10","q11","q12","q13","q14","q15","q16","q17","q18","q19","q20","q21","q22","q23","q24","q25","q26","q27","q28","q29","q30","q31","q32","q33","q34","q35","q36","q37","q38","q39","q40","q41","q42","q43","q44","q45","q46","q47","q48","q49","q50","q51","q52","q53","q54","q55","q56","q57","q58","q59","q60","q61","q62","q63","q64","q65","q66","q67","q68","q69","q70","q71","q72","q73","q74","q75","q76","q77","q78","q79","q80","q81","q82","q83","q84","q85","q86","q87","q88","q89","q90","q91","q92","q93","q94","q95","q96","q97","q98","q99","q100" + "omr-two-lines.jpg","samples/experimental/1-timelines-and-dots/two-lines/omr-two-lines.jpg","outputs/experimental/1-timelines-and-dots/two-lines/CheckedOMRs/omr-two-lines.jpg","0","142","NAMAN BADRI MANDER ","295","5","7","A","C","C","B","A","A","C","B","D","A","A","C","A","A","A","B","A","A","C","C","B","D","A","C","B","C","C","A","D","C","D","B","B","D","C","B","D","C","C","B","D","C","B","C","B","D","C","D","D","C","B","D","C","B","C","B","D","C","A","B","B","B","A","C","D","D","C","A","A","C","A","D","B","B","A","A","C","B","C","A","D","C","C","D","D","A","B","A","B","D","C","C","D","D","B","C","B","D","C","C" ''', }) diff --git a/src/tests/__snapshots__/test_edge_cases.ambr b/src/tests/__snapshots__/test_edge_cases.ambr new file mode 100644 index 00000000..5524658a --- /dev/null +++ b/src/tests/__snapshots__/test_edge_cases.ambr @@ -0,0 +1,7 @@ +# serializer version: 1 +# name: test_config_low_dimensions_error_case + 'Error: No marker found in patch topLeftMarker' +# --- +# name: test_config_low_dimensions_safe_case + None +# --- diff --git a/src/tests/test_all_samples.py b/src/tests/test_all_samples.py index 9efe94ff..f1b454f7 100644 --- a/src/tests/test_all_samples.py +++ b/src/tests/test_all_samples.py @@ -42,43 +42,58 @@ def extract_sample_outputs(output_dir): return sample_outputs +def test_run_omr_marker_mobile(mocker, snapshot): + sample_outputs = run_sample(mocker, "1-mobile-camera") + assert snapshot == sample_outputs + + +def test_run_omr_marker(mocker, snapshot): + sample_outputs = run_sample(mocker, "2-omr-marker") + assert snapshot == sample_outputs + + +def test_run_bonus_marking(mocker, snapshot): + sample_outputs = run_sample(mocker, "3-answer-key/bonus-marking") + assert snapshot == sample_outputs + + def test_run_answer_key_using_csv(mocker, snapshot): - sample_outputs = run_sample(mocker, "answer-key/using-csv") + sample_outputs = run_sample(mocker, "3-answer-key/using-csv") assert snapshot == sample_outputs -def test_run_answer_key_weighted_answers(mocker, snapshot): - sample_outputs = run_sample(mocker, "answer-key/weighted-answers") +def test_run_answer_key_using_image(mocker, snapshot): + sample_outputs = run_sample(mocker, "3-answer-key/using-image") assert snapshot == sample_outputs -def test_run_sample1(mocker, snapshot): - sample_outputs = run_sample(mocker, "sample1") +def test_run_answer_key_weighted_answers(mocker, snapshot): + sample_outputs = run_sample(mocker, "3-answer-key/weighted-answers") assert snapshot == sample_outputs -def test_run_sample2(mocker, snapshot): - sample_outputs = run_sample(mocker, "sample2") +def test_run_crop_four_dots(mocker, snapshot): + sample_outputs = run_sample(mocker, "experimental/1-timelines-and-dots/four-dots") assert snapshot == sample_outputs -def test_run_sample3(mocker, snapshot): - sample_outputs = run_sample(mocker, "sample3") +def test_run_crop_two_dots_one_line(mocker, snapshot): + sample_outputs = run_sample(mocker, "experimental/1-timelines-and-dots/four-dots") assert snapshot == sample_outputs -def test_run_sample4(mocker, snapshot): - sample_outputs = run_sample(mocker, "sample4") +def test_run_two_lines(mocker, snapshot): + sample_outputs = run_sample(mocker, "experimental/1-timelines-and-dots/two-lines") assert snapshot == sample_outputs -def test_run_sample5(mocker, snapshot): - sample_outputs = run_sample(mocker, "sample5") +def test_run_template_shifts(mocker, snapshot): + sample_outputs = run_sample(mocker, "experimental/2-template-shifts") assert snapshot == sample_outputs -def test_run_sample6(mocker, snapshot): - sample_outputs = run_sample(mocker, "sample6") +def test_run_feature_based_alignment(mocker, snapshot): + sample_outputs = run_sample(mocker, "experimental/3-feature-based-alignment") assert snapshot == sample_outputs @@ -105,8 +120,3 @@ def test_run_community_Shamanth(mocker, snapshot): def test_run_community_UmarFarootAPS(mocker, snapshot): sample_outputs = run_sample(mocker, "community/UmarFarootAPS") assert snapshot == sample_outputs - - -def test_run_community_UPSC_mock(mocker, snapshot): - sample_outputs = run_sample(mocker, "community/UPSC-mock") - assert snapshot == sample_outputs diff --git a/src/tests/test_edge_cases.py b/src/tests/test_edge_cases.py index f3d6fd45..565e981e 100644 --- a/src/tests/test_edge_cases.py +++ b/src/tests/test_edge_cases.py @@ -44,14 +44,31 @@ def extract_output_data(path): ) -def test_config_low_dimensions(mocker): - def modify_config(config): - config["dimensions"]["processing_height"] = 1000 - config["dimensions"]["processing_width"] = 1000 +def test_config_low_dimensions_error_case(mocker, snapshot): + def modify_template(template): + template["preProcessors"][0]["options"]["processingImageShape"] = [ + 1640 // 4, + 1332 // 4, + ] + template["preProcessors"][0]["options"]["markerDimensions"] = [20, 20] + + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) + assert str(exception) == snapshot - exception = write_jsons_and_run(mocker, modify_config=modify_config) +def test_config_low_dimensions_safe_case(mocker, snapshot): + def modify_template(template): + template["preProcessors"][0]["options"]["processingImageShape"] = [ + 1640 // 2, + 1332 // 2, + ] + template["preProcessors"][0]["options"]["markerDimensions"] = [20, 20] + + sample_outputs, exception = write_jsons_and_run( + mocker, modify_template=modify_template + ) assert str(exception) == "No Error" + assert snapshot == sample_outputs def test_different_bubble_dimensions(mocker): @@ -59,25 +76,33 @@ def test_different_bubble_dimensions(mocker): remove_file(BASE_RESULTS_CSV_PATH) remove_file(BASE_MULTIMARKED_CSV_PATH) - exception = write_jsons_and_run(mocker) + _, exception = write_jsons_and_run(mocker) assert str(exception) == "No Error" + original_output_data = extract_output_data(BASE_RESULTS_CSV_PATH) + assert not original_output_data.empty + assert len(original_output_data) == 1 def modify_template(template): # Incorrect global bubble size template["bubbleDimensions"] = [5, 5] # Correct bubble size for MCQBlock1a1 - template["fieldBlocks"]["MCQBlock1a1"]["bubbleDimensions"] = [32, 32] + template["fieldBlocks"]["MCQBlock1a1"]["bubbleDimensions"] = [ + 32, + 32, + ] # Incorrect bubble size for MCQBlock1a11 - template["fieldBlocks"]["MCQBlock1a11"]["bubbleDimensions"] = [10, 10] + template["fieldBlocks"]["MCQBlock1a11"]["bubbleDimensions"] = [ + 5, + 5, + ] remove_file(BASE_RESULTS_CSV_PATH) remove_file(BASE_MULTIMARKED_CSV_PATH) - exception = write_jsons_and_run(mocker, modify_template=modify_template) + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert str(exception) == "No Error" results_output_data = extract_output_data(BASE_RESULTS_CSV_PATH) - assert results_output_data.empty output_data = extract_output_data(BASE_MULTIMARKED_CSV_PATH) diff --git a/src/tests/test_samples/sample1/boilerplate.py b/src/tests/test_samples/sample1/boilerplate.py index 74dda465..30fb68f3 100644 --- a/src/tests/test_samples/sample1/boilerplate.py +++ b/src/tests/test_samples/sample1/boilerplate.py @@ -1,5 +1,5 @@ TEMPLATE_BOILERPLATE = { - "pageDimensions": [300, 400], + "templateDimensions": [300, 400], "bubbleDimensions": [25, 25], "preProcessors": [{"name": "CropPage", "options": {"morphKernel": [10, 10]}}], "fieldBlocks": { diff --git a/src/tests/test_samples/sample1/sample.png b/src/tests/test_samples/sample1/sample.png index d8db0994..08c618ad 100644 Binary files a/src/tests/test_samples/sample1/sample.png and b/src/tests/test_samples/sample1/sample.png differ diff --git a/src/tests/test_samples/sample2/boilerplate.py b/src/tests/test_samples/sample2/boilerplate.py index f43c3093..6c12d808 100644 --- a/src/tests/test_samples/sample2/boilerplate.py +++ b/src/tests/test_samples/sample2/boilerplate.py @@ -1,12 +1,15 @@ TEMPLATE_BOILERPLATE = { - "pageDimensions": [2550, 3300], + "templateDimensions": [2550, 3300], "bubbleDimensions": [32, 32], + "processingImageShape": [1640, 1332], "preProcessors": [ { "name": "CropOnMarkers", "options": { - "relativePath": "omr_marker.jpg", - "sheetToMarkerWidthRatio": 17, + "type": "FOUR_MARKERS", + "referenceImage": "omr_marker.jpg", + "markerDimensions": [40, 40], + "tuningOptions": {"marker_rescale_range": [80, 120]}, }, } ], @@ -29,11 +32,9 @@ } CONFIG_BOILERPLATE = { - "dimensions": { - "display_height": 960, - "display_width": 1280, - "processing_height": 1640, - "processing_width": 1332, + "outputs": { + "show_image_level": 0, + "filter_out_multimarked_files": True, + "display_image_dimensions": [960, 1280], }, - "outputs": {"show_image_level": 0, "filter_out_multimarked_files": True}, } diff --git a/src/tests/test_samples/sample2/sample.jpg b/src/tests/test_samples/sample2/sample.jpg index 2eef7c32..0e78a7aa 100644 Binary files a/src/tests/test_samples/sample2/sample.jpg and b/src/tests/test_samples/sample2/sample.jpg differ diff --git a/src/tests/test_template_validations.py b/src/tests/test_template_validations.py index bd321a72..bc28ecd6 100644 --- a/src/tests/test_template_validations.py +++ b/src/tests/test_template_validations.py @@ -50,7 +50,8 @@ def test_empty_template(mocker): def modify_template(_): return {} - exception = write_jsons_and_run(mocker, modify_template=modify_template) + print("\nExpecting invalid template json error logs:") + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert ( str(exception) == f"Provided Template JSON is Invalid: '{BASE_SAMPLE_TEMPLATE_PATH}'" @@ -61,7 +62,8 @@ def test_invalid_field_type(mocker): def modify_template(template): template["fieldBlocks"]["MCQ_Block_1"]["fieldType"] = "X" - exception = write_jsons_and_run(mocker, modify_template=modify_template) + print("\nExpecting invalid template json error logs:") + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert ( str(exception) == f"Provided Template JSON is Invalid: '{BASE_SAMPLE_TEMPLATE_PATH}'" @@ -72,7 +74,7 @@ def test_overflow_labels(mocker): def modify_template(template): template["fieldBlocks"]["MCQ_Block_1"]["fieldLabels"] = ["q1..100"] - exception = write_jsons_and_run(mocker, modify_template=modify_template) + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert ( str(exception) == "Overflowing field block 'MCQ_Block_1' with origin [65, 60] and dimensions [189, 5173] in template with dimensions [300, 400]" @@ -81,9 +83,9 @@ def modify_template(template): def test_overflow_safe_dimensions(mocker): def modify_template(template): - template["pageDimensions"] = [255, 400] + template["templateDimensions"] = [255, 400] - exception = write_jsons_and_run(mocker, modify_template=modify_template) + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert str(exception) == "No Error" @@ -97,9 +99,9 @@ def modify_template(template): }, } - exception = write_jsons_and_run(mocker, modify_template=modify_template) + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert str(exception) == ( - "The field strings for field block New_Block overlap with other existing fields" + "The field strings for field block New_Block overlap with other existing fields: {'q5'}" ) @@ -109,7 +111,7 @@ def modify_template(template): "label1": ["q1..2", "q2..3"], } - exception = write_jsons_and_run(mocker, modify_template=modify_template) + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert ( str(exception) == "Given field string 'q2..3' has overlapping field(s) with other fields in 'Custom Label: label1': ['q1..2', 'q2..3']" @@ -123,7 +125,7 @@ def modify_template(template): "label2": ["q2..3"], } - exception = write_jsons_and_run(mocker, modify_template=modify_template) + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert ( str(exception) == "The field strings for custom label 'label2' overlap with other existing custom labels" @@ -134,7 +136,7 @@ def test_missing_field_block_labels(mocker): def modify_template(template): template["customLabels"] = {"Combined": ["qX", "qY"]} - exception = write_jsons_and_run(mocker, modify_template=modify_template) + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert ( str(exception) == "Missing field block label(s) in the given template for ['qX', 'qY'] from 'Combined'" @@ -145,7 +147,7 @@ def test_missing_output_columns(mocker): def modify_template(template): template["outputColumns"] = ["qX", "q1..5"] - exception = write_jsons_and_run(mocker, modify_template=modify_template) + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert str(exception) == ( "Some columns are missing in the field blocks for the given output columns" ) @@ -155,5 +157,5 @@ def test_safe_missing_label_columns(mocker): def modify_template(template): template["outputColumns"] = ["q1..4"] - exception = write_jsons_and_run(mocker, modify_template=modify_template) + _, exception = write_jsons_and_run(mocker, modify_template=modify_template) assert str(exception) == "No Error" diff --git a/src/tests/utils.py b/src/tests/utils.py index f8e5bfab..6f4f3a33 100644 --- a/src/tests/utils.py +++ b/src/tests/utils.py @@ -23,11 +23,10 @@ def setup_mocker_patches(mocker): def run_entry_point(input_path, output_dir): args = { "autoAlign": False, - "debug": False, + "debug": True, "input_paths": [input_path], "output_dir": output_dir, "setLayout": False, - "silent": True, } with freeze_time(FROZEN_TIMESTAMP): entry_point_for_args(args) @@ -82,9 +81,9 @@ def write_jsons_and_run( modify_evaluation, evaluation_boilerplate, sample_evaluation_path ) - exception = "No Error" + sample_outputs, exception = "No output", "No Error" try: - run_sample(mocker, sample_path) + sample_outputs = run_sample(mocker, sample_path) except Exception as e: exception = e @@ -92,6 +91,6 @@ def write_jsons_and_run( remove_file(sample_config_path) remove_file(sample_evaluation_path) - return exception + return sample_outputs, exception return write_jsons_and_run diff --git a/src/utils/__init__.py b/src/utils/__init__.py index e69de29b..81434d7e 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -0,0 +1 @@ +# https://docs.python.org/3/tutorial/modules.html#:~:text=The%20__init__.py,on%20the%20module%20search%20path. diff --git a/src/constants.py b/src/utils/constants.py similarity index 76% rename from src/constants.py rename to src/utils/constants.py index f1cdc87b..9ccfbce0 100644 --- a/src/constants.py +++ b/src/utils/constants.py @@ -1,11 +1,3 @@ -""" - - OMRChecker - - Author: Udayraj Deshmukh - Github: https://github.com/Udayraj123 - -""" from dotmap import DotMap # Filenames @@ -23,7 +15,7 @@ _dynamic=False, ) -FIELD_TYPES = { +BUILTIN_FIELD_TYPES = { "QTYPE_INT": { "bubbleValues": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"], "direction": "vertical", @@ -42,13 +34,14 @@ # } -# TODO: move to interaction.py +# TODO: Move TEXT_SIZE, etc into a class TEXT_SIZE = 0.95 -CLR_BLACK = (50, 150, 150) -CLR_WHITE = (250, 250, 250) -CLR_GRAY = (130, 130, 130) +CLR_BLACK = (0, 0, 0) CLR_DARK_GRAY = (100, 100, 100) - -# TODO: move to config.json -GLOBAL_PAGE_THRESHOLD_WHITE = 200 -GLOBAL_PAGE_THRESHOLD_BLACK = 100 +CLR_DARK_GREEN = (20, 255, 20) +CLR_GRAY = (130, 130, 130) +CLR_LIGHT_GRAY = (200, 200, 200) +CLR_GREEN = (100, 200, 100) +CLR_WHITE = (255, 255, 255) +MARKED_TEMPLATE_TRANSPARENCY = 0.65 +BONUS_SYMBOL = "*" diff --git a/src/utils/file.py b/src/utils/file.py index 5513381f..8932c79f 100644 --- a/src/utils/file.py +++ b/src/utils/file.py @@ -6,7 +6,7 @@ import pandas as pd -from src.logger import logger +from src.utils.logger import logger def load_json(path, **rest): @@ -14,8 +14,9 @@ def load_json(path, **rest): with open(path, "r") as f: loaded = json.load(f, **rest) except json.decoder.JSONDecodeError as error: - logger.critical(f"Error when loading json file at: '{path}'\n{error}") - exit(1) + logger.critical(error) + raise Exception(f"Error when loading json file at: '{path}'") + return loaded @@ -23,6 +24,7 @@ class Paths: def __init__(self, output_dir): self.output_dir = output_dir self.save_marked_dir = output_dir.joinpath("CheckedOMRs") + self.image_metrics_dir = output_dir.joinpath("ImageMetrics") self.results_dir = output_dir.joinpath("Results") self.manual_dir = output_dir.joinpath("Manual") self.errors_dir = self.manual_dir.joinpath("ErrorFiles") @@ -31,20 +33,34 @@ def __init__(self, output_dir): def setup_dirs_for_paths(paths): logger.info("Checking Directories...") + + # Main output directories for save_output_dir in [paths.save_marked_dir]: if not os.path.exists(save_output_dir): logger.info(f"Created : {save_output_dir}") os.makedirs(save_output_dir) os.mkdir(save_output_dir.joinpath("stack")) + os.mkdir(save_output_dir.joinpath("colored")) os.mkdir(save_output_dir.joinpath("_MULTI_")) os.mkdir(save_output_dir.joinpath("_MULTI_", "stack")) - - for save_output_dir in [paths.manual_dir, paths.results_dir]: + os.mkdir(save_output_dir.joinpath("_MULTI_", "colored")) + + # Image buckets + for save_output_dir in [ + paths.manual_dir, + paths.multi_marked_dir, + paths.errors_dir, + ]: if not os.path.exists(save_output_dir): logger.info(f"Created : {save_output_dir}") os.makedirs(save_output_dir) + os.mkdir(save_output_dir.joinpath("colored")) - for save_output_dir in [paths.multi_marked_dir, paths.errors_dir]: + # Non-image directories + for save_output_dir in [ + paths.results_dir, + paths.image_metrics_dir, + ]: if not os.path.exists(save_output_dir): logger.info(f"Created : {save_output_dir}") os.makedirs(save_output_dir) diff --git a/src/utils/image.py b/src/utils/image.py index bba6f6d1..a191b67f 100644 --- a/src/utils/image.py +++ b/src/utils/image.py @@ -1,43 +1,97 @@ -""" - - OMRChecker - - Author: Udayraj Deshmukh - Github: https://github.com/Udayraj123 - -""" import cv2 -import matplotlib.pyplot as plt import numpy as np +from matplotlib import pyplot +from shapely import LineString, Point -from src.logger import logger +from src.processors.constants import EDGE_TYPES_IN_ORDER, EdgeType +from src.utils.constants import ( + CLR_BLACK, + CLR_DARK_GRAY, + CLR_GRAY, + CLR_GREEN, + CLR_WHITE, + TEXT_SIZE, +) +from src.utils.logger import logger +from src.utils.math import MathUtils -plt.rcParams["figure.figsize"] = (10.0, 8.0) +pyplot.rcParams["figure.figsize"] = (10.0, 8.0) CLAHE_HELPER = cv2.createCLAHE(clipLimit=5.0, tileGridSize=(8, 8)) class ImageUtils: """A Static-only Class to hold common image processing utilities & wrappers over OpenCV functions""" + @staticmethod + def read_image_util(file_path, tuning_config): + if tuning_config.outputs.show_colored_outputs: + colored_image = cv2.imread(file_path, cv2.IMREAD_COLOR) + gray_image = cv2.cvtColor(colored_image, cv2.COLOR_BGR2GRAY) + else: + gray_image = cv2.imread(file_path, cv2.IMREAD_GRAYSCALE) + colored_image = None + return gray_image, colored_image + @staticmethod def save_img(path, final_marked): logger.info(f"Saving Image to '{path}'") cv2.imwrite(path, final_marked) @staticmethod - def resize_util(img, u_width, u_height=None): - if u_height is None: - h, w = img.shape[:2] - u_height = int(h * u_width / w) - return cv2.resize(img, (int(u_width), int(u_height))) + def resize_to_shape(img, image_shape): + h, w = image_shape + return ImageUtils.resize_util(img, w, h) @staticmethod - def resize_util_h(img, u_height, u_width=None): + def resize_to_dimensions(img, image_dimensions): + w, h = image_dimensions + return ImageUtils.resize_util(img, w, h) + + def resize_util(img, u_width=None, u_height=None): + h, w = img.shape[:2] + if u_height is None: + u_height = int(h * u_width / w) if u_width is None: - h, w = img.shape[:2] u_width = int(w * u_height / h) + + if u_height == h and u_width == w: + # No need to resize + return img return cv2.resize(img, (int(u_width), int(u_height))) + @staticmethod + def get_cropped_rectangle_destination_points(ordered_page_corners): + # Note: This utility would just find a good size ratio for the cropped image to look more realistic + # but since we're anyway resizing the image, it doesn't make much sense to use these calculations + (tl, tr, br, bl) = ordered_page_corners + + length_t = MathUtils.distance(tr, tl) + length_b = MathUtils.distance(br, bl) + length_r = MathUtils.distance(tr, br) + length_l = MathUtils.distance(tl, bl) + + # compute the width of the new image, which will be the + max_width = max(int(length_t), int(length_b)) + + # compute the height of the new image, which will be the + max_height = max(int(length_r), int(length_l)) + + # now that we have the dimensions of the new image, construct + # the set of destination points to obtain a "birds eye view", + # (i.e. top-down view) of the image + + destination_points = np.array( + [ + [0, 0], + [max_width - 1, 0], + [max_width - 1, max_height - 1], + [0, max_height - 1], + ], + dtype="float32", + ) + warped_dimensions = (max_width, max_height) + return destination_points, warped_dimensions + @staticmethod def grab_contours(cnts): # source: imutils package @@ -69,7 +123,7 @@ def grab_contours(cnts): return cnts @staticmethod - def normalize_util(img, alpha=0, beta=255): + def normalize(img, alpha=0, beta=255): return cv2.normalize(img, alpha, beta, norm_type=cv2.NORM_MINMAX) @staticmethod @@ -98,58 +152,374 @@ def adjust_gamma(image, gamma=1.0): return cv2.LUT(image, table) @staticmethod - def four_point_transform(image, pts): - # obtain a consistent order of the points and unpack them - # individually - rect = ImageUtils.order_points(pts) - (tl, tr, br, bl) = rect + def get_control_destination_points_from_contour( + source_contour, destination_line, max_points=None + ): + # TODO: can use shapely intersections too? + total_points = len(source_contour) + if max_points is None: + max_points = total_points + assert max_points >= 2 + start, end = destination_line - # compute the width of the new image, which will be the - width_a = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2)) - width_b = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2)) + destination_line_length = MathUtils.distance(start, end) + contour_length = 0 + for i in range(1, total_points): + contour_length += MathUtils.distance( + source_contour[i], source_contour[i - 1] + ) - max_width = max(int(width_a), int(width_b)) - # max_width = max(int(np.linalg.norm(br-bl)), int(np.linalg.norm(tr-tl))) + # TODO: replace with this if the assertion passes on more samples + cv2_arclength = cv2.arcLength( + np.array(source_contour, dtype="float32"), closed=False + ) + assert ( + abs(cv2_arclength - contour_length) < 0.001 + ), f"{contour_length:.3f} != {cv2_arclength:.3f}" - # compute the height of the new image, which will be the - height_a = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2)) - height_b = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2)) - max_height = max(int(height_a), int(height_b)) - # max_height = max(int(np.linalg.norm(tr-br)), int(np.linalg.norm(tl-br))) + # average_min_gap = (contour_length / (max_points - 1)) - 1 - # now that we have the dimensions of the new image, construct - # the set of destination points to obtain a "birds eye view", - # (i.e. top-down view) of the image, again specifying points - # in the top-left, top-right, bottom-right, and bottom-left - # order - dst = np.array( - [ - [0, 0], - [max_width - 1, 0], - [max_width - 1, max_height - 1], - [0, max_height - 1], - ], - dtype="float32", + # Initialize with first point mapping + control_points, destination_points = [source_contour[0]], [start] + current_arc_length = 0 + # current_arc_gap = 0 + previous_point = None + for i in range(1, total_points): + boundary_point, previous_point = source_contour[i], source_contour[i - 1] + edge_length = MathUtils.distance(boundary_point, previous_point) + + # TODO: figure out an alternative to support maxPoints + # current_arc_gap += edge_length + # if current_arc_gap > average_min_gap : + # current_arc_length += current_arc_gap + # current_arc_gap = 0 + + # Including all points for now - + current_arc_length += edge_length + length_ratio = current_arc_length / contour_length + destination_point = MathUtils.get_point_on_line_by_ratio( + destination_line, length_ratio + ) + control_points.append(boundary_point) + destination_points.append(destination_point) + + assert len(destination_points) <= max_points + + # Assert that the float error is not skewing the estimations badly + assert ( + MathUtils.distance(destination_points[-1], end) / destination_line_length + < 0.02 + ), f"{destination_points[-1]} != {end}" + + return control_points, destination_points + + @staticmethod + def split_patch_contour_on_corners(patch_corners, source_contour): + ordered_patch_corners, _ = MathUtils.order_four_points( + patch_corners, dtype="float32" ) - transform_matrix = cv2.getPerspectiveTransform(rect, dst) - warped = cv2.warpPerspective(image, transform_matrix, (max_width, max_height)) + # TODO: consider snapping the corners to the contour + # TODO: consider using split from shapely after snap_corners - # return the warped image - return warped + source_contour = np.float32(source_contour) + + edge_contours_map = { + EdgeType.TOP: [], + EdgeType.RIGHT: [], + EdgeType.BOTTOM: [], + EdgeType.LEFT: [], + } + edge_line_strings = {} + for i, edge_type in enumerate(EDGE_TYPES_IN_ORDER): + edge_line_strings[edge_type] = LineString( + [ordered_patch_corners[i], ordered_patch_corners[(i + 1) % 4]] + ) + + # segments = split(boundary, MultiPoint(corners)).geoms + for boundary_point in source_contour: + edge_distances = [ + ( + Point(boundary_point).distance(edge_line_strings[edge_type]), + edge_type, + ) + for edge_type in EDGE_TYPES_IN_ORDER + ] + min_distance, nearest_edge_type = min(edge_distances) + distance_warning = "*" if min_distance > 10 else "" + logger.info( + f"boundary_point={boundary_point}\t nearest_edge_type={nearest_edge_type}\t min_distance={min_distance:.2f}{distance_warning}" + ) + # TODO: Each edge contour's points should be in the clockwise order + edge_contours_map[nearest_edge_type].append(tuple(boundary_point)) + + # Add corner points and ensure clockwise order + for i, edge_type in enumerate(EDGE_TYPES_IN_ORDER): + start_point, end_point = ( + ordered_patch_corners[i], + ordered_patch_corners[(i + 1) % 4], + ) + edge_contour = edge_contours_map[edge_type] + + if len(edge_contour) == 0: + logger.critical( + ordered_patch_corners, source_contour, edge_contours_map + ) + logger.warning( + f"No closest points found for {edge_type}: {edge_contours_map}" + ) + else: + # Ensure correct order + if MathUtils.distance( + start_point, edge_contour[-1] + ) < MathUtils.distance(start_point, edge_contour[0]): + edge_contours_map[edge_type].reverse() + + # Each contour should necessarily start & end with a corner point + edge_contours_map[edge_type].insert(0, tuple(start_point)) + edge_contours_map[edge_type].append(tuple(end_point)) + + return ordered_patch_corners, edge_contours_map + + @staticmethod + def get_vstack_image_grid(debug_vstack): + padded_hstack = [ + ImageUtils.get_padded_hstack(hstack) for hstack in debug_vstack + ] + return ImageUtils.get_padded_vstack(padded_hstack) + + @staticmethod + def get_padded_hstack(hstack): + max_height = max(image.shape[0] for image in hstack) + padded_hstack = [ + ImageUtils.pad_image_to_height(image, max_height) for image in hstack + ] + + return np.hstack(padded_hstack) + + @staticmethod + def get_padded_vstack(vstack): + max_width = max(image.shape[1] for image in vstack) + padded_vstack = [ + ImageUtils.pad_image_to_width(image, max_width) for image in vstack + ] + + return np.vstack(padded_vstack) + + @staticmethod + def pad_image_to_height(image, max_height, value=CLR_WHITE): + return cv2.copyMakeBorder( + image, + 0, + max_height - image.shape[0], + 0, + 0, + cv2.BORDER_CONSTANT, + value, + ) @staticmethod - def order_points(pts): - rect = np.zeros((4, 2), dtype="float32") + def pad_image_to_width(image, max_width, value=CLR_WHITE): + return cv2.copyMakeBorder( + image, + 0, + 0, + 0, + max_width - image.shape[1], + cv2.BORDER_CONSTANT, + value, + ) + + def pad_image_from_center(image, padding_width, padding_height=0, value=255): + # TODO: support colored images for this util + input_height, input_width = image.shape[:2] + pad_range = [ + padding_height, + padding_height + input_height, + padding_width, + padding_width + input_width, + ] + white_image = value * np.ones( + (padding_height * 2 + input_height, padding_width * 2 + input_width), + np.uint8, + ) + white_image[pad_range[0] : pad_range[1], pad_range[2] : pad_range[3]] = image + + return white_image, pad_range + + @staticmethod + def draw_matches(image, from_points, warped_image, to_points): + horizontal_stack = ImageUtils.get_padded_hstack([image, warped_image]) + h, w = image.shape[:2] + from_points = MathUtils.get_tuple_points(from_points) + to_points = MathUtils.get_tuple_points(to_points) + for from_point, to_point in zip(from_points, to_points): + horizontal_stack = cv2.line( + horizontal_stack, + from_point, + (w + to_point[0], to_point[1]), + color=CLR_GREEN, + thickness=3, + ) + return horizontal_stack - # the top-left point will have the smallest sum, whereas - # the bottom-right point will have the largest sum - s = pts.sum(axis=1) - rect[0] = pts[np.argmin(s)] - rect[2] = pts[np.argmax(s)] - diff = np.diff(pts, axis=1) - rect[1] = pts[np.argmin(diff)] - rect[3] = pts[np.argmax(diff)] + @staticmethod + def draw_box_diagonal( + image, + position, + position_diagonal, + color=CLR_DARK_GRAY, + border=3, + ): + cv2.rectangle( + image, + position, + position_diagonal, + color, + border, + ) + + @staticmethod + def draw_contour( + image, + contour, + color=CLR_GREEN, + thickness=2, + ): + assert None not in contour, "Invalid contour provided" + cv2.drawContours( + image, + [np.intp(contour)], + contourIdx=-1, + color=color, + thickness=thickness, + ) + + @staticmethod + def draw_box( + image, + position, + box_dimensions, + color=None, + style="BOX_HOLLOW", + thickness_factor=1 / 12, + border=3, + centered=False, + ): + assert position is not None + x, y = position + box_w, box_h = box_dimensions + + position = ( + int(x + box_w * thickness_factor), + int(y + box_h * thickness_factor), + ) + position_diagonal = ( + int(x + box_w - box_w * thickness_factor), + int(y + box_h - box_h * thickness_factor), + ) + + if centered: + centered_position = [ + (3 * position[0] - position_diagonal[0]) // 2, + (3 * position[1] - position_diagonal[1]) // 2, + ] + centered_diagonal = [ + (position[0] + position_diagonal[0]) // 2, + (position[1] + position_diagonal[1]) // 2, + ] + position = centered_position + position_diagonal = centered_diagonal + + if style == "BOX_HOLLOW": + if color is None: + color = CLR_GRAY + elif style == "BOX_FILLED": + if color is None: + color = CLR_DARK_GRAY + border = -1 + + ImageUtils.draw_box_diagonal( + image, + position, + position_diagonal, + color, + border, + ) + return position, position_diagonal + + @staticmethod + def draw_arrows( + image, + start_points, + end_points, + color=CLR_GREEN, + thickness=2, + line_type=cv2.LINE_AA, + tip_length=0.1, + ): + start_points = MathUtils.get_tuple_points(start_points) + end_points = MathUtils.get_tuple_points(end_points) + for start_point, end_point in zip(start_points, end_points): + image = cv2.arrowedLine( + image, + start_point, + end_point, + color, + thickness, + line_type, + tipLength=tip_length, + ) + + return image + + @staticmethod + def draw_text( + image, + text_value, + position, + centered=False, + font_face=cv2.FONT_HERSHEY_SIMPLEX, + text_size=TEXT_SIZE, + color=CLR_BLACK, + thickness=2, + # available LineTypes: FILLED, LINE_4, LINE_8, LINE_AA + line_type=cv2.LINE_AA, + ): + if centered: + assert not callable(position) + text_position = position + position = lambda size_x, size_y: ( + text_position[0] - size_x // 2, + text_position[1] + size_y // 2, + ) + + if callable(position): + size_x, size_y = cv2.getTextSize( + text_value, + font_face, + text_size, + thickness, + )[0] + position = position(size_x, size_y) + + position = (int(position[0]), int(position[1])) + cv2.putText( + image, + text_value, + position, + font_face, + text_size, + color, + thickness, + lineType=line_type, + ) + + @staticmethod + def draw_symbol(image, symbol, position, position_diagonal, color=CLR_BLACK): + center_position = lambda size_x, size_y: ( + (position[0] + position_diagonal[0] - size_x) // 2, + (position[1] + position_diagonal[1] + size_y) // 2, + ) - # return the ordered coordinates - return rect + ImageUtils.draw_text(image, symbol, center_position, color=color) diff --git a/src/utils/interaction.py b/src/utils/interaction.py index 1036f5b3..7da19253 100644 --- a/src/utils/interaction.py +++ b/src/utils/interaction.py @@ -1,17 +1,18 @@ from dataclasses import dataclass import cv2 +from matplotlib import pyplot from screeninfo import get_monitors -from src.logger import logger from src.utils.image import ImageUtils +from src.utils.logger import logger monitor_window = get_monitors()[0] @dataclass class ImageMetrics: - # TODO: Move TEXT_SIZE, etc here and find a better class name + # TODO: fix window metrics doesn't account for the doc/taskbar on macos window_width, window_height = monitor_window.width, monitor_window.height # for positioning image windows window_x, window_y = 0, 0 @@ -24,21 +25,31 @@ class InteractionUtils: image_metrics = ImageMetrics() @staticmethod - def show(name, origin, pause=1, resize=False, reset_pos=None, config=None): + def show( + name, + image, + pause=1, + resize_to_width=False, + resize_to_height=False, + reset_pos=None, + config=None, + ): image_metrics = InteractionUtils.image_metrics - if origin is None: + if image is None: logger.info(f"'{name}' - NoneType image to show!") if pause: cv2.destroyAllWindows() return - if resize: - if not config: - raise Exception("config not provided for resizing the image to show") - img = ImageUtils.resize_util(origin, config.dimensions.display_width) + if config is not None: + display_width, display_height = config.outputs.display_image_dimensions + if resize_to_width: + image_to_show = ImageUtils.resize_util(image, u_width=display_width) + elif resize_to_height: + image_to_show = ImageUtils.resize_util(image, u_height=display_height) else: - img = origin + image_to_show = image - cv2.imshow(name, img) + cv2.imshow(name, image_to_show) if reset_pos: image_metrics.window_x = reset_pos[0] @@ -50,14 +61,16 @@ def show(name, origin, pause=1, resize=False, reset_pos=None, config=None): image_metrics.window_y, ) - h, w = img.shape[:2] + h, w = image_to_show.shape[:2] # Set next window position margin = 25 - w += margin h += margin + w += margin - w, h = w // 2, h // 2 + # TODO: get ppi for correct positioning? + adjustment_ratio = 3 + h, w = h // adjustment_ratio, w // adjustment_ratio if image_metrics.window_x + w > image_metrics.window_width: image_metrics.window_x = 0 if image_metrics.window_y + h > image_metrics.window_height: @@ -72,12 +85,11 @@ def show(name, origin, pause=1, resize=False, reset_pos=None, config=None): f"Showing '{name}'\n\t Press Q on image to continue. Press Ctrl + C in terminal to exit" ) - wait_q() + close_all_on_wait_key("q") InteractionUtils.image_metrics.window_x = 0 InteractionUtils.image_metrics.window_y = 0 -@dataclass class Stats: # TODO Fill these for stats # Move qbox_vals here? @@ -87,8 +99,10 @@ class Stats: files_not_moved = 0 -def wait_q(): +def close_all_on_wait_key(key="q"): esc_key = 27 - while cv2.waitKey(1) & 0xFF not in [ord("q"), esc_key]: + while cv2.waitKey(1) & 0xFF not in [ord(key), esc_key]: pass cv2.destroyAllWindows() + # also close open plots! + pyplot.close() diff --git a/src/logger.py b/src/utils/logger.py similarity index 63% rename from src/logger.py rename to src/utils/logger.py index 27f44b44..d321e167 100644 --- a/src/logger.py +++ b/src/utils/logger.py @@ -1,12 +1,10 @@ import logging -from typing import Union from rich.console import Console from rich.logging import RichHandler FORMAT = "%(message)s" -# TODO: set logging level from config.json dynamically logging.basicConfig( level=logging.INFO, format="%(message)s", @@ -14,33 +12,47 @@ handlers=[RichHandler(rich_tracebacks=True)], ) +DEFAULT_LOG_LEVEL_MAP = { + "critical": True, + "error": True, + "warning": True, + "info": True, + "debug": True, +} + class Logger: def __init__( self, name, - level: Union[int, str] = logging.NOTSET, message_format="%(message)s", date_format="[%X]", ): self.log = logging.getLogger(name) - self.log.setLevel(level) + self.log.setLevel(logging.DEBUG) self.log.__format__ = message_format self.log.__date_format__ = date_format + self.reset_log_levels() + + def set_log_levels(self, show_logs_by_type): + self.show_logs_by_type = {**DEFAULT_LOG_LEVEL_MAP, **show_logs_by_type} + + def reset_log_levels(self): + self.show_logs_by_type = DEFAULT_LOG_LEVEL_MAP - def debug(self, *msg: object, sep=" ", end="\n") -> None: + def debug(self, *msg: object, sep=" "): return self.logutil("debug", *msg, sep=sep) - def info(self, *msg: object, sep=" ", end="\n") -> None: + def info(self, *msg: object, sep=" "): return self.logutil("info", *msg, sep=sep) - def warning(self, *msg: object, sep=" ", end="\n") -> None: + def warning(self, *msg: object, sep=" "): return self.logutil("warning", *msg, sep=sep) - def error(self, *msg: object, sep=" ", end="\n") -> None: + def error(self, *msg: object, sep=" "): return self.logutil("error", *msg, sep=sep) - def critical(self, *msg: object, sep=" ", end="\n") -> None: + def critical(self, *msg: object, sep=" "): return self.logutil("critical", *msg, sep=sep) def stringify(func): @@ -58,10 +70,12 @@ def inner(self, method_type: str, *msg: object, sep=" "): # stack-frame - self.log.debug - logutil - stringify - log method - caller @stringify def logutil(self, method_type: str, *msg: object, sep=" ") -> None: - func = getattr(self.log, method_type, None) - if not func: + if self.show_logs_by_type[method_type] is False: + return + logger_func = getattr(self.log, method_type, None) + if not logger_func: raise AttributeError(f"Logger has no method {method_type}") - return func(sep.join(msg), stacklevel=4) + return logger_func(sep.join(msg), stacklevel=4) logger = Logger(__name__) diff --git a/src/utils/math.py b/src/utils/math.py new file mode 100644 index 00000000..121fbfd6 --- /dev/null +++ b/src/utils/math.py @@ -0,0 +1,127 @@ +import math + +import numpy as np + +from src.processors.constants import EdgeType +from src.utils.logger import logger + + +class MathUtils: + """A Static-only Class to hold common math utilities & wrappers for easy integration with OpenCV and OMRChecker""" + + # TODO: move into math utils + @staticmethod + def distance(point1, point2): + return math.hypot(point1[0] - point2[0], point1[1] - point2[1]) + + @staticmethod + def shift_points_from_origin(new_origin, list_of_points): + return list( + map( + lambda point: [ + new_origin[0] + point[0], + new_origin[1] + point[1], + ], + list_of_points, + ) + ) + + @staticmethod + def get_point_on_line_by_ratio(edge_line, length_ratio): + start, end = edge_line + return [ + start[0] + (end[0] - start[0]) * length_ratio, + start[1] + (end[1] - start[1]) * length_ratio, + ] + + @staticmethod + def order_four_points(points, dtype="int"): + points = np.array(points, dtype=dtype) + + # the top-left point will have the smallest sum, whereas + # the bottom-right point will have the largest sum + sum = points.sum(axis=1) + diff = np.diff(points, axis=1) + ordered_indices = [ + np.argmin(sum), + np.argmin(diff), + np.argmax(sum), + np.argmax(diff), + ] + rect = points[ordered_indices] + # returns the ordered coordinates (tl, tr, br, bl) + return rect, ordered_indices + + @staticmethod + def get_tuple_points(points): + return [(int(point[0]), int(point[1])) for point in points] + + @staticmethod + def get_bounding_box_of_points(points): + min_x, min_y = np.min(points, axis=0) + max_x, max_y = np.max(points, axis=0) + # returns the ordered coordinates (tl, tr, br, bl) + bounding_box = np.array( + [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)] + ) + box_dimensions = (int(max_x - min_x), int(max_y - min_y)) + return bounding_box, box_dimensions + + @staticmethod + def validate_rect(approx): + return len(approx) == 4 and MathUtils.check_max_cosine(approx.reshape(4, 2)) + + @staticmethod + def get_rectangle_points_from_box(origin, dimensions): + x, y = origin + w, h = dimensions + # order same as order_four_points: (tl, tr, br, bl) + return MathUtils.get_rectangle_points(x, y, w, h) + + @staticmethod + def get_rectangle_points(x, y, w, h): + # order same as order_four_points: (tl, tr, br, bl) + return [ + [x, y], + [x + w, y], + [x + w, y + h], + [x, y + h], + ] + + @staticmethod + def select_edge_from_rectangle(rectangle, edge_type): + tl, tr, br, bl = rectangle + if edge_type == EdgeType.TOP: + return [tl, tr] + if edge_type == EdgeType.RIGHT: + return [tr, br] + if edge_type == EdgeType.BOTTOM: + return [br, bl] + if edge_type == EdgeType.LEFT: + return [bl, tl] + return [tl, tr] + + @staticmethod + def check_max_cosine(approx): + # assumes 4 points present + max_cosine = 0 + min_cosine = 1.5 + for i in range(2, 5): + cosine = abs(MathUtils.angle(approx[i % 4], approx[i - 2], approx[i - 1])) + max_cosine = max(cosine, max_cosine) + min_cosine = min(cosine, min_cosine) + + if max_cosine >= 0.35: + logger.warning("Quadrilateral is not a rectangle.") + return False + return True + + @staticmethod + def angle(p_1, p_2, p_0): + dx1 = float(p_1[0] - p_0[0]) + dy1 = float(p_1[1] - p_0[1]) + dx2 = float(p_2[0] - p_0[0]) + dy2 = float(p_2[1] - p_0[1]) + return (dx1 * dx2 + dy1 * dy2) / np.sqrt( + (dx1 * dx1 + dy1 * dy1) * (dx2 * dx2 + dy2 * dy2) + 1e-10 + ) diff --git a/src/utils/parsing.py b/src/utils/parsing.py index cb6901c6..a480064d 100644 --- a/src/utils/parsing.py +++ b/src/utils/parsing.py @@ -2,12 +2,14 @@ from copy import deepcopy from fractions import Fraction +import numpy as np from deepmerge import Merger from dotmap import DotMap -from src.constants import FIELD_LABEL_NUMBER_REGEX -from src.defaults import CONFIG_DEFAULTS, TEMPLATE_DEFAULTS from src.schemas.constants import FIELD_STRING_REGEX_GROUPS +from src.schemas.defaults import CONFIG_DEFAULTS, TEMPLATE_DEFAULTS +from src.schemas.defaults.evaluation import EVALUATION_CONFIG_DEFAULTS +from src.utils.constants import FIELD_LABEL_NUMBER_REGEX from src.utils.file import load_json from src.utils.validations import ( validate_config_json, @@ -62,8 +64,11 @@ def open_template_with_defaults(template_path): return user_template -def open_evaluation_with_validation(evaluation_path): +def open_evaluation_with_defaults(evaluation_path): user_evaluation_config = load_json(evaluation_path) + user_evaluation_config = OVERRIDE_MERGER.merge( + deepcopy(EVALUATION_CONFIG_DEFAULTS), user_evaluation_config + ) validate_evaluation_json(user_evaluation_config, evaluation_path) return user_evaluation_config @@ -111,3 +116,17 @@ def parse_float_or_fraction(result): else: result = float(result) return result + + +def default_dump(obj): + return ( + bool(obj) + if isinstance(obj, np.bool_) + else ( + obj.to_json() + if hasattr(obj, "to_json") + else obj.__dict__ + if hasattr(obj, "__dict__") + else obj + ) + ) diff --git a/src/utils/validations.py b/src/utils/validations.py index 0c2ec839..f9185158 100644 --- a/src/utils/validations.py +++ b/src/utils/validations.py @@ -1,19 +1,11 @@ -""" - - OMRChecker - - Author: Udayraj Deshmukh - Github: https://github.com/Udayraj123 - -""" import re import jsonschema from jsonschema import validate from rich.table import Table -from src.logger import console, logger from src.schemas import SCHEMA_JSONS, SCHEMA_VALIDATORS +from src.utils.logger import console, logger def validate_evaluation_json(json_data, evaluation_path): @@ -62,18 +54,18 @@ def validate_template_json(json_data, template_path): key, validator, msg = parse_validation_error(error) # Print preProcessor name in case of options error - if key == "preProcessors": - preProcessorName = json_data["preProcessors"][error.path[1]]["name"] + if key == "preProcessors" and len(error.path) > 2: + preProcessorJson = json_data["preProcessors"][error.path[1]] + preProcessorName = preProcessorJson.get("name", "UNKNOWN") preProcessorKey = error.path[2] - table.add_row(f"{key}.{preProcessorName}.{preProcessorKey}", msg) + key = f"{key}.{preProcessorName}.{preProcessorKey}" elif validator == "required": requiredProperty = re.findall(r"'(.*?)'", msg)[0] - table.add_row( - f"{key}.{requiredProperty}", - f"{msg}. Check for spelling errors and make sure it is in camelCase", + key = f"{key}.{requiredProperty}" + msg = ( + f"{msg}. Check for spelling errors and make sure it is in camelCase" ) - else: - table.add_row(key, msg) + table.add_row(key, msg) console.print(table, justify="center") raise Exception( f"Provided Template JSON is Invalid: '{template_path}'"