OpenCvScanEngine.kt
272 lignes · 10491 octets
package fr.ebii.card2vcf.scan import android.graphics.Bitmap import android.graphics.PointF import org.opencv.android.OpenCVLoader import org.opencv.android.Utils import org.opencv.core.Mat import org.opencv.core.MatOfPoint import org.opencv.core.MatOfPoint2f import org.opencv.core.Point import org.opencv.core.Size import org.opencv.imgproc.Imgproc import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.hypot import kotlin.math.max class OpenCvScanEngine : ScanEngine { override fun scan(source: Bitmap, manualCorners: ScanCorners?): ScanResult { if (!ensureOpenCv()) throw ScanException("OpenCV non initialisé") val src = Mat() Utils.bitmapToMat(source, src) try { val working = resizeIfNeeded(src) val corners: MatOfPoint2f val detected: Boolean if (manualCorners != null) { corners = manualCorners.toMatPoints() detected = true } else { val found = detectDocumentCorners(working) if (found != null) { corners = found detected = true } else { corners = fullFrameCorners(working) detected = false } } // Pas de deskew via findNonZero : alloue des millions de points et tue l'app (OOM). val warped = perspectiveCorrect(working, corners) val outBitmap = Bitmap.createBitmap(warped.cols(), warped.rows(), Bitmap.Config.ARGB_8888) Utils.matToBitmap(warped, outBitmap) warped.release() if (working !== src) working.release() return ScanResult( bitmap = outBitmap, angleDegrees = estimateSkewAngle(corners), confidence = when { manualCorners != null -> 1f detected -> 0.8f else -> 0.4f }, corners = corners.toScanCorners(), ) } finally { src.release() } } private fun resizeIfNeeded(src: Mat): Mat { val maxDim = max(src.rows(), src.cols()) if (maxDim <= MAX_DIM) return src.clone() val scale = MAX_DIM.toDouble() / maxDim val resized = Mat() Imgproc.resize(src, resized, Size(src.cols() * scale, src.rows() * scale)) return resized } /** Retourne les coins détectés, ou null si aucune stratégie n'a convergé. */ private fun detectDocumentCorners(src: Mat): MatOfPoint2f? { val gray = toGray(src) val imageArea = src.rows() * src.cols() try { detectViaAdaptiveThreshold(gray, imageArea)?.let { return it } detectViaCanny(gray, imageArea)?.let { return it } detectViaMinAreaRect(gray, imageArea)?.let { return it } return null } finally { gray.release() } } private fun detectViaAdaptiveThreshold(gray: Mat, imageArea: Int): MatOfPoint2f? { val blurred = Mat() val thresh = Mat() val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0)) try { Imgproc.GaussianBlur(gray, blurred, Size(5.0, 5.0), 0.0) Imgproc.adaptiveThreshold( blurred, thresh, 255.0, Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY, 11, 2.0, ) Imgproc.morphologyEx(thresh, thresh, Imgproc.MORPH_CLOSE, kernel) return findQuadContour(thresh, imageArea, minAreaRatio = 0.03, epsilons = listOf(0.02, 0.035, 0.05)) } finally { blurred.release() thresh.release() kernel.release() } } private fun detectViaCanny(gray: Mat, imageArea: Int): MatOfPoint2f? { val edges = Mat() val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0)) try { Imgproc.GaussianBlur(gray, edges, Size(5.0, 5.0), 0.0) Imgproc.Canny(edges, edges, 40.0, 120.0) Imgproc.dilate(edges, edges, kernel) return findQuadContour(edges, imageArea, minAreaRatio = 0.05, epsilons = listOf(0.02, 0.04, 0.06)) } finally { edges.release() kernel.release() } } /** Dernier recours : boîte englobante orientée du plus grand contour. */ private fun detectViaMinAreaRect(gray: Mat, imageArea: Int): MatOfPoint2f? { val thresh = Mat() try { Imgproc.threshold(gray, thresh, 0.0, 255.0, Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU) val contours = mutableListOf<MatOfPoint>() Imgproc.findContours(thresh, contours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) val largest = contours.maxByOrNull { Imgproc.contourArea(it) } ?: return null val area = Imgproc.contourArea(largest) if (area < imageArea * 0.12) return null val rect = Imgproc.minAreaRect(MatOfPoint2f(*largest.toArray().map { Point(it.x, it.y) }.toTypedArray())) val box = MatOfPoint2f() Imgproc.boxPoints(rect, box) return orderCorners(box) } finally { thresh.release() } } private fun findQuadContour( binary: Mat, imageArea: Int, minAreaRatio: Double, epsilons: List<Double>, ): MatOfPoint2f? { val contours = mutableListOf<MatOfPoint>() Imgproc.findContours(binary, contours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE) contours.sortByDescending { Imgproc.contourArea(it) } val minArea = imageArea * minAreaRatio for (contour in contours.take(25)) { val area = Imgproc.contourArea(contour) if (area < minArea) continue val curve = MatOfPoint2f(*contour.toArray()) val peri = Imgproc.arcLength(curve, true) for (eps in epsilons) { val approx = MatOfPoint2f() Imgproc.approxPolyDP(curve, approx, eps * peri, true) if (approx.total() == 4L && isConvexQuad(approx)) { return orderCorners(approx) } } } return null } private fun isConvexQuad(approx: MatOfPoint2f): Boolean { val pts = approx.toArray() if (pts.size != 4) return false var sign = 0 for (i in pts.indices) { val a = pts[i] val b = pts[(i + 1) % 4] val c = pts[(i + 2) % 4] val cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x) if (kotlin.math.abs(cross) < 1.0) continue val s = if (cross > 0) 1 else -1 if (sign == 0) sign = s else if (sign != s) return false } return true } private fun fullFrameCorners(src: Mat, marginRatio: Double = 0.02): MatOfPoint2f { val mx = src.cols() * marginRatio val my = src.rows() * marginRatio val w = src.cols() - 1.0 val h = src.rows() - 1.0 return MatOfPoint2f( Point(mx, my), Point(w - mx, my), Point(w - mx, h - my), Point(mx, h - my), ) } private fun toGray(src: Mat): Mat { val gray = Mat() when (src.channels()) { 4 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY) 3 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY) else -> src.copyTo(gray) } return gray } private fun perspectiveCorrect(src: Mat, corners: MatOfPoint2f): Mat { val pts = corners.toArray() val widthA = distance(pts[2], pts[3]) val widthB = distance(pts[1], pts[0]) val maxWidth = max(widthA, widthB).toInt().coerceAtLeast(1) val heightA = distance(pts[1], pts[2]) val heightB = distance(pts[0], pts[3]) val maxHeight = max(heightA, heightB).toInt().coerceAtLeast(1) val srcPts = MatOfPoint2f(pts[0], pts[1], pts[2], pts[3]) val dstPts = MatOfPoint2f( Point(0.0, 0.0), Point(maxWidth - 1.0, 0.0), Point(maxWidth - 1.0, maxHeight - 1.0), Point(0.0, maxHeight - 1.0), ) val matrix = Imgproc.getPerspectiveTransform(srcPts, dstPts) val out = Mat() Imgproc.warpPerspective(src, out, matrix, Size(maxWidth.toDouble(), maxHeight.toDouble())) matrix.release() return out } private fun orderCorners(approx: MatOfPoint2f): MatOfPoint2f { val pts = approx.toArray().sortedWith(compareBy({ it.y }, { it.x })).toMutableList() val top = pts.take(2).sortedBy { it.x } val bottom = pts.takeLast(2).sortedBy { it.x } return MatOfPoint2f(top[0], top[1], bottom[1], bottom[0]) } private fun estimateSkewAngle(corners: MatOfPoint2f): Float { val pts = corners.toArray() val dx = pts[1].x - pts[0].x val dy = pts[1].y - pts[0].y return Math.toDegrees(kotlin.math.atan2(dy, dx)).toFloat() } private fun distance(a: Point, b: Point): Double = hypot(a.x - b.x, a.y - b.y) private fun ScanCorners.toMatPoints(): MatOfPoint2f = MatOfPoint2f( Point(topLeft.x.toDouble(), topLeft.y.toDouble()), Point(topRight.x.toDouble(), topRight.y.toDouble()), Point(bottomRight.x.toDouble(), bottomRight.y.toDouble()), Point(bottomLeft.x.toDouble(), bottomLeft.y.toDouble()), ) private fun MatOfPoint2f.toScanCorners(): ScanCorners { val p = toArray() return ScanCorners( topLeft = PointF(p[0].x.toFloat(), p[0].y.toFloat()), topRight = PointF(p[1].x.toFloat(), p[1].y.toFloat()), bottomRight = PointF(p[2].x.toFloat(), p[2].y.toFloat()), bottomLeft = PointF(p[3].x.toFloat(), p[3].y.toFloat()), ) } companion object { private const val MAX_DIM = 1500 private val initialized = AtomicBoolean(false) fun ensureOpenCv(): Boolean { if (initialized.get()) return true synchronized(OpenCvScanEngine::class.java) { if (OpenCVLoader.initLocal()) { initialized.set(true) return true } } return false } } }
GitRust