Live analysis: make the detected quad move smoothly (#28)
This commit is contained in:
@@ -47,6 +47,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -64,6 +65,7 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import org.fairscan.app.ui.components.CameraPermissionState
|
import org.fairscan.app.ui.components.CameraPermissionState
|
||||||
import org.fairscan.imageprocessing.Point
|
import org.fairscan.imageprocessing.Point
|
||||||
|
import org.fairscan.imageprocessing.Quad
|
||||||
import org.fairscan.imageprocessing.scaledTo
|
import org.fairscan.imageprocessing.scaledTo
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
@@ -207,17 +209,32 @@ fun bindCameraUseCases(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AnalysisOverlay(liveAnalysisState: LiveAnalysisState, debugMode: Boolean) {
|
fun AnalysisOverlay(liveAnalysisState: LiveAnalysisState, debugMode: Boolean) {
|
||||||
val binaryMask = liveAnalysisState.binaryMask
|
val binaryMask = liveAnalysisState.binaryMask ?: return
|
||||||
if (binaryMask == null) {
|
val targetQuad = liveAnalysisState.stableQuad
|
||||||
return
|
var displayedQuad by remember { mutableStateOf<Quad?>(null) }
|
||||||
}
|
|
||||||
val quadColor = MaterialTheme.colorScheme.primary
|
val quadColor = MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
|
LaunchedEffect(targetQuad) {
|
||||||
|
if (targetQuad == null) {
|
||||||
|
displayedQuad = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
displayedQuad = displayedQuad?.let { current ->
|
||||||
|
lerpQuad(current, targetQuad, 0.15f)
|
||||||
|
} ?: targetQuad
|
||||||
|
|
||||||
|
withFrameNanos { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
if (debugMode) {
|
if (debugMode) {
|
||||||
drawMask(this, binaryMask)
|
drawMask(this, binaryMask)
|
||||||
}
|
}
|
||||||
if (liveAnalysisState.documentQuad != null) {
|
displayedQuad?.let { quad ->
|
||||||
val scaledQuad = liveAnalysisState.documentQuad.scaledTo(
|
val scaledQuad = quad.scaledTo(
|
||||||
fromWidth = binaryMask.width,
|
fromWidth = binaryMask.width,
|
||||||
fromHeight = binaryMask.height,
|
fromHeight = binaryMask.height,
|
||||||
toWidth = size.width.toInt(),
|
toWidth = size.width.toInt(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ data class LiveAnalysisState(
|
|||||||
val inferenceTime: Long = 0L,
|
val inferenceTime: Long = 0L,
|
||||||
val binaryMask: Bitmap? = null,
|
val binaryMask: Bitmap? = null,
|
||||||
val documentQuad: Quad? = null,
|
val documentQuad: Quad? = null,
|
||||||
|
val stableQuad: Quad? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CameraUiState(
|
data class CameraUiState(
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
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
|
||||||
@@ -59,6 +58,7 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
|||||||
|
|
||||||
private var _liveAnalysisState = MutableStateFlow(LiveAnalysisState())
|
private var _liveAnalysisState = MutableStateFlow(LiveAnalysisState())
|
||||||
val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow()
|
val liveAnalysisState: StateFlow<LiveAnalysisState> = _liveAnalysisState.asStateFlow()
|
||||||
|
private var quadStabilizer = QuadStabilizer()
|
||||||
|
|
||||||
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
|
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
|
||||||
val captureState: StateFlow<CaptureState> = _captureState
|
val captureState: StateFlow<CaptureState> = _captureState
|
||||||
@@ -68,17 +68,20 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
|||||||
imageSegmentationService.initialize()
|
imageSegmentationService.initialize()
|
||||||
imageSegmentationService.segmentation
|
imageSegmentationService.segmentation
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.map {
|
.collect { result ->
|
||||||
// TODO Should we really call toBinaryMask if it's used only in debug mode?
|
// TODO Should we really call toBinaryMask if it's used only in debug mode?
|
||||||
val binaryMask = it.segmentation.toBinaryMask()
|
val binaryMask = result.segmentation.toBinaryMask()
|
||||||
LiveAnalysisState(
|
val rawQuad = detectDocumentQuad(
|
||||||
inferenceTime = it.inferenceTime,
|
result.segmentation,
|
||||||
binaryMask = binaryMask,
|
isLiveAnalysis = true
|
||||||
documentQuad = detectDocumentQuad(it.segmentation, isLiveAnalysis = true),
|
)
|
||||||
|
val stableQuad = quadStabilizer.update(rawQuad)
|
||||||
|
_liveAnalysisState.value = LiveAnalysisState(
|
||||||
|
inferenceTime = result.inferenceTime,
|
||||||
|
binaryMask = binaryMask,
|
||||||
|
documentQuad = rawQuad,
|
||||||
|
stableQuad = stableQuad,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
.collect {
|
|
||||||
_liveAnalysisState.value = it
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Pierre-Yves Nicolas
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the Free
|
||||||
|
* Software Foundation, either version 3 of the License, or (at your option)
|
||||||
|
* any later version.
|
||||||
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
* more details.
|
||||||
|
* You should have received a copy of the GNU General Public License along with
|
||||||
|
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package org.fairscan.app.ui.screens.camera
|
||||||
|
|
||||||
|
import org.fairscan.imageprocessing.Point
|
||||||
|
import org.fairscan.imageprocessing.Quad
|
||||||
|
import org.fairscan.imageprocessing.norm
|
||||||
|
|
||||||
|
class QuadStabilizer {
|
||||||
|
|
||||||
|
private var stableCount = 0
|
||||||
|
private var lastRawQuad: Quad? = null
|
||||||
|
|
||||||
|
fun update(rawQuad: Quad?): Quad? {
|
||||||
|
lastRawQuad = rawQuad
|
||||||
|
|
||||||
|
if (rawQuad == null) {
|
||||||
|
stableCount = 0
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastRaw = lastRawQuad
|
||||||
|
if (lastRaw == null) {
|
||||||
|
stableCount = 1
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val dist = lastRaw.maxCornerDistanceTo(rawQuad)
|
||||||
|
// 20f is based on the assumption that the preview has a size of 640×480
|
||||||
|
if (dist < 20f) {
|
||||||
|
stableCount++
|
||||||
|
} else {
|
||||||
|
stableCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (stableCount >= 3) rawQuad else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Quad.maxCornerDistanceTo(other: Quad): Float {
|
||||||
|
return listOf(
|
||||||
|
norm(topLeft, other.topLeft),
|
||||||
|
norm(topRight, other.topRight),
|
||||||
|
norm(bottomRight, other.bottomRight),
|
||||||
|
norm(bottomLeft, other.bottomLeft),
|
||||||
|
).max().toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lerp(a: Point, b: Point, alpha: Float): Point {
|
||||||
|
return Point(
|
||||||
|
x = a.x + alpha * (b.x - a.x),
|
||||||
|
y = a.y + alpha * (b.y - a.y)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lerpQuad(a: Quad, b: Quad, alpha: Float): Quad {
|
||||||
|
return Quad(
|
||||||
|
topLeft = lerp(a.topLeft, b.topLeft, alpha),
|
||||||
|
topRight = lerp(a.topRight, b.topRight, alpha),
|
||||||
|
bottomRight = lerp(a.bottomRight, b.bottomRight, alpha),
|
||||||
|
bottomLeft = lerp(a.bottomLeft, b.bottomLeft, alpha),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user