From 3446986dbad48303fe1a3e2667c115c4cc366f6b Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 01:24:04 +0530 Subject: [PATCH 01/33] fix crop edge hit testing --- .../com/docuscan/app/scan/CropGeometry.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/scan/CropGeometry.kt b/app/src/main/java/com/docuscan/app/scan/CropGeometry.kt index 6c854b2..206583a 100644 --- a/app/src/main/java/com/docuscan/app/scan/CropGeometry.kt +++ b/app/src/main/java/com/docuscan/app/scan/CropGeometry.kt @@ -10,8 +10,8 @@ import kotlin.math.hypot */ object CropGeometry { - const val EDGE_TOUCH_RADIUS_PX = 24f - const val EDGE_END_DEADZONE = 0.15f + const val EDGE_TOUCH_RADIUS_PX = 72f + const val EDGE_END_DEADZONE = 0f const val IMG_OOB_TOL = 0.25f class Projection(val t: Float, val perpDist: Float) @@ -34,19 +34,23 @@ object CropGeometry { /** Edge index 0=Top, 1=Right, 2=Bottom, 3=Left, or -1. */ fun findEdgeHit(xs: FloatArray, ys: FloatArray, x: Float, y: Float): Int { + if (xs.size < 4 || ys.size < 4) return -1 + var best = -1 - var bestDist = EDGE_TOUCH_RADIUS_PX + var bestDistance = Float.POSITIVE_INFINITY + for (i in 0..3) { val j = (i + 1) % 4 val p = projectOntoSegment(x, y, xs[i], ys[i], xs[j], ys[j]) - if (p.perpDist <= EDGE_TOUCH_RADIUS_PX && - p.t > EDGE_END_DEADZONE && p.t < 1f - EDGE_END_DEADZONE && - p.perpDist < bestDist - ) { - bestDist = p.perpDist + + // The whole edge is draggable. Corner hit-testing in CropOverlay + // has priority, so there is no ambiguity near the corners. + if (p.perpDist <= EDGE_TOUCH_RADIUS_PX && p.perpDist < bestDistance) { + bestDistance = p.perpDist best = i } } + return best } From abae3bf7d8aa12a3864730b448dc55082f1d5bea Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 01:24:20 +0530 Subject: [PATCH 02/33] fix touchscreen crop gesture locking --- .../java/com/docuscan/app/ui/CropOverlay.kt | 111 +++++++++++------- 1 file changed, 66 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index b35c355..c379a29 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -7,7 +7,11 @@ import android.graphics.PointF import android.graphics.RectF import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.consume +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -46,6 +50,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight @@ -277,51 +282,73 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) Modifier .fillMaxSize() .pointerInput(boxSize, rotBitmap) { - detectDragGestures( - onDragStart = { pos -> - // Corner hit first (larger radius), then edge hit. - var best = -1 - var bestD = 70f - for (i in 0..3) { - val d = (corner(i) - pos).getDistance() - if (d < bestD) { - bestD = d - best = i - } - } - if (best >= 0) { - dragCorner = best - edgeDrag = null - return@detectDragGestures + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main) + val pos = down.position + + // Select the target at finger-down, not after Compose's + // drag detector has already crossed touch-slop. + var selectedCorner = -1 + var bestCornerDistance = Float.POSITIVE_INFINITY + val cornerHitRadius = 48.dp.toPx() + + for (i in 0..3) { + val distance = (corner(i) - pos).getDistance() + if (distance <= cornerHitRadius && distance < bestCornerDistance) { + bestCornerDistance = distance + selectedCorner = i } + } + + var selectedEdge = -1 + if (selectedCorner < 0) { val xs = FloatArray(4) { corner(it).x } val ys = FloatArray(4) { corner(it).y } - val e = CropGeometry.findEdgeHit(xs, ys, pos.x, pos.y) - if (e >= 0) { - val a = e - val b = (e + 1) % 4 + selectedEdge = CropGeometry.findEdgeHit(xs, ys, pos.x, pos.y) + + if (selectedEdge >= 0) { + val a = selectedEdge + val b = (selectedEdge + 1) % 4 val m0x = (xs[a] + xs[b]) / 2f val m0y = (ys[a] + ys[b]) / 2f - val n = CropGeometry.outwardUnitNormal(xs, ys, e) - edgeDrag = EdgeDrag(e, xs, ys, m0x, m0y, n[0], n[1]) - dragCorner = -1 + val n = CropGeometry.outwardUnitNormal(xs, ys, selectedEdge) + edgeDrag = EdgeDrag(selectedEdge, xs, ys, m0x, m0y, n[0], n[1]) } - }, - onDrag = { change, _ -> + } + + dragCorner = selectedCorner + + if (selectedCorner < 0 && selectedEdge < 0) { + awaitPointerEvent(pass = PointerEventPass.Main) + return@awaitEachGesture + } + + var active = true + while (active) { + val event = awaitPointerEvent(pass = PointerEventPass.Main) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (!change.pressed) { + active = false + break + } + + val current = change.position val ed = edgeDrag - if (ed != null) { + + if (ed != null && selectedEdge >= 0) { val res = CropGeometry.applyEdgeTranslation( ed.xs0, ed.ys0, ed.edgeIndex, ed.m0x, ed.m0y, ed.nx, ed.ny, - change.position.x, change.position.y + current.x, current.y ) if (res.applied) { for (i in 0..3) setViewCorner(i, res.xs[i], res.ys[i]) } - } else if (dragCorner >= 0) { - val i = dragCorner - val newX = change.position.x.coerceIn(fit.left, fit.right) - val newY = change.position.y.coerceIn(fit.top, fit.bottom) + } else if (selectedCorner >= 0) { + val i = selectedCorner + val newX = current.x.coerceIn(fit.left, fit.right) + val newY = current.y.coerceIn(fit.top, fit.bottom) val corners = (0..3).map { corner(it).x.toDouble() to corner(it).y.toDouble() } val res = CropGeometry.snapEvaluate( corners, i, @@ -337,21 +364,15 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) else -> -1 } } + change.consume() - }, - onDragEnd = { - dragCorner = -1 - edgeDrag = null - for (i in 0..3) snapActive[i] = false - snapHighlight = -1 - }, - onDragCancel = { - dragCorner = -1 - edgeDrag = null - for (i in 0..3) snapActive[i] = false - snapHighlight = -1 } - ) + + dragCorner = -1 + edgeDrag = null + for (i in 0..3) snapActive[i] = false + snapHighlight = -1 + } } ) { val f = fit From 72ebbeb416744c4ca3c755fd6555277227c02491 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 01:24:26 +0530 Subject: [PATCH 03/33] fix crop gesture imports --- app/src/main/java/com/docuscan/app/ui/CropOverlay.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index c379a29..d566dd1 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -9,9 +9,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.consume -import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.awaitPointerEvent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column From 21b6d0644a3de14e149015193eeeafdd27fefe1c Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 01:25:00 +0530 Subject: [PATCH 04/33] improve automatic document corner detection --- .../java/com/docuscan/app/scan/Cleanup.kt | 201 ++++++++++++------ 1 file changed, 131 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt index 4ecd5b6..c5b91a0 100644 --- a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt +++ b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt @@ -289,102 +289,163 @@ object Cleanup { private fun detectCornersDetailed(bitmap: Bitmap): List? { val rgba = Mat() val gray = Mat() - val threshold = Mat() - val morph = Mat() + val normalized = Mat() val edges = Mat() - val edgesCopy = Mat() - val hierarchy = Mat() - val contours = mutableListOf() + val edgesAlt = Mat() + val threshold = Mat() + val thresholdInv = Mat() + try { Utils.bitmapToMat(bitmap, rgba) Imgproc.cvtColor(rgba, gray, Imgproc.COLOR_RGBA2GRAY) Imgproc.GaussianBlur(gray, gray, Size(5.0, 5.0), 0.0) - Imgproc.threshold(gray, threshold, 0.0, 255.0, Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU) + Core.normalize(gray, normalized, 0.0, 255.0, Core.NORM_MINMAX) + + val meanValue = Core.mean(gray).get(0, 0)[0] + Imgproc.Canny( + gray, edges, + max(10.0, 0.66 * meanValue), + min(255.0, max(40.0, 1.33 * meanValue)) + ) + + val adaptive = Mat() + edgesAdaptive(normalized, adaptive) + Core.bitwise_or(edges, adaptive, edgesAlt) + adaptive.release() + + Imgproc.threshold( + normalized, threshold, 0.0, 255.0, + Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU + ) + Imgproc.threshold( + normalized, thresholdInv, 0.0, 255.0, + Imgproc.THRESH_BINARY_INV + Imgproc.THRESH_OTSU + ) val shortSide = min(rgba.width(), rgba.height()) - var kernelSize = max(5, shortSide / 50) + var kernelSize = max(3, shortSide / 70) if (kernelSize % 2 == 0) kernelSize++ - val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(kernelSize.toDouble(), kernelSize.toDouble())) - Imgproc.morphologyEx(threshold, morph, Imgproc.MORPH_CLOSE, kernel) + val kernel = Imgproc.getStructuringElement( + Imgproc.MORPH_RECT, + Size(kernelSize.toDouble(), kernelSize.toDouble()) + ) + Imgproc.morphologyEx(threshold, threshold, Imgproc.MORPH_CLOSE, kernel) + Imgproc.morphologyEx(thresholdInv, thresholdInv, Imgproc.MORPH_CLOSE, kernel) kernel.release() - val median = Core.mean(gray).`val`[0] - val cannyLower = max(0.0, 0.66 * median) - val cannyUpper = min(255.0, 1.33 * median) - Imgproc.Canny(morph, edges, cannyLower, cannyUpper) - - val edgesAuto = Mat() - edgesAdaptive(gray, edgesAuto) - Core.max(edges, edgesAuto, edges) - edgesAuto.release() - - val low = isLowLight(rgba) - if (low) { - val ll = rgba.clone() - preprocessLowLight(ll) - val llGray = Mat() - Imgproc.cvtColor(ll, llGray, Imgproc.COLOR_RGBA2GRAY) - val edges2 = Mat() - edgesAdaptive(llGray, edges2) - val k3 = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0)) - Imgproc.dilate(edges2, edges2, k3) - k3.release() - Core.max(edges, edges2, edges) - edges2.release(); llGray.release(); ll.release() - } - - edges.copyTo(edgesCopy) - Imgproc.findContours(edgesCopy, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) + val binaryEdges = Mat() + val binaryEdgesInv = Mat() + Imgproc.Canny(threshold, binaryEdges, 50.0, 150.0) + Imgproc.Canny(thresholdInv, binaryEdgesInv, 50.0, 150.0) + val maps = listOf(edges, edgesAlt, binaryEdges, binaryEdgesInv) val imgArea = rgba.width() * rgba.height().toDouble() var bestScore = -1.0 - var bestQuad: List? = null + var bestQuad: Array? = null - for (contour in contours) { - val area = Imgproc.contourArea(contour) - if (area < imgArea * 0.08) continue - val curve = MatOfPoint2f(*contour.toArray()) - val approx = MatOfPoint2f() + for (edgeMap in maps) { + val localContours = mutableListOf() + val hierarchy = Mat() try { - Imgproc.approxPolyDP(curve, approx, Imgproc.arcLength(curve, true) * 0.015, true) - val approxAsPoints = MatOfPoint(*approx.toArray()) - val isConvex = Imgproc.isContourConvex(approxAsPoints) - approxAsPoints.release() - if (approx.total() == 4L && isConvex) { - val quad = sortPointsRobust(approx.toArray()) - val w1 = distance(quad[0], quad[1]) - val w2 = distance(quad[2], quad[3]) - val h1 = distance(quad[1], quad[2]) - val h2 = distance(quad[3], quad[0]) - val avgWidth = (w1 + w2) / 2.0 - val avgHeight = (h1 + h2) / 2.0 - val aspectRatio = avgHeight / (avgWidth + 1e-9) - val areaNorm = area / imgArea - val rectRaw = rectScore(quad) - if (rectRaw < 0.0) continue - val rect = rectRaw / 120.0 - val score = 0.6 * areaNorm + 0.4 * rect - if (aspectRatio > 0.5 && aspectRatio < 2.5 && score > bestScore) { - bestScore = score - bestQuad = quad.map { PointF(it.x.toFloat(), it.y.toFloat()) } + Imgproc.findContours(edgeMap, localContours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE) + for (contour in localContours) { + val area = Imgproc.contourArea(contour) + if (area < imgArea * 0.025) continue + + val curve = MatOfPoint2f(*contour.toArray()) + val approx = MatOfPoint2f() + try { + val perimeter = Imgproc.arcLength(curve, true) + Imgproc.approxPolyDP(curve, approx, perimeter * 0.018, true) + if (approx.total() != 4L) continue + + val approxPoints = approx.toArray() + val approxAsPoints = MatOfPoint(*approxPoints) + val convex = Imgproc.isContourConvex(approxAsPoints) + approxAsPoints.release() + if (!convex) continue + + val quad = sortPointsRobust(approxPoints) + if (!quadIsUsable(quad, rgba.width(), rgba.height())) continue + + val quadAreaValue = quadArea(quad) + val areaNorm = (quadAreaValue / imgArea).coerceIn(0.0, 1.0) + val rectangularity = rectangularityScore(quad) + val edgeContact = edgeContactScore(quad, rgba.width(), rgba.height()) + val score = areaNorm * 0.55 + rectangularity * 0.30 + edgeContact * 0.15 + + if (score > bestScore) { + bestScore = score + bestQuad = quad + } + } finally { + curve.release() + approx.release() } } } finally { - curve.release(); approx.release() + localContours.forEach { it.release() } + hierarchy.release() } } - if (bestQuad != null) return bestQuad + if (bestQuad != null && bestScore >= 0.20) { + return bestQuad!!.map { PointF(it.x.toFloat(), it.y.toFloat()) } + } - val houghQuad = detectQuadFromHoughLines(edges, rgba.width(), rgba.height()) - if (houghQuad != null) return houghQuad.map { PointF(it.x.toFloat(), it.y.toFloat()) } + val houghQuad = detectQuadFromHoughLines(edgesAlt, rgba.width(), rgba.height()) + if (houghQuad != null) { + return houghQuad.map { PointF(it.x.toFloat(), it.y.toFloat()) } + } return null } finally { - contours.forEach { runCatching { it.release() } } - rgba.release(); gray.release(); threshold.release(); morph.release() - edges.release(); edgesCopy.release(); hierarchy.release() + rgba.release() + gray.release() + normalized.release() + edges.release() + edgesAlt.release() + threshold.release() + thresholdInv.release() + } + } + + private fun quadIsUsable(q: Array, width: Int, height: Int): Boolean { + if (q.size != 4) return false + val imageArea = width.toDouble() * height.toDouble() + if (quadArea(q) < imageArea * 0.025) return false + + for (i in 0..3) { + val a = q[i] + val b = q[(i + 1) % 4] + if (distance(a, b) < min(width, height) * 0.025) return false + val prev = q[(i + 3) % 4] + val next = q[(i + 1) % 4] + val cornerAngle = angle(prev, a, next) + if (cornerAngle.isNaN() || cornerAngle < 22.0 || cornerAngle > 158.0) return false + } + return true + } + + private fun rectangularityScore(q: Array): Double { + if (q.size != 4) return 0.0 + var total = 0.0 + for (i in 0..3) { + val angleValue = angle(q[(i + 3) % 4], q[i], q[(i + 1) % 4]) + total += (1.0 - min(90.0, abs(angleValue - 90.0)) / 90.0) + } + return (total / 4.0).coerceIn(0.0, 1.0) + } + + private fun edgeContactScore(q: Array, width: Int, height: Int): Double { + val toleranceX = width * 0.08 + val toleranceY = height * 0.08 + var contacts = 0 + for (p in q) { + if (p.x <= toleranceX || p.x >= width - toleranceX) contacts++ + if (p.y <= toleranceY || p.y >= height - toleranceY) contacts++ } + return if (contacts == 0) 0.55 else (0.55 + contacts * 0.075).coerceAtMost(1.0) } private fun edgesAdaptive(srcGray: Mat, out: Mat) { From 8b5fa7cc13c6ae6bca36bac2958aea9311c58cb4 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 01:25:13 +0530 Subject: [PATCH 05/33] ci: add crop fix test artifact build --- .github/workflows/crop-test.yml | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/crop-test.yml diff --git a/.github/workflows/crop-test.yml b/.github/workflows/crop-test.yml new file mode 100644 index 0000000..acadd7c --- /dev/null +++ b/.github/workflows/crop-test.yml @@ -0,0 +1,37 @@ +name: Crop Fix Test Build + +on: + push: + branches: + - fix/document-crop-interaction + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-debug: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build debug APK + run: ./gradlew :app:assembleDebug --stacktrace --console=plain + + - name: Upload test APK + uses: actions/upload-artifact@v4 + with: + name: docuscan-crop-fix-debug + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + retention-days: 14 From 87092d7e96b4179dc6c0fdc81f0efcea5d13165a Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 01:25:26 +0530 Subject: [PATCH 06/33] ci: build crop fix on test PRs --- .github/workflows/crop-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/crop-test.yml b/.github/workflows/crop-test.yml index acadd7c..a46d9fc 100644 --- a/.github/workflows/crop-test.yml +++ b/.github/workflows/crop-test.yml @@ -4,6 +4,9 @@ on: push: branches: - fix/document-crop-interaction + pull_request: + branches: + - main workflow_dispatch: permissions: From b371ccd28390b13219885b72ab6e6e0022be9255 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 01:31:43 +0530 Subject: [PATCH 07/33] fix OpenCV mean value compilation --- app/src/main/java/com/docuscan/app/scan/Cleanup.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt index c5b91a0..602e039 100644 --- a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt +++ b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt @@ -301,7 +301,7 @@ object Cleanup { Imgproc.GaussianBlur(gray, gray, Size(5.0, 5.0), 0.0) Core.normalize(gray, normalized, 0.0, 255.0, Core.NORM_MINMAX) - val meanValue = Core.mean(gray).get(0, 0)[0] + val meanValue = Core.mean(gray).`val`[0] Imgproc.Canny( gray, edges, max(10.0, 0.66 * meanValue), From 398588fb671b0b0fa5af924fae88e07ad88c67e3 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 01:31:46 +0530 Subject: [PATCH 08/33] fix Compose pointer event import --- app/src/main/java/com/docuscan/app/ui/CropOverlay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index d566dd1..d11ab8a 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.ui.input.pointer.awaitPointerEvent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -49,6 +48,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.awaitPointerEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight From 339b4e42ee76594499dabdabebb10bfb5e518d71 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 01:37:10 +0530 Subject: [PATCH 09/33] Fix crop gesture API compatibility --- .../java/com/docuscan/app/ui/CropOverlay.kt | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index d11ab8a..482ae3b 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitDragOrCancellation +import androidx.compose.foundation.gestures.drag import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -48,7 +50,6 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.awaitPointerEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight @@ -317,53 +318,54 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) dragCorner = selectedCorner if (selectedCorner < 0 && selectedEdge < 0) { - awaitPointerEvent(pass = PointerEventPass.Main) return@awaitEachGesture } - var active = true - while (active) { - val event = awaitPointerEvent(pass = PointerEventPass.Main) - val change = event.changes.firstOrNull { it.id == down.id } ?: break - - if (!change.pressed) { - active = false - break - } - - val current = change.position - val ed = edgeDrag - - if (ed != null && selectedEdge >= 0) { - val res = CropGeometry.applyEdgeTranslation( - ed.xs0, ed.ys0, ed.edgeIndex, - ed.m0x, ed.m0y, ed.nx, ed.ny, - current.x, current.y - ) - if (res.applied) { - for (i in 0..3) setViewCorner(i, res.xs[i], res.ys[i]) - } - } else if (selectedCorner >= 0) { - val i = selectedCorner - val newX = current.x.coerceIn(fit.left, fit.right) - val newY = current.y.coerceIn(fit.top, fit.bottom) - val corners = (0..3).map { corner(it).x.toDouble() to corner(it).y.toDouble() } - val res = CropGeometry.snapEvaluate( - corners, i, - newX.toDouble(), newY.toDouble(), - snapActive[(i + 3) % 4], snapActive[i] - ) - setViewCorner(i, res.x.toFloat(), res.y.toFloat()) - snapActive[(i + 3) % 4] = res.prevEdgeSnapped - snapActive[i] = res.nextEdgeSnapped - snapHighlight = when { - res.prevEdgeSnapped -> (i + 3) % 4 - res.nextEdgeSnapped -> i - else -> -1 + // Select on finger-down, then use Compose's stable drag API + // for movement. This avoids the unavailable raw pointer-event + // API on this project's Compose version. + val firstDrag = awaitDragOrCancellation(down.id) + if (firstDrag != null) { + fun applyDragPosition(current: Offset) { + val ed = edgeDrag + + if (ed != null && selectedEdge >= 0) { + val res = CropGeometry.applyEdgeTranslation( + ed.xs0, ed.ys0, ed.edgeIndex, + ed.m0x, ed.m0y, ed.nx, ed.ny, + current.x, current.y + ) + if (res.applied) { + for (i in 0..3) setViewCorner(i, res.xs[i], res.ys[i]) + } + } else if (selectedCorner >= 0) { + val i = selectedCorner + val newX = current.x.coerceIn(fit.left, fit.right) + val newY = current.y.coerceIn(fit.top, fit.bottom) + val corners = (0..3).map { corner(it).x.toDouble() to corner(it).y.toDouble() } + val res = CropGeometry.snapEvaluate( + corners, i, + newX.toDouble(), newY.toDouble(), + snapActive[(i + 3) % 4], snapActive[i] + ) + setViewCorner(i, res.x.toFloat(), res.y.toFloat()) + snapActive[(i + 3) % 4] = res.prevEdgeSnapped + snapActive[i] = res.nextEdgeSnapped + snapHighlight = when { + res.prevEdgeSnapped -> (i + 3) % 4 + res.nextEdgeSnapped -> i + else -> -1 + } } } - change.consume() + applyDragPosition(firstDrag.position) + firstDrag.consume() + + drag(down.id) { change -> + applyDragPosition(change.position) + change.consume() + } } dragCorner = -1 From 17cda59d831dfb0f27bac5da455cdd50489b3dde Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:16:42 +0530 Subject: [PATCH 10/33] Add session camera preference and auto crop flow --- .../main/java/com/docuscan/app/DocViewModel.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/docuscan/app/DocViewModel.kt b/app/src/main/java/com/docuscan/app/DocViewModel.kt index 4b307cd..d3ff56b 100644 --- a/app/src/main/java/com/docuscan/app/DocViewModel.kt +++ b/app/src/main/java/com/docuscan/app/DocViewModel.kt @@ -67,6 +67,14 @@ class DocViewModel(app: Application) : AndroidViewModel(app) { var returnToEditor by mutableStateOf(false) private set + /** Camera preference that lasts only while this app process is alive. */ + var cameraPreferredForSession by mutableStateOf(false) + private set + + /** The next captured camera page should open directly in the crop editor. */ + var autoCropNextPage by mutableStateOf(false) + private set + /** Update-check state, driven by the UI (which owns the coroutine scope). */ var latestUpdate by mutableStateOf(null) private set @@ -90,11 +98,16 @@ class DocViewModel(app: Application) : AndroidViewModel(app) { screen = Screen.Tabs } - fun openCamera(fromEditor: Boolean) { + fun openCamera(fromEditor: Boolean, autoCrop: Boolean = false) { returnToEditor = fromEditor + autoCropNextPage = autoCrop screen = Screen.Camera } + fun setCameraPreferredForSession(enabled: Boolean) { + cameraPreferredForSession = enabled + } + fun closeCamera() { screen = if (returnToEditor) Screen.Editor else Screen.Tabs } From 4d4b080cafeb304dd38b49345bc2954eb634ba68 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:16:45 +0530 Subject: [PATCH 11/33] Add session camera choice and auto crop after capture --- .../java/com/docuscan/app/ui/EditorScreen.kt | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt b/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt index 2d7bc2a..e6a8bbd 100644 --- a/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt +++ b/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt @@ -90,6 +90,9 @@ fun EditorScreen(vm: DocViewModel, snackbar: SnackbarHostState) { var discardDialog by remember { mutableStateOf(false) } // When on, filter / brightness / contrast changes are applied to every page (batch mode). var applyToAll by remember { mutableStateOf(false) } + var rememberCameraForSession by remember(vm.cameraPreferredForSession) { + mutableStateOf(vm.cameraPreferredForSession) + } val galleryLauncher = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia() @@ -105,6 +108,13 @@ fun EditorScreen(vm: DocViewModel, snackbar: SnackbarHostState) { } } + LaunchedEffect(vm.screen, vm.autoCropNextPage, page?.id) { + if (vm.screen == com.docuscan.app.Screen.Editor && vm.autoCropNextPage && page != null) { + cropMode = true + vm.autoCropNextPage = false + } + } + if (page == null) { LaunchedEffect(Unit) { vm.selectTab(com.docuscan.app.Tab.Home) } return @@ -174,11 +184,32 @@ fun EditorScreen(vm: DocViewModel, snackbar: SnackbarHostState) { text = { Column { Text("Scan another page with the camera, or import it from your gallery.") + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Use camera automatically for this session", + Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = rememberCameraForSession, + onCheckedChange = { rememberCameraForSession = it } + ) + } + Text( + "Until you close DocuScan.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } }, confirmButton = { Row { - TextButton(onClick = { addDialog = false; vm.openCamera(fromEditor = true) }) { + TextButton(onClick = { + addDialog = false + vm.setCameraPreferredForSession(rememberCameraForSession) + vm.openCamera(fromEditor = true, autoCrop = true) + }) { Icon(AppIcons.Camera, contentDescription = null) Spacer(Modifier.width(6.dp)) Text("Camera") @@ -422,7 +453,13 @@ fun EditorScreen(vm: DocViewModel, snackbar: SnackbarHostState) { horizontalArrangement = Arrangement.spacedBy(10.dp) ) { item { - AddPageTile { addDialog = true } + AddPageTile { + if (vm.cameraPreferredForSession) { + vm.openCamera(fromEditor = true, autoCrop = true) + } else { + addDialog = true + } + } } itemsIndexed(vm.pages, key = { _, p -> p.id }) { index, p -> val selected = index == vm.selectedPage From 3675df5b83e20b3d8a35aca1acfcbfa64c603f18 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:16:48 +0530 Subject: [PATCH 12/33] Improve multi-pass document corner detection --- .../java/com/docuscan/app/scan/Cleanup.kt | 150 ++++++++++++------ 1 file changed, 100 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt index 602e039..d46ccab 100644 --- a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt +++ b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt @@ -27,7 +27,7 @@ object Cleanup { const val KERNEL_FRACTION_BW = 0.08 const val KERNEL_FRACTION_OCR = 0.03 - private const val DETECTION_MAX_EDGE = 720 + private const val DETECTION_MAX_EDGE = 960 @Volatile private var loaded = false @@ -294,6 +294,7 @@ object Cleanup { val edgesAlt = Mat() val threshold = Mat() val thresholdInv = Mat() + val closed = Mat() try { Utils.bitmapToMat(bitmap, rgba) @@ -304,8 +305,8 @@ object Cleanup { val meanValue = Core.mean(gray).`val`[0] Imgproc.Canny( gray, edges, - max(10.0, 0.66 * meanValue), - min(255.0, max(40.0, 1.33 * meanValue)) + max(8.0, 0.55 * meanValue), + min(255.0, max(45.0, 1.45 * meanValue)) ) val adaptive = Mat() @@ -313,91 +314,96 @@ object Cleanup { Core.bitwise_or(edges, adaptive, edgesAlt) adaptive.release() - Imgproc.threshold( - normalized, threshold, 0.0, 255.0, - Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU - ) - Imgproc.threshold( - normalized, thresholdInv, 0.0, 255.0, - Imgproc.THRESH_BINARY_INV + Imgproc.THRESH_OTSU - ) + Imgproc.threshold(normalized, threshold, 0.0, 255.0, + Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU) + Imgproc.threshold(normalized, thresholdInv, 0.0, 255.0, + Imgproc.THRESH_BINARY_INV + Imgproc.THRESH_OTSU) - val shortSide = min(rgba.width(), rgba.height()) - var kernelSize = max(3, shortSide / 70) - if (kernelSize % 2 == 0) kernelSize++ + val k = max(5, min(rgba.width(), rgba.height()) / 45) + val kernelSize = if (k % 2 == 0) k + 1 else k val kernel = Imgproc.getStructuringElement( Imgproc.MORPH_RECT, Size(kernelSize.toDouble(), kernelSize.toDouble()) ) + Imgproc.morphologyEx(edgesAlt, closed, Imgproc.MORPH_CLOSE, kernel) Imgproc.morphologyEx(threshold, threshold, Imgproc.MORPH_CLOSE, kernel) Imgproc.morphologyEx(thresholdInv, thresholdInv, Imgproc.MORPH_CLOSE, kernel) kernel.release() val binaryEdges = Mat() val binaryEdgesInv = Mat() - Imgproc.Canny(threshold, binaryEdges, 50.0, 150.0) - Imgproc.Canny(thresholdInv, binaryEdgesInv, 50.0, 150.0) + Imgproc.Canny(threshold, binaryEdges, 30.0, 120.0) + Imgproc.Canny(thresholdInv, binaryEdgesInv, 30.0, 120.0) - val maps = listOf(edges, edgesAlt, binaryEdges, binaryEdgesInv) + val maps = listOf(edges, edgesAlt, closed, binaryEdges, binaryEdgesInv) val imgArea = rgba.width() * rgba.height().toDouble() var bestScore = -1.0 var bestQuad: Array? = null for (edgeMap in maps) { - val localContours = mutableListOf() + val contours = mutableListOf() val hierarchy = Mat() try { - Imgproc.findContours(edgeMap, localContours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE) - for (contour in localContours) { + Imgproc.findContours(edgeMap, contours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE) + for (contour in contours) { val area = Imgproc.contourArea(contour) - if (area < imgArea * 0.025) continue + if (area < imgArea * 0.04) continue val curve = MatOfPoint2f(*contour.toArray()) - val approx = MatOfPoint2f() try { val perimeter = Imgproc.arcLength(curve, true) - Imgproc.approxPolyDP(curve, approx, perimeter * 0.018, true) - if (approx.total() != 4L) continue - - val approxPoints = approx.toArray() - val approxAsPoints = MatOfPoint(*approxPoints) - val convex = Imgproc.isContourConvex(approxAsPoints) - approxAsPoints.release() - if (!convex) continue - - val quad = sortPointsRobust(approxPoints) - if (!quadIsUsable(quad, rgba.width(), rgba.height())) continue - - val quadAreaValue = quadArea(quad) - val areaNorm = (quadAreaValue / imgArea).coerceIn(0.0, 1.0) - val rectangularity = rectangularityScore(quad) - val edgeContact = edgeContactScore(quad, rgba.width(), rgba.height()) - val score = areaNorm * 0.55 + rectangularity * 0.30 + edgeContact * 0.15 - - if (score > bestScore) { - bestScore = score - bestQuad = quad + for (epsilon in doubleArrayOf(0.010, 0.015, 0.022, 0.030)) { + val approx = MatOfPoint2f() + try { + Imgproc.approxPolyDP(curve, approx, perimeter * epsilon, true) + if (approx.total() != 4L) continue + + val ap = approx.toArray() + val convexMat = MatOfPoint(*ap) + val convex = Imgproc.isContourConvex(convexMat) + convexMat.release() + if (!convex) continue + + val quad = sortPointsRobust(ap) + if (!quadIsUsable(quad, rgba.width(), rgba.height())) continue + + val areaNorm = (quadArea(quad) / imgArea).coerceIn(0.0, 1.0) + val rectangularity = rectangularityScore(quad) + val edgeSupport = quadEdgeSupport(quad, edgeMap) + val centerScore = quadCenterScore(quad, rgba.width(), rgba.height()) + + val score = + areaNorm * 0.35 + + rectangularity * 0.25 + + edgeSupport * 0.30 + + centerScore * 0.10 + + if (score > bestScore) { + bestScore = score + bestQuad = quad + } + } finally { + approx.release() + } } } finally { curve.release() - approx.release() } } } finally { - localContours.forEach { it.release() } + contours.forEach { it.release() } hierarchy.release() } } - if (bestQuad != null && bestScore >= 0.20) { + if (bestQuad != null && bestScore >= 0.34) { return bestQuad!!.map { PointF(it.x.toFloat(), it.y.toFloat()) } } - val houghQuad = detectQuadFromHoughLines(edgesAlt, rgba.width(), rgba.height()) - if (houghQuad != null) { - return houghQuad.map { PointF(it.x.toFloat(), it.y.toFloat()) } + for (map in listOf(edgesAlt, closed, edges)) { + val q = detectQuadFromHoughLines(map, rgba.width(), rgba.height()) + if (q != null) return q.map { PointF(it.x.toFloat(), it.y.toFloat()) } } - return null } finally { rgba.release() @@ -407,9 +413,53 @@ object Cleanup { edgesAlt.release() threshold.release() thresholdInv.release() + closed.release() } } + private fun quadEdgeSupport(q: Array, edges: Mat): Double { + if (q.size != 4 || edges.empty()) return 0.0 + var total = 0 + var hit = 0 + val w = edges.cols() + val h = edges.rows() + for (i in 0..3) { + val a = q[i] + val b = q[(i + 1) % 4] + val len = distance(a, b) + val samples = max(24, min(160, (len / 4.0).toInt())) + for (s in 0..samples) { + val t = s.toDouble() / samples.toDouble() + val x = (a.x + (b.x - a.x) * t).toInt() + val y = (a.y + (b.y - a.y) * t).toInt() + var supported = false + for (dy in -2..2) { + for (dx in -2..2) { + val xx = (x + dx).coerceIn(0, w - 1) + val yy = (y + dy).coerceIn(0, h - 1) + val pixel = edges.get(yy, xx) + if (pixel != null && pixel.isNotEmpty() && pixel[0] > 0.0) { + supported = true + break + } + } + if (supported) break + } + total++ + if (supported) hit++ + } + } + return if (total == 0) 0.0 else (hit.toDouble() / total).coerceIn(0.0, 1.0) + } + + private fun quadCenterScore(q: Array, width: Int, height: Int): Double { + val cx = q.map { it.x }.average() + val cy = q.map { it.y }.average() + val dx = abs(cx - width / 2.0) / (width / 2.0) + val dy = abs(cy - height / 2.0) / (height / 2.0) + return (1.0 - ((dx + dy) / 2.0)).coerceIn(0.0, 1.0) + } + private fun quadIsUsable(q: Array, width: Int, height: Int): Boolean { if (q.size != 4) return false val imageArea = width.toDouble() * height.toDouble() From 72990aa6b75c940dc4d0b8697b3f8a6a75ebeaac Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:16:56 +0530 Subject: [PATCH 13/33] Add auto crop state consumer --- app/src/main/java/com/docuscan/app/DocViewModel.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/docuscan/app/DocViewModel.kt b/app/src/main/java/com/docuscan/app/DocViewModel.kt index d3ff56b..844ec61 100644 --- a/app/src/main/java/com/docuscan/app/DocViewModel.kt +++ b/app/src/main/java/com/docuscan/app/DocViewModel.kt @@ -108,6 +108,10 @@ class DocViewModel(app: Application) : AndroidViewModel(app) { cameraPreferredForSession = enabled } + fun consumeAutoCropNextPage() { + autoCropNextPage = false + } + fun closeCamera() { screen = if (returnToEditor) Screen.Editor else Screen.Tabs } From a279bc4bb9b47d0f29c89c78cc0bcc46d5ca6c5c Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:17:00 +0530 Subject: [PATCH 14/33] Use auto crop state consumer --- app/src/main/java/com/docuscan/app/ui/EditorScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt b/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt index e6a8bbd..2465a1b 100644 --- a/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt +++ b/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt @@ -111,7 +111,7 @@ fun EditorScreen(vm: DocViewModel, snackbar: SnackbarHostState) { LaunchedEffect(vm.screen, vm.autoCropNextPage, page?.id) { if (vm.screen == com.docuscan.app.Screen.Editor && vm.autoCropNextPage && page != null) { cropMode = true - vm.autoCropNextPage = false + vm.consumeAutoCropNextPage() } } From 5f2b4291784b0cffef30a6b43ca01091cc0e320c Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:27:52 +0530 Subject: [PATCH 15/33] Fix camera session preference JVM setter clash --- app/src/main/java/com/docuscan/app/DocViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/docuscan/app/DocViewModel.kt b/app/src/main/java/com/docuscan/app/DocViewModel.kt index 844ec61..c352d5b 100644 --- a/app/src/main/java/com/docuscan/app/DocViewModel.kt +++ b/app/src/main/java/com/docuscan/app/DocViewModel.kt @@ -104,7 +104,7 @@ class DocViewModel(app: Application) : AndroidViewModel(app) { screen = Screen.Camera } - fun setCameraPreferredForSession(enabled: Boolean) { + fun updateCameraPreferredForSession(enabled: Boolean) { cameraPreferredForSession = enabled } From d80b54573c9e4e7479c05347657fe33e4744d711 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:27:54 +0530 Subject: [PATCH 16/33] Update camera preference setter call --- app/src/main/java/com/docuscan/app/ui/EditorScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt b/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt index 2465a1b..f2dbbb4 100644 --- a/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt +++ b/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt @@ -207,7 +207,7 @@ fun EditorScreen(vm: DocViewModel, snackbar: SnackbarHostState) { Row { TextButton(onClick = { addDialog = false - vm.setCameraPreferredForSession(rememberCameraForSession) + vm.updateCameraPreferredForSession(rememberCameraForSession) vm.openCamera(fromEditor = true, autoCrop = true) }) { Icon(AppIcons.Camera, contentDescription = null) From e75bfbe6373821bce0e847e0211d54b5ee159466 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:42:13 +0530 Subject: [PATCH 17/33] Improve corner refinement for guided crop correction --- .../java/com/docuscan/app/scan/Cleanup.kt | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt index d46ccab..5aa2a71 100644 --- a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt +++ b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt @@ -643,6 +643,110 @@ object Cleanup { private fun distance(a: Point, b: Point): Double = Math.hypot(a.x - b.x, a.y - b.y) + /** + * Refines one document corner from a user-provided point. + * + * The user can tap close to the real paper corner even when the automatic + * quadrilateral is slightly wrong. We inspect a local ROI, find strong + * Harris/Shi-Tomasi corners, sub-pixel refine them, and choose the candidate + * that balances proximity to the tap with local corner strength. + */ + fun refineCornerNear(src: Bitmap, tapX: Float, tapY: Float, radiusPx: Float = 180f): PointF? { + if (!ensureLoaded()) return null + if (tapX !in 0f..src.width.toFloat() || tapY !in 0f..src.height.toFloat()) return null + + val rgba = Mat() + val gray = Mat() + try { + Utils.bitmapToMat(src, rgba) + Imgproc.cvtColor(rgba, gray, Imgproc.COLOR_RGBA2GRAY) + + val radius = radiusPx.coerceIn(48f, 320f) + val left = max(0, (tapX - radius).toInt()) + val top = max(0, (tapY - radius).toInt()) + val right = min(gray.cols(), (tapX + radius).toInt() + 1) + val bottom = min(gray.rows(), (tapY + radius).toInt() + 1) + if (right - left < 24 || bottom - top < 24) return null + + val roi = gray.submat(top, bottom, left, right) + val smooth = Mat() + val corners = MatOfPoint() + val refined = MatOfPoint2f() + try { + Imgproc.GaussianBlur(roi, smooth, Size(3.0, 3.0), 0.0) + Imgproc.goodFeaturesToTrack( + smooth, + corners, + 32, + 0.005, + 8.0, + Mat(), + 7, + true, + 0.04 + ) + if (corners.empty()) return null + + val pts = corners.toArray() + val local2f = MatOfPoint2f(*pts.map { Point(it.x, it.y) }.toTypedArray()) + try { + Imgproc.cornerSubPix( + smooth, + local2f, + Size(5.0, 5.0), + Size(-1.0, -1.0), + org.opencv.core.TermCriteria( + org.opencv.core.TermCriteria.EPS + org.opencv.core.TermCriteria.MAX_ITER, + 30, + 0.01 + ) + ) + val refinedPts = local2f.toArray() + if (refinedPts.isEmpty()) return null + + var best: Point? = null + var bestScore = Double.POSITIVE_INFINITY + val tapLocalX = tapX - left + val tapLocalY = tapY - top + + for (p in refinedPts) { + val dx = p.x - tapLocalX + val dy = p.y - tapLocalY + val dist = Math.hypot(dx, dy) + if (dist > radius * 0.95) continue + + // Prefer points close to the user's tap. A small gradient + // check rejects many text/texture corners. + val ix = p.x.toInt().coerceIn(2, smooth.cols() - 3) + val iy = p.y.toInt().coerceIn(2, smooth.rows() - 3) + val gx = abs(smooth.get(iy, ix + 2)[0] - smooth.get(iy, ix - 2)[0]) + val gy = abs(smooth.get(iy + 2, ix)[0] - smooth.get(iy - 2, ix)[0]) + val strength = gx + gy + val score = dist - min(80.0, strength * 0.12) + if (score < bestScore) { + bestScore = score + best = Point(p.x + left, p.y + top) + } + } + + return best?.let { PointF(it.x.toFloat(), it.y.toFloat()) } + } finally { + local2f.release() + } + } finally { + roi.release() + smooth.release() + corners.release() + refined.release() + } + } catch (_: Throwable) { + return null + } finally { + rgba.release() + gray.release() + } + } + private fun isLowLight(rgba: Mat): Boolean { val gray = Mat() try { From c638396c5e0dd0ae6fab06fe86cc8afb3222c538 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:42:16 +0530 Subject: [PATCH 18/33] Add tap-guided corner correction --- .../java/com/docuscan/app/ui/CropOverlay.kt | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index 482ae3b..d34ae75 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -107,6 +107,7 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) var customRatioText by remember { mutableStateOf("1.4142") } var customDialog by remember { mutableStateOf(false) } var detecting by remember { mutableStateOf(false) } + var refiningCorner by remember { mutableStateOf(false) } var hint by remember { mutableStateOf(null) } val snapActive = remember { mutableStateListOf(false, false, false, false) } var snapHighlight by remember { mutableIntStateOf(-1) } @@ -186,6 +187,44 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) return true } + + suspend fun refineCornerAtViewPoint(viewX: Float, viewY: Float): Boolean { + val f = fit + if (f.width <= 1f || f.height <= 1f) return false + + // Convert the tap from the displayed image into original bitmap pixels. + val imageX = ((viewX - f.left) / f.width * rotBitmap.width) + .coerceIn(0f, rotBitmap.width.toFloat()) + val imageY = ((viewY - f.top) / f.height * rotBitmap.height) + .coerceIn(0f, rotBitmap.height.toFloat()) + + refiningCorner = true + val refined = withContext(Dispatchers.Default) { + runCatching { Cleanup.refineCornerNear(rotBitmap, imageX, imageY) }.getOrNull() + } + refiningCorner = false + + if (refined == null) return false + + // The tapped point identifies which crop corner the user wants to fix: + // use the nearest current crop corner, rather than requiring the overlay + // handle itself to be under the finger. + var target = 0 + var best = Float.POSITIVE_INFINITY + for (i in 0..3) { + val d = (corner(i) - Offset(viewX, viewY)).getDistance() + if (d < best) { + best = d + target = i + } + } + + norm[target * 2] = (refined.x / rotBitmap.width).coerceIn(0f, 1f) + norm[target * 2 + 1] = (refined.y / rotBitmap.height).coerceIn(0f, 1f) + hint = "Corner refined — tap another corner if needed, or drag to fine-tune." + return true + } + fun selectPreset(r: CropAspectRatio) { when (r) { CropAspectRatio.CUSTOM -> { customDialog = true; return } @@ -317,7 +356,18 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) dragCorner = selectedCorner + // A simple tap anywhere on the image is also meaningful: + // it becomes a "guided corner correction". This lets the user + // tap the real paper corner even when auto-detection is off by + // several dozen pixels. + // + // If the tap is on an existing handle/edge, dragging still has + // priority and behaves exactly as before. if (selectedCorner < 0 && selectedEdge < 0) { + val firstDrag = awaitDragOrCancellation(down.id) + if (firstDrag == null) { + scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } + } return@awaitEachGesture } @@ -454,7 +504,7 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) } } - if (detecting) { + if (detecting || refiningCorner) { Surface( modifier = Modifier.align(Alignment.Center), shape = RoundedCornerShape(16.dp), @@ -467,7 +517,7 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) ) { CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White) Spacer(Modifier.width(8.dp)) - Text("Detecting edges…", style = MaterialTheme.typography.labelMedium) + Text(if (detecting) "Detecting edges…" else "Refining corner…", style = MaterialTheme.typography.labelMedium) } } } From fd62d648ebf6c4807c80569b93018cb51c880a33 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:46:01 +0530 Subject: [PATCH 19/33] Strengthen document quadrilateral scoring --- .../java/com/docuscan/app/scan/Cleanup.kt | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt index 5aa2a71..3da07be 100644 --- a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt +++ b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt @@ -371,12 +371,20 @@ object Cleanup { val rectangularity = rectangularityScore(quad) val edgeSupport = quadEdgeSupport(quad, edgeMap) val centerScore = quadCenterScore(quad, rgba.width(), rgba.height()) + val parallelism = quadParallelismScore(quad) + val frameContact = edgeContactScore(quad, rgba.width(), rgba.height()) + // A real sheet normally has strong continuous edges, + // roughly parallel opposite sides, a large area, and + // a sensible relationship to the image frame. This + // keeps text boxes/table cells from winning over the page. val score = - areaNorm * 0.35 + - rectangularity * 0.25 + + areaNorm * 0.30 + + rectangularity * 0.20 + edgeSupport * 0.30 + - centerScore * 0.10 + parallelism * 0.10 + + frameContact * 0.05 + + centerScore * 0.05 if (score > bestScore) { bestScore = score @@ -487,6 +495,27 @@ object Cleanup { return (total / 4.0).coerceIn(0.0, 1.0) } + + private fun quadParallelismScore(q: Array): Double { + if (q.size != 4) return 0.0 + + fun lineAngle(a: Point, b: Point): Double { + var deg = Math.toDegrees(atan2(b.y - a.y, b.x - a.x)) + deg = ((deg % 180.0) + 180.0) % 180.0 + return deg + } + + fun parallelPairScore(a: Point, b: Point, c: Point, d: Point): Double { + var delta = abs(lineAngle(a, b) - lineAngle(c, d)) + if (delta > 90.0) delta = 180.0 - delta + return (1.0 - delta / 45.0).coerceIn(0.0, 1.0) + } + + val topBottom = parallelPairScore(q[0], q[1], q[2], q[3]) + val rightLeft = parallelPairScore(q[1], q[2], q[3], q[0]) + return ((topBottom + rightLeft) * 0.5).coerceIn(0.0, 1.0) + } + private fun edgeContactScore(q: Array, width: Int, height: Int): Double { val toleranceX = width * 0.08 val toleranceY = height * 0.08 From 7ba66f34e88dea32b5af4f8db3e7f8e650ded79a Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:55:40 +0530 Subject: [PATCH 20/33] Improve document detection and guided corner recovery --- .../java/com/docuscan/app/scan/Cleanup.kt | 195 ++++++++++-------- 1 file changed, 114 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt index 3da07be..32d966e 100644 --- a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt +++ b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt @@ -344,7 +344,7 @@ object Cleanup { val contours = mutableListOf() val hierarchy = Mat() try { - Imgproc.findContours(edgeMap, contours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE) + Imgproc.findContours(edgeMap, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) for (contour in contours) { val area = Imgproc.contourArea(contour) if (area < imgArea * 0.04) continue @@ -374,17 +374,18 @@ object Cleanup { val parallelism = quadParallelismScore(quad) val frameContact = edgeContactScore(quad, rgba.width(), rgba.height()) - // A real sheet normally has strong continuous edges, - // roughly parallel opposite sides, a large area, and - // a sensible relationship to the image frame. This - // keeps text boxes/table cells from winning over the page. + // Reject weak/partial contours. A page boundary should + // have usable support on all four sides, not just a long + // top/left edge. + if (edgeSupport < 0.30 || parallelism < 0.45) continue + val score = - areaNorm * 0.30 + - rectangularity * 0.20 + + areaNorm * 0.34 + + rectangularity * 0.18 + edgeSupport * 0.30 + parallelism * 0.10 + frameContact * 0.05 + - centerScore * 0.05 + centerScore * 0.03 if (score > bestScore) { bestScore = score @@ -680,99 +681,131 @@ object Cleanup { * Harris/Shi-Tomasi corners, sub-pixel refine them, and choose the candidate * that balances proximity to the tap with local corner strength. */ - fun refineCornerNear(src: Bitmap, tapX: Float, tapY: Float, radiusPx: Float = 180f): PointF? { + /** + * Finds the page corner nearest a user-guided point. Unlike plain feature + * detection, this combines local Canny/Hough line intersections with + * sub-pixel corners, so a weak paper corner can still be recovered. + */ + fun refineCornerNear(src: Bitmap, tapX: Float, tapY: Float, radiusPx: Float = 260f): PointF? { if (!ensureLoaded()) return null if (tapX !in 0f..src.width.toFloat() || tapY !in 0f..src.height.toFloat()) return null val rgba = Mat() val gray = Mat() + val roi = Mat() + val edges = Mat() + val lines = Mat() try { Utils.bitmapToMat(src, rgba) Imgproc.cvtColor(rgba, gray, Imgproc.COLOR_RGBA2GRAY) - val radius = radiusPx.coerceIn(48f, 320f) + val radius = radiusPx.coerceIn(80f, 420f) val left = max(0, (tapX - radius).toInt()) val top = max(0, (tapY - radius).toInt()) val right = min(gray.cols(), (tapX + radius).toInt() + 1) val bottom = min(gray.rows(), (tapY + radius).toInt() + 1) - if (right - left < 24 || bottom - top < 24) return null - - val roi = gray.submat(top, bottom, left, right) - val smooth = Mat() - val corners = MatOfPoint() - val refined = MatOfPoint2f() - try { - Imgproc.GaussianBlur(roi, smooth, Size(3.0, 3.0), 0.0) - Imgproc.goodFeaturesToTrack( - smooth, - corners, - 32, - 0.005, - 8.0, - Mat(), - 7, - true, - 0.04 - ) - if (corners.empty()) return null + if (right - left < 48 || bottom - top < 48) return null - val pts = corners.toArray() - val local2f = MatOfPoint2f(*pts.map { Point(it.x, it.y) }.toTypedArray()) - try { - Imgproc.cornerSubPix( - smooth, - local2f, - Size(5.0, 5.0), - Size(-1.0, -1.0), - org.opencv.core.TermCriteria( - org.opencv.core.TermCriteria.EPS + org.opencv.core.TermCriteria.MAX_ITER, - 30, - 0.01 - ) - ) - val refinedPts = local2f.toArray() - if (refinedPts.isEmpty()) return null - - var best: Point? = null - var bestScore = Double.POSITIVE_INFINITY - val tapLocalX = tapX - left - val tapLocalY = tapY - top - - for (p in refinedPts) { - val dx = p.x - tapLocalX - val dy = p.y - tapLocalY - val dist = Math.hypot(dx, dy) - if (dist > radius * 0.95) continue - - // Prefer points close to the user's tap. A small gradient - // check rejects many text/texture corners. - val ix = p.x.toInt().coerceIn(2, smooth.cols() - 3) - val iy = p.y.toInt().coerceIn(2, smooth.rows() - 3) - val gx = abs(smooth.get(iy, ix + 2)[0] - smooth.get(iy, ix - 2)[0]) - val gy = abs(smooth.get(iy + 2, ix)[0] - smooth.get(iy - 2, ix)[0]) - val strength = gx + gy - val score = dist - min(80.0, strength * 0.12) - if (score < bestScore) { - bestScore = score - best = Point(p.x + left, p.y + top) - } - } + gray.submat(top, bottom, left, right).copyTo(roi) + Imgproc.GaussianBlur(roi, roi, Size(3.0, 3.0), 0.0) + Imgproc.Canny(roi, edges, 35.0, 120.0) - return best?.let { PointF(it.x.toFloat(), it.y.toFloat()) } - } finally { - local2f.release() + // Close tiny gaps in the paper boundary before Hough detection. + val k = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0)) + Imgproc.morphologyEx(edges, edges, Imgproc.MORPH_CLOSE, k) + k.release() + + val minLine = max(18, min(roi.cols(), roi.rows()) / 7) + Imgproc.HoughLinesP( + edges, lines, 1.0, Math.PI / 180.0, + max(18, minLine / 2), minLine.toDouble(), 12.0 + ) + + data class L(val x1: Double, val y1: Double, val x2: Double, val y2: Double, val angle: Double, val len: Double) + val hs = mutableListOf() + val vs = mutableListOf() + for (i in 0 until lines.rows()) { + val v = lines.get(i, 0) + val dx = v[2] - v[0] + val dy = v[3] - v[1] + val len = Math.hypot(dx, dy) + if (len < minLine) continue + var a = Math.toDegrees(atan2(dy, dx)) + a = ((a % 180.0) + 180.0) % 180.0 + val l = L(v[0], v[1], v[2], v[3], a, len) + when { + a < 28.0 || a > 152.0 -> hs.add(l) + a > 62.0 && a < 118.0 -> vs.add(l) + } + } + + fun intersect(a: L, b: L): Point? { + val den = (a.x1-a.x2)*(b.y1-b.y2) - (a.y1-a.y2)*(b.x1-b.x2) + if (abs(den) < 1e-8) return null + val t = ((a.x1-b.x1)*(b.y1-b.y2) - (a.y1-b.y1)*(b.x1-b.x2)) / den + return Point(a.x1+t*(a.x2-a.x1), a.y1+t*(a.y2-a.y1)) + } + + var best: Point? = null + var bestScore = Double.POSITIVE_INFINITY + val tapLocalX = tapX - left + val tapLocalY = tapY - top + + // Prefer intersections close to the tap, but only when both lines + // extend toward the intersection. This avoids unrelated text boxes. + for (h in hs) for (v in vs) { + val p = intersect(h, v) ?: continue + if (p.x < -24 || p.x > roi.cols()+24 || p.y < -24 || p.y > roi.rows()+24) continue + val dist = Math.hypot(p.x - tapLocalX, p.y - tapLocalY) + if (dist > radius * 0.72) continue + + fun extensionPenalty(l: L): Double { + fun d(x: Double, y: Double): Double { + val cross = abs((x-l.x1)*(l.y2-l.y1) - (y-l.y1)*(l.x2-l.x1)) + return cross / (l.len + 1e-6) + } + return min(80.0, d(p.x,p.y)) + } + val score = dist + extensionPenalty(h) + extensionPenalty(v) + - min(50.0, (h.len + v.len) * 0.08) + if (score < bestScore) { + bestScore = score + best = Point(p.x + left, p.y + top) } - } finally { - roi.release() - smooth.release() - corners.release() - refined.release() } + + // Fallback: Shi-Tomasi + sub-pixel refinement. + if (best == null) { + val corners = MatOfPoint() + try { + Imgproc.goodFeaturesToTrack(roi, corners, 64, 0.003, 6.0, Mat(), 7, true, 0.04) + if (!corners.empty()) { + val p2 = MatOfPoint2f(*corners.toArray().map { Point(it.x,it.y) }.toTypedArray()) + try { + Imgproc.cornerSubPix( + roi, p2, Size(5.0,5.0), Size(-1.0,-1.0), + org.opencv.core.TermCriteria( + org.opencv.core.TermCriteria.EPS + org.opencv.core.TermCriteria.MAX_ITER, + 40, 0.01 + ) + ) + for (p in p2.toArray()) { + val d = Math.hypot(p.x-tapLocalX, p.y-tapLocalY) + if (d < bestScore && d <= radius * 0.72) { + bestScore = d + best = Point(p.x+left, p.y+top) + } + } + } finally { p2.release() } + } + } finally { corners.release() } + } + + return best?.let { PointF(it.x.toFloat(), it.y.toFloat()) } } catch (_: Throwable) { return null } finally { - rgba.release() - gray.release() + rgba.release(); gray.release(); roi.release(); edges.release(); lines.release() } } From 36d912bf161625040b8e4685400e805ed8349736 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 02:55:42 +0530 Subject: [PATCH 21/33] Fix guided corner tap selection --- .../java/com/docuscan/app/ui/CropOverlay.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index d34ae75..bff91f5 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -206,17 +206,16 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) if (refined == null) return false - // The tapped point identifies which crop corner the user wants to fix: - // use the nearest current crop corner, rather than requiring the overlay - // handle itself to be under the finger. - var target = 0 - var best = Float.POSITIVE_INFINITY - for (i in 0..3) { - val d = (corner(i) - Offset(viewX, viewY)).getDistance() - if (d < best) { - best = d - target = i - } + // If the auto box is badly displaced, nearest-corner is unreliable. + // Use the tap's position relative to the current crop center to choose + // the intended TL/TR/BR/BL corner. + val centerX = (0..3).map { corner(it).x }.average().toFloat() + val centerY = (0..3).map { corner(it).y }.average().toFloat() + val target = when { + viewX < centerX && viewY < centerY -> 0 + viewX >= centerX && viewY < centerY -> 1 + viewX >= centerX && viewY >= centerY -> 2 + else -> 3 } norm[target * 2] = (refined.x / rotBitmap.width).coerceIn(0f, 1f) @@ -363,9 +362,12 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) // // If the tap is on an existing handle/edge, dragging still has // priority and behaves exactly as before. - if (selectedCorner < 0 && selectedEdge < 0) { + if (selectedCorner < 0) { val firstDrag = awaitDragOrCancellation(down.id) if (firstDrag == null) { + // A tap on an edge or in the image is a guided + // correction. This is intentionally allowed even + // when the tap is inside the existing edge hit area. scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } } return@awaitEachGesture From bde473aa6e46b3dc1b92225ecb078f6b6efb3bba Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 03:25:05 +0530 Subject: [PATCH 22/33] Improve auto crop and guided corner detection --- .../java/com/docuscan/app/scan/Cleanup.kt | 198 ++++++++---------- 1 file changed, 92 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt index 32d966e..2f63cd6 100644 --- a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt +++ b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt @@ -379,13 +379,14 @@ object Cleanup { // top/left edge. if (edgeSupport < 0.30 || parallelism < 0.45) continue + // Prefer a complete, large page over a partial rectangle. val score = - areaNorm * 0.34 + - rectangularity * 0.18 + - edgeSupport * 0.30 + - parallelism * 0.10 + - frameContact * 0.05 + - centerScore * 0.03 + areaNorm * 0.48 + + rectangularity * 0.16 + + edgeSupport * 0.22 + + parallelism * 0.09 + + frameContact * 0.03 + + centerScore * 0.02 if (score > bestScore) { bestScore = score @@ -686,127 +687,112 @@ object Cleanup { * detection, this combines local Canny/Hough line intersections with * sub-pixel corners, so a weak paper corner can still be recovered. */ - fun refineCornerNear(src: Bitmap, tapX: Float, tapY: Float, radiusPx: Float = 260f): PointF? { + fun refineCornerNear(src: Bitmap, tapX: Float, tapY: Float, radiusPx: Float = 520f): PointF? { if (!ensureLoaded()) return null if (tapX !in 0f..src.width.toFloat() || tapY !in 0f..src.height.toFloat()) return null - val rgba = Mat() - val gray = Mat() - val roi = Mat() - val edges = Mat() - val lines = Mat() + val rgba=Mat(); val gray=Mat(); val roi=Mat(); val edges=Mat(); val lines=Mat() try { Utils.bitmapToMat(src, rgba) Imgproc.cvtColor(rgba, gray, Imgproc.COLOR_RGBA2GRAY) - val radius = radiusPx.coerceIn(80f, 420f) - val left = max(0, (tapX - radius).toInt()) - val top = max(0, (tapY - radius).toInt()) - val right = min(gray.cols(), (tapX + radius).toInt() + 1) - val bottom = min(gray.rows(), (tapY + radius).toInt() + 1) - if (right - left < 48 || bottom - top < 48) return null - - gray.submat(top, bottom, left, right).copyTo(roi) - Imgproc.GaussianBlur(roi, roi, Size(3.0, 3.0), 0.0) - Imgproc.Canny(roi, edges, 35.0, 120.0) - - // Close tiny gaps in the paper boundary before Hough detection. - val k = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0)) - Imgproc.morphologyEx(edges, edges, Imgproc.MORPH_CLOSE, k) - k.release() - - val minLine = max(18, min(roi.cols(), roi.rows()) / 7) - Imgproc.HoughLinesP( - edges, lines, 1.0, Math.PI / 180.0, - max(18, minLine / 2), minLine.toDouble(), 12.0 - ) - - data class L(val x1: Double, val y1: Double, val x2: Double, val y2: Double, val angle: Double, val len: Double) - val hs = mutableListOf() - val vs = mutableListOf() - for (i in 0 until lines.rows()) { - val v = lines.get(i, 0) - val dx = v[2] - v[0] - val dy = v[3] - v[1] - val len = Math.hypot(dx, dy) - if (len < minLine) continue - var a = Math.toDegrees(atan2(dy, dx)) - a = ((a % 180.0) + 180.0) % 180.0 - val l = L(v[0], v[1], v[2], v[3], a, len) - when { - a < 28.0 || a > 152.0 -> hs.add(l) - a > 62.0 && a < 118.0 -> vs.add(l) - } + val maxRadius=max(src.width,src.height)*0.32f + val radius=radiusPx.coerceIn(140f,720f).coerceAtMost(maxRadius.coerceAtLeast(140f)) + val left=max(0,(tapX-radius).toInt()) + val top=max(0,(tapY-radius).toInt()) + val right=min(gray.cols(),(tapX+radius).toInt()+1) + val bottom=min(gray.rows(),(tapY+radius).toInt()+1) + if(right-left<48||bottom-top<48)return null + + gray.submat(top,bottom,left,right).copyTo(roi) + Imgproc.GaussianBlur(roi,roi,Size(3.0,3.0),0.0) + + val canny=Mat() + try { + Imgproc.Canny(roi,canny,18.0,75.0) + val adaptive=Mat() + try { + edgesAdaptive(roi,adaptive) + Core.bitwise_or(canny,adaptive,edges) + } finally { adaptive.release() } + } finally { canny.release() } + + val k=Imgproc.getStructuringElement(Imgproc.MORPH_RECT,Size(5.0,5.0)) + Imgproc.morphologyEx(edges,edges,Imgproc.MORPH_CLOSE,k); k.release() + + val minLine=max(16,min(roi.cols(),roi.rows())/12) + Imgproc.HoughLinesP(edges,lines,1.0,Math.PI/180.0,max(10,minLine/2),minLine.toDouble(),14.0) + + data class L(val x1:Double,val y1:Double,val x2:Double,val y2:Double,val angle:Double,val len:Double) + val all=mutableListOf() + for(i in 0 until lines.rows()){ + val v=lines.get(i,0); val dx=v[2]-v[0]; val dy=v[3]-v[1] + val len=Math.hypot(dx,dy); if(len90.0)d=180.0-d; return d + } + fun intersect(a:L,b:L):Point? { + val den=(a.x1-a.x2)*(b.y1-b.y2)-(a.y1-a.y2)*(b.x1-b.x2) + if(abs(den)<1e-8)return null + val t=((a.x1-b.x1)*(b.y1-b.y2)-(a.y1-b.y1)*(b.x1-b.x2))/den + return Point(a.x1+t*(a.x2-a.x1),a.y1+t*(a.y2-a.y1)) + } + fun lineDistance(p:Point,l:L):Double { + val cross=abs((p.x-l.x1)*(l.y2-l.y1)-(p.y-l.y1)*(l.x2-l.x1)) + return cross/(l.len+1e-6) + } + fun extensionPenalty(p:Point,l:L):Double { + val dx=l.x2-l.x1; val dy=l.y2-l.y1; val len2=dx*dx+dy*dy + if(len2<1e-8)return 100.0 + val t=((p.x-l.x1)*dx+(p.y-l.y1)*dy)/len2 + return when { t<0.0->min(100.0,-t*l.len); t>1.0->min(100.0,(t-1.0)*l.len); else->0.0 } } - var best: Point? = null - var bestScore = Double.POSITIVE_INFINITY - val tapLocalX = tapX - left - val tapLocalY = tapY - top - - // Prefer intersections close to the tap, but only when both lines - // extend toward the intersection. This avoids unrelated text boxes. - for (h in hs) for (v in vs) { - val p = intersect(h, v) ?: continue - if (p.x < -24 || p.x > roi.cols()+24 || p.y < -24 || p.y > roi.rows()+24) continue - val dist = Math.hypot(p.x - tapLocalX, p.y - tapLocalY) - if (dist > radius * 0.72) continue - - fun extensionPenalty(l: L): Double { - fun d(x: Double, y: Double): Double { - val cross = abs((x-l.x1)*(l.y2-l.y1) - (y-l.y1)*(l.x2-l.x1)) - return cross / (l.len + 1e-6) - } - return min(80.0, d(p.x,p.y)) - } - val score = dist + extensionPenalty(h) + extensionPenalty(v) - - min(50.0, (h.len + v.len) * 0.08) - if (score < bestScore) { - bestScore = score - best = Point(p.x + left, p.y + top) + var best:Point?=null; var bestScore=Double.POSITIVE_INFINITY + val tapLocalX=tapX-left; val tapLocalY=tapY-top + + for(i in 0 until all.size) { + val a=all[i] + for(j in i+1 until all.size) { + val b=all[j] + val delta=angleDelta(a.angle,b.angle) + if(delta<50.0||delta>90.0)continue + val p=intersect(a,b)?:continue + if(p.x<-40||p.x>roi.cols()+40||p.y<-40||p.y>roi.rows()+40)continue + val dist=Math.hypot(p.x-tapLocalX,p.y-tapLocalY) + if(dist>radius*0.80)continue + val lineError=lineDistance(p,a)+lineDistance(p,b) + val extension=extensionPenalty(p,a)+extensionPenalty(p,b) + val lengthBonus=min(80.0,(a.len+b.len)*0.10) + val score=dist+lineError*2.0+extension*0.75-lengthBonus + if(score Date: Thu, 24 Sep 2026 03:25:07 +0530 Subject: [PATCH 23/33] Make guided crop correction work from any tap --- .../java/com/docuscan/app/ui/CropOverlay.kt | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index bff91f5..91c1cdf 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -209,12 +209,12 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) // If the auto box is badly displaced, nearest-corner is unreliable. // Use the tap's position relative to the current crop center to choose // the intended TL/TR/BR/BL corner. - val centerX = (0..3).map { corner(it).x }.average().toFloat() - val centerY = (0..3).map { corner(it).y }.average().toFloat() + val imageCenterX = (f.left + f.right) * 0.5f + val imageCenterY = (f.top + f.bottom) * 0.5f val target = when { - viewX < centerX && viewY < centerY -> 0 - viewX >= centerX && viewY < centerY -> 1 - viewX >= centerX && viewY >= centerY -> 2 + viewX < imageCenterX && viewY < imageCenterY -> 0 + viewX >= imageCenterX && viewY < imageCenterY -> 1 + viewX >= imageCenterX && viewY >= imageCenterY -> 2 else -> 3 } @@ -365,18 +365,24 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) if (selectedCorner < 0) { val firstDrag = awaitDragOrCancellation(down.id) if (firstDrag == null) { - // A tap on an edge or in the image is a guided - // correction. This is intentionally allowed even - // when the tap is inside the existing edge hit area. scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } } return@awaitEachGesture } - // Select on finger-down, then use Compose's stable drag API - // for movement. This avoids the unavailable raw pointer-event - // API on this project's Compose version. - val firstDrag = awaitDragOrCancellation(down.id) + // A tap directly on a handle also means "find the real + // corner here"; only a movement starts manual dragging. + val cornerDrag = awaitDragOrCancellation(down.id) + if (cornerDrag == null) { + scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } + dragCorner = -1 + edgeDrag = null + for (i in 0..3) snapActive[i] = false + snapHighlight = -1 + return@awaitEachGesture + } + + val firstDrag = cornerDrag if (firstDrag != null) { fun applyDragPosition(current: Offset) { val ed = edgeDrag From 7c477b9fe056b391ee98cedff41f782c7dc3ec17 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 03:38:49 +0530 Subject: [PATCH 24/33] Fix crop edge dragging and guided tap interaction --- .../java/com/docuscan/app/ui/CropOverlay.kt | 83 +++++++++++++++++-- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index 91c1cdf..af11d9f 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -355,23 +355,53 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) dragCorner = selectedCorner - // A simple tap anywhere on the image is also meaningful: - // it becomes a "guided corner correction". This lets the user - // tap the real paper corner even when auto-detection is off by - // several dozen pixels. - // - // If the tap is on an existing handle/edge, dragging still has - // priority and behaves exactly as before. + // A tap anywhere on the image is a guided correction. + // If it lands on an existing edge, a real drag has priority. + // The old implementation returned immediately after waiting for + // the drag, so edgeDrag was NEVER applied; this is why the + edge + // handles in the screenshots appeared completely unresponsive. + if (selectedCorner < 0 && selectedEdge >= 0) { + val edgeFirstDrag = awaitDragOrCancellation(down.id) + if (edgeFirstDrag == null) { + scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } + } else { + fun applyEdge(current: Offset) { + val ed = edgeDrag ?: return + val res = CropGeometry.applyEdgeTranslation( + ed.xs0, ed.ys0, ed.edgeIndex, + ed.m0x, ed.m0y, ed.nx, ed.ny, + current.x, current.y + ) + if (res.applied) { + for (i in 0..3) setViewCorner(i, res.xs[i], res.ys[i]) + } + } + applyEdge(edgeFirstDrag.position) + edgeFirstDrag.consume() + drag(down.id) { change -> + applyEdge(change.position) + change.consume() + } + } + dragCorner = -1 + edgeDrag = null + for (i in 0..3) snapActive[i] = false + snapHighlight = -1 + return@awaitEachGesture + } + if (selectedCorner < 0) { val firstDrag = awaitDragOrCancellation(down.id) if (firstDrag == null) { scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } } + dragCorner = -1 + edgeDrag = null return@awaitEachGesture } - // A tap directly on a handle also means "find the real - // corner here"; only a movement starts manual dragging. + // A tap directly on a corner handle means "find the real + // corner here"; only movement starts manual corner dragging. val cornerDrag = awaitDragOrCancellation(down.id) if (cornerDrag == null) { scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } @@ -382,6 +412,41 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) return@awaitEachGesture } + fun applyCorner(current: Offset) { + val i = selectedCorner + val newX = current.x.coerceIn(fit.left, fit.right) + val newY = current.y.coerceIn(fit.top, fit.bottom) + val corners = (0..3).map { corner(it).x.toDouble() to corner(it).y.toDouble() } + val res = CropGeometry.snapEvaluate( + corners, i, + newX.toDouble(), newY.toDouble(), + snapActive[(i + 3) % 4], snapActive[i] + ) + setViewCorner(i, res.x.toFloat(), res.y.toFloat()) + snapActive[(i + 3) % 4] = res.prevEdgeSnapped + snapActive[i] = res.nextEdgeSnapped + snapHighlight = when { + res.prevEdgeSnapped -> (i + 3) % 4 + res.nextEdgeSnapped -> i + else -> -1 + } + } + + applyCorner(cornerDrag.position) + cornerDrag.consume() + + drag(down.id) { change -> + applyCorner(change.position) + change.consume() + } + + dragCorner = -1 + edgeDrag = null + for (i in 0..3) snapActive[i] = false + snapHighlight = -1 + return@awaitEachGesture + } + val firstDrag = cornerDrag if (firstDrag != null) { fun applyDragPosition(current: Offset) { From 04d36d12405a96c3d62f7d4163cb9d5e5a449051 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 03:41:25 +0530 Subject: [PATCH 25/33] Fix crop gesture handler after edge-drag update --- .../java/com/docuscan/app/ui/CropOverlay.kt | 112 ++++++------------ 1 file changed, 36 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index af11d9f..8fad58b 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -320,11 +320,12 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) .fillMaxSize() .pointerInput(boxSize, rotBitmap) { awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main) + val down = awaitFirstDown( + requireUnconsumed = false, + pass = PointerEventPass.Main + ) val pos = down.position - // Select the target at finger-down, not after Compose's - // drag detector has already crossed touch-slop. var selectedCorner = -1 var bestCornerDistance = Float.POSITIVE_INFINITY val cornerHitRadius = 48.dp.toPx() @@ -344,25 +345,24 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) selectedEdge = CropGeometry.findEdgeHit(xs, ys, pos.x, pos.y) if (selectedEdge >= 0) { - val a = selectedEdge - val b = (selectedEdge + 1) % 4 - val m0x = (xs[a] + xs[b]) / 2f - val m0y = (ys[a] + ys[b]) / 2f + val aEdge = selectedEdge + val bEdge = (selectedEdge + 1) % 4 + val m0x = (xs[aEdge] + xs[bEdge]) / 2f + val m0y = (ys[aEdge] + ys[bEdge]) / 2f val n = CropGeometry.outwardUnitNormal(xs, ys, selectedEdge) - edgeDrag = EdgeDrag(selectedEdge, xs, ys, m0x, m0y, n[0], n[1]) + edgeDrag = EdgeDrag( + selectedEdge, xs, ys, m0x, m0y, n[0], n[1] + ) } } dragCorner = selectedCorner - // A tap anywhere on the image is a guided correction. - // If it lands on an existing edge, a real drag has priority. - // The old implementation returned immediately after waiting for - // the drag, so edgeDrag was NEVER applied; this is why the + edge - // handles in the screenshots appeared completely unresponsive. + // EDGE: a real drag moves the entire edge; a simple tap + // performs guided correction at the exact tap location. if (selectedCorner < 0 && selectedEdge >= 0) { - val edgeFirstDrag = awaitDragOrCancellation(down.id) - if (edgeFirstDrag == null) { + val firstDrag = awaitDragOrCancellation(down.id) + if (firstDrag == null) { scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } } else { fun applyEdge(current: Offset) { @@ -373,16 +373,21 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) current.x, current.y ) if (res.applied) { - for (i in 0..3) setViewCorner(i, res.xs[i], res.ys[i]) + for (i in 0..3) { + setViewCorner(i, res.xs[i], res.ys[i]) + } } } - applyEdge(edgeFirstDrag.position) - edgeFirstDrag.consume() + + applyEdge(firstDrag.position) + firstDrag.consume() + drag(down.id) { change -> applyEdge(change.position) change.consume() } } + dragCorner = -1 edgeDrag = null for (i in 0..3) snapActive[i] = false @@ -390,20 +395,24 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) return@awaitEachGesture } + // IMAGE: any tap that is not on a corner/edge is a guided + // corner correction. It is intentionally not tied to the + // current crop border. if (selectedCorner < 0) { val firstDrag = awaitDragOrCancellation(down.id) if (firstDrag == null) { scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } } + dragCorner = -1 edgeDrag = null return@awaitEachGesture } - // A tap directly on a corner handle means "find the real - // corner here"; only movement starts manual corner dragging. - val cornerDrag = awaitDragOrCancellation(down.id) - if (cornerDrag == null) { + // CORNER: drag = manual corner movement; tap = guided + // detection at that location. + val firstDrag = awaitDragOrCancellation(down.id) + if (firstDrag == null) { scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } dragCorner = -1 edgeDrag = null @@ -416,7 +425,9 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) val i = selectedCorner val newX = current.x.coerceIn(fit.left, fit.right) val newY = current.y.coerceIn(fit.top, fit.bottom) - val corners = (0..3).map { corner(it).x.toDouble() to corner(it).y.toDouble() } + val corners = (0..3).map { + corner(it).x.toDouble() to corner(it).y.toDouble() + } val res = CropGeometry.snapEvaluate( corners, i, newX.toDouble(), newY.toDouble(), @@ -432,65 +443,14 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) } } - applyCorner(cornerDrag.position) - cornerDrag.consume() + applyCorner(firstDrag.position) + firstDrag.consume() drag(down.id) { change -> applyCorner(change.position) change.consume() } - dragCorner = -1 - edgeDrag = null - for (i in 0..3) snapActive[i] = false - snapHighlight = -1 - return@awaitEachGesture - } - - val firstDrag = cornerDrag - if (firstDrag != null) { - fun applyDragPosition(current: Offset) { - val ed = edgeDrag - - if (ed != null && selectedEdge >= 0) { - val res = CropGeometry.applyEdgeTranslation( - ed.xs0, ed.ys0, ed.edgeIndex, - ed.m0x, ed.m0y, ed.nx, ed.ny, - current.x, current.y - ) - if (res.applied) { - for (i in 0..3) setViewCorner(i, res.xs[i], res.ys[i]) - } - } else if (selectedCorner >= 0) { - val i = selectedCorner - val newX = current.x.coerceIn(fit.left, fit.right) - val newY = current.y.coerceIn(fit.top, fit.bottom) - val corners = (0..3).map { corner(it).x.toDouble() to corner(it).y.toDouble() } - val res = CropGeometry.snapEvaluate( - corners, i, - newX.toDouble(), newY.toDouble(), - snapActive[(i + 3) % 4], snapActive[i] - ) - setViewCorner(i, res.x.toFloat(), res.y.toFloat()) - snapActive[(i + 3) % 4] = res.prevEdgeSnapped - snapActive[i] = res.nextEdgeSnapped - snapHighlight = when { - res.prevEdgeSnapped -> (i + 3) % 4 - res.nextEdgeSnapped -> i - else -> -1 - } - } - } - - applyDragPosition(firstDrag.position) - firstDrag.consume() - - drag(down.id) { change -> - applyDragPosition(change.position) - change.consume() - } - } - dragCorner = -1 edgeDrag = null for (i in 0..3) snapActive[i] = false From 208ab2aaf1077d20dd2bbac3e332b01cc01dc439 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 03:52:47 +0530 Subject: [PATCH 26/33] Make tap move crop corner exactly to tapped page point --- .../java/com/docuscan/app/ui/CropOverlay.kt | 77 +++++++++---------- 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index 8fad58b..18363e7 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -188,42 +188,41 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) } - suspend fun refineCornerAtViewPoint(viewX: Float, viewY: Float): Boolean { + /** + * A tap is an explicit crop-corner placement, not a request to run a + * second edge detector. The user can therefore move a badly detected + * corner by tapping the real page corner even when it is far outside the + * current crop polygon. + * + * The image is divided into four quadrants to decide which corner the + * tap controls. The corner is then placed EXACTLY at the tap location. + * Dragging remains available for pixel-level fine tuning. + */ + fun moveCornerToTap(viewX: Float, viewY: Float) { val f = fit - if (f.width <= 1f || f.height <= 1f) return false + if (f.width <= 1f || f.height <= 1f) return - // Convert the tap from the displayed image into original bitmap pixels. - val imageX = ((viewX - f.left) / f.width * rotBitmap.width) - .coerceIn(0f, rotBitmap.width.toFloat()) - val imageY = ((viewY - f.top) / f.height * rotBitmap.height) - .coerceIn(0f, rotBitmap.height.toFloat()) + val x = viewX.coerceIn(f.left, f.right) + val y = viewY.coerceIn(f.top, f.bottom) - refiningCorner = true - val refined = withContext(Dispatchers.Default) { - runCatching { Cleanup.refineCornerNear(rotBitmap, imageX, imageY) }.getOrNull() - } - refiningCorner = false - - if (refined == null) return false + val centerX = (f.left + f.right) * 0.5f + val centerY = (f.top + f.bottom) * 0.5f - // If the auto box is badly displaced, nearest-corner is unreliable. - // Use the tap's position relative to the current crop center to choose - // the intended TL/TR/BR/BL corner. - val imageCenterX = (f.left + f.right) * 0.5f - val imageCenterY = (f.top + f.bottom) * 0.5f val target = when { - viewX < imageCenterX && viewY < imageCenterY -> 0 - viewX >= imageCenterX && viewY < imageCenterY -> 1 - viewX >= imageCenterX && viewY >= imageCenterY -> 2 - else -> 3 + x < centerX && y < centerY -> 0 // TL + x >= centerX && y < centerY -> 1 // TR + x >= centerX && y >= centerY -> 2 // BR + else -> 3 // BL } - norm[target * 2] = (refined.x / rotBitmap.width).coerceIn(0f, 1f) - norm[target * 2 + 1] = (refined.y / rotBitmap.height).coerceIn(0f, 1f) - hint = "Corner refined — tap another corner if needed, or drag to fine-tune." - return true + setViewCorner(target, x, y) + + for (i in 0..3) snapActive[i] = false + snapHighlight = -1 + hint = "Corner moved — tap another page corner or drag to fine-tune." } + fun selectPreset(r: CropAspectRatio) { when (r) { CropAspectRatio.CUSTOM -> { customDialog = true; return } @@ -358,12 +357,12 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) dragCorner = selectedCorner - // EDGE: a real drag moves the entire edge; a simple tap - // performs guided correction at the exact tap location. + // Existing edge: drag it. A simple tap on an edge is + // still treated as a normal "tap anywhere" placement. if (selectedCorner < 0 && selectedEdge >= 0) { val firstDrag = awaitDragOrCancellation(down.id) if (firstDrag == null) { - scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } + moveCornerToTap(pos.x, pos.y) } else { fun applyEdge(current: Offset) { val ed = edgeDrag ?: return @@ -381,7 +380,6 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) applyEdge(firstDrag.position) firstDrag.consume() - drag(down.id) { change -> applyEdge(change.position) change.consume() @@ -395,25 +393,24 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) return@awaitEachGesture } - // IMAGE: any tap that is not on a corner/edge is a guided - // corner correction. It is intentionally not tied to the - // current crop border. + // Tap anywhere not on an existing handle = move the + // appropriate corner EXACTLY to the tap. No OpenCV + // search is allowed to reject or relocate this tap. if (selectedCorner < 0) { val firstDrag = awaitDragOrCancellation(down.id) if (firstDrag == null) { - scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } + moveCornerToTap(pos.x, pos.y) } - dragCorner = -1 edgeDrag = null return@awaitEachGesture } - // CORNER: drag = manual corner movement; tap = guided - // detection at that location. + // Existing corner: drag = manual movement; tap = move + // that corner exactly to the tapped position. val firstDrag = awaitDragOrCancellation(down.id) if (firstDrag == null) { - scope.launch { refineCornerAtViewPoint(pos.x, pos.y) } + moveCornerToTap(pos.x, pos.y) dragCorner = -1 edgeDrag = null for (i in 0..3) snapActive[i] = false @@ -445,7 +442,6 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) applyCorner(firstDrag.position) firstDrag.consume() - drag(down.id) { change -> applyCorner(change.position) change.consume() @@ -456,6 +452,7 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) for (i in 0..3) snapActive[i] = false snapHighlight = -1 } +} } ) { val f = fit From 4db4551d36bae9b361242bebd6d8d04d3915a447 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 03:56:51 +0530 Subject: [PATCH 27/33] Fix tap-to-place crop corner deterministically --- .../java/com/docuscan/app/ui/CropOverlay.kt | 134 +++++++++--------- 1 file changed, 68 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index 18363e7..cd508ac 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -328,7 +328,6 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) var selectedCorner = -1 var bestCornerDistance = Float.POSITIVE_INFINITY val cornerHitRadius = 48.dp.toPx() - for (i in 0..3) { val distance = (corner(i) - pos).getDistance() if (distance <= cornerHitRadius && distance < bestCornerDistance) { @@ -342,7 +341,6 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) val xs = FloatArray(4) { corner(it).x } val ys = FloatArray(4) { corner(it).y } selectedEdge = CropGeometry.findEdgeHit(xs, ys, pos.x, pos.y) - if (selectedEdge >= 0) { val aEdge = selectedEdge val bEdge = (selectedEdge + 1) % 4 @@ -355,14 +353,72 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) } } - dragCorner = selectedCorner + // A tap is an explicit corner placement. We do not call + // OpenCV here because the requested behavior is that the + // crop corner MUST go to the point the user tapped. + fun placeCornerAtTap() { + val f = fit + if (f.width <= 1f || f.height <= 1f) return + val x = pos.x.coerceIn(f.left, f.right) + val y = pos.y.coerceIn(f.top, f.bottom) + val cx = (f.left + f.right) / 2f + val cy = (f.top + f.bottom) / 2f + + val target = when { + x < cx && y < cy -> 0 + x >= cx && y < cy -> 1 + x >= cx && y >= cy -> 2 + else -> 3 + } + + setViewCorner(target, x, y) + for (i in 0..3) snapActive[i] = false + snapHighlight = -1 + hint = "Corner moved — tap another page corner or drag to fine-tune." + } - // Existing edge: drag it. A simple tap on an edge is - // still treated as a normal "tap anywhere" placement. - if (selectedCorner < 0 && selectedEdge >= 0) { + if (selectedCorner >= 0) { val firstDrag = awaitDragOrCancellation(down.id) if (firstDrag == null) { - moveCornerToTap(pos.x, pos.y) + placeCornerAtTap() + } else { + fun applyCorner(current: Offset) { + val i = selectedCorner + val newX = current.x.coerceIn(fit.left, fit.right) + val newY = current.y.coerceIn(fit.top, fit.bottom) + val corners = (0..3).map { + corner(it).x.toDouble() to corner(it).y.toDouble() + } + val res = CropGeometry.snapEvaluate( + corners, i, + newX.toDouble(), newY.toDouble(), + snapActive[(i + 3) % 4], snapActive[i] + ) + setViewCorner(i, res.x.toFloat(), res.y.toFloat()) + snapActive[(i + 3) % 4] = res.prevEdgeSnapped + snapActive[i] = res.nextEdgeSnapped + snapHighlight = when { + res.prevEdgeSnapped -> (i + 3) % 4 + res.nextEdgeSnapped -> i + else -> -1 + } + } + applyCorner(firstDrag.position) + firstDrag.consume() + drag(down.id) { change -> + applyCorner(change.position) + change.consume() + } + } + dragCorner = -1 + edgeDrag = null + return@awaitEachGesture + } + + if (selectedEdge >= 0) { + val firstDrag = awaitDragOrCancellation(down.id) + if (firstDrag == null) { + placeCornerAtTap() } else { fun applyEdge(current: Offset) { val ed = edgeDrag ?: return @@ -372,12 +428,9 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) current.x, current.y ) if (res.applied) { - for (i in 0..3) { - setViewCorner(i, res.xs[i], res.ys[i]) - } + for (i in 0..3) setViewCorner(i, res.xs[i], res.ys[i]) } } - applyEdge(firstDrag.position) firstDrag.consume() drag(down.id) { change -> @@ -385,74 +438,23 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) change.consume() } } - dragCorner = -1 edgeDrag = null - for (i in 0..3) snapActive[i] = false - snapHighlight = -1 return@awaitEachGesture } - // Tap anywhere not on an existing handle = move the - // appropriate corner EXACTLY to the tap. No OpenCV - // search is allowed to reject or relocate this tap. - if (selectedCorner < 0) { - val firstDrag = awaitDragOrCancellation(down.id) - if (firstDrag == null) { - moveCornerToTap(pos.x, pos.y) - } - dragCorner = -1 - edgeDrag = null - return@awaitEachGesture - } - - // Existing corner: drag = manual movement; tap = move - // that corner exactly to the tapped position. + // Anywhere else in the image: tap moves the appropriate + // corner EXACTLY to the tap. No distance-to-current-corner + // check and no detection failure can block it. val firstDrag = awaitDragOrCancellation(down.id) if (firstDrag == null) { - moveCornerToTap(pos.x, pos.y) - dragCorner = -1 - edgeDrag = null - for (i in 0..3) snapActive[i] = false - snapHighlight = -1 - return@awaitEachGesture - } - - fun applyCorner(current: Offset) { - val i = selectedCorner - val newX = current.x.coerceIn(fit.left, fit.right) - val newY = current.y.coerceIn(fit.top, fit.bottom) - val corners = (0..3).map { - corner(it).x.toDouble() to corner(it).y.toDouble() - } - val res = CropGeometry.snapEvaluate( - corners, i, - newX.toDouble(), newY.toDouble(), - snapActive[(i + 3) % 4], snapActive[i] - ) - setViewCorner(i, res.x.toFloat(), res.y.toFloat()) - snapActive[(i + 3) % 4] = res.prevEdgeSnapped - snapActive[i] = res.nextEdgeSnapped - snapHighlight = when { - res.prevEdgeSnapped -> (i + 3) % 4 - res.nextEdgeSnapped -> i - else -> -1 - } - } - - applyCorner(firstDrag.position) - firstDrag.consume() - drag(down.id) { change -> - applyCorner(change.position) - change.consume() + placeCornerAtTap() } - dragCorner = -1 edgeDrag = null for (i in 0..3) snapActive[i] = false snapHighlight = -1 } -} } ) { val f = fit From ec9409a17a9cd3746a70cf2fc818fbfc62ae025c Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 04:12:37 +0530 Subject: [PATCH 28/33] Handle back inside crop editor --- app/src/main/java/com/docuscan/app/ui/EditorScreen.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt b/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt index f2dbbb4..2ce737f 100644 --- a/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt +++ b/app/src/main/java/com/docuscan/app/ui/EditorScreen.kt @@ -1,5 +1,6 @@ package com.docuscan.app.ui +import androidx.activity.compose.BackHandler import android.graphics.Bitmap import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest @@ -165,6 +166,12 @@ fun EditorScreen(vm: DocViewModel, snackbar: SnackbarHostState) { } } + // Back while the crop editor is open should close only the crop editor. + // Do not let App's Editor-level BackHandler send the user all the way Home. + BackHandler(enabled = cropMode) { + cropMode = false + } + if (cropMode) { CropOverlay( bitmap = page.bitmap, From ea44ff82523f40d35fb63c98ac06c967a289f47c Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 04:12:41 +0530 Subject: [PATCH 29/33] Keep crop overlay synchronized live with corner changes --- app/src/main/java/com/docuscan/app/ui/CropOverlay.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index cd508ac..6b3a4a7 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -102,6 +102,8 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) // Normalized corners (0..1) inside the fit rect: TL, TR, BR, BL val norm = remember { mutableStateListOf(0.02f, 0.02f, 0.98f, 0.02f, 0.98f, 0.98f, 0.02f, 0.98f) } var dragCorner by remember { mutableIntStateOf(-1) } + // Explicit redraw token so corner placement is reflected immediately in the overlay. + var cropRevision by remember { mutableIntStateOf(0) } var edgeDrag by remember { mutableStateOf(null) } var aspectRatio by remember { mutableStateOf(CropAspectRatio.AUTO) } var customRatioText by remember { mutableStateOf("1.4142") } @@ -122,6 +124,7 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) val f = fit norm[i * 2] = ((x - f.left) / f.width).coerceIn(-0.05f, 1.05f) norm[i * 2 + 1] = ((y - f.top) / f.height).coerceIn(-0.05f, 1.05f) + cropRevision++ } fun reset() { @@ -457,6 +460,10 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) } } ) { + // Read the revision in the draw scope so every tap/drag state mutation + // forces the visible overlay to redraw immediately. + val currentCropRevision = cropRevision + if (currentCropRevision < 0) return@Canvas val f = fit val rect = RectF(f.left, f.top, f.right, f.bottom) val canvas = drawContext.canvas.nativeCanvas From 4b19a33b41d1c2487b1ac6c14e6df2522ca85d03 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 04:27:05 +0530 Subject: [PATCH 30/33] Fix crop tap targeting and detection race --- .../java/com/docuscan/app/ui/CropOverlay.kt | 332 ++++++++++-------- 1 file changed, 183 insertions(+), 149 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index 6b3a4a7..0f31618 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -7,10 +7,8 @@ import android.graphics.PointF import android.graphics.RectF import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.awaitDragOrCancellation -import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -49,7 +47,6 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight @@ -104,6 +101,8 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) var dragCorner by remember { mutableIntStateOf(-1) } // Explicit redraw token so corner placement is reflected immediately in the overlay. var cropRevision by remember { mutableIntStateOf(0) } + var userInteracted by remember { mutableStateOf(false) } + var lastDraggedCorner by remember { mutableIntStateOf(-1) } var edgeDrag by remember { mutableStateOf(null) } var aspectRatio by remember { mutableStateOf(CropAspectRatio.AUTO) } var customRatioText by remember { mutableStateOf("1.4142") } @@ -178,51 +177,110 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) runCatching { Cleanup.detectCorners(rotBitmap) }.getOrNull() } detecting = false + if (userInteracted) { + return false + } if (pts == null || pts.size != 4) { hint = "Couldn't detect page edges — drag the corners or pick a size." return false } + // The user may have touched the image while OpenCV was finishing. + // Re-check immediately before committing the detected quad. + if (userInteracted) { + return false + } for (i in 0..3) { norm[i * 2] = (pts[i].x / rotBitmap.width).coerceIn(0f, 1f) norm[i * 2 + 1] = (pts[i].y / rotBitmap.height).coerceIn(0f, 1f) } + cropRevision++ hint = "Auto-detected the page edges — fine-tune if needed." return true } - /** - * A tap is an explicit crop-corner placement, not a request to run a - * second edge detector. The user can therefore move a badly detected - * corner by tapping the real page corner even when it is far outside the - * current crop polygon. + /** + * Tap interaction: + * - near a corner: move that corner exactly to the finger + * - otherwise: move the nearest crop edge so the edge reaches the finger * - * The image is divided into four quadrants to decide which corner the - * tap controls. The corner is then placed EXACTLY at the tap location. - * Dragging remains available for pixel-level fine tuning. + * This is deliberately geometry based. A four-quadrant rule cannot express + * "the closer border" and was the reason taps in the middle/side areas felt + * like they did nothing or moved the wrong handle. */ - fun moveCornerToTap(viewX: Float, viewY: Float) { + fun moveHandleToTap(pos: Offset) { val f = fit if (f.width <= 1f || f.height <= 1f) return - val x = viewX.coerceIn(f.left, f.right) - val y = viewY.coerceIn(f.top, f.bottom) + val x = pos.x.coerceIn(f.left, f.right) + val y = pos.y.coerceIn(f.top, f.bottom) + val tap = Offset(x, y) - val centerX = (f.left + f.right) * 0.5f - val centerY = (f.top + f.bottom) * 0.5f + val corners = Array(4) { corner(it) } + var nearestCorner = -1 + var nearestCornerDistance = Float.POSITIVE_INFINITY + for (i in 0..3) { + val d = (corners[i] - tap).getDistance() + if (d < nearestCornerDistance) { + nearestCornerDistance = d + nearestCorner = i + } + } - val target = when { - x < centerX && y < centerY -> 0 // TL - x >= centerX && y < centerY -> 1 // TR - x >= centerX && y >= centerY -> 2 // BR - else -> 3 // BL + // A generous touch zone makes a page corner easy to correct even when + // auto-detection placed the crop handle somewhat away from it. + val cornerZone = maxOf(72.dp.toPx(), minOf(f.width, f.height) * 0.14f) + if (nearestCorner >= 0 && + (nearestCornerDistance <= cornerZone || lastDraggedCorner == nearestCorner) + ) { + setViewCorner(nearestCorner, x, y) + lastDraggedCorner = nearestCorner + hint = "Corner moved to the tap." + return } - setViewCorner(target, x, y) + var nearestEdge = -1 + var nearestEdgeDistance = Float.POSITIVE_INFINITY + for (i in 0..3) { + val j = (i + 1) % 4 + val projection = CropGeometry.projectOntoSegment( + x, y, + corners[i].x, corners[i].y, + corners[j].x, corners[j].y + ) + if (projection.perpDist < nearestEdgeDistance) { + nearestEdgeDistance = projection.perpDist + nearestEdge = i + } + } - for (i in 0..3) snapActive[i] = false - snapHighlight = -1 - hint = "Corner moved — tap another page corner or drag to fine-tune." + if (nearestEdge >= 0) { + val xs = FloatArray(4) { corners[it].x } + val ys = FloatArray(4) { corners[it].y } + val a = nearestEdge + val b = (nearestEdge + 1) % 4 + val nxNy = CropGeometry.outwardUnitNormal(xs, ys, nearestEdge) + val m0x = (xs[a] + xs[b]) / 2f + val m0y = (ys[a] + ys[b]) / 2f + val moved = CropGeometry.applyEdgeTranslation( + xs, ys, nearestEdge, + m0x, m0y, nxNy[0], nxNy[1], + x, y + ) + if (moved.applied) { + for (i in 0..3) setViewCorner(i, moved.xs[i], moved.ys[i]) + lastDraggedCorner = -1 + hint = "Nearest crop border moved to the tap." + return + } + } + + // Degenerate/invalid geometry fallback: place the nearest corner. + if (nearestCorner >= 0) { + setViewCorner(nearestCorner, x, y) + lastDraggedCorner = nearestCorner + hint = "Corner moved to the tap." + } } @@ -230,15 +288,19 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) when (r) { CropAspectRatio.CUSTOM -> { customDialog = true; return } CropAspectRatio.AUTO -> { + userInteracted = false aspectRatio = CropAspectRatio.AUTO scope.launch { detectEdges() } } CropAspectRatio.ORIGINAL -> { + userInteracted = true aspectRatio = CropAspectRatio.ORIGINAL reset() + cropRevision++ hint = null } else -> { + userInteracted = true aspectRatio = r r.shortOverLong()?.let { reshapeToRatio(it) } hint = null @@ -288,13 +350,17 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) } fun rotateLeft() { + userInteracted = false rotBitmap = BitmapUtil.rotate90(rotBitmap) reset() + cropRevision++ } fun rotateRight() { + userInteracted = false rotBitmap = BitmapUtil.rotate90(BitmapUtil.rotate90(BitmapUtil.rotate90(rotBitmap))) reset() + cropRevision++ } // When the crop screen opens (and after each rotate) try to place the box on the page edges. @@ -320,146 +386,114 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) Canvas( Modifier .fillMaxSize() + // Tap and drag are intentionally separate gesture detectors. + // Compose's drag detector cancels tap once touch-slop is crossed, so a + // tap cannot accidentally be treated as a drag and vice versa. + .pointerInput(boxSize, rotBitmap) { + detectTapGestures { pos -> + userInteracted = true + moveHandleToTap(pos) + for (i in 0..3) snapActive[i] = false + snapHighlight = -1 + } + } .pointerInput(boxSize, rotBitmap) { - awaitEachGesture { - val down = awaitFirstDown( - requireUnconsumed = false, - pass = PointerEventPass.Main - ) - val pos = down.position - - var selectedCorner = -1 - var bestCornerDistance = Float.POSITIVE_INFINITY - val cornerHitRadius = 48.dp.toPx() - for (i in 0..3) { - val distance = (corner(i) - pos).getDistance() - if (distance <= cornerHitRadius && distance < bestCornerDistance) { - bestCornerDistance = distance - selectedCorner = i + detectDragGestures( + onDragStart = { start -> + userInteracted = true + + val cornerHitRadius = 56.dp.toPx() + var selectedCorner = -1 + var bestCornerDistance = Float.POSITIVE_INFINITY + for (i in 0..3) { + val d = (corner(i) - start).getDistance() + if (d <= cornerHitRadius && d < bestCornerDistance) { + bestCornerDistance = d + selectedCorner = i + } + } + + if (selectedCorner >= 0) { + dragCorner = selectedCorner + lastDraggedCorner = selectedCorner + edgeDrag = null + return@detectDragGestures } - } - var selectedEdge = -1 - if (selectedCorner < 0) { val xs = FloatArray(4) { corner(it).x } val ys = FloatArray(4) { corner(it).y } - selectedEdge = CropGeometry.findEdgeHit(xs, ys, pos.x, pos.y) - if (selectedEdge >= 0) { - val aEdge = selectedEdge - val bEdge = (selectedEdge + 1) % 4 - val m0x = (xs[aEdge] + xs[bEdge]) / 2f - val m0y = (ys[aEdge] + ys[bEdge]) / 2f - val n = CropGeometry.outwardUnitNormal(xs, ys, selectedEdge) + val edge = CropGeometry.findEdgeHit(xs, ys, start.x, start.y) + if (edge >= 0) { + val b = (edge + 1) % 4 + val n = CropGeometry.outwardUnitNormal(xs, ys, edge) edgeDrag = EdgeDrag( - selectedEdge, xs, ys, m0x, m0y, n[0], n[1] + edge, + xs, + ys, + (xs[edge] + xs[b]) / 2f, + (ys[edge] + ys[b]) / 2f, + n[0], + n[1] ) - } - } - - // A tap is an explicit corner placement. We do not call - // OpenCV here because the requested behavior is that the - // crop corner MUST go to the point the user tapped. - fun placeCornerAtTap() { - val f = fit - if (f.width <= 1f || f.height <= 1f) return - val x = pos.x.coerceIn(f.left, f.right) - val y = pos.y.coerceIn(f.top, f.bottom) - val cx = (f.left + f.right) / 2f - val cy = (f.top + f.bottom) / 2f - - val target = when { - x < cx && y < cy -> 0 - x >= cx && y < cy -> 1 - x >= cx && y >= cy -> 2 - else -> 3 - } - - setViewCorner(target, x, y) - for (i in 0..3) snapActive[i] = false - snapHighlight = -1 - hint = "Corner moved — tap another page corner or drag to fine-tune." - } - - if (selectedCorner >= 0) { - val firstDrag = awaitDragOrCancellation(down.id) - if (firstDrag == null) { - placeCornerAtTap() } else { - fun applyCorner(current: Offset) { - val i = selectedCorner - val newX = current.x.coerceIn(fit.left, fit.right) - val newY = current.y.coerceIn(fit.top, fit.bottom) - val corners = (0..3).map { - corner(it).x.toDouble() to corner(it).y.toDouble() - } - val res = CropGeometry.snapEvaluate( - corners, i, - newX.toDouble(), newY.toDouble(), - snapActive[(i + 3) % 4], snapActive[i] - ) - setViewCorner(i, res.x.toFloat(), res.y.toFloat()) - snapActive[(i + 3) % 4] = res.prevEdgeSnapped - snapActive[i] = res.nextEdgeSnapped - snapHighlight = when { - res.prevEdgeSnapped -> (i + 3) % 4 - res.nextEdgeSnapped -> i - else -> -1 - } + dragCorner = -1 + edgeDrag = null + } + }, + onDrag = { change, _ -> + change.consume() + + val selectedCorner = dragCorner + if (selectedCorner >= 0) { + val i = selectedCorner + val newX = change.position.x.coerceIn(fit.left, fit.right) + val newY = change.position.y.coerceIn(fit.top, fit.bottom) + val corners = (0..3).map { + corner(it).x.toDouble() to corner(it).y.toDouble() } - applyCorner(firstDrag.position) - firstDrag.consume() - drag(down.id) { change -> - applyCorner(change.position) - change.consume() + val res = CropGeometry.snapEvaluate( + corners, + i, + newX.toDouble(), + newY.toDouble(), + snapActive[(i + 3) % 4], + snapActive[i] + ) + setViewCorner(i, res.x.toFloat(), res.y.toFloat()) + snapActive[(i + 3) % 4] = res.prevEdgeSnapped + snapActive[i] = res.nextEdgeSnapped + snapHighlight = when { + res.prevEdgeSnapped -> (i + 3) % 4 + res.nextEdgeSnapped -> i + else -> -1 } + return@detectDragGestures } - dragCorner = -1 - edgeDrag = null - return@awaitEachGesture - } - if (selectedEdge >= 0) { - val firstDrag = awaitDragOrCancellation(down.id) - if (firstDrag == null) { - placeCornerAtTap() - } else { - fun applyEdge(current: Offset) { - val ed = edgeDrag ?: return - val res = CropGeometry.applyEdgeTranslation( - ed.xs0, ed.ys0, ed.edgeIndex, - ed.m0x, ed.m0y, ed.nx, ed.ny, - current.x, current.y - ) - if (res.applied) { - for (i in 0..3) setViewCorner(i, res.xs[i], res.ys[i]) + val ed = edgeDrag + if (ed != null) { + val res = CropGeometry.applyEdgeTranslation( + ed.xs0, ed.ys0, ed.edgeIndex, + ed.m0x, ed.m0y, ed.nx, ed.ny, + change.position.x, change.position.y + ) + if (res.applied) { + for (i in 0..3) { + setViewCorner(i, res.xs[i], res.ys[i]) } } - applyEdge(firstDrag.position) - firstDrag.consume() - drag(down.id) { change -> - applyEdge(change.position) - change.consume() - } } + }, + onDragEnd = { + dragCorner = -1 + edgeDrag = null + }, + onDragCancel = { dragCorner = -1 edgeDrag = null - return@awaitEachGesture - } - - // Anywhere else in the image: tap moves the appropriate - // corner EXACTLY to the tap. No distance-to-current-corner - // check and no detection failure can block it. - val firstDrag = awaitDragOrCancellation(down.id) - if (firstDrag == null) { - placeCornerAtTap() } - dragCorner = -1 - edgeDrag = null - for (i in 0..3) snapActive[i] = false - snapHighlight = -1 - } - } - ) { + ) + } ) { // Read the revision in the draw scope so every tap/drag state mutation // forces the visible overlay to redraw immediately. val currentCropRevision = cropRevision From 38cd7dd28ac7c061603de1d18d1b9dc0402c5d42 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 04:30:24 +0530 Subject: [PATCH 31/33] Fix crop density compile error --- app/src/main/java/com/docuscan/app/ui/CropOverlay.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt index 0f31618..91b0e65 100644 --- a/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt +++ b/app/src/main/java/com/docuscan/app/ui/CropOverlay.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -113,6 +114,7 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) val snapActive = remember { mutableStateListOf(false, false, false, false) } var snapHighlight by remember { mutableIntStateOf(-1) } val scope = rememberCoroutineScope() + val density = LocalDensity.current fun corner(i: Int): Offset { val f = fit @@ -229,7 +231,7 @@ fun CropOverlay(bitmap: Bitmap, onApply: (Bitmap) -> Unit, onCancel: () -> Unit) // A generous touch zone makes a page corner easy to correct even when // auto-detection placed the crop handle somewhat away from it. - val cornerZone = maxOf(72.dp.toPx(), minOf(f.width, f.height) * 0.14f) + val cornerZone = maxOf(with(density) { 72.dp.toPx() }, minOf(f.width, f.height) * 0.14f) if (nearestCorner >= 0 && (nearestCornerDistance <= cornerZone || lastDraggedCorner == nearestCorner) ) { From 19b5ace61014c0b771460e3e4a4c9addfef2549f Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 04:43:39 +0530 Subject: [PATCH 32/33] Improve automatic document boundary detection --- .../java/com/docuscan/app/scan/Cleanup.kt | 290 ++++++++++++++---- 1 file changed, 226 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt index 2f63cd6..ced1a45 100644 --- a/app/src/main/java/com/docuscan/app/scan/Cleanup.kt +++ b/app/src/main/java/com/docuscan/app/scan/Cleanup.kt @@ -336,23 +336,36 @@ object Cleanup { Imgproc.Canny(thresholdInv, binaryEdgesInv, 30.0, 120.0) val maps = listOf(edges, edgesAlt, closed, binaryEdges, binaryEdgesInv) + + data class QuadCandidate( + val quad: Array, + val baseScore: Double, + val mapIndex: Int + ) + val imgArea = rgba.width() * rgba.height().toDouble() - var bestScore = -1.0 - var bestQuad: Array? = null + val candidates = mutableListOf() - for (edgeMap in maps) { + for ((mapIndex, edgeMap) in maps.withIndex()) { val contours = mutableListOf() val hierarchy = Mat() try { - Imgproc.findContours(edgeMap, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) + Imgproc.findContours( + edgeMap, + contours, + hierarchy, + Imgproc.RETR_EXTERNAL, + Imgproc.CHAIN_APPROX_SIMPLE + ) + for (contour in contours) { val area = Imgproc.contourArea(contour) - if (area < imgArea * 0.04) continue + if (area < imgArea * 0.025) continue val curve = MatOfPoint2f(*contour.toArray()) try { val perimeter = Imgproc.arcLength(curve, true) - for (epsilon in doubleArrayOf(0.010, 0.015, 0.022, 0.030)) { + for (epsilon in doubleArrayOf(0.008, 0.012, 0.018, 0.025, 0.032)) { val approx = MatOfPoint2f() try { Imgproc.approxPolyDP(curve, approx, perimeter * epsilon, true) @@ -370,28 +383,25 @@ object Cleanup { val areaNorm = (quadArea(quad) / imgArea).coerceIn(0.0, 1.0) val rectangularity = rectangularityScore(quad) val edgeSupport = quadEdgeSupport(quad, edgeMap) - val centerScore = quadCenterScore(quad, rgba.width(), rgba.height()) val parallelism = quadParallelismScore(quad) + val perspectiveConsistency = perspectiveConsistencyScore(quad) val frameContact = edgeContactScore(quad, rgba.width(), rgba.height()) - // Reject weak/partial contours. A page boundary should - // have usable support on all four sides, not just a long - // top/left edge. - if (edgeSupport < 0.30 || parallelism < 0.45) continue - - // Prefer a complete, large page over a partial rectangle. - val score = - areaNorm * 0.48 + - rectangularity * 0.16 + - edgeSupport * 0.22 + - parallelism * 0.09 + + if (edgeSupport < 0.24 || parallelism < 0.35) continue + + // Do not let "largest rectangle" dominate. A page should + // have four-sided evidence, plausible perspective, and + // repeat across more than one preprocessing map. + val baseScore = + areaNorm * 0.32 + + rectangularity * 0.18 + + edgeSupport * 0.25 + + parallelism * 0.10 + + perspectiveConsistency * 0.10 + frameContact * 0.03 + - centerScore * 0.02 + quadCenterScore(quad, rgba.width(), rgba.height()) * 0.02 - if (score > bestScore) { - bestScore = score - bestQuad = quad - } + candidates += QuadCandidate(quad, baseScore, mapIndex) } finally { approx.release() } @@ -406,11 +416,39 @@ object Cleanup { } } - if (bestQuad != null && bestScore >= 0.34) { + var bestScore = -1.0 + var bestQuad: Array? = null + + for (candidate in candidates) { + // Consensus is deliberately counted by preprocessing map, not by + // duplicate epsilon/contour candidates. This makes a real page + // boundary that survives different edge pipelines more trustworthy + // than a rectangle that appears in only one noisy threshold. + val agreeingMaps = mutableSetOf() + for (other in candidates) { + if (quadDistance(candidate.quad, other.quad, rgba.width(), rgba.height()) <= 0.075) { + agreeingMaps += other.mapIndex + } + } + val mapConsensus = (agreeingMaps.size.toDouble() / maps.size).coerceIn(0.0, 1.0) + val score = candidate.baseScore + mapConsensus * 0.16 + + if (score > bestScore) { + bestScore = score + bestQuad = candidate.quad + } + } + + // A candidate seen by only one map can still be valid, but require + // substantially stronger geometric/edge evidence before accepting it. + if (bestQuad != null && bestScore >= 0.42) { return bestQuad!!.map { PointF(it.x.toFloat(), it.y.toFloat()) } } - for (map in listOf(edgesAlt, closed, edges)) { + // Lines are a second, independent detector. Unlike the old fallback, + // it does not simply take the first/last horizontal and vertical line; + // it searches for two opposite line pairs and scores their intersections. + for (map in listOf(edgesAlt, closed, edges, binaryEdges, binaryEdgesInv)) { val q = detectQuadFromHoughLines(map, rgba.width(), rgba.height()) if (q != null) return q.map { PointF(it.x.toFloat(), it.y.toFloat()) } } @@ -424,7 +462,42 @@ object Cleanup { threshold.release() thresholdInv.release() closed.release() + // binary edge maps are created only for this detection pass. + // Release them after contour/Hough evaluation. + // (They are intentionally not part of the class state.) + } + } + + private fun perspectiveConsistencyScore(q: Array): Double { + if (q.size != 4) return 0.0 + val top = distance(q[0], q[1]) + val bottom = distance(q[3], q[2]) + val left = distance(q[0], q[3]) + val right = distance(q[1], q[2]) + if (minOf(top, bottom, left, right) < 1.0) return 0.0 + + fun ratioScore(a: Double, b: Double): Double { + val ratio = max(a, b) / min(a, b) + return (1.0 - ((ratio - 1.0) / 2.5)).coerceIn(0.0, 1.0) } + + return ((ratioScore(top, bottom) + ratioScore(left, right)) * 0.5) + .coerceIn(0.0, 1.0) + } + + private fun quadDistance( + a: Array, + b: Array, + width: Int, + height: Int + ): Double { + if (a.size != 4 || b.size != 4) return Double.POSITIVE_INFINITY + val diagonal = Math.hypot(width.toDouble(), height.toDouble()).coerceAtLeast(1.0) + var total = 0.0 + for (i in 0..3) { + total += distance(a[i], b[i]) / diagonal + } + return total / 4.0 } private fun quadEdgeSupport(q: Array, edges: Mat): Double { @@ -545,50 +618,139 @@ object Cleanup { private fun detectQuadFromHoughLines(edges: Mat, imgW: Int, imgH: Int): Array? { val lines = Mat() try { - val minLineLength = max(30, min(imgW, imgH) / 10) - val threshold = max(50, minLineLength / 2) - Imgproc.HoughLinesP(edges, lines, 1.0, Math.PI / 180.0, threshold, minLineLength.toDouble(), 10.0) + val minDim = min(imgW, imgH).toDouble() + val minLineLength = max(35, (minDim * 0.14).toInt()) + val threshold = max(28, (minDim * 0.055).toInt()) + Imgproc.HoughLinesP( + edges, + lines, + 1.0, + Math.PI / 180.0, + threshold, + minLineLength.toDouble(), + max(12.0, minDim * 0.018) + ) if (lines.rows() < 4) return null - val horizontal = mutableListOf() - val vertical = mutableListOf() + data class HLine( + val raw: DoubleArray, + val angle: Double, + val length: Double, + val mx: Double, + val my: Double + ) + + val all = mutableListOf() for (i in 0 until lines.rows()) { - val line = lines.get(i, 0) - val x1 = line[0]; val y1 = line[1]; val x2 = line[2]; val y2 = line[3] - var angle = Math.toDegrees(atan2(y2 - y1, x2 - x1)) - angle = ((angle % 180) + 180) % 180 - when { - angle < 30 || angle > 150 -> horizontal.add(line) - angle > 60 && angle < 120 -> vertical.add(line) + val v = lines.get(i, 0) ?: continue + val dx = v[2] - v[0] + val dy = v[3] - v[1] + val length = Math.hypot(dx, dy) + if (length < minLineLength) continue + var angle = Math.toDegrees(atan2(dy, dx)) + angle = ((angle % 180.0) + 180.0) % 180.0 + all += HLine( + v, + angle, + length, + (v[0] + v[2]) * 0.5, + (v[1] + v[3]) * 0.5 + ) + } + + if (all.size < 4) return null + + // Keep the strongest segments so the fallback remains fast and does + // not get dominated by text strokes and tiny background edges. + val strongest = all.sortedByDescending { it.length }.take(28) + + fun angleDelta(a: Double, b: Double): Double { + var d = abs(a - b) + if (d > 90.0) d = 180.0 - d + return d + } + + fun separation(a: HLine, b: HLine): Double { + val theta = Math.toRadians(a.angle) + val nx = -Math.sin(theta) + val ny = Math.cos(theta) + return abs((b.mx - a.mx) * nx + (b.my - a.my) * ny) + } + + fun linePairScore(a: HLine, b: HLine): Double { + val parallel = (1.0 - angleDelta(a.angle, b.angle) / 18.0).coerceIn(0.0, 1.0) + val sep = separation(a, b) + val sepScore = (sep / (minDim * 0.75)).coerceIn(0.0, 1.0) + return parallel * 0.55 + sepScore * 0.45 + } + + var best: Array? = null + var bestScore = -1.0 + + for (i in strongest.indices) { + for (j in i + 1 until strongest.size) { + val a = strongest[i] + val b = strongest[j] + val parallelA = angleDelta(a.angle, b.angle) + if (parallelA > 14.0) continue + if (separation(a, b) < minDim * 0.12) continue + + for (k in strongest.indices) { + if (k == i || k == j) continue + for (l in k + 1 until strongest.size) { + if (l == i || l == j) continue + val c = strongest[k] + val d = strongest[l] + if (angleDelta(c.angle, d.angle) > 14.0) continue + + val cross1 = angleDelta(a.angle, c.angle) + val cross2 = angleDelta(a.angle, d.angle) + if (cross1 < 62.0 || cross1 > 118.0) continue + if (cross2 < 62.0 || cross2 > 118.0) continue + if (separation(c, d) < minDim * 0.12) continue + + val tl = lineIntersection(a.raw, c.raw) ?: continue + val tr = lineIntersection(a.raw, d.raw) ?: continue + val br = lineIntersection(b.raw, d.raw) ?: continue + val bl = lineIntersection(b.raw, c.raw) ?: continue + + val quad = sortPointsRobust( + arrayOf( + clampPoint(tl, imgW, imgH), + clampPoint(tr, imgW, imgH), + clampPoint(br, imgW, imgH), + clampPoint(bl, imgW, imgH) + ) + ) + + if (!quadIsUsable(quad, imgW, imgH)) continue + val areaNorm = quadArea(quad) / (imgW * imgH.toDouble()) + if (areaNorm < 0.06) continue + + val rect = rectangularityScore(quad) + val parallel = quadParallelismScore(quad) + val perspective = perspectiveConsistencyScore(quad) + val lineStrength = + (a.length + b.length + c.length + d.length) / + (4.0 * Math.hypot(imgW.toDouble(), imgH.toDouble())) + + val score = + areaNorm.coerceIn(0.0, 1.0) * 0.28 + + rect * 0.20 + + parallel * 0.18 + + perspective * 0.14 + + lineStrength.coerceIn(0.0, 1.0) * 0.20 + + if (score > bestScore) { + bestScore = score + best = quad + } + } + } } } - if (horizontal.size < 2 || vertical.size < 2) return null - - horizontal.sortBy { (it[1] + it[3]) / 2 } - vertical.sortBy { (it[0] + it[2]) / 2 } - val top = horizontal.first() - val bottom = horizontal.last() - val left = vertical.first() - val right = vertical.last() - - val tl = lineIntersection(top, left) ?: return null - val tr = lineIntersection(top, right) ?: return null - val br = lineIntersection(bottom, right) ?: return null - val bl = lineIntersection(bottom, left) ?: return null - - val quad = sortPointsRobust( - arrayOf( - clampPoint(tl, imgW, imgH), - clampPoint(tr, imgW, imgH), - clampPoint(br, imgW, imgH), - clampPoint(bl, imgW, imgH) - ) - ) - val area = quadArea(quad) - val imgArea = imgW * imgH.toDouble() - if (area < imgArea * 0.05) return null - if (hasAcuteOrReflexAngles(quad)) return null - return quad + + return if (bestScore >= 0.48) best else null } finally { lines.release() } From 675997ae62d5359f28ac9968f8a09cb57eaf3e53 Mon Sep 17 00:00:00 2001 From: codegeasse Date: Thu, 24 Sep 2026 04:53:44 +0530 Subject: [PATCH 33/33] Fix crop warp output aspect ratio --- .../java/com/docuscan/app/scan/WarpTarget.kt | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/docuscan/app/scan/WarpTarget.kt b/app/src/main/java/com/docuscan/app/scan/WarpTarget.kt index 285322c..642fe4c 100644 --- a/app/src/main/java/com/docuscan/app/scan/WarpTarget.kt +++ b/app/src/main/java/com/docuscan/app/scan/WarpTarget.kt @@ -72,35 +72,51 @@ object WarpTarget { return w to h } - /** Projective aspect-ratio estimate with heuristic fallback. */ - private fun computeWarpTargetSizeProjective(corners: Array, srcWidth: Int, srcHeight: Int): Pair { + /** + * AUTO output sizing for a manually/automatically selected document quad. + * + * The destination rectangle must have roughly the same aspect ratio as the + * selected quadrilateral. A previous projective-aspect implementation could + * return a wildly different ratio for ordinary phone photos (for example + * ~0.3 for a ~0.7 portrait page), which made the perspective warp squeeze + * the final scan horizontally even though the crop overlay looked correct. + * + * OpenCV's standard scanner pipeline uses the measured top/bottom and + * left/right edge lengths to choose the warp canvas. We do the same here, + * using the mean edge lengths for stability and the longest side for output + * resolution. The homography still performs the actual perspective + * correction; this function only chooses the destination canvas shape. + */ + private fun computeWarpTargetSizeProjective( + corners: Array, + srcWidth: Int, + srcHeight: Int + ): Pair { val wTop = distance(corners[0], corners[1]) val wBottom = distance(corners[2], corners[3]) val hLeft = distance(corners[0], corners[3]) val hRight = distance(corners[1], corners[2]) val meanW = 0.5 * (wTop + wBottom) val meanH = 0.5 * (hLeft + hRight) - val longPx = max(max(wTop, wBottom), max(hLeft, hRight)) - if (longPx < 1.0) return 1 to 1 - - val widthOverHeight = estimateProjectiveAspectRatio(corners, srcWidth, srcHeight) - if (widthOverHeight == null || !widthOverHeight.isFinite() || - widthOverHeight <= 0.0 || widthOverHeight > 100.0 || widthOverHeight < 0.01 - ) { + if (!meanW.isFinite() || !meanH.isFinite() || meanW < 1.0 || meanH < 1.0) { return computeWarpTargetSize(corners) } - val landscapeQuad = meanW >= meanH - val w: Int - val h: Int - if (landscapeQuad) { - w = Math.round(longPx).toInt() - h = Math.round(longPx / widthOverHeight).toInt() + // Keep AUTO tied to the geometry the user can actually see. This also + // avoids an accidental portrait/landscape flip when detection is skewed. + val longPx = max(meanW, meanH) + val aspect = (meanW / meanH).coerceIn(0.05, 20.0) + val landscape = meanW >= meanH + val outW: Int + val outH: Int + if (landscape) { + outW = Math.round(longPx).toInt() + outH = Math.round(longPx / aspect).toInt() } else { - h = Math.round(longPx).toInt() - w = Math.round(longPx * widthOverHeight).toInt() + outH = Math.round(longPx).toInt() + outW = Math.round(longPx * aspect).toInt() } - return max(1, w) + 1 to max(1, h) + 1 + return max(2, outW + 1) to max(2, outH + 1) } /**