diff --git a/README.md b/README.md index ebe0f8307b6753070000924de6a0d0fc538254ee..1907714e593ec71f117ec15632cb5cf264b40a85 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ </p> </div> +[](https://forthebadge.com) [](https://forthebadge.com) [](https://forthebadge.com) +[](https://developer.android.com/) [](https://kotlinlang.org/) [](https://gradle.org/) [](https://opencv.org/) + <!-- TABLE OF CONTENTS --> <details> <summary>Table of Contents</summary> @@ -32,13 +35,42 @@ <li><a href="#installation">Installation</a></li> </ul> </li> + <li><a href="#how-to-use">How To Use</a></li> <li><a href="#Development">Development</a></li> - <li><a href="#usage">Usage</a></li> - <li><a href="#contact">Contact</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. + +<p align="right">(<a href="#readme-top">back to top</a>)</p> + +# 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 📃 + + +<p align="right">(<a href="#readme-top">back to top</a>)</p> + +# How To Use +1. Navigasi ke folder apk +2. Unduh file apk lalu pindahkan ke perangkat android +3. Buka file apk di perangkat android +4. Install aplikasi +5. Buka aplikasi + +Alternatif lain untuk menjalankan aplikasi: +1. Clone repository https://gitlab.informatika.org/k-02-09/omrekap.git +2. Buka project menggunakan Android Studio +3. Run aplikasi menggunakan emulator atau perangkat android + # Development ### Clone the repository ```bash @@ -64,20 +96,22 @@ git clone https://gitlab.informatika.org/k-02-09/omrekap <p align="right">(<a href="#readme-top">back to top</a>)</p> -# Built With -* [](https://developer.android.com/) -* [](https://kotlinlang.org/) -* [](https://gradle.org/) -* [](https://opencv.org/) - -# 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. - +# Contributors +<table> + <tbody> + <tr> + <td align="center" valign="top" width="14.28%"><a href="https://github.com/Altair1618"><img src="https://github.com/Altair1618" 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://github.com/Enliven26" 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://github.com/dhanikanovlisa" 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://github.com/Genvictus" 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://github.com/Michaelu670" 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> <p align="right">(<a href="#readme-top">back to top</a>)</p> -# Usage - -# Contact +# Acknowledgments +* [OpenCV](https://opencv.org/) +* [Kotlin](https://kotlinlang.org/) +* [Android](https://developer.android.com/) -# Acknowledgments \ No newline at end of file +<p align="right">(<a href="#readme-top">back to top</a>)</p> \ No newline at end of file diff --git a/app/src/main/assets/omr_config.json b/app/src/main/assets/omr_config.json index e16411192251b88ebfbcc2acba3e8be1b2e91ad7..2fb34a2a37a02d97b771c462a9942714f82291c3 100644 --- a/app/src/main/assets/omr_config.json +++ b/app/src/main/assets/omr_config.json @@ -2,9 +2,9 @@ "omrConfigs": { "102": { "contents": { - "FIRST": "Anis", - "SECOND": "Bowo", - "THIRD": "Janggar" + "FIRST": "Anies Rasyid Baswedan", + "SECOND": "Prabowo Subianto", + "THIRD": "Ganjar Pranowo" }, "contourOMRHelperConfig": { "darkIntensityThreshold": 150, 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..57b8e465b689af083551e8e6d1c084e63c54a8a9 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,9 +111,22 @@ 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.6F) 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-- @@ -117,6 +160,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 +269,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(1.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 fd04515845871f632a9db5e4e3af06518fbb598e..c60aaea0cfdacaf3a0f5131cce2b206254350625 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 @@ -21,11 +21,21 @@ 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 public var appContext: Context? = 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 @@ -33,6 +43,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>, @@ -57,6 +73,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 @@ -97,6 +118,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 @@ -137,20 +163,36 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c return darkestRow } - private fun getPerfectCircle(x: Double, y: Double, radius: Double): MatOfPoint { - val numPoints = 100 // Adjust as needed + /** + * 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]))); + 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 @@ -160,6 +202,11 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c 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 } @@ -189,6 +236,12 @@ 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>() } @@ -283,6 +336,10 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c 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>() @@ -335,12 +392,16 @@ class ContourOMRHelper(private val config: ContourOMRHelperConfig) : OMRHelper(c Utils.matToBitmap(display, bitmap) SaveHelper.saveImage(appContext!!, bitmap, "test", "lol.png") - } 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) @@ -382,12 +443,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..adbf0748ef303e9fb4a0a2c34cdb2d859d7a8712 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,6 +97,14 @@ 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>, 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/screenshot.png b/screenshots/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..32e8c622bc2b037092480c6259c72388b890412b Binary files /dev/null and b/screenshots/screenshot.png differ