From c014c2cda9a41265146d483e8e786857f257ca8b Mon Sep 17 00:00:00 2001 From: Daniele De Matteo Date: Tue, 21 Jul 2020 14:46:22 +0200 Subject: [PATCH] first commit --- .gitignore | 14 + .idea/codeStyles/Project.xml | 122 ++++++++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/gradle.xml | 21 ++ .idea/jarRepositories.xml | 35 +++ .idea/markdown-navigator | 4 + .idea/markdown-navigator-enh.xml | 29 ++ .idea/markdown-navigator.xml | 55 ++++ .idea/misc.xml | 9 + .idea/runConfigurations.xml | 12 + .idea/vcs.xml | 6 + LICENSE.md | 21 ++ README.md | 45 +++ app/.gitignore | 1 + app/build.gradle | 113 ++++++++ app/proguard-rules.pro | 21 ++ .../ExampleInstrumentedTest.kt | 24 ++ app/src/main/AndroidManifest.xml | 26 ++ .../net/kuama/documentscanner/data/Corners.kt | 6 + .../net/kuama/documentscanner/data/Loader.kt | 45 +++ .../documentscanner/domain/FindPaperSheet.kt | 118 ++++++++ .../domain/PerspectiveTransform.kt | 65 +++++ .../documentscanner/domain/UriToBitmap.kt | 44 +++ .../kuama/documentscanner/domain/UseCase.kt | 21 ++ .../documentscanner/exceptions/NullCorners.kt | 3 + .../presentation/BaseScannerActivity.kt | 115 ++++++++ .../documentscanner/presentation/PaperRect.kt | 141 ++++++++++ .../presentation/ScannerActivity.kt | 22 ++ .../presentation/ScannerViewModel.kt | 264 ++++++++++++++++++ .../kuama/documentscanner/support/Either.kt | 80 ++++++ .../documentscanner/support/MatOfPoint.kt | 18 ++ .../drawable-v24/ic_launcher_foreground.xml | 30 ++ app/src/main/res/drawable/ic_check_24.xml | 5 + app/src/main/res/drawable/ic_close_24.xml | 5 + app/src/main/res/drawable/ic_flash_off.xml | 5 + app/src/main/res/drawable/ic_flash_on.xml | 5 + .../res/drawable/ic_launcher_background.xml | 170 +++++++++++ app/src/main/res/drawable/ic_lens.xml | 5 + app/src/main/res/layout/activity_main.xml | 33 +++ app/src/main/res/layout/activity_scanner.xml | 115 ++++++++ app/src/main/res/layout/content_main.xml | 6 + app/src/main/res/layout/fragment_first.xml | 28 ++ app/src/main/res/layout/fragment_second.xml | 27 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3593 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5339 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2636 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3388 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4926 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7472 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7909 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 11873 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10652 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 16570 bytes app/src/main/res/navigation/nav_graph.xml | 28 ++ app/src/main/res/values/colors.xml | 8 + app/src/main/res/values/dimens.xml | 3 + app/src/main/res/values/strings.xml | 17 ++ app/src/main/res/values/styles.xml | 19 ++ .../kuama/documentscanner/ExampleUnitTest.kt | 16 ++ build.gradle | 25 ++ gradle.properties | 23 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 ++++++++++++ gradlew.bat | 84 ++++++ settings.gradle | 2 + 68 files changed, 2352 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/jarRepositories.xml create mode 100644 .idea/markdown-navigator create mode 100644 .idea/markdown-navigator-enh.xml create mode 100644 .idea/markdown-navigator.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/vcs.xml create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/net/kuama/documentscanner/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/net/kuama/documentscanner/data/Corners.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/data/Loader.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/domain/FindPaperSheet.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/domain/PerspectiveTransform.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/domain/UriToBitmap.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/domain/UseCase.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/exceptions/NullCorners.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/presentation/BaseScannerActivity.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/presentation/PaperRect.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/presentation/ScannerActivity.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/presentation/ScannerViewModel.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/support/Either.kt create mode 100644 app/src/main/java/net/kuama/documentscanner/support/MatOfPoint.kt create mode 100644 app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_check_24.xml create mode 100644 app/src/main/res/drawable/ic_close_24.xml create mode 100644 app/src/main/res/drawable/ic_flash_off.xml create mode 100644 app/src/main/res/drawable/ic_flash_on.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_lens.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_scanner.xml create mode 100644 app/src/main/res/layout/content_main.xml create mode 100644 app/src/main/res/layout/fragment_first.xml create mode 100644 app/src/main/res/layout/fragment_second.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/navigation/nav_graph.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/test/java/net/kuama/documentscanner/ExampleUnitTest.kt create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..603b140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..88ea3aa --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,122 @@ + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..dc35fde --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..c3ff808 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/markdown-navigator b/.idea/markdown-navigator new file mode 100644 index 0000000..7f7ebda --- /dev/null +++ b/.idea/markdown-navigator @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/markdown-navigator-enh.xml b/.idea/markdown-navigator-enh.xml new file mode 100644 index 0000000..12fb99d --- /dev/null +++ b/.idea/markdown-navigator-enh.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/markdown-navigator.xml b/.idea/markdown-navigator.xml new file mode 100644 index 0000000..4463382 --- /dev/null +++ b/.idea/markdown-navigator.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..7bfef59 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..7f68460 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1837bb8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Kuama + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..12f79bc --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +[![](https://jitpack.io/v/kuamanet/android-document-scanner.svg)](https://jitpack.io/#kuamanet/android-document-scanner) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +# Android Document Scanner +Contains an activity that allows the user to scan a A4 paper with the smartphone camera. +It is based on CameraX and OpenCV + +### Installation +Add it in your root `build.gradle` at the end of repositories: +```groovy +allprojects { + repositories { + ... + maven { url 'https://jitpack.io' } + } +} +``` +Add the dependency +```groovy +dependencies { + implementation 'com.github.kuamanet:android-document-scanner:Tag' +} +``` + +### Usage +Inherit from `BaseScannerActivity` + +```kotlin +class ScannerActivity : BaseScannerActivity() { + override fun onError(throwable: Throwable) { + when (throwable) { + is NullCorners -> Toast.makeText( + this, + R.string.null_corners, Toast.LENGTH_LONG + ) + .show() + else -> Toast.makeText(this, throwable.message, Toast.LENGTH_LONG).show() + } + } + + override fun onDocumentAccepted(path: String) { + TODO("Do something with the path to the file") + } +} + +``` \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..4b52cb2 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,113 @@ +apply plugin: "com.android.library" +apply plugin: "kotlin-android" +apply plugin: "kotlin-android-extensions" +apply plugin: "com.diffplug.spotless" +apply plugin: "maven-publish" + +android { + compileSdkVersion 29 + buildToolsVersion "30.0.0" + + defaultConfig { + + minSdkVersion 24 + targetSdkVersion 29 + versionCode project.property("version_code").toInteger() + versionName project.property("version_name") as String + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } +} + +dependencies { + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "androidx.core:core-ktx:1.3.0" + implementation "androidx.appcompat:appcompat:1.1.0" + implementation "com.github.kuamanet:android-native-opencv:0.1" + // ViewModel + api "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" + // LiveData + api "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0" + // Lifecycles only (without ViewModel or LiveData) + api "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0" + + api "androidx.activity:activity-ktx:1.1.0" + + // CameraX core library using camera2 implementation + implementation "androidx.camera:camera-camera2:1.0.0-beta06" + // CameraX Lifecycle Library + implementation "androidx.camera:camera-lifecycle:1.0.0-beta06" + // CameraX View class + implementation "androidx.camera:camera-view:1.0.0-alpha13" + + // zoomable image view + implementation "com.github.chrisbanes:PhotoView:2.3.0" + implementation 'com.google.android.material:material:1.1.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.navigation:navigation-fragment-ktx:2.2.2' + implementation 'androidx.navigation:navigation-ui-ktx:2.2.2' + + testImplementation "junit:junit:4.13" + androidTestImplementation "androidx.test.ext:junit:1.1.1" + androidTestImplementation "androidx.test.espresso:espresso-core:3.2.0" + +} + +spotless { + kotlin { + target "**/*.kt" + // EditorConfig support is broken: https://github.com/diffplug/spotless/issues/142 + ktlint("0.35.0").userData(["disabled_rules": "no-wildcard-imports,import-ordering,chain-wrapping"]) + } +} + +task sourceJar(type: Jar) { + from android.sourceSets.main.kotlin.srcDirs + from android.sourceSets.main.java.srcDirs + from fileTree(dir: 'src/libs', include: ['*.jar']) + classifier "sources" +} + +task androidSourcesJar(type: Jar) { + archiveClassifier.set('sources') + from android.sourceSets.main.java.srcDirs +} + +// Because the components are created only during the afterEvaluate phase, you must +// configure your publications using the afterEvaluate() lifecycle method. +afterEvaluate { + publishing { + publications { + // Creates a Maven publication called "release". + release(MavenPublication) { + // Applies the component for the release build variant. + from components.release + + // Adds javadocs and sources as separate jars. + // artifact androidJavadocsJar + artifact androidSourcesJar + + groupId 'net.kuama.android' + artifactId 'documentscanner' + version project.property('version_name') + } + } + } +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/net/kuama/documentscanner/ExampleInstrumentedTest.kt b/app/src/androidTest/java/net/kuama/documentscanner/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..3cdb355 --- /dev/null +++ b/app/src/androidTest/java/net/kuama/documentscanner/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package net.kuama.documentscanner + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("net.kuama.documentscanner", appContext.packageName) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8b6b0e9 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/net/kuama/documentscanner/data/Corners.kt b/app/src/main/java/net/kuama/documentscanner/data/Corners.kt new file mode 100644 index 0000000..edfa76f --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/data/Corners.kt @@ -0,0 +1,6 @@ +package net.kuama.scanner.data + +import org.opencv.core.Point +import org.opencv.core.Size + +data class Corners(val corners: List, val size: Size) diff --git a/app/src/main/java/net/kuama/documentscanner/data/Loader.kt b/app/src/main/java/net/kuama/documentscanner/data/Loader.kt new file mode 100644 index 0000000..f9d1c66 --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/data/Loader.kt @@ -0,0 +1,45 @@ +package net.kuama.documentscanner.data + +import android.content.Context +import org.opencv.android.BaseLoaderCallback +import org.opencv.android.LoaderCallbackInterface +import org.opencv.android.OpenCVLoader +import java.lang.ref.WeakReference + +enum class OpenCvStatus { + LOADED, ERROR +} + +class Loader(context: Context) { + private val reference = WeakReference(context) + + private var onLoad: ((OpenCvStatus) -> Unit)? = null + private val mLoaderCallback = object : BaseLoaderCallback(context.applicationContext) { + override fun onManagerConnected(status: Int) { + when (status) { + LoaderCallbackInterface.SUCCESS -> { + // Load native library after(!) OpenCV initialization + System.loadLibrary("native-lib") + onLoad?.invoke(OpenCvStatus.LOADED) + } + else -> { + super.onManagerConnected(status) + onLoad?.invoke(OpenCvStatus.ERROR) + } + } + } + } + + fun load(callback: (OpenCvStatus) -> Unit) = reference.get()?.let { + onLoad = callback + if (!OpenCVLoader.initDebug()) { + OpenCVLoader.initAsync( + OpenCVLoader.OPENCV_VERSION, + it.applicationContext, + mLoaderCallback + ) + } else { + mLoaderCallback.onManagerConnected(LoaderCallbackInterface.SUCCESS) + } + } +} diff --git a/app/src/main/java/net/kuama/documentscanner/domain/FindPaperSheet.kt b/app/src/main/java/net/kuama/documentscanner/domain/FindPaperSheet.kt new file mode 100644 index 0000000..e072ca5 --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/domain/FindPaperSheet.kt @@ -0,0 +1,118 @@ +package net.kuama.documentscanner.domain + +import android.graphics.Bitmap +import net.kuama.scanner.data.Corners +import net.kuama.documentscanner.support.Either +import net.kuama.documentscanner.support.Left +import net.kuama.documentscanner.support.Right +import net.kuama.documentscanner.support.shape +import org.opencv.android.Utils +import org.opencv.core.* +import org.opencv.imgproc.Imgproc +import java.util.* +import kotlin.Comparator +import kotlin.collections.ArrayList + +/** + * A good initial number to use during the white-color filter operation + * see [FindPaperSheet.run] + */ +internal const val THRESHOLD_BASE = 48.0 + +/** + * Tries to find an A4 document inside the provided image. + * Returns the corners of the A4 document, if found + */ +class FindPaperSheet : UseCase, FindPaperSheet.Params>() { + + class Params( + val bitmap: Bitmap, + val sensitivity: Double = THRESHOLD_BASE, + val returnOriginalMat: Boolean = false + ) + + override suspend fun run(params: Params): Either> = + try { + val mat = Mat() + // move the bitmap to a mat + Utils.bitmapToMat(params.bitmap, mat) + + val hsv = Mat() + Imgproc.cvtColor(mat, hsv, Imgproc.COLOR_BGR2HSV) + + // keep only white-ish colors + val mask = Mat() + + Core.inRange( + hsv, + Scalar(0.0, 0.0, 255 - params.sensitivity), + Scalar(255.0, params.sensitivity, 255.0), + mask + ) + + var contours: MutableList = ArrayList() + val hierarchy = Mat() + Imgproc.findContours( + mask, + contours, + hierarchy, + Imgproc.RETR_LIST, + Imgproc.CHAIN_APPROX_SIMPLE + ) + + hierarchy.release() + contours = contours + .filter { it.shape.size == 4 } + .toTypedArray() + .toMutableList() + + contours.sortWith(Comparator { lhs, rhs -> + Imgproc.contourArea(rhs).compareTo(Imgproc.contourArea(lhs)) + }) + + if (params.returnOriginalMat) { + Utils.matToBitmap(mat, params.bitmap) + } else { + params.bitmap.recycle() + } + + Right(contours.firstOrNull()?.let { + val foundPoints: Array = sortPoints(it.shape) + Pair( + params.bitmap, + Corners( + foundPoints.toList(), + mat.size() + ) + ) + } ?: Pair(params.bitmap, null)) + } catch (throwable: Throwable) { + Left(Failure(throwable)) + } + + private fun sortPoints(src: Array): Array { + val srcPoints = src.toList() + val result = arrayOf(null, null, null, null) + val sumComparator: Comparator = + Comparator { lhs, rhs -> + java.lang.Double.valueOf(lhs.y + lhs.x).compareTo(rhs.y + rhs.x) + } + val diffComparator: Comparator = + Comparator { lhs, rhs -> + java.lang.Double.valueOf(lhs.y - lhs.x).compareTo(rhs.y - rhs.x) + } + + // top-left corner = minimal sum + result[0] = Collections.min(srcPoints, sumComparator) + + // bottom-right corner = maximal sum + result[2] = Collections.max(srcPoints, sumComparator) + + // top-right corner = minimal difference + result[1] = Collections.min(srcPoints, diffComparator) + + // bottom-left corner = maximal difference + result[3] = Collections.max(srcPoints, diffComparator) + return result.filterNotNull().toTypedArray() + } +} diff --git a/app/src/main/java/net/kuama/documentscanner/domain/PerspectiveTransform.kt b/app/src/main/java/net/kuama/documentscanner/domain/PerspectiveTransform.kt new file mode 100644 index 0000000..fc35490 --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/domain/PerspectiveTransform.kt @@ -0,0 +1,65 @@ +package net.kuama.documentscanner.domain + +import android.graphics.Bitmap +import net.kuama.scanner.data.Corners +import net.kuama.documentscanner.support.Either +import net.kuama.documentscanner.support.Left +import net.kuama.documentscanner.support.Right +import org.opencv.android.Utils +import org.opencv.core.CvType +import org.opencv.core.Mat +import org.opencv.imgproc.Imgproc +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.sqrt + +/** + * Given a set of corners (see [FindPaperSheet]), and a source image, + * crops the corners from the image and transform the shape represented by the corners + * into a rectangle + */ +class PerspectiveTransform : UseCase() { + class Params(val bitmap: Bitmap, val corners: Corners) + + override suspend fun run(params: Params): Either = try { + val src = Mat() + Utils.bitmapToMat(params.bitmap, src) + + val tl = params.corners.corners[0] ?: error("Invalid corners") + val tr = params.corners.corners[1] ?: error("Invalid corners") + val br = params.corners.corners[2] ?: error("Invalid corners") + val bl = params.corners.corners[3] ?: error("Invalid corners") + val widthA = sqrt( + (br.x - bl.x).pow(2.0) + (br.y - bl.y).pow(2.0) + ) + val widthB = sqrt( + (tr.x - tl.x).pow(2.0) + (tr.y - tl.y).pow(2.0) + ) + val dw = max(widthA, widthB) + val maxWidth = dw.toInt() + val heightA = sqrt( + (tr.x - br.x).pow(2.0) + (tr.y - br.y).pow(2.0) + ) + val heightB = sqrt( + (tl.x - bl.x).pow(2.0) + (tl.y - bl.y).pow(2.0) + ) + val dh = max(heightA, heightB) + val maxHeight = java.lang.Double.valueOf(dh).toInt() + val doc = Mat(maxHeight, maxWidth, CvType.CV_8UC4) + val srcMat = Mat(4, 1, CvType.CV_32FC2) + val dstMat = Mat(4, 1, CvType.CV_32FC2) + srcMat.put(0, 0, tl.x, tl.y, tr.x, tr.y, br.x, br.y, bl.x, bl.y) + dstMat.put(0, 0, 0.0, 0.0, dw, 0.0, dw, dh, 0.0, dh) + val m = Imgproc.getPerspectiveTransform(srcMat, dstMat) + Imgproc.warpPerspective(src, doc, m, doc.size()) + val bitmap = Bitmap.createBitmap(doc.cols(), doc.rows(), Bitmap.Config.ARGB_8888) + Utils.matToBitmap(doc, bitmap) + srcMat.release() + dstMat.release() + m.release() + doc.release() + Right(bitmap) + } catch (throwable: Throwable) { + Left(Failure(throwable)) + } +} diff --git a/app/src/main/java/net/kuama/documentscanner/domain/UriToBitmap.kt b/app/src/main/java/net/kuama/documentscanner/domain/UriToBitmap.kt new file mode 100644 index 0000000..6fa1be8 --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/domain/UriToBitmap.kt @@ -0,0 +1,44 @@ +package net.kuama.documentscanner.domain + +import android.content.ContentResolver +import android.graphics.* +import android.media.ExifInterface +import android.net.Uri +import android.os.ParcelFileDescriptor +import net.kuama.documentscanner.support.Either +import net.kuama.documentscanner.support.Left +import net.kuama.documentscanner.support.Right +import java.io.FileDescriptor + +/** + * Given a image URI, tries to load the image into a bitmap, checking also the image rotation + */ +class UriToBitmap : UseCase() { + + class Params(val uri: Uri, val contentResolver: ContentResolver) + + override suspend fun run(params: Params): Either = try { + val parcelFileDescriptor: ParcelFileDescriptor = + params.contentResolver.openFileDescriptor(params.uri, "r")!! + val fileDescriptor: FileDescriptor = parcelFileDescriptor.fileDescriptor + val image = BitmapFactory.decodeFileDescriptor(fileDescriptor) + parcelFileDescriptor.close() + val exif = ExifInterface(params.uri.path.toString()) + val orientation = + exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + val matrix = Matrix() + + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90F) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180F) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270F) + } + + val rotatedBitmap = + Bitmap.createBitmap(image, 0, 0, image.width, image.height, matrix, true) + image.recycle() + Right(rotatedBitmap) + } catch (throwable: Throwable) { + Left(Failure(throwable)) + } +} diff --git a/app/src/main/java/net/kuama/documentscanner/domain/UseCase.kt b/app/src/main/java/net/kuama/documentscanner/domain/UseCase.kt new file mode 100644 index 0000000..2c565f6 --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/domain/UseCase.kt @@ -0,0 +1,21 @@ +package net.kuama.documentscanner.domain + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import net.kuama.documentscanner.support.Either + +abstract class UseCase where Type : Any? { + + abstract suspend fun run(params: Params): Either + + operator fun invoke(params: Params, onResult: (Either) -> Unit = {}) { + val job = GlobalScope.async { run(params) } + GlobalScope.launch(Dispatchers.Main) { onResult(job.await()) } + } + + class None +} + +class Failure(val origin: Throwable) diff --git a/app/src/main/java/net/kuama/documentscanner/exceptions/NullCorners.kt b/app/src/main/java/net/kuama/documentscanner/exceptions/NullCorners.kt new file mode 100644 index 0000000..b204e3b --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/exceptions/NullCorners.kt @@ -0,0 +1,3 @@ +package net.kuama.documentscanner.exceptions + +class NullCorners : Throwable(message = "Paper not detected") diff --git a/app/src/main/java/net/kuama/documentscanner/presentation/BaseScannerActivity.kt b/app/src/main/java/net/kuama/documentscanner/presentation/BaseScannerActivity.kt new file mode 100644 index 0000000..53390b8 --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/presentation/BaseScannerActivity.kt @@ -0,0 +1,115 @@ +package net.kuama.documentscanner.presentation + +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.widget.SeekBar +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +import kotlinx.android.synthetic.main.activity_scanner.* +import net.kuama.documentscanner.R +import net.kuama.documentscanner.data.Loader +import java.io.File + +abstract class BaseScannerActivity : AppCompatActivity() { + private lateinit var viewModel: ScannerViewModel + + private var thresholdValue = 48 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + setContentView(R.layout.activity_scanner) + val viewModel: ScannerViewModel by viewModels() + viewModel.isBusy.observe(this, Observer { isBusy -> + if (isBusy) { + progress.visibility = View.VISIBLE + } else { + progress.visibility = View.INVISIBLE + } + }) + + viewModel.errors.observe(this, Observer { + onError(it) + Log.e(ScannerActivity::class.java.simpleName, it.message, it) + }) + + viewModel.corners.observe(this, Observer { + it?.let { corners -> + hud.onCornersDetected(corners) + } ?: { + hud.onCornersNotDetected() + }() + }) + + viewModel.documentPreview.observe(this, Observer { + documentPreview.setImageBitmap(it) + previewWrap.visibility = View.VISIBLE + }) + + viewModel.flashStatus.observe(this, Observer { status -> + flashToggle.setImageResource( + when (status) { + FlashStatus.ON -> R.drawable.ic_flash_on + FlashStatus.OFF -> R.drawable.ic_flash_off + else -> R.drawable.ic_flash_off + } + ) + }) + + threshold.max = 255 + threshold.progress = thresholdValue + + threshold.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + viewModel.onThresholdChange(progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + } + }) + + flashToggle.setOnClickListener { + viewModel.onFlashToggle() + } + + shutter.setOnClickListener { + viewModel.onTakePicture(getOutputDirectory(), this) + } + + closePreview.setOnClickListener { + previewWrap.visibility = View.GONE + viewModel.onClosePreview() + } + + confirmDocument.setOnClickListener { + onDocumentAccepted(viewModel.documentPath!!) + } + + this.viewModel = viewModel + } + + /** Use external media if it is available, our app's file directory otherwise */ + private fun getOutputDirectory(): File { + val appContext = applicationContext + val mediaDir = externalMediaDirs.firstOrNull()?.let { + File(it, appContext.resources.getString(R.string.app_name)).apply { mkdirs() } + } + return if (mediaDir != null && mediaDir.exists()) + mediaDir else appContext.filesDir + } + + override fun onResume() { + super.onResume() + viewModel.onViewCreated(Loader(this), this, viewFinder) + } + + abstract fun onError(throwable: Throwable) + abstract fun onDocumentAccepted(path: String) +} diff --git a/app/src/main/java/net/kuama/documentscanner/presentation/PaperRect.kt b/app/src/main/java/net/kuama/documentscanner/presentation/PaperRect.kt new file mode 100644 index 0000000..07a5c8a --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/presentation/PaperRect.kt @@ -0,0 +1,141 @@ +package net.kuama.documentscanner.presentation + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import net.kuama.scanner.data.Corners +import org.opencv.core.Point +import kotlin.math.abs + +class PaperRectangle : View { + constructor(context: Context) : super(context) + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) + constructor(context: Context, attributes: AttributeSet, defTheme: Int) : super(context, attributes, defTheme) + + private val rectPaint = Paint() + private val circlePaint = Paint() + private val fillPaint = Paint() + private var ratioX: Double = 1.0 + private var ratioY: Double = 1.0 + private var tl: Point = Point() + private var tr: Point = Point() + private var br: Point = Point() + private var bl: Point = Point() + private val path: Path = Path() + private var point2Move = Point() + private var cropMode = false + private var latestDownX = 0.0F + private var latestDownY = 0.0F + + init { + rectPaint.color = Color.parseColor("#3454D1") + rectPaint.isAntiAlias = true + rectPaint.isDither = true + rectPaint.strokeWidth = 6F + rectPaint.style = Paint.Style.STROKE + rectPaint.strokeJoin = Paint.Join.ROUND // set the join to round you want + rectPaint.strokeCap = Paint.Cap.ROUND // set the paint cap to round too + rectPaint.pathEffect = CornerPathEffect(10f) + + fillPaint.color = Color.parseColor("#3454D1") + fillPaint.alpha = 60 + fillPaint.isAntiAlias = true + fillPaint.isDither = true + fillPaint.strokeWidth = 6F + fillPaint.style = Paint.Style.FILL + fillPaint.strokeJoin = Paint.Join.ROUND // set the join to round you want + fillPaint.strokeCap = Paint.Cap.ROUND // set the paint cap to round too + fillPaint.pathEffect = CornerPathEffect(10f) + + circlePaint.color = Color.LTGRAY + circlePaint.isDither = true + circlePaint.isAntiAlias = true + circlePaint.strokeWidth = 4F + circlePaint.style = Paint.Style.STROKE + } + + fun onCornersDetected(corners: Corners) { + ratioX = corners.size.width.div(measuredWidth) + ratioY = corners.size.height.div(measuredHeight) + tl = corners.corners[0] ?: Point() + tr = corners.corners[1] ?: Point() + br = corners.corners[2] ?: Point() + bl = corners.corners[3] ?: Point() + resize() + path.reset() + path.moveTo(tl.x.toFloat(), tl.y.toFloat()) + path.lineTo(tr.x.toFloat(), tr.y.toFloat()) + path.lineTo(br.x.toFloat(), br.y.toFloat()) + path.lineTo(bl.x.toFloat(), bl.y.toFloat()) + path.close() + + invalidate() + } + + fun onCornersNotDetected() { + path.reset() + invalidate() + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + canvas?.drawPath(path, fillPaint) + canvas?.drawPath(path, rectPaint) + if (cropMode) { + canvas?.drawCircle(tl.x.toFloat(), tl.y.toFloat(), 20F, circlePaint) + canvas?.drawCircle(tr.x.toFloat(), tr.y.toFloat(), 20F, circlePaint) + canvas?.drawCircle(bl.x.toFloat(), bl.y.toFloat(), 20F, circlePaint) + canvas?.drawCircle(br.x.toFloat(), br.y.toFloat(), 20F, circlePaint) + } + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + + if (!cropMode) { + return false + } + when (event?.action) { + MotionEvent.ACTION_DOWN -> { + latestDownX = event.x + latestDownY = event.y + calculatePoint2Move(event.x, event.y) + } + MotionEvent.ACTION_MOVE -> { + point2Move.x = (event.x - latestDownX) + point2Move.x + point2Move.y = (event.y - latestDownY) + point2Move.y + movePoints() + latestDownY = event.y + latestDownX = event.x + } + } + return true + } + + private fun calculatePoint2Move(downX: Float, downY: Float) { + val points = listOf(tl, tr, br, bl) + point2Move = points.minBy { abs((it.x - downX).times(it.y - downY)) } ?: tl + } + + private fun movePoints() { + path.reset() + path.moveTo(tl.x.toFloat(), tl.y.toFloat()) + path.lineTo(tr.x.toFloat(), tr.y.toFloat()) + path.lineTo(br.x.toFloat(), br.y.toFloat()) + path.lineTo(bl.x.toFloat(), bl.y.toFloat()) + path.close() + invalidate() + } + + private fun resize() { + tl.x = tl.x.div(ratioX) + tl.y = tl.y.div(ratioY) + tr.x = tr.x.div(ratioX) + tr.y = tr.y.div(ratioY) + br.x = br.x.div(ratioX) + br.y = br.y.div(ratioY) + bl.x = bl.x.div(ratioX) + bl.y = bl.y.div(ratioY) + } +} diff --git a/app/src/main/java/net/kuama/documentscanner/presentation/ScannerActivity.kt b/app/src/main/java/net/kuama/documentscanner/presentation/ScannerActivity.kt new file mode 100644 index 0000000..6ab05e2 --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/presentation/ScannerActivity.kt @@ -0,0 +1,22 @@ +package net.kuama.documentscanner.presentation + +import android.widget.Toast +import net.kuama.documentscanner.R +import net.kuama.documentscanner.exceptions.NullCorners + +class ScannerActivity : BaseScannerActivity() { + override fun onError(throwable: Throwable) { + when (throwable) { + is NullCorners -> Toast.makeText( + this, + R.string.null_corners, Toast.LENGTH_LONG + ) + .show() + else -> Toast.makeText(this, throwable.message, Toast.LENGTH_LONG).show() + } + } + + override fun onDocumentAccepted(path: String) { + Toast.makeText(this, path, Toast.LENGTH_LONG).show() + } +} diff --git a/app/src/main/java/net/kuama/documentscanner/presentation/ScannerViewModel.kt b/app/src/main/java/net/kuama/documentscanner/presentation/ScannerViewModel.kt new file mode 100644 index 0000000..78703e4 --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/presentation/ScannerViewModel.kt @@ -0,0 +1,264 @@ +package net.kuama.documentscanner.presentation + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import androidx.appcompat.app.AppCompatActivity +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.google.common.util.concurrent.ListenableFuture +import net.kuama.documentscanner.data.Loader +import net.kuama.documentscanner.data.OpenCvStatus +import net.kuama.documentscanner.domain.* +import net.kuama.documentscanner.exceptions.NullCorners +import net.kuama.scanner.data.Corners +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.Executor + +private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" + +enum class FlashStatus { + ON, OFF +} + +class ScannerViewModel : ViewModel() { + /** + * Observable data + */ + val isBusy = MutableLiveData() + val openCv = MutableLiveData() + val corners = MutableLiveData() + val errors = MutableLiveData() + + val flashStatus = MutableLiveData() + val documentPreview = MutableLiveData() + + private var didLoadOpenCv = false + + /** + * Use cases + */ + private val findPaperSheetUseCase: FindPaperSheet = FindPaperSheet() + private val perspectiveTransform: PerspectiveTransform = PerspectiveTransform() + private val uriToBitmap: UriToBitmap = UriToBitmap() + + /** + * See [THRESHOLD_BASE] + */ + private var threshold = THRESHOLD_BASE + + /** + * Tries to load OpenCv native libraries + */ + fun onViewCreated( + loader: Loader, + scannerActivity: AppCompatActivity, + viewFinder: PreviewView + ) { + isBusy.value = true + + setupCamera(scannerActivity, viewFinder) { + if (!didLoadOpenCv) { + loader.load { + isBusy.value = false + openCv.value = it + didLoadOpenCv = true + } + } else { + isBusy.value = false + } + } + } + + fun onThresholdChange(threshold: Int) { + this.threshold = threshold.toDouble() + } + + fun onFlashToggle() { + flashStatus.value?.let { currentValue -> + flashStatus.value = when (currentValue) { + FlashStatus.ON -> FlashStatus.OFF + FlashStatus.OFF -> FlashStatus.ON + } + } ?: { + // default flash status is off + flashStatus.value = FlashStatus.ON + }() + + when (flashStatus.value) { + FlashStatus.ON -> camera?.cameraControl?.enableTorch(true) + FlashStatus.OFF -> camera?.cameraControl?.enableTorch(false) + null -> camera?.cameraControl?.enableTorch(false) + } + } + + fun onTakePicture(outputDirectory: File, context: Context) { + isBusy.value = true + + val photoFile = File( + outputDirectory, + SimpleDateFormat( + FILENAME_FORMAT, Locale.US + ).format(System.currentTimeMillis()) + ".jpg" + ) + + // Create output options object which contains file + metadata + val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() + + imageCapture.takePicture( + outputOptions, + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onError(exc: ImageCaptureException) { + errors.value = exc + } + + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + lastUri = Uri.fromFile(photoFile) + + uriToBitmap( + UriToBitmap.Params( + uri = lastUri!!, + contentResolver = context.contentResolver + ) + ) { + it.fold(::handleFailure) { preview -> + analyze(preview, returnOriginalMat = true) { pair -> + pair.second?.let { + perspectiveTransform( + PerspectiveTransform.Params( + bitmap = pair.first, + corners = pair.second!! + ) + ) { result -> + isBusy.value = false + result.fold(::handleFailure) { documentPreview -> + this@ScannerViewModel.documentPreview.value = + documentPreview + } + } + } ?: { + errors.value = NullCorners() + isBusy.value = false + }() + } + } + } + } + }) + } + + fun onClosePreview() { + lastUri?.let { + val file = File(it.path!!) + if (file.exists()) { + file.delete() + } + } + } + + val documentPath: String? + get() = lastUri?.path + +// CameraX setup + + private var lastUri: Uri? = null + + // Preview + private val preview: Preview by lazy { + Preview.Builder().build() + } + + // Select back camera + private val cameraSelector: CameraSelector = + CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() + + private val imageAnalysis: ImageAnalysis by lazy { + ImageAnalysis.Builder().apply { + setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + }.build() + } + + private val imageCapture: ImageCapture by lazy { + ImageCapture.Builder() + .build() + } + + private var cameraProvider: ProcessCameraProvider? = null + private var camera: Camera? = null + private var executor: Executor? = null + + private fun setupCamera( + lifecycleOwner: AppCompatActivity, + viewFinder: PreviewView, + then: () -> Unit + ) { + isBusy.value = true + val cameraProviderFuture: ListenableFuture = + ProcessCameraProvider.getInstance(lifecycleOwner) + executor = ContextCompat.getMainExecutor(lifecycleOwner) + + cameraProviderFuture.addListener(Runnable { + // Used to bind the lifecycle of cameras to the lifecycle owner + cameraProvider = cameraProviderFuture.get() + try { + // Unbind use cases before possible rebinding + cameraProvider!!.unbindAll() + + imageAnalysis.setAnalyzer(executor!!, ImageAnalysis.Analyzer { proxy -> + // could not find a performing way to transform + // the proxy to a bitmap, so we are reading + // the bitmap directly from the preview view + viewFinder.bitmap?.let { + analyze(it, onSuccess = { + proxy.close() + }) + } ?: { + corners.value = null + proxy.close() + }() + }) + + // Bind use cases to camera + camera = cameraProvider!!.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageAnalysis, + imageCapture + ) + preview.setSurfaceProvider(viewFinder.createSurfaceProvider()) + } catch (exc: Exception) { + errors.value = exc + } + }, executor!!) + then.invoke() + } + + private fun analyze( + bitmap: Bitmap, + onSuccess: (() -> Unit)? = null, + returnOriginalMat: Boolean = false, + callback: ((Pair) -> Unit)? = null + ) { + findPaperSheetUseCase(FindPaperSheet.Params(bitmap, threshold, returnOriginalMat)) { + it.fold(::handleFailure) { pair: Pair -> + callback?.invoke(pair) ?: { + corners.value = pair.second + }() + onSuccess?.invoke() + } + } + } + + private fun handleFailure(failure: Failure) { + errors.value = failure.origin + isBusy.value = false + } +} diff --git a/app/src/main/java/net/kuama/documentscanner/support/Either.kt b/app/src/main/java/net/kuama/documentscanner/support/Either.kt new file mode 100644 index 0000000..f18f7df --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/support/Either.kt @@ -0,0 +1,80 @@ +package net.kuama.documentscanner.support + +sealed class Either { + + abstract fun fold(left: (L) -> Unit, right: (R) -> Unit) + + abstract fun mapLeft(f: (L) -> ML): Either + + abstract fun mapRight(f: (R) -> MR): Either + + abstract fun map(leftF: (L) -> ML, rightF: (R) -> MR): Either + + abstract fun flatMapLeft(f: (L) -> Either): Either + + abstract fun flatMapRight(f: (R) -> Either): Either + + abstract fun flatMap(leftF: (L) -> Either, rightF: (R) -> Either): Either + + abstract fun filterLeft(filter: (L) -> Boolean, supplier: () -> R): Either + + abstract fun filterRight(filter: (R) -> Boolean, supplier: () -> L): Either +} + +data class Left( + private val value: L +) : Either() { + + override fun fold(left: (L) -> Unit, right: (R) -> Unit) = left(value) + + override fun mapLeft(f: (L) -> ML) = + Left(f(value)) + + override fun mapRight(f: (R) -> MR) = + Left(value) + + override fun map(leftF: (L) -> ML, rightF: (R) -> MR) = + Left(leftF(value)) + + override fun flatMapLeft(f: (L) -> Either) = f(value) + + override fun flatMapRight(f: (R) -> Either) = + Left(value) + + override fun flatMap(leftF: (L) -> Either, rightF: (R) -> Either) = leftF(value) + + override fun filterLeft(filter: (L) -> Boolean, supplier: () -> R) = if (filter(value)) this else Right( + supplier() + ) + + override fun filterRight(filter: (R) -> Boolean, supplier: () -> L) = this +} + +data class Right( + private val value: R +) : Either() { + + override fun fold(left: (L) -> Unit, right: (R) -> Unit) = right(value) + + override fun mapLeft(f: (L) -> ML) = + Right(value) + + override fun mapRight(f: (R) -> MR) = + Right(f(value)) + + override fun map(leftF: (L) -> ML, rightF: (R) -> MR) = + Right(rightF(value)) + + override fun flatMapLeft(f: (L) -> Either) = + Right(value) + + override fun flatMapRight(f: (R) -> Either) = f(value) + + override fun flatMap(leftF: (L) -> Either, rightF: (R) -> Either) = rightF(value) + + override fun filterLeft(filter: (L) -> Boolean, supplier: () -> R) = this + + override fun filterRight(filter: (R) -> Boolean, supplier: () -> L) = if (filter(value)) this else Left( + supplier() + ) +} diff --git a/app/src/main/java/net/kuama/documentscanner/support/MatOfPoint.kt b/app/src/main/java/net/kuama/documentscanner/support/MatOfPoint.kt new file mode 100644 index 0000000..255d86e --- /dev/null +++ b/app/src/main/java/net/kuama/documentscanner/support/MatOfPoint.kt @@ -0,0 +1,18 @@ +package net.kuama.documentscanner.support + +import org.opencv.core.MatOfPoint +import org.opencv.core.MatOfPoint2f +import org.opencv.core.Point +import org.opencv.imgproc.Imgproc + +/** + * A list of [MatOfPoint] representing an approximated contour + */ +val MatOfPoint.shape: Array + get() { + val c2f = MatOfPoint2f(*toArray()) + val peri = Imgproc.arcLength(c2f, true) + val approx = MatOfPoint2f() + Imgproc.approxPolyDP(c2f, approx, 0.02 * peri, true) + return approx.toArray() + } diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check_24.xml b/app/src/main/res/drawable/ic_check_24.xml new file mode 100644 index 0000000..e083a74 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_close_24.xml b/app/src/main/res/drawable/ic_close_24.xml new file mode 100644 index 0000000..70db409 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_flash_off.xml b/app/src/main/res/drawable/ic_flash_off.xml new file mode 100644 index 0000000..a7f19f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_flash_off.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_flash_on.xml b/app/src/main/res/drawable/ic_flash_on.xml new file mode 100644 index 0000000..118d8a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_flash_on.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_lens.xml b/app/src/main/res/drawable/ic_lens.xml new file mode 100644 index 0000000..1cb42e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_lens.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..0c46d5d --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_scanner.xml b/app/src/main/res/layout/activity_scanner.xml new file mode 100644 index 0000000..571ccd7 --- /dev/null +++ b/app/src/main/res/layout/activity_scanner.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml new file mode 100644 index 0000000..3691469 --- /dev/null +++ b/app/src/main/res/layout/content_main.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_first.xml b/app/src/main/res/layout/fragment_first.xml new file mode 100644 index 0000000..fb44a3d --- /dev/null +++ b/app/src/main/res/layout/fragment_first.xml @@ -0,0 +1,28 @@ + + + + + +