diff --git a/.claude/overlay1.png b/.claude/overlay1.png new file mode 100644 index 0000000..04317dc Binary files /dev/null and b/.claude/overlay1.png differ diff --git a/.claude/overlay2.png b/.claude/overlay2.png new file mode 100644 index 0000000..1e317fd Binary files /dev/null and b/.claude/overlay2.png differ diff --git a/.claude/overlay3.png b/.claude/overlay3.png new file mode 100644 index 0000000..6b84d8e Binary files /dev/null and b/.claude/overlay3.png differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d9db4a..466c03f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/eu/geyskens/pdfscan/scan/DocumentAnalyzer.kt b/app/src/main/java/eu/geyskens/pdfscan/scan/DocumentAnalyzer.kt new file mode 100644 index 0000000..8ead113 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/scan/DocumentAnalyzer.kt @@ -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?, 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?, 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 + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt b/app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt index 9072cbd..88eb043 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt @@ -22,17 +22,25 @@ object EdgeDetector { fun detect(bitmap: Bitmap): List? { 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? { + 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() } } diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt index 55aac25..e6286cb 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt @@ -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?>(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) },