Extract module imageprocessing (#76)

This commit is contained in:
pynicolas
2025-12-06 08:08:20 +01:00
committed by GitHub
parent 8ac844d8fd
commit 3d9d5565f1
22 changed files with 91 additions and 45 deletions

2
.idea/compiler.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" /> <bytecodeTargetLevel target="11" />
</component> </component>
</project> </project>

1
.idea/gradle.xml generated
View File

@@ -11,6 +11,7 @@
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/imageprocessing" />
</set> </set>
</option> </option>
</GradleProjectSettings> </GradleProjectSettings>

2
.idea/kotlinc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.1.0" /> <option name="version" value="2.2.21" />
</component> </component>
</project> </project>

2
.idea/misc.xml generated
View File

@@ -1,6 +1,6 @@
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

@@ -88,8 +88,10 @@ android {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
kotlinOptions { kotlin {
jvmTarget = "11" compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -101,6 +103,10 @@ apply(from = "download-tflite.gradle.kts")
dependencies { dependencies {
implementation(project(":imageprocessing")) {
exclude(group = "org.openpnp", module = "opencv")
}
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)

View File

@@ -22,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.fairscan.imageprocessing.scaledTo
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.fail import org.junit.Assert.fail
import org.junit.Test import org.junit.Test

View File

@@ -29,6 +29,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.fairscan.app.data.Logger import org.fairscan.app.data.Logger
import org.fairscan.imageprocessing.Mask
import org.opencv.core.CvType import org.opencv.core.CvType
import org.opencv.core.Mat import org.opencv.core.Mat
import org.tensorflow.lite.DataType import org.tensorflow.lite.DataType
@@ -132,7 +133,11 @@ class ImageSegmentationService(private val context: Context, private val logger:
return maskFloats return maskFloats
} }
data class Segmentation(private val probmap: FloatArray, val width: Int, val height: Int) { data class Segmentation(
private val probmap: FloatArray,
override val width: Int,
override val height: Int
): Mask {
fun get(x: Int, y: Int): Float = probmap[y * width + x] fun get(x: Int, y: Int): Float = probmap[y * width + x]
fun toBinaryMask(): Bitmap { fun toBinaryMask(): Bitmap {
val bmp = createBitmap(width, height, Bitmap.Config.ARGB_8888) val bmp = createBitmap(width, height, Bitmap.Config.ARGB_8888)
@@ -144,7 +149,8 @@ class ImageSegmentationService(private val context: Context, private val logger:
bmp.setPixels(pixels, 0, width, 0, 0, width, height) bmp.setPixels(pixels, 0, width, 0, 0, width, height)
return bmp return bmp
} }
fun toMat(): Mat {
override fun toMat(): Mat {
val mat = Mat(height, width, CvType.CV_32FC1) val mat = Mat(height, width, CvType.CV_32FC1)
mat.put(0, 0, probmap) mat.put(0, 0, probmap)
return mat return mat

View File

@@ -53,8 +53,8 @@ import androidx.core.graphics.scale
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import org.fairscan.app.domain.Point import org.fairscan.imageprocessing.Point
import org.fairscan.app.domain.scaledTo import org.fairscan.imageprocessing.scaledTo
import org.fairscan.app.ui.components.CameraPermissionState import org.fairscan.app.ui.components.CameraPermissionState
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors

View File

@@ -16,7 +16,7 @@ package org.fairscan.app.ui.screens.camera
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import org.fairscan.app.domain.Quad import org.fairscan.imageprocessing.Quad
@Immutable @Immutable
data class LiveAnalysisState( data class LiveAnalysisState(

View File

@@ -17,6 +17,7 @@ package org.fairscan.app.ui.screens.camera
import android.graphics.Bitmap import android.graphics.Bitmap
import android.util.Log import android.util.Log
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.core.graphics.createBitmap
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -30,9 +31,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.fairscan.app.AppContainer import org.fairscan.app.AppContainer
import org.fairscan.app.domain.detectDocumentQuad import org.fairscan.imageprocessing.Quad
import org.fairscan.app.domain.extractDocument import org.fairscan.imageprocessing.detectDocumentQuad
import org.fairscan.app.domain.scaledTo import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.scaledTo
import org.opencv.android.Utils
import org.opencv.core.Mat
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
sealed interface CameraEvent { sealed interface CameraEvent {
@@ -143,7 +147,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
} }
if (quad != null) { if (quad != null) {
val resizedQuad = quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height) val resizedQuad = quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
corrected = extractDocument(bitmap, resizedQuad, imageProxy.imageInfo.rotationDegrees) corrected = extractDocumentFromBitmap(bitmap, resizedQuad, imageProxy.imageInfo.rotationDegrees)
} }
} }
return@withContext corrected return@withContext corrected
@@ -179,3 +183,15 @@ sealed class CaptureState {
val processed: Bitmap val processed: Bitmap
) : CaptureState() ) : CaptureState()
} }
fun extractDocumentFromBitmap(originalBitmap: Bitmap, quad: Quad, rotationDegrees: Int): Bitmap {
val inputMat = Mat()
Utils.bitmapToMat(originalBitmap, inputMat)
return toBitmap(extractDocument(inputMat, quad, rotationDegrees))
}
private fun toBitmap(mat: Mat): Bitmap {
val outputBitmap = createBitmap(mat.cols(), mat.rows())
Utils.matToBitmap(mat, outputBitmap)
return outputBitmap
}

View File

@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.aboutLibrariesAndroid) apply false alias(libs.plugins.aboutLibrariesAndroid) apply false
alias(libs.plugins.license) alias(libs.plugins.license)
alias(libs.plugins.jetbrains.kotlin.jvm) apply false
} }
license { license {

View File

@@ -14,6 +14,7 @@ datastore = "1.2.0"
documentfile = "1.1.0" documentfile = "1.1.0"
litert = "1.4.1" litert = "1.4.1"
opencv = "4.12.0" opencv = "4.12.0"
opencv_java = "4.9.0-0"
assertj = "3.27.6" assertj = "3.27.6"
pdfbox = "2.0.27.0" pdfbox = "2.0.27.0"
zoomable = "2.9.0" zoomable = "2.9.0"
@@ -22,6 +23,7 @@ protobuf = "0.9.5"
protobufJavaLite = "4.33.1" protobufJavaLite = "4.33.1"
kotlinSerialization = "1.9.0" kotlinSerialization = "1.9.0"
reorderable = "3.0.0" reorderable = "3.0.0"
jetbrainsKotlinJvm = "2.2.21"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -54,6 +56,7 @@ litert = { group = "com.google.ai.edge.litert", name = "litert", version.ref = "
litert-support = { group = "com.google.ai.edge.litert", name = "litert-support", version.ref = "litert" } litert-support = { group = "com.google.ai.edge.litert", name = "litert-support", version.ref = "litert" }
litert-metadata = { group = "com.google.ai.edge.litert", name = "litert-metadata", version.ref = "litert" } litert-metadata = { group = "com.google.ai.edge.litert", name = "litert-metadata", version.ref = "litert" }
opencv = { group="org.opencv", name="opencv", version.ref = "opencv" } opencv = { group="org.opencv", name="opencv", version.ref = "opencv" }
opencvjava = { group="org.openpnp", name="opencv", version.ref = "opencv_java" }
pdfbox = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox" } pdfbox = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox" }
zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
@@ -70,3 +73,4 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
license = { id = "com.github.hierynomus.license", version.ref = "license" } license = { id = "com.github.hierynomus.license", version.ref = "license" }
aboutLibrariesAndroid = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } aboutLibrariesAndroid = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" }
protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }

1
imageprocessing/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,19 @@
plugins {
id("java-library")
alias(libs.plugins.jetbrains.kotlin.jvm)
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
}
}
dependencies {
implementation(libs.opencvjava)
testImplementation(kotlin("test"))
testImplementation(libs.assertj)
}

View File

@@ -12,15 +12,11 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.fairscan.app.domain package org.fairscan.imageprocessing
import android.graphics.Bitmap import org.fairscan.imageprocessing.quad.detectDocumentQuadFromProbmap
import androidx.core.graphics.createBitmap import org.fairscan.imageprocessing.quad.findQuadFromRightAngles
import org.fairscan.app.domain.ImageSegmentationService.Segmentation import org.fairscan.imageprocessing.quad.minAreaRect
import org.fairscan.app.domain.quad.detectDocumentQuadFromProbmap
import org.fairscan.app.domain.quad.findQuadFromRightAngles
import org.fairscan.app.domain.quad.minAreaRect
import org.opencv.android.Utils
import org.opencv.core.Core import org.opencv.core.Core
import org.opencv.core.CvType import org.opencv.core.CvType
import org.opencv.core.Mat import org.opencv.core.Mat
@@ -31,7 +27,13 @@ import org.opencv.imgproc.Imgproc
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
fun detectDocumentQuad(mask: Segmentation, isLiveAnalysis: Boolean, minQuadAreaRatio: Double = 0.02): Quad? { interface Mask {
val width: Int
val height: Int
fun toMat(): Mat
}
fun detectDocumentQuad(mask: Mask, isLiveAnalysis: Boolean, minQuadAreaRatio: Double = 0.02): Quad? {
val mat = mask.toMat() val mat = mask.toMat()
val (biggest: MatOfPoint2f?, area) = biggestContour(mat) val (biggest: MatOfPoint2f?, area) = biggestContour(mat)
var vertices: List<Point>? var vertices: List<Point>?
@@ -114,7 +116,7 @@ fun refineMask(original: Mat): Mat {
return opened return opened
} }
fun extractDocument(originalBitmap: Bitmap, quad: Quad, rotationDegrees: Int): Bitmap { fun extractDocument(inputMat: Mat, quad: Quad, rotationDegrees: Int): Mat {
val widthTop = norm(quad.topLeft, quad.topRight) val widthTop = norm(quad.topLeft, quad.topRight)
val widthBottom = norm(quad.bottomLeft, quad.bottomRight) val widthBottom = norm(quad.bottomLeft, quad.bottomRight)
val targetWidth = (widthTop + widthBottom) / 2 val targetWidth = (widthTop + widthBottom) / 2
@@ -137,8 +139,6 @@ fun extractDocument(originalBitmap: Bitmap, quad: Quad, rotationDegrees: Int): B
) )
val transform = Imgproc.getPerspectiveTransform(srcPoints, dstPoints) val transform = Imgproc.getPerspectiveTransform(srcPoints, dstPoints)
val inputMat = Mat()
Utils.bitmapToMat(originalBitmap, inputMat)
val outputMat = Mat() val outputMat = Mat()
val outputSize = Size(targetWidth.toDouble(), targetHeight.toDouble()) val outputSize = Size(targetWidth.toDouble(), targetHeight.toDouble())
Imgproc.warpPerspective(inputMat, outputMat, transform, outputSize) Imgproc.warpPerspective(inputMat, outputMat, transform, outputSize)
@@ -147,7 +147,7 @@ fun extractDocument(originalBitmap: Bitmap, quad: Quad, rotationDegrees: Int): B
val enhanced = enhanceCapturedImage(resized) val enhanced = enhanceCapturedImage(resized)
val rotated = rotate(enhanced, rotationDegrees) val rotated = rotate(enhanced, rotationDegrees)
return toBitmap(rotated) return rotated
} }
fun resize(original: Mat, targetMax: Double): Mat { fun resize(original: Mat, targetMax: Double): Mat {
@@ -177,12 +177,6 @@ fun rotate(input: Mat, degrees: Int): Mat {
return output return output
} }
private fun toBitmap(mat: Mat): Bitmap {
val outputBitmap = createBitmap(mat.cols(), mat.rows())
Utils.matToBitmap(mat, outputBitmap)
return outputBitmap
}
fun Point.toCv(): org.opencv.core.Point { fun Point.toCv(): org.opencv.core.Point {
return org.opencv.core.Point(x, y) return org.opencv.core.Point(x, y)
} }

View File

@@ -12,7 +12,7 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.fairscan.app.domain package org.fairscan.imageprocessing
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.hypot import kotlin.math.hypot

View File

@@ -12,9 +12,8 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.fairscan.app.domain package org.fairscan.imageprocessing
import android.util.Log
import org.opencv.core.Core import org.opencv.core.Core
import org.opencv.core.CvType import org.opencv.core.CvType
import org.opencv.core.Mat import org.opencv.core.Mat
@@ -25,12 +24,10 @@ import kotlin.math.max
fun enhanceCapturedImage(img: Mat): Mat { fun enhanceCapturedImage(img: Mat): Mat {
return if (isColoredDocument(img)) { return if (isColoredDocument(img)) {
Log.i("PostProcessing", "color document")
val result = Mat() val result = Mat()
Core.convertScaleAbs(img, result, 1.2, 10.0) Core.convertScaleAbs(img, result, 1.2, 10.0)
result result
} else { } else {
Log.i("PostProcessing", "grayscale document")
val gray = multiScaleRetinex(img) val gray = multiScaleRetinex(img)
val contrastedGray = enhanceContrastAuto(gray) val contrastedGray = enhanceContrastAuto(gray)
val result = Mat() val result = Mat()

View File

@@ -12,7 +12,7 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.fairscan.app.domain.quad package org.fairscan.imageprocessing.quad
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.core.CvType import org.opencv.core.CvType

View File

@@ -12,9 +12,9 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.fairscan.app.domain.quad package org.fairscan.imageprocessing.quad
import org.fairscan.app.domain.Point import org.fairscan.imageprocessing.Point
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin

View File

@@ -12,9 +12,9 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.fairscan.app.domain.quad package org.fairscan.imageprocessing.quad
import org.fairscan.app.domain.Point import org.fairscan.imageprocessing.Point
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.acos import kotlin.math.acos
import kotlin.math.sqrt import kotlin.math.sqrt

View File

@@ -12,7 +12,7 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.fairscan.app.domain package org.fairscan.imageprocessing
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy import org.assertj.core.api.Assertions.assertThatThrownBy

View File

@@ -21,4 +21,4 @@ dependencyResolutionManagement {
rootProject.name = "FairScan" rootProject.name = "FairScan"
include(":app") include(":app")
include(":imageprocessing")