Add live edge-detection overlay while aiming; v0.2.0
All checks were successful
Build APK / build (push) Successful in 6m9s
All checks were successful
Build APK / build (push) Successful in 6m9s
Bind a CameraX ImageAnalysis use case that runs EdgeDetector on downscaled YUV frames (throttled ~6/s) and draw the detected document quad as a live green overlay on the preview, mapped through the FILL_CENTER crop. Preview switched to TextureView (COMPATIBLE) mode so the Compose overlay renders on top. Bump versionCode 4 / versionName 0.2.0. Fixes #4 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BIN
.claude/overlay1.png
Normal file
BIN
.claude/overlay1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
.claude/overlay2.png
Normal file
BIN
.claude/overlay2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
.claude/overlay3.png
Normal file
BIN
.claude/overlay3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -24,8 +24,8 @@ android {
|
|||||||
applicationId = "eu.geyskens.pdfscan"
|
applicationId = "eu.geyskens.pdfscan"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "0.1.2"
|
versionName = "0.2.0"
|
||||||
|
|
||||||
// OpenCV native libs are large; ship only the ABIs real phones use.
|
// OpenCV native libs are large; ship only the ABIs real phones use.
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package eu.geyskens.pdfscan.scan
|
||||||
|
|
||||||
|
import android.graphics.PointF
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
|
import org.opencv.core.Core
|
||||||
|
import org.opencv.core.CvType
|
||||||
|
import org.opencv.core.Mat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [EdgeDetector] on live camera frames (throttled) and reports the detected document
|
||||||
|
* corners, normalized to a display-oriented frame, together with that frame's aspect ratio
|
||||||
|
* (width / height) so the overlay can map them onto the preview.
|
||||||
|
*/
|
||||||
|
class DocumentAnalyzer(
|
||||||
|
private val onResult: (corners: List<PointF>?, frameAspect: Float) -> Unit,
|
||||||
|
) : ImageAnalysis.Analyzer {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var lastRun = 0L
|
||||||
|
|
||||||
|
override fun analyze(image: ImageProxy) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastRun < THROTTLE_MS) {
|
||||||
|
image.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastRun = now
|
||||||
|
val result = try {
|
||||||
|
detect(image)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
android.util.Log.w("DocAnalyzer", "detect failed", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
image.close()
|
||||||
|
if (result != null) onResult(result.first, result.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detect(image: ImageProxy): Pair<List<PointF>?, Float> {
|
||||||
|
// The Y plane of the YUV frame is already a grayscale image.
|
||||||
|
val plane = image.planes[0]
|
||||||
|
val rowStride = plane.rowStride
|
||||||
|
val buffer = plane.buffer
|
||||||
|
val out = ByteArray(image.width * image.height)
|
||||||
|
for (row in 0 until image.height) {
|
||||||
|
buffer.position(row * rowStride)
|
||||||
|
buffer.get(out, row * image.width, image.width)
|
||||||
|
}
|
||||||
|
val gray = Mat(image.height, image.width, CvType.CV_8UC1)
|
||||||
|
gray.put(0, 0, out)
|
||||||
|
|
||||||
|
// Rotate the sensor-oriented frame to match how the preview is displayed.
|
||||||
|
when (image.imageInfo.rotationDegrees) {
|
||||||
|
90 -> Core.rotate(gray, gray, Core.ROTATE_90_CLOCKWISE)
|
||||||
|
180 -> Core.rotate(gray, gray, Core.ROTATE_180)
|
||||||
|
270 -> Core.rotate(gray, gray, Core.ROTATE_90_COUNTERCLOCKWISE)
|
||||||
|
}
|
||||||
|
val aspect = gray.cols().toFloat() / gray.rows()
|
||||||
|
return EdgeDetector.detectFromGray(gray) to aspect // detectFromGray releases gray
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val THROTTLE_MS = 150L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,17 +22,25 @@ object EdgeDetector {
|
|||||||
fun detect(bitmap: Bitmap): List<PointF>? {
|
fun detect(bitmap: Bitmap): List<PointF>? {
|
||||||
val src = Mat()
|
val src = Mat()
|
||||||
Utils.bitmapToMat(bitmap, src)
|
Utils.bitmapToMat(bitmap, src)
|
||||||
try {
|
|
||||||
val scale = PROCESS_WIDTH / src.cols()
|
|
||||||
val work = Mat()
|
|
||||||
Imgproc.resize(src, work, Size(PROCESS_WIDTH, src.rows() * scale))
|
|
||||||
|
|
||||||
val gray = Mat()
|
val gray = Mat()
|
||||||
Imgproc.cvtColor(work, gray, Imgproc.COLOR_RGBA2GRAY)
|
Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY)
|
||||||
Imgproc.GaussianBlur(gray, gray, Size(5.0, 5.0), 0.0)
|
src.release()
|
||||||
|
return detectFromGray(gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects on a full-resolution single-channel grayscale [gray] Mat (e.g. a camera
|
||||||
|
* frame's Y plane). Corners are normalized to [gray]'s dimensions. Releases [gray].
|
||||||
|
*/
|
||||||
|
fun detectFromGray(gray: Mat): List<PointF>? {
|
||||||
|
try {
|
||||||
|
val scale = PROCESS_WIDTH / gray.cols()
|
||||||
|
val work = Mat()
|
||||||
|
Imgproc.resize(gray, work, Size(PROCESS_WIDTH, gray.rows() * scale))
|
||||||
|
Imgproc.GaussianBlur(work, work, Size(5.0, 5.0), 0.0)
|
||||||
|
|
||||||
val edges = Mat()
|
val edges = Mat()
|
||||||
Imgproc.Canny(gray, edges, 50.0, 150.0)
|
Imgproc.Canny(work, edges, 50.0, 150.0)
|
||||||
// Close small gaps so contours form a continuous loop.
|
// Close small gaps so contours form a continuous loop.
|
||||||
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0))
|
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0))
|
||||||
Imgproc.dilate(edges, edges, kernel)
|
Imgproc.dilate(edges, edges, kernel)
|
||||||
@@ -65,7 +73,7 @@ object EdgeDetector {
|
|||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
} finally {
|
} finally {
|
||||||
src.release()
|
gray.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package eu.geyskens.pdfscan.ui.screens
|
package eu.geyskens.pdfscan.ui.screens
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.graphics.PointF
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.MediaActionSound
|
import android.media.MediaActionSound
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||||
|
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||||
import androidx.camera.core.ImageCapture
|
import androidx.camera.core.ImageCapture
|
||||||
import androidx.camera.core.ImageCaptureException
|
import androidx.camera.core.ImageCaptureException
|
||||||
import androidx.camera.core.Preview
|
import androidx.camera.core.Preview
|
||||||
@@ -11,6 +15,7 @@ import androidx.camera.lifecycle.ProcessCameraProvider
|
|||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -42,6 +47,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
@@ -54,6 +60,7 @@ import com.google.accompanist.permissions.isGranted
|
|||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
import eu.geyskens.pdfscan.Routes
|
import eu.geyskens.pdfscan.Routes
|
||||||
import eu.geyskens.pdfscan.ScanViewModel
|
import eu.geyskens.pdfscan.ScanViewModel
|
||||||
|
import eu.geyskens.pdfscan.scan.DocumentAnalyzer
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
@@ -96,12 +103,27 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
|
|||||||
val pages by vm.pages.collectAsState()
|
val pages by vm.pages.collectAsState()
|
||||||
|
|
||||||
val executor = remember { Executors.newSingleThreadExecutor() }
|
val executor = remember { Executors.newSingleThreadExecutor() }
|
||||||
|
// Same 4:3 aspect for preview and analysis so the live overlay lines up with the preview.
|
||||||
|
val resolutionSelector = remember {
|
||||||
|
ResolutionSelector.Builder()
|
||||||
|
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
val imageCapture = remember {
|
val imageCapture = remember {
|
||||||
ImageCapture.Builder()
|
ImageCapture.Builder()
|
||||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
val imageAnalysis = remember {
|
||||||
|
ImageAnalysis.Builder()
|
||||||
|
.setResolutionSelector(resolutionSelector)
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
var capturing by remember { mutableStateOf(false) }
|
var capturing by remember { mutableStateOf(false) }
|
||||||
|
// Live document corners (normalized, display-oriented) + the analysis frame aspect ratio.
|
||||||
|
var liveCorners by remember { mutableStateOf<List<PointF>?>(null) }
|
||||||
|
var frameAspect by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
// Capture feedback: system shutter sound + a brief white flash over the preview,
|
// Capture feedback: system shutter sound + a brief white flash over the preview,
|
||||||
// so there's clear confirmation even when the phone is on silent.
|
// so there's clear confirmation even when the phone is on silent.
|
||||||
@@ -120,15 +142,27 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
|
|||||||
}
|
}
|
||||||
|
|
||||||
val previewView = remember {
|
val previewView = remember {
|
||||||
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
|
PreviewView(context).apply {
|
||||||
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
|
// TextureView mode so the Compose overlay draws on top of the preview
|
||||||
|
// (SurfaceView mode composites on a separate layer that hides the overlay).
|
||||||
|
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
val provider = ProcessCameraProvider.getInstance(context).await()
|
val provider = ProcessCameraProvider.getInstance(context).await()
|
||||||
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
val preview = Preview.Builder()
|
||||||
|
.setResolutionSelector(resolutionSelector)
|
||||||
|
.build()
|
||||||
|
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
||||||
|
imageAnalysis.setAnalyzer(executor, DocumentAnalyzer { corners, aspect ->
|
||||||
|
liveCorners = corners
|
||||||
|
frameAspect = aspect
|
||||||
|
})
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(
|
provider.bindToLifecycle(
|
||||||
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture
|
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture, imageAnalysis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +172,27 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Live edge-detection overlay, mapped onto the FILL_CENTER-cropped preview.
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
val corners = liveCorners
|
||||||
|
if (corners != null && corners.size == 4 && frameAspect > 0f) {
|
||||||
|
val viewAspect = size.width / size.height
|
||||||
|
val dispW: Float
|
||||||
|
val dispH: Float
|
||||||
|
if (frameAspect > viewAspect) {
|
||||||
|
dispH = size.height; dispW = size.height * frameAspect
|
||||||
|
} else {
|
||||||
|
dispW = size.width; dispH = size.width / frameAspect
|
||||||
|
}
|
||||||
|
val offX = (size.width - dispW) / 2f
|
||||||
|
val offY = (size.height - dispH) / 2f
|
||||||
|
val pts = corners.map { Offset(offX + it.x * dispW, offY + it.y * dispH) }
|
||||||
|
for (i in pts.indices) {
|
||||||
|
drawLine(Color(0xFF00E676), pts[i], pts[(i + 1) % 4], strokeWidth = 5f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Top bar: settings.
|
// Top bar: settings.
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { navController.navigate(Routes.SETTINGS) },
|
onClick = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
|||||||
Reference in New Issue
Block a user