Add live edge-detection overlay while aiming; v0.2.0
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:
2026-07-13 19:08:48 +02:00
parent 3c77e9ab3e
commit 3a3f18153f
7 changed files with 142 additions and 14 deletions

BIN
.claude/overlay1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
.claude/overlay2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
.claude/overlay3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -24,8 +24,8 @@ android {
applicationId = "eu.geyskens.pdfscan"
minSdk = 26
targetSdk = 35
versionCode = 3
versionName = "0.1.2"
versionCode = 4
versionName = "0.2.0"
// OpenCV native libs are large; ship only the ABIs real phones use.
ndk {

View File

@@ -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
}
}

View File

@@ -22,17 +22,25 @@ object EdgeDetector {
fun detect(bitmap: Bitmap): List<PointF>? {
val src = Mat()
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()
Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY)
src.release()
return detectFromGray(gray)
}
val gray = Mat()
Imgproc.cvtColor(work, gray, Imgproc.COLOR_RGBA2GRAY)
Imgproc.GaussianBlur(gray, gray, Size(5.0, 5.0), 0.0)
/**
* 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()
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.
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0))
Imgproc.dilate(edges, edges, kernel)
@@ -65,7 +73,7 @@ object EdgeDetector {
}
return best
} finally {
src.release()
gray.release()
}
}

View File

@@ -1,9 +1,13 @@
package eu.geyskens.pdfscan.ui.screens
import android.Manifest
import android.graphics.PointF
import android.media.AudioManager
import android.media.MediaActionSound
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.ImageCaptureException
import androidx.camera.core.Preview
@@ -11,6 +15,7 @@ import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -42,6 +47,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -54,6 +60,7 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import eu.geyskens.pdfscan.Routes
import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.scan.DocumentAnalyzer
import kotlinx.coroutines.launch
import java.io.File
import java.util.concurrent.Executors
@@ -96,12 +103,27 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
val pages by vm.pages.collectAsState()
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 {
ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build()
}
val imageAnalysis = remember {
ImageAnalysis.Builder()
.setResolutionSelector(resolutionSelector)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
}
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,
// 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 {
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) {
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.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(),
)
// 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.
IconButton(
onClick = { navController.navigate(Routes.SETTINGS) },