diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index c384c722d2b1ab4ec86e1b0bd01ee303f1350be9..621029c9755d123c91d49dfc106367fecfe12c48 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -53,8 +53,5 @@ jobs: - name: Run unit tests run: ./gradlew test - - name: Run integration tests - run: ./gradlew connectedAndroidTest - - name: Code formatting run: ./gradlew spotlessCheck diff --git a/README.md b/README.md index 29c9f22813f3c160a22d2e79c525e6d4891968b1..d59ae2bf9a8dc4271ae5c926006803ee0ac4eea4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,69 @@ -# OMRekap +<!-- PROJECT LOGO --> +<br /> +<div align="center"> + <a href="https://gitlab.informatika.org/k-02-09/omrekap.git"> + <img src="screenshots/icon_launcher.png" alt="Logo" width="80" height="80"> + </a> -## Development + <h3 align="center">OMRekap</h3> + + <p align="center"> + Aplikasi Rekapitulasi Pemilihan Umum + <br /> + <a href="https://drive.google.com/file/d/17xJabhFr3tFBLDdku4rONEyfcQLBBodp/view?usp=sharing"><strong>Explore the docs »</strong></a> + <br /> + </p> +</div> + +[](https://forthebadge.com) [](https://forthebadge.com) [](https://forthebadge.com) +<br /> +[](https://developer.android.com/) [](https://kotlinlang.org/) [](https://gradle.org/) [](https://opencv.org/) + +<!-- TABLE OF CONTENTS --> +<details> + <summary>Table of Contents</summary> + <ol> + <li> + <a href="#about-the-project">About The Project</a> + </li> + <li> + <a href="#features">Features</a> + </li> + <li><a href="#how-to-use">How To Use</a></li> + <li><a href="#development">Development</a></li> + <li><a href="#contributors">Contributors</a></li> + <li><a href="#acknowledgments">Acknowledgments</a></li> + </ol> +</details> + + +# About The Project +OMRekap adalah aplikasi rekapitulasi pemilihan umum yang dibuat untuk memudahkan proses rekapitulasi suara pada pemilihan umum. +Aplikasi ini dibuat menggunakan bahasa pemrograman Kotlin dan menggunakan OpenCV untuk mendeteksi dan mengenali lembar suara. + + + +# Features +1. Mengambil foto kertas plano menggunakan kamera 📷 atau menggunakan galeri 🖼 +2. Melihat hasil foto yang diambil pada halaman berikutnya +3. Mendeteksi dan mengenali lembar suara +4. Menampilkan hasil rekapitulasi suara +5. Menyimpan hasil rekapitulasi suara dalam bentuk foto 🖼 dan JSON 📃 + + +# How To Use +1. Akses halaman https://bit.ly/OMRekap menggunakan browser. +2. Terdapat 2 folder pada drive tersebut. Anda dapat memilih menggunakan Universal Build dengn size .apk lebih besar atau memilih Slim Build dengan ukuran lebih kecil sesuai dengan arsitektur prosesor Anda. +3. Unduh file .apk yang tersedia di dalam folder tersebut ke perangkat Android. +4. Lakukan instalasi file .apk tersebut pada perangkat Android. + +Sebagai alternatif, langkah-langkah berikut juga dapat dilakukan dalam instalasi aplikasi ini. +1. Akses repository pada [GitLab](https://gitlab.informatika.org/k-02-09/omrekap.git) atau [GitHub](https://github.com/Altair1618/OMRekap) +2. Unduh hasil release terbaru pada repository. +3. Buka proyek tersebut menggunakan Android Studio. +4. Lakukan build dan jalankan aplikasi. + +# Development ### Clone the repository ```bash git clone https://gitlab.informatika.org/k-02-09/omrekap @@ -20,5 +83,21 @@ git clone https://gitlab.informatika.org/k-02-09/omrekap * Update spotless.gradle based on Ktlint rules [here](https://pinterest.github.io/ktlint/0.50.0/rules/configuration-ktlint/) * Clean gradle cache ```bash -./gradlew clean -* ``` +./gradlew clean +``` + +# Contributors +<table> + <tr> + <td align="center" valign="top" width="14.28%"><a href="https://github.com/Altair16181"><img src="https://avatars.githubusercontent.com/u/91373980?v=4" width="100px;" alt="Farhan Nabil Suryono"/><br /><sub><b>Farhan Nabil Suryono</b></sub></a><br /><a href="https://github.com/codesandbox/codesandbox-client/issues?q=author%3ACompuIves" title="Bug reports">ðŸ›</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Code">💻</a> <a href="#design-CompuIves" title="Design">🎨</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Documentation">📖</a> <a href="#infra-CompuIves" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/codesandbox/codesandbox-client/pulls?q=is%3Apr+reviewed-by%3ACompuIves" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Tests">âš ï¸</a> <a href="#tool-CompuIves" title="Tools">🔧</a></td> + <td align="center" valign="top" width="14.28%"><a href="https://github.com/Enliven26"><img src="https://avatars.githubusercontent.com/u/89065724?v=4" width="100px;" alt="Johanes Lee"/><br /><sub><b>Johanes Lee</b></sub></a><br /><a href="https://github.com/codesandbox/codesandbox-client/issues?q=author%3ACompuIves" title="Bug reports">ðŸ›</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Code">💻</a> <a href="#design-CompuIves" title="Design">🎨</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Documentation">📖</a> <a href="#infra-CompuIves" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/codesandbox/codesandbox-client/pulls?q=is%3Apr+reviewed-by%3ACompuIves" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Tests">âš ï¸</a> <a href="#tool-CompuIves" title="Tools">🔧</a></td> + <td align="center" valign="top" width="14.28%"><a href="https://github.com/dhanikanovlisa"><img src="https://avatars.githubusercontent.com/u/110590843?v=4" width="100px;" alt="Dhanika Novlisariyanti"/><br /><sub><b>Dhanika Novlisariyanti</b></sub></a><br /><a href="https://github.com/codesandbox/codesandbox-client/issues?q=author%3ACompuIves" title="Bug reports">ðŸ›</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Code">💻</a> <a href="#design-CompuIves" title="Design">🎨</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Documentation">📖</a> <a href="#infra-CompuIves" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/codesandbox/codesandbox-client/pulls?q=is%3Apr+reviewed-by%3ACompuIves" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Tests">âš ï¸</a> <a href="#tool-CompuIves" title="Tools">🔧</a></td> + <td align="center" valign="top" width="14.28%"><a href="https://github.com/Genvictus"><img src="https://avatars.githubusercontent.com/u/92362538?v=4" width="100px;" alt="Johann Christian Kandani"/><br /><sub><b>Johann Christian Kandani</b></sub></a><br /><a href="https://github.com/codesandbox/codesandbox-client/issues?q=author%3ACompuIves" title="Bug reports">ðŸ›</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Code">💻</a> <a href="#design-CompuIves" title="Design">🎨</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Documentation">📖</a> <a href="#infra-CompuIves" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/codesandbox/codesandbox-client/pulls?q=is%3Apr+reviewed-by%3ACompuIves" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Tests">âš ï¸</a> <a href="#tool-CompuIves" title="Tools">🔧</a></td> + <td align="center" valign="top" width="14.28%"><a href="https://github.com/Michaelu670"><img src="https://avatars.githubusercontent.com/u/91373980?v=4" width="100px;" alt="Michael Utama"/><br /><sub><b>Michael Utama</b></sub></a><br /><a href="https://github.com/codesandbox/codesandbox-client/issues?q=author%3ACompuIves" title="Bug reports">ðŸ›</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Code">💻</a> <a href="#design-CompuIves" title="Design">🎨</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Documentation">📖</a> <a href="#infra-CompuIves" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/codesandbox/codesandbox-client/pulls?q=is%3Apr+reviewed-by%3ACompuIves" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/codesandbox/codesandbox-client/commits?author=CompuIves" title="Tests">âš ï¸</a> <a href="#tool-CompuIves" title="Tools">🔧</a></td> + </tbody> +</table> + +# Acknowledgments +* [OpenCV](https://opencv.org/) +* [Kotlin](https://kotlinlang.org/) +* [Android](https://developer.android.com/) diff --git a/app/src/androidTest/java/com/k2_9/omrekap/omr/ContourOMRHelperTest.kt b/app/src/androidTest/java/com/k2_9/omrekap/omr/ContourOMRHelperTest.kt index df80cec45f2b315013c526c9af4e20d7bd2d4b7a..a50449f7072360e75c06f48efcbdf88b02bf622b 100644 --- a/app/src/androidTest/java/com/k2_9/omrekap/omr/ContourOMRHelperTest.kt +++ b/app/src/androidTest/java/com/k2_9/omrekap/omr/ContourOMRHelperTest.kt @@ -29,7 +29,7 @@ class ContourOMRHelperTest { appContext = InstrumentationRegistry.getInstrumentation().targetContext // Load the image resource as a Bitmap - val imageMat = Utils.loadResource(appContext, R.raw.test) + val imageMat = Utils.loadResource(appContext, R.raw.test2) val templateLoader = CircleTemplateLoader(appContext, R.raw.circle_template) // Convert if image is not grayscale @@ -76,8 +76,8 @@ class ContourOMRHelperTest { SaveHelper.saveImage(appContext, imgSecond, "test", "test_contour_omr_second") SaveHelper.saveImage(appContext, imgThird, "test", "test_contour_omr_third") - assert(resultFirst == 172) - assert(resultSecond == 24) - assert(resultThird == 2) + assert(resultFirst == 87) + assert(resultSecond == 91) + assert(resultThird == 22) } } diff --git a/app/src/main/assets/omr_config.json b/app/src/main/assets/omr_config.json index bc4515a20ec7d237055426a5704de08b28f07bd9..2fb34a2a37a02d97b771c462a9942714f82291c3 100644 --- a/app/src/main/assets/omr_config.json +++ b/app/src/main/assets/omr_config.json @@ -2,13 +2,13 @@ "omrConfigs": { "102": { "contents": { - "FIRST": "Anis", - "SECOND": "Bowo", - "THIRD": "Janggar" + "FIRST": "Anies Rasyid Baswedan", + "SECOND": "Prabowo Subianto", + "THIRD": "Ganjar Pranowo" }, "contourOMRHelperConfig": { - "darkIntensityThreshold": 200, - "darkPercentageThreshold": 0.9, + "darkIntensityThreshold": 150, + "darkPercentageThreshold": 0.7, "maxAspectRatio": 1.5, "maxRadius": 25, "minAspectRatio": 0.5, diff --git a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/CircleTemplateLoader.kt b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/CircleTemplateLoader.kt index f2deec73f6eedfca7503d262ac9a0c3c07b565a3..c1bac3f1706db8e7d5883b55ea56b4d72f835815 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/CircleTemplateLoader.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/CircleTemplateLoader.kt @@ -6,7 +6,16 @@ import org.opencv.core.MatOfByte import org.opencv.imgcodecs.Imgcodecs import java.io.InputStream +/** + * Load the circle template image + * @param appContext application context + * @param resId resource id of the circle template image + */ class CircleTemplateLoader(private val appContext: Context, private val resId: Int) { + /** + * Load the template image + * @return template image + */ fun loadTemplateImage(): Mat { val inputStream: InputStream = appContext.resources.openRawResource(resId) val byteArray = inputStream.readBytes() diff --git a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/ContourOMRHelperConfig.kt b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/ContourOMRHelperConfig.kt index 4053bca16b8d67e8257f02100f6df05c50644608..911d92de906d3a2500131ade2b1bced7f15a1441 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/ContourOMRHelperConfig.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/ContourOMRHelperConfig.kt @@ -1,5 +1,15 @@ package com.k2_9.omrekap.data.configs.omr +/** + * Configuration for the OMR helper + * @param omrCropper cropper for the OMR section + * @param minRadius minimum radius of the circle + * @param maxRadius maximum radius of the circle + * @param minAspectRatio minimum aspect ratio of the circle + * @param maxAspectRatio maximum aspect ratio of the circle + * @param darkPercentageThreshold threshold for the percentage of dark pixels in the circle + * @param darkIntensityThreshold threshold for the intensity of dark pixels in the circle + */ class ContourOMRHelperConfig( omrCropper: OMRCropper, minRadius: Int, diff --git a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRCropper.kt b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRCropper.kt index 7ee53ff4e727bd4262169b5e000e10cd1867b3d7..66f5e63e64be8740cceddaa5f82065fa3fdbb4ca 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRCropper.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRCropper.kt @@ -3,7 +3,16 @@ package com.k2_9.omrekap.data.configs.omr import org.opencv.core.Mat import org.opencv.core.Rect +/** + * Cropper for OMR section + * @param config configuration for the cropper + */ class OMRCropper(val config: OMRCropperConfig) { + /** + * Crop the image to the section + * @param section section to be cropped + * @return cropped image + */ fun crop(section: OMRSection): Mat { val (x, y) = config.getSectionPosition(section) val (width, height) = config.omrSectionSize @@ -13,6 +22,11 @@ class OMRCropper(val config: OMRCropperConfig) { return Mat(config.image, roi) } + /** + * Get the position of the section + * @param section section to get the position + * @return position of the section + */ fun sectionPosition(section: OMRSection): Rect { val (x, y) = config.getSectionPosition(section) val (width, height) = config.omrSectionSize diff --git a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRCropperConfig.kt b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRCropperConfig.kt index fabe92619a12e5b9fb6d70a870eb82248b5a1cb7..9a9be96683bdda6d5c7f9208dfff9337db843edd 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRCropperConfig.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRCropperConfig.kt @@ -2,6 +2,12 @@ package com.k2_9.omrekap.data.configs.omr import org.opencv.core.Mat +/** + * Configuration for the cropper + * @param image image to be cropped + * @param omrSectionSize size of the OMR section + * @param omrSectionPosition position of the OMR section + */ class OMRCropperConfig( image: Mat?, val omrSectionSize: Pair<Int, Int>, @@ -39,10 +45,19 @@ class OMRCropperConfig( } } + /** + * Get the position of the section + * @param section section to get the position + * @return position of the section + */ fun getSectionPosition(section: OMRSection): Pair<Int, Int> { return omrSectionPosition[section]!! } + /** + * Set the image + * @param image image to be set + */ fun setImage(image: Mat) { require(omrSectionSize.first <= image.width() && omrSectionSize.second <= image.height()) { "OMR section size must be less than or equal to the image size" diff --git a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRHelperConfig.kt b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRHelperConfig.kt index 0022677d2746eec2256441f2115ce5caf0f7ff90..8a72269ca793ab161edb79929fb849fd2ea13878 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRHelperConfig.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRHelperConfig.kt @@ -1,5 +1,9 @@ package com.k2_9.omrekap.data.configs.omr +/** + * Configuration for the OMR helper + * @param omrCropper cropper for the OMR section + */ open class OMRHelperConfig( val omrCropper: OMRCropper, ) diff --git a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRSection.kt b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRSection.kt index d6f0ce8be6cbb4c13adda1c8c5760bfed2995b3b..3fc63501aad63dc15c66fff2f9bba645d5c487c7 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRSection.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/OMRSection.kt @@ -1,5 +1,8 @@ package com.k2_9.omrekap.data.configs.omr +/** + * Enum for OMR section + */ enum class OMRSection { FIRST, SECOND, diff --git a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/TemplateMatchingOMRHelperConfig.kt b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/TemplateMatchingOMRHelperConfig.kt index f3d83c34d2ece54916ac5368670d518ffbdd704d..59d62fb4c337f8a0a2f9bf870073b634669b7843 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/configs/omr/TemplateMatchingOMRHelperConfig.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/configs/omr/TemplateMatchingOMRHelperConfig.kt @@ -2,6 +2,12 @@ package com.k2_9.omrekap.data.configs.omr import org.opencv.core.Mat +/** + * Configuration for the OMR helper using template matching + * @param omrCropper cropper for the OMR section + * @param templateLoader loader for the template image + * @param similarityThreshold threshold for the similarity between the template and the cropped image + */ class TemplateMatchingOMRHelperConfig( omrCropper: OMRCropper, templateLoader: CircleTemplateLoader?, diff --git a/app/src/main/java/com/k2_9/omrekap/data/models/CornerPoints.kt b/app/src/main/java/com/k2_9/omrekap/data/models/CornerPoints.kt index e3f8f5c8efd4dbf98b66475e340d3b026a7b652d..af17fa9ff64c843614f2f2e9549c6cd38df47751 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/models/CornerPoints.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/models/CornerPoints.kt @@ -2,6 +2,13 @@ package com.k2_9.omrekap.data.models import org.opencv.core.Point +/** + * Data class for corner points of a document + * @param topLeft top left corner point + * @param topRight top right corner point + * @param bottomRight bottom right corner point + * @param bottomLeft bottom left corner point + */ data class CornerPoints( val topLeft: Point, val topRight: Point, diff --git a/app/src/main/java/com/k2_9/omrekap/data/models/ImageSaveData.kt b/app/src/main/java/com/k2_9/omrekap/data/models/ImageSaveData.kt index e8186b427965953e6d3a871588ebb36ae17977b8..0002067620ea33fd1cf20a94bb353de878a5d61c 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/models/ImageSaveData.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/models/ImageSaveData.kt @@ -3,6 +3,13 @@ package com.k2_9.omrekap.data.models import android.graphics.Bitmap import java.time.Instant +/** + * Data class for saving image data + * @param rawImage raw image + * @param annotatedImage image with annotations + * @param data map of candidate names and their vote counts + * @param timestamp timestamp of the image + */ data class ImageSaveData( val rawImage: Bitmap, var annotatedImage: Bitmap, diff --git a/app/src/main/java/com/k2_9/omrekap/data/models/OMRBaseConfiguration.kt b/app/src/main/java/com/k2_9/omrekap/data/models/OMRBaseConfiguration.kt index f5cc6f3d16a24b0c378d4888bfa920251d6e2284..040dab38380013093d29be26b45452ecfdccb748 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/models/OMRBaseConfiguration.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/models/OMRBaseConfiguration.kt @@ -11,6 +11,12 @@ data class OMRBaseConfiguration( val omrConfigs: Map<String, OMRConfigurationParameter>, ) +/** + * Configuration parameters for OMR detection + * @param contents map of OMR section and candidate name + * @param contourOMRHelperConfig configuration for contour-based OMR detection + * @param templateMatchingOMRHelperConfig configuration for template matching-based OMR detection + */ data class OMRConfigurationParameter( val contents: Map<OMRSection, String>, val contourOMRHelperConfig: ContourOMRHelperConfig, diff --git a/app/src/main/java/com/k2_9/omrekap/data/repository/OMRConfigRepository.kt b/app/src/main/java/com/k2_9/omrekap/data/repository/OMRConfigRepository.kt index 05dbebd85b7a681ee9c63483e9cca47cd8dd8cc1..94433d83c2722836a493bd0dbccb7a2408def592 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/repository/OMRConfigRepository.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/repository/OMRConfigRepository.kt @@ -8,7 +8,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException +/** Repository for loading OMR configurations from JSON */ + object OMRConfigRepository { + /** + * Load OMR configurations from JSON file + * @param context application context + * @return OMRBaseConfiguration object + */ suspend fun loadConfigurations(context: Context): OMRBaseConfiguration? { val jsonString = withContext(Dispatchers.IO) { @@ -23,12 +30,22 @@ object OMRConfigRepository { } } + /** + * Get the JSON string of the OMR configuration + * @param omrBaseConfiguration OMR configuration object + * @return JSON string of the configuration + */ fun printConfigurationJson(omrBaseConfiguration: OMRBaseConfiguration): String { val jsonString = OMRJsonConfigLoader.toJson(omrBaseConfiguration) Log.d("JSONConfigRepo", jsonString) return jsonString } + /** + * Read the JSON configuration file + * @param context application context + * @return JSON string of the configuration + */ private fun readConfigString(context: Context): String? { val inputStream = context.assets.open("omr_config.json") return try { diff --git a/app/src/main/java/com/k2_9/omrekap/data/repository/OMRJsonConfigLoader.kt b/app/src/main/java/com/k2_9/omrekap/data/repository/OMRJsonConfigLoader.kt index 9cf3d98d63238614c21d4ec83682d2d6ea66ef62..2a3d64f215e5d6c764163f6560bf919d89540676 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/repository/OMRJsonConfigLoader.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/repository/OMRJsonConfigLoader.kt @@ -3,13 +3,26 @@ package com.k2_9.omrekap.data.repository import com.google.gson.Gson import com.k2_9.omrekap.data.models.OMRBaseConfiguration +/** + * JSON configuration loader for OMR configurations + */ object OMRJsonConfigLoader { private val gson = Gson() + /** + * Parse JSON string to OMRBaseConfiguration object + * @param jsonString JSON string + * @return OMRBaseConfiguration object + */ fun parseJson(jsonString: String): OMRBaseConfiguration? { return gson.fromJson(jsonString, OMRBaseConfiguration::class.java) } + /** + * Convert OMRBaseConfiguration object to JSON string + * @param omrBaseConfiguration OMRBaseConfiguration object + * @return JSON string + */ fun toJson(omrBaseConfiguration: OMRBaseConfiguration): String { return gson.toJson(omrBaseConfiguration) } diff --git a/app/src/main/java/com/k2_9/omrekap/data/view_models/ImageDataViewModel.kt b/app/src/main/java/com/k2_9/omrekap/data/view_models/ImageDataViewModel.kt index 3f3696f69d6b899efbc3b27931cff9f98d1561d0..bfa8ed238f76c2884a1175ba201b4a9c17ebfe83 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/view_models/ImageDataViewModel.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/view_models/ImageDataViewModel.kt @@ -21,10 +21,18 @@ import org.opencv.core.Mat import org.opencv.imgproc.Imgproc import java.time.Instant +/** + * ViewModel for image data processing in Result page + */ class ImageDataViewModel : ViewModel() { private val _data = MutableLiveData<ImageSaveData?>() val data = _data as LiveData<ImageSaveData?> + /** + * Process the image data with OMR detection + * @param data image data to be processed + * @param circleTemplateLoader loader for circle template + */ fun processImage( data: ImageSaveData, circleTemplateLoader: CircleTemplateLoader, @@ -86,11 +94,12 @@ class ImageDataViewModel : ViewModel() { for ((section, value) in it) { stringKeyResult[pageContent[section]!!] = value - annotatedImage = ImageAnnotationHelper.annotateOMR( - annotatedImage, - contourOMRHelper.getSectionPosition(section), - value - ) + annotatedImage = + ImageAnnotationHelper.annotateOMR( + annotatedImage, + contourOMRHelper.getSectionPosition(section), + value, + ) Log.d("Result", "${pageContent[section]}: $value") } } diff --git a/app/src/main/java/com/k2_9/omrekap/data/view_models/PreviewViewModel.kt b/app/src/main/java/com/k2_9/omrekap/data/view_models/PreviewViewModel.kt index 89451f9810cb009b0616a64bfe160e24281fcf3e..6176644ccc0759b69e373c436c82124b6b6b6658 100644 --- a/app/src/main/java/com/k2_9/omrekap/data/view_models/PreviewViewModel.kt +++ b/app/src/main/java/com/k2_9/omrekap/data/view_models/PreviewViewModel.kt @@ -10,10 +10,17 @@ import com.k2_9.omrekap.utils.PreprocessHelper import kotlinx.coroutines.launch import java.time.Instant +/** + * ViewModel for image data processing in Preview page + */ class PreviewViewModel : ViewModel() { private val _data = MutableLiveData<ImageSaveData>() val data = _data as LiveData<ImageSaveData> + /** + * Preprocess the image data + * @param img image to be preprocessed + */ fun preprocessImage(img: Bitmap) { viewModelScope.launch { val data = ImageSaveData(img, img, mapOf(), Instant.now()) diff --git a/app/src/main/java/com/k2_9/omrekap/utils/AprilTagHelper.kt b/app/src/main/java/com/k2_9/omrekap/utils/AprilTagHelper.kt index ecd7aac2e6f4ad0d645ecfe2496faae0f671195f..7a61a3b2899176f2c868376d5cd734bf2d6254e0 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/AprilTagHelper.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/AprilTagHelper.kt @@ -74,6 +74,11 @@ object AprilTagHelper { return (idList to corners) } + /** + * Annotate the detected april tag and its ID to the image + * @param imageBitmap image to be annotated + * @return annotated image in OpenCV's Mat type + */ fun annotateImage(imageBitmap: Bitmap): Mat { val res = getAprilTagId(imageBitmap) val cornerPoints = res.second @@ -81,6 +86,11 @@ object AprilTagHelper { return ImageAnnotationHelper.annotateAprilTag(prepareImage(imageBitmap), cornerPoints, ids) } + /** + * Prepare detector with given dictionary + * @param detectorDictionary dictionary to be used for detection + * @return prepared ArucoDetector + */ private fun prepareDetector(detectorDictionary: Dictionary): ArucoDetector { // initialize detector parameters val detectorParameters = DetectorParameters() @@ -88,6 +98,11 @@ object AprilTagHelper { return ArucoDetector(detectorDictionary, detectorParameters) } + /** + * Prepare image for ArucoDetector with preprocessing + * @param imageBitmap image to be prepared + * @return prepared image in OpenCV's Mat type + */ private fun prepareImage(imageBitmap: Bitmap): Mat { // transform to OpenCV Mat data val imageMat = Mat() diff --git a/app/src/main/java/com/k2_9/omrekap/utils/CropHelper.kt b/app/src/main/java/com/k2_9/omrekap/utils/CropHelper.kt index 1ff85ab9f1d9b408bcd941ce7794f09f61c50aed..735408ef7f45bdc8a01c29260cce1c0a1197636a 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/CropHelper.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/CropHelper.kt @@ -4,18 +4,24 @@ import android.graphics.Bitmap import android.util.Log import com.k2_9.omrekap.data.models.CornerPoints import org.opencv.android.Utils +import org.opencv.core.Core import org.opencv.core.CvType import org.opencv.core.Mat import org.opencv.core.MatOfPoint2f import org.opencv.core.Point +import org.opencv.core.Size import org.opencv.imgproc.Imgproc import org.opencv.imgproc.Imgproc.COLOR_BGR2GRAY import org.opencv.imgproc.Imgproc.cvtColor import org.opencv.imgproc.Imgproc.getPerspectiveTransform import org.opencv.imgproc.Imgproc.warpPerspective +import kotlin.math.min import kotlin.math.pow import kotlin.math.sqrt +/** + * Helper class for cropping image based on corner points detection + */ object CropHelper { private const val UPPER_LEFT: Int = 0 private const val UPPER_RIGHT: Int = 1 @@ -24,6 +30,10 @@ object CropHelper { private lateinit var pattern: Mat + /** + * Load corner pattern image + * @param patternBitmap pattern image in Bitmap + */ fun loadPattern(patternBitmap: Bitmap) { // Load only if pattern hasn't been loaded if (::pattern.isInitialized) return @@ -36,15 +46,23 @@ object CropHelper { this.pattern = PreprocessHelper.preprocessPattern(this.pattern) } + /** + * Detect corner points in the image + * @param img image to be processed + * @return corner points + */ fun detectCorner(img: Mat): CornerPoints { // If pattern hasn't been loaded, throw exception if (!::pattern.isInitialized) { throw Exception("Pattern not loaded!") } - val imgGray = img.clone() + var imgGray = img.clone() cvtColor(img, imgGray, COLOR_BGR2GRAY) + // do local normalization here + imgGray = localNormalize(imgGray) + val resultMatrix = Mat( img.height() - pattern.height() + 1, @@ -69,10 +87,22 @@ object CropHelper { ) val pointsList: MutableList<PointsAndWeight> = mutableListOf() - - for (i in 0 until resultMatrix.height() step 4) { - for (j in 0 until resultMatrix.width() step 4) { - pointsList.add(PointsAndWeight(i, j, resultMatrix.get(i, j)[0])) + val diagonalLength = (resultMatrix.height().toDouble().pow(2) * resultMatrix.width().toDouble().pow(2)).pow(1 / 2) + + for (i in 0 until resultMatrix.height() step 2) { + for (j in 0 until resultMatrix.width() step 2) { + pointsList.add( + PointsAndWeight( + i, + j, + resultMatrix.get(i, j)[0] * + getWeight( + ( + min(i, resultMatrix.height() - i).toDouble().pow(2) * min(j, resultMatrix.width() - j).toDouble().pow(2) + ).pow(1 / 2) / diagonalLength, + ), + ), + ) } } @@ -81,29 +111,38 @@ object CropHelper { pointsList.forEach { if (needChange == 0) return@forEach - val corner = nearWhichCorner(it.x, it.y, resultMatrix.height(), resultMatrix.width(), limFrac = 0.1F) + val corner = nearWhichCorner(it.x, it.y, resultMatrix.height(), resultMatrix.width(), limFrac = 0.4F) if (corner == -1) return@forEach + if (it.weight > 0.45) { + // Corner not found, throw exception + val exceptionMessage = + "Not all corners found: {" + + (if (needed[0]) "Upper left," else "") + + (if (needed[1]) "Upper right," else "") + + (if (needed[2]) "Lower right," else "") + + (if (needed[3]) "Lower left," else "") + + "}" + // throw NotFoundException(exceptionMessage); + Log.e("Corner", exceptionMessage) + } + if (needed[corner]) { needed[corner] = false needChange-- - val pointFromIt = Point(it.y.toDouble(), it.x.toDouble()) + val pointFromIt = Point(it.y.toDouble() + pattern.width().toDouble() / 2, it.x.toDouble() + pattern.height().toDouble() / 2) when (corner) { UPPER_LEFT -> { upperLeftPoint = pointFromIt } UPPER_RIGHT -> { upperRightPoint = pointFromIt - upperRightPoint.x += pattern.height().toDouble() } LOWER_RIGHT -> { lowerRightPoint = pointFromIt - lowerRightPoint.x += pattern.height().toDouble() - lowerRightPoint.y += pattern.width().toDouble() } LOWER_LEFT -> { lowerLeftPoint = pointFromIt - lowerLeftPoint.y += pattern.width().toDouble() } } } @@ -117,6 +156,12 @@ object CropHelper { return CornerPoints(upperLeftPoint, upperRightPoint, lowerRightPoint, lowerLeftPoint) } + /** + * Transform image based on corner points into a rectangle + * @param img image to be processed + * @param points corner points of the image + * @return tilted corrected image + */ fun fourPointTransform( img: Mat, points: CornerPoints, @@ -220,4 +265,56 @@ object CropHelper { else -> -1 } } + + /** + * Apply local normalization to an image + * source: https://stackoverflow.com/questions/43240604/python-local-normalization-in-opencv + * @see - https://bigwww.epfl.ch/demo/ip/demos/local-normalization/ + * + * @param img input matrix + * @return local normalized img + */ + private fun localNormalize(img: Mat): Mat { + // convert img to CV_32F + val gray = Mat() + img.convertTo(gray, CvType.CV_32F, 1.0 / 255.0) + + val blur = Mat() + Imgproc.GaussianBlur(gray, blur, Size(0.0, 0.0), 2.0, 2.0) + + val num = Mat() + Core.subtract(gray, blur, num) + + val numSquared = Mat() + Core.multiply(num, num, numSquared) + val blur2 = Mat() + Imgproc.GaussianBlur(numSquared, blur2, Size(0.0, 0.0), 20.0, 20.0) + + val den = Mat() + Core.sqrt(blur2, den) + + val div = Mat() + Core.divide(num, den, div) + + Core.normalize(div, div, 0.0, 1.0, Core.NORM_MINMAX) + + // Convert back to uint8 + val result = Mat() + div.convertTo(result, CvType.CV_8U, 255.0) + + return result + } + + /** + * Weight for a point + * Far from corner means bigger weight + * + * Smaller weight are more likely to be chosen as corner + * + * @param normDistance distance from nearest corner divided by diagonal + * @return weight + */ + private fun getWeight(normDistance: Double): Double { + return 1 + 1 * normDistance.pow(0.5) + } } diff --git a/app/src/main/java/com/k2_9/omrekap/utils/ImageAnnotationHelper.kt b/app/src/main/java/com/k2_9/omrekap/utils/ImageAnnotationHelper.kt index 7ae0919b1ac5c1853ee617ee699afc7463c54d2e..5aef2454dd5950a3fad16c20be3b13670a5d03ba 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/ImageAnnotationHelper.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/ImageAnnotationHelper.kt @@ -8,7 +8,16 @@ import org.opencv.core.Rect import org.opencv.core.Scalar import org.opencv.imgproc.Imgproc +/** + * Helper class for annotating image with detected objects + */ object ImageAnnotationHelper { + /** + * Annotate the corner points of a document + * @param img image to be annotated + * @param cornerPoints corner points of the document + * @return image with annotations + */ fun annotateCorner( img: Mat, cornerPoints: CornerPoints, @@ -21,6 +30,13 @@ object ImageAnnotationHelper { return imgWithAnnotations } + /** + * Annotate the detected AprilTag + * @param img image to be annotated + * @param cornerPoints corner points of the AprilTag + * @param id ID of the AprilTag + * @return image with annotations + */ fun annotateAprilTag( img: Mat, cornerPoints: List<Mat>, @@ -76,6 +92,13 @@ object ImageAnnotationHelper { return imgWithAnnotations } + /** + * Annotate the detected vote count in the image gained from template matching + * @param img image to be annotated + * @param cornerPoints corner points of the detected object + * @param contourNumber number of the detected object + * @return image with annotations + */ fun annotateTemplateMatchingOMR( img: Mat, cornerPoints: List<Rect>, @@ -99,6 +122,13 @@ object ImageAnnotationHelper { return imgWithAnnotations } + /** + * Annotate the detected vote count in the image gained from contour detection + * @param img image to be annotated + * @param cornerPoints corner points of the detected object + * @param contourNumber number of the detected object + * @return image with annotations + */ fun annotateContourOMR( img: Mat, cornerPoints: List<MatOfPoint>, @@ -121,6 +151,13 @@ object ImageAnnotationHelper { return imgWithAnnotations } + /** + * Annotate the detected OMR result in the image + * @param img image to be annotated + * @param section section of the detected OMR + * @param result result of the detected OMR + * @return image with annotations + */ fun annotateOMR( img: Mat, section: Rect, diff --git a/app/src/main/java/com/k2_9/omrekap/utils/ImageSaveDataHolder.kt b/app/src/main/java/com/k2_9/omrekap/utils/ImageSaveDataHolder.kt index 2f396779aefa96385121e8ab3ca48b759fc545b0..2e6590e651cd94615fc6c6da12ab9973dc1723bb 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/ImageSaveDataHolder.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/ImageSaveDataHolder.kt @@ -3,13 +3,24 @@ package com.k2_9.omrekap.utils import android.util.Log import com.k2_9.omrekap.data.models.ImageSaveData +/** + * Decorator for ImageSaveData + */ object ImageSaveDataHolder { private var imageSaveData: ImageSaveData? = null + /** + * Set the ImageSaveData + * @param data ImageSaveData to be saved + */ fun save(data: ImageSaveData) { imageSaveData = data } + /** + * Get the ImageSaveData + * @return ImageSaveData + */ fun get(): ImageSaveData { if (imageSaveData == null) { Log.e("ImageSaveDataHolder", "ImageSaveData is null") diff --git a/app/src/main/java/com/k2_9/omrekap/utils/PermissionHelper.kt b/app/src/main/java/com/k2_9/omrekap/utils/PermissionHelper.kt index 70e9154a83d6f9a32ac8eab7e6eeb9993ad334d5..32384e66b55bdce4b69abfacb6383c3f3de209fe 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/PermissionHelper.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/PermissionHelper.kt @@ -6,7 +6,17 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +/** + * Helper class for handling permissions + */ object PermissionHelper { + /** + * Request permission from the user + * @param activity activity context + * @param permission permission to be requested + * @param verbose show toast message if permission is denied + * @param operation operation to be executed if permission is granted + */ fun requirePermission( activity: AppCompatActivity, permission: String, diff --git a/app/src/main/java/com/k2_9/omrekap/utils/PreprocessHelper.kt b/app/src/main/java/com/k2_9/omrekap/utils/PreprocessHelper.kt index 6858fea2f2763f3614b355552d129cb58e959607..cef13167020d753bd20b9bbf11a1bd4c1e6bf181 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/PreprocessHelper.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/PreprocessHelper.kt @@ -9,10 +9,18 @@ import org.opencv.core.Size import org.opencv.imgproc.Imgproc import java.time.Instant +/** + * Helper class for preprocessing image + */ object PreprocessHelper { private const val FINAL_WIDTH = 900.0 private const val FINAL_HEIGHT = 1600.0 + /** + * Preprocess the image data + * @param data image data to be preprocessed + * @return preprocessed image data + */ fun preprocessImage(data: ImageSaveData): ImageSaveData { // Initialize Mats val mainImageMat = Mat() diff --git a/app/src/main/java/com/k2_9/omrekap/utils/SaveHelper.kt b/app/src/main/java/com/k2_9/omrekap/utils/SaveHelper.kt index 793eec738fbf85cd17276948700c49df932fc3c7..e612364b09b79a49c8ac4dd25e15ab1e4cb30eda 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/SaveHelper.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/SaveHelper.kt @@ -19,7 +19,15 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +/** + * Helper class for saving images and JSON data + */ object SaveHelper { + /** + * Save the image and JSON data to the device + * @param context the application context + * @param data the image and JSON data to be saved + */ suspend fun save( context: Context, data: ImageSaveData, @@ -48,6 +56,12 @@ object SaveHelper { } } + /** + * Convert the selected file URI to a Bitmap + * @param context the application context + * @param selectedFileUri the URI of the selected file + * @return the converted Bitmap + */ fun uriToBitmap( context: Context, selectedFileUri: Uri, @@ -59,11 +73,22 @@ object SaveHelper { return image } + /** + * Generate a folder name based on the current date and time + * @return the generated folder name + */ private fun generateFolderName(): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) return sdf.format(Date()) } + /** + * Save the image to the Documents/OMRekap/folderName directory + * @param context the application context + * @param image the image to be saved + * @param folderName the folder name where the image will be saved + * @param fileName the file name of the image + */ fun saveImage( context: Context, image: Bitmap, @@ -79,6 +104,13 @@ object SaveHelper { } } + /** + * Save the JSON to the Documents/OMRekap/folderName directory + * @param context the application context + * @param data the JSON data to be saved + * @param folderName the folder name where the JSON will be saved + * @param fileName the file name of the JSON + */ private fun saveJSON( context: Context, data: Map<String, Int?>, diff --git a/app/src/main/java/com/k2_9/omrekap/utils/omr/ContourOMRHelper.kt b/app/src/main/java/com/k2_9/omrekap/utils/omr/ContourOMRHelper.kt index b5a4d9c47de76e4a1c7c9d19bdbafd2175a0e077..c74ffb06f4e11f83739750492869f89c9c514ea3 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/omr/ContourOMRHelper.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/omr/ContourOMRHelper.kt @@ -10,14 +10,29 @@ import org.opencv.core.Core import org.opencv.core.CvType import org.opencv.core.Mat import org.opencv.core.MatOfPoint +import org.opencv.core.Point import org.opencv.core.Rect import org.opencv.core.Scalar import org.opencv.imgproc.Imgproc - +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.sin + +/** + * Helper for Optical Mark Recognition (OMR) using contours + * @param config configuration for the OMR helper + */ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(config) { private var currentSectionGray: Mat? = null private var currentSectionBinary: Mat? = null + /** + * Create information object about the contour + * @param center center of the contour + * @param size size of the contour + * @return ContourInfo object + */ private fun createContourInfo(contour: Mat): ContourInfo { val rect = Imgproc.boundingRect(contour) val centerX = rect.x + rect.width / 2 @@ -25,6 +40,12 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c return ContourInfo(Pair(centerX, centerY), Pair(rect.width, rect.height)) } + /** + * Filter contours based on the intensities and return the filtered contour infos + * @param contourInfos list of contour infos + * @param intensities list of intensities + * @return filtered list of contour infos + */ private fun getContourInfo( filledContours: List<Mat>, filledIntensities: List<Int>, @@ -49,6 +70,11 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c return filterContourInfos(contourInfos, sortedIntensities.map { it.toDouble() }) } + /** + * Predict the number based on the detected filled circle contours + * @param contours list of filled circle contours + * @return predicted number + */ private fun predictForFilledCircle(contours: List<MatOfPoint>): Int { // Predict the number based on the filled circle contours @@ -89,6 +115,11 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c return contourInfosToNumbers(contourInfos) } + /** + * Get the darkest row in the column + * @param colContours list of contours in the column + * @return index of the darkest row + */ private fun getDarkestRow(colContours: List<MatOfPoint>): Int? { // Initialize variables to store the darkest row and its intensity var darkestRow: Int? = null @@ -129,6 +160,50 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c return darkestRow } + /** + * Create a perfect circle contour + * @param x x-coordinate of the center + * @param y y-coordinate of the center + * @param radius radius of the circle + * @return perfect circle contour + */ + private fun getPerfectCircle( + x: Double, + y: Double, + radius: Double, + ): MatOfPoint { + val numPoints = 100 // Adjust as needed + val theta = DoubleArray(numPoints) { it * 2 * Math.PI / numPoints } + val circleX = DoubleArray(numPoints) { x + radius * cos(theta[it]) } + val circleY = DoubleArray(numPoints) { y + radius * sin(theta[it]) } + + val circleContour = MatOfPoint() + for (i in 0 until numPoints) { + circleContour.push_back(MatOfPoint(Point(circleX[i], circleY[i]))) + } + + return circleContour + } + + /** + * Replace the contour with a perfect circle + * @param contour contour to be replaced + * @return perfect circle contour + */ + private fun replaceWithPerfectCircle(contour: MatOfPoint): MatOfPoint { + val rect = Imgproc.boundingRect(contour) + val centroidX = rect.x + rect.width.toDouble() / 2 + val centroidY = rect.y + rect.height.toDouble() / 2 + val radius = maxOf(rect.width.toDouble(), rect.height.toDouble()) / 2 + + return getPerfectCircle(centroidX, centroidY, radius) + } + + /** + * Get the combined number from the darkest rows of each column, given 10 contours for each column + * @param darkestRows list of 10 detected contours for each column + * @return combined number + */ private fun compareAll(contours: List<MatOfPoint>): Int { // Sort contours by column and then by row val contoursSorted = contours.sortedBy { Imgproc.boundingRect(it).x } @@ -159,6 +234,109 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c return getCombinedNumbers(darkestRows.map { it ?: 0 }) } + /** + * Complete missing contours by filling the missing circles + * @param contours list of detected contours + * @return list of completed contours + */ + private fun completeMissingContours(contours: List<MatOfPoint>): List<MatOfPoint> { + val sortedContours = contours.sortedBy { Imgproc.boundingRect(it).y } + val columnMap = Array(3) { mutableListOf<MatOfPoint>() } + val rectColumnMap = Array(3) { mutableListOf<Rect>() } + val sortedRects = sortedContours.map { Imgproc.boundingRect(it) } + + fun getColumnIndex(index: Int): Int { + return floor((max(0.0, sortedRects[index].x.toDouble()) / config.omrCropper.config.omrSectionSize.first.toDouble()) * 3.0).toInt() + } + + for ((idx, rect) in sortedRects.withIndex()) { + val columnIndex = getColumnIndex(idx) + columnMap[columnIndex].add(contours[idx]) + rectColumnMap[columnIndex].add(rect) + } + + val averageX = DoubleArray(3) + + for ((idx, columns) in columnMap.withIndex()) { + if (columns.isEmpty()) { + // no contour in this column, skip entirely + return contours + } + averageX[idx] = rectColumnMap[idx].sumOf { it.x + it.width / 2.0 } / columns.size + } + + val result = mutableListOf<MatOfPoint>() + val fillRecord = booleanArrayOf(false, false, false) + var ySum = 0.0 + var radiusSum = 0.0 + var lowestY = -1 + + var idx = 0 + + fun getLowestY(index: Int) = sortedRects[index].y + sortedRects[index].height + + while (idx < sortedContours.size) { + val contour = sortedContours[idx] + val columnIndex = getColumnIndex(idx) + val currentLowestY = getLowestY(idx) + + if (fillRecord[columnIndex] || (lowestY != -1 && (sortedRects[idx].y + sortedRects[idx].height / 2) > lowestY)) { + val nonFilledColumn = (0 until 3).filter { !fillRecord[it] } + val filledCount = 3 - nonFilledColumn.size + + if (filledCount == 0) { + lowestY = currentLowestY + continue + } + + val y = ySum / filledCount + val radius = radiusSum / filledCount + + for (i in nonFilledColumn) { + val x = averageX[i] + result.add(getPerfectCircle(x, y, radius)) + fillRecord[i] = true + } + lowestY = currentLowestY + } else { + result.add(contour) + ySum += sortedRects[idx].y + sortedRects[idx].height.toDouble() / 2 + radiusSum += max(sortedRects[idx].width.toDouble(), sortedRects[idx].height.toDouble()) / 2 + fillRecord[columnIndex] = true + idx++ + lowestY = max(lowestY, currentLowestY) + } + + val allFilled = fillRecord.all { it } + + if (allFilled) { + fillRecord.fill(false) + ySum = 0.0 + radiusSum = 0.0 + } + } + + val nonFilledColumn = (0 until 3).filter { !fillRecord[it] } + val filledCount = 3 - nonFilledColumn.size + + if (filledCount > 0) { + val y = ySum / filledCount + val radius = radiusSum / filledCount + + for (i in nonFilledColumn) { + val x = averageX[i] + result.add(getPerfectCircle(x, y, radius)) + fillRecord[i] = true + } + } + + return result + } + + /** + * Detect the circles in the OMR section + * @return list of detected contours + */ private fun getAllContours(): List<MatOfPoint> { // Find circle contours in cropped OMR section val contours = mutableListOf<MatOfPoint>() @@ -184,7 +362,7 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c val maxAR = config.maxAspectRatio if (rect.width in minLength..maxLength && rect.height in minLength..maxLength && ar >= minAR && ar <= maxAR) { - filteredContours.add(contour) + filteredContours.add(replaceWithPerfectCircle(contour)) } else { Log.d( "ContourOMRHelper", @@ -193,9 +371,26 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c } } + if (filteredContours.size < 30) { + Log.d( + "ContourOMRHelper", + "Detected ${filteredContours.size} contours, attempting to complete missing contours", + ) + val completedContours = completeMissingContours(filteredContours) + Log.d( + "ContourOMRHelper", + "Completed missing contours, now have ${completedContours.size} contours", + ) + } + return filteredContours } + /** + * Detect the number for the OMR section + * @param contours list of detected contours + * @return detected number + */ override fun detect(section: OMRSection): Int { val omrSectionImage = config.omrCropper.crop(section) @@ -237,12 +432,20 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c } } - // Get Section Position For Annotating Purpose + /** + * Get the position of the OMR section + * @param section OMR section + * @return position of the OMR section + */ fun getSectionPosition(section: OMRSection): Rect { return config.omrCropper.sectionPosition(section) } - // Annotating Image For Testing Purpose + /** + * Annotate the image with the detected contour + * @param contourNumber detected contour number + * @return annotated image + */ fun annotateImage(contourNumber: Int): Bitmap { var annotatedImg = currentSectionGray!!.clone() val contours = getAllContours() diff --git a/app/src/main/java/com/k2_9/omrekap/utils/omr/OMRConfigDetector.kt b/app/src/main/java/com/k2_9/omrekap/utils/omr/OMRConfigDetector.kt index 81b7e87d87cc409b0b7ba48873deb4f9c25c5aad..e31612eb5f049c0a02f20ecdc0d4db1641b3cad6 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/omr/OMRConfigDetector.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/omr/OMRConfigDetector.kt @@ -13,6 +13,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.opencv.core.Mat +/** + * Detector for OMR configuration + */ object OMRConfigDetector { private lateinit var loadedConfig: OMRBaseConfiguration private var job: Job? = null @@ -20,6 +23,8 @@ object OMRConfigDetector { /** * Initialize and load the detection configuration data. * Make sure to run this before detecting configurations + * @param context application context + * @throws Exception if failed to load the configuration */ fun loadConfiguration(context: Context) { if (!this::loadedConfig.isInitialized) { diff --git a/app/src/main/java/com/k2_9/omrekap/utils/omr/OMRHelper.kt b/app/src/main/java/com/k2_9/omrekap/utils/omr/OMRHelper.kt index efdbb5eff0a190c90484b056296a60a0b4dc5840..5c452d1150ee750dd6ccc413f22bb912c218bd03 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/omr/OMRHelper.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/omr/OMRHelper.kt @@ -5,12 +5,29 @@ import com.k2_9.omrekap.data.configs.omr.OMRSection import kotlin.math.abs import kotlin.math.floor +/** + * Helper for Optical Mark Recognition (OMR) + * @param config configuration for the OMR helper + */ abstract class OMRHelper(private val config: OMRHelperConfig) { + /** + * Information about the contour + * @param center center of the contour + * @param size size of the contour + */ class ContourInfo(val center: Pair<Int, Int>, val size: Pair<Int, Int>) { + /** Check if the contour is overlapping with another contour + * @param other other contour to check + * @return true if the contour is overlapping with the other contour, false otherwise + */ fun isOverlapping(other: ContourInfo): Boolean { return isColumnOverlapping(other) && isRowOverlapping(other) } + /** Check if the contour is overlapping with another contour horizontally + * @param other other contour to check + * @return true if the contour is overlapping with the other contour horizontally, false otherwise + */ fun isColumnOverlapping(other: ContourInfo): Boolean { val x1 = center.first val x2 = other.center.first @@ -20,6 +37,10 @@ abstract class OMRHelper(private val config: OMRHelperConfig) { return abs(x1 - x2) * 2 < w1 + w2 } + /** Check if the contour is overlapping with another contour vertically + * @param other other contour to check + * @return true if the contour is overlapping with the other contour vertically, false otherwise + */ fun isRowOverlapping(other: ContourInfo): Boolean { val y1 = center.second val y2 = other.center.second @@ -30,13 +51,27 @@ abstract class OMRHelper(private val config: OMRHelperConfig) { } } + /** + * Error when detecting the filled circles + * @param message error message + */ class DetectionError(message: String) : Exception(message) + /** + * Combine the detected numbers into a single integer + * @param numbers list of detected numbers + * @return combined numbers + */ protected fun getCombinedNumbers(numbers: List<Int>): Int { // Combine the detected numbers into a single integer return numbers.joinToString("").toInt() } + /** + * Convert contour infos to numbers + * @param contourInfos list of contour infos + * @return detected numbers + */ protected fun contourInfosToNumbers(contourInfos: List<ContourInfo?>): Int { // Return the detected numbers based on the vertical position of the filled circles for each column if (contourInfos.size != 3) { @@ -62,18 +97,28 @@ abstract class OMRHelper(private val config: OMRHelperConfig) { return getCombinedNumbers(result) } + /** + * Filter contour infos: + * remove overlapping contour infos and choose the one with the highest intensity + * automatically assign null to the column with no filled circle + * @param contourInfos list of contour infos + * @param filledIntensities list of filled intensities + * @return filtered contour infos + */ protected fun filterContourInfos( contourInfos: List<ContourInfo>, filledIntensities: List<Double>, ): List<ContourInfo?> { val mutableContourInfos = contourInfos.toMutableList() val uniqueContourInfos = mutableListOf<ContourInfo?>() + val filledIntensitiesCopy = filledIntensities.toMutableList() // Group by overlapping contour infos and choose the one with the highest intensity for (i in 0 until mutableContourInfos.size - 1) { if (mutableContourInfos[i].isColumnOverlapping(mutableContourInfos[i + 1])) { - if (filledIntensities[i] > filledIntensities[i + 1]) { + if (filledIntensitiesCopy[i] > filledIntensitiesCopy[i + 1]) { mutableContourInfos[i + 1] = mutableContourInfos[i] + filledIntensitiesCopy[i + 1] = filledIntensitiesCopy[i] } continue } else { diff --git a/app/src/main/java/com/k2_9/omrekap/utils/omr/TemplateMatchingOMRHelper.kt b/app/src/main/java/com/k2_9/omrekap/utils/omr/TemplateMatchingOMRHelper.kt index d6a481447dd3c0196607dc6daf28611fca841452..1f5e7956b8876f11062ec1a7fc5a402eda6e0068 100644 --- a/app/src/main/java/com/k2_9/omrekap/utils/omr/TemplateMatchingOMRHelper.kt +++ b/app/src/main/java/com/k2_9/omrekap/utils/omr/TemplateMatchingOMRHelper.kt @@ -10,11 +10,18 @@ import org.opencv.core.Point import org.opencv.core.Rect import org.opencv.imgproc.Imgproc +/** + * Helper for Optical Mark Recognition (OMR) using template matching + * @param config configuration for the OMR helper + */ class TemplateMatchingOMRHelper(private val config: TemplateMatchingOMRHelperConfig) : OMRHelper(config) { private var currentSectionGray: Mat? = null private var currentSectionBinary: Mat? = null + /** Get the rectangles of the matched template in the current section + * @return list of pairs of rectangles and their similarity scores + */ private fun getMatchRectangles(): List<Pair<Rect, Double>> { // Load the template image val template = config.template @@ -67,6 +74,10 @@ class TemplateMatchingOMRHelper(private val config: TemplateMatchingOMRHelperCon return matchedRectangles } + /** Get the contour information from the matched rectangles + * @param matchedRectangles list of pairs of rectangles and their similarity scores + * @return pair of list of contour information and list of similarity scores + */ private fun getContourInfos(matchedRectangles: List<Pair<Rect, Double>>): Pair<List<ContourInfo>, List<Double>> { // Initialize a set to keep track of added rectangles val addedRectangles = mutableSetOf<Rect>() @@ -115,6 +126,10 @@ class TemplateMatchingOMRHelper(private val config: TemplateMatchingOMRHelperCon return Pair(sortedContours, sortedSimilarities) } + /** Annotation for the image with the detected filled circles + * @param contourNumber detected number for the filled circles + * @return annotated image as Bitmap + */ fun annotateImage(contourNumber: Int): Bitmap { val annotatedImg = currentSectionGray!!.clone() val matchedRectangles = getMatchRectangles() @@ -136,6 +151,10 @@ class TemplateMatchingOMRHelper(private val config: TemplateMatchingOMRHelperCon return annotatedImageBitmap } + /** Detect the filled circles in the section + * @param section the OMR section to detect + * @return detected number for the filled circles + */ override fun detect(section: OMRSection): Int { val omrSectionImage = config.omrCropper.crop(section) diff --git a/app/src/main/java/com/k2_9/omrekap/views/activities/PreviewActivity.kt b/app/src/main/java/com/k2_9/omrekap/views/activities/PreviewActivity.kt index 240353e7fb36bdba05d31a7b1bf4440eb645ca60..bd37f1d804eb529ed06dceffd24edd6f1dcd1d07 100644 --- a/app/src/main/java/com/k2_9/omrekap/views/activities/PreviewActivity.kt +++ b/app/src/main/java/com/k2_9/omrekap/views/activities/PreviewActivity.kt @@ -50,7 +50,7 @@ class PreviewActivity : AppCompatActivity() { val bitmapOptions = BitmapFactory.Options() bitmapOptions.inPreferredConfig = Bitmap.Config.ALPHA_8 bitmapOptions.inScaled = false - val cornerPatternBitmap: Bitmap = BitmapFactory.decodeResource(resources, R.raw.corner_pattern, bitmapOptions) + val cornerPatternBitmap: Bitmap = BitmapFactory.decodeResource(resources, R.raw.mark20, bitmapOptions) CropHelper.loadPattern(cornerPatternBitmap) diff --git a/app/src/main/res/drawable/template_kotak.png b/app/src/main/res/drawable/template_kotak.png new file mode 100644 index 0000000000000000000000000000000000000000..3b6d7026d84edab7bbc8b4278f6be4ba4639db1b Binary files /dev/null and b/app/src/main/res/drawable/template_kotak.png differ diff --git a/app/src/main/res/raw/mark16.png b/app/src/main/res/raw/mark16.png new file mode 100644 index 0000000000000000000000000000000000000000..e34e5106f50567a9baecca15a4ba92bd1c58670f Binary files /dev/null and b/app/src/main/res/raw/mark16.png differ diff --git a/app/src/main/res/raw/mark20.png b/app/src/main/res/raw/mark20.png new file mode 100644 index 0000000000000000000000000000000000000000..7c65cbf6ea9c87669d1c11dec0d6d510b7ac3561 Binary files /dev/null and b/app/src/main/res/raw/mark20.png differ diff --git a/screenshots/icon_launcher.png b/screenshots/icon_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..aa395d072f23a6945ac7318a44a7054d41ddbcd2 Binary files /dev/null and b/screenshots/icon_launcher.png differ diff --git a/screenshots/screenshot.png b/screenshots/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..32e8c622bc2b037092480c6259c72388b890412b Binary files /dev/null and b/screenshots/screenshot.png differ