Initial commit: PaperScan — lokale Android documentscanner voor Paperless-ngx
Native Kotlin/Compose app met CameraX + OpenCV randdetectie/perspectiefcorrectie, multi-page PDF-export, en Paperless-ngx upload (of Android deelmenu). Alles lokaal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
45
app/src/main/java/eu/geyskens/pdfscan/scan/BitmapIo.kt
Normal file
45
app/src/main/java/eu/geyskens/pdfscan/scan/BitmapIo.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
package eu.geyskens.pdfscan.scan
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import java.io.File
|
||||
|
||||
/** Loads captured JPEGs, honoring EXIF orientation and capping resolution for memory safety. */
|
||||
object BitmapIo {
|
||||
|
||||
private const val MAX_DIMENSION = 2600
|
||||
|
||||
fun loadOriented(file: File): Bitmap {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||
|
||||
var sample = 1
|
||||
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
while (longest / sample > MAX_DIMENSION) sample *= 2
|
||||
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath, opts)
|
||||
?: error("Kon afbeelding niet decoderen: ${file.name}")
|
||||
|
||||
val orientation = ExifInterface(file.absolutePath)
|
||||
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
return applyExifOrientation(bitmap, orientation)
|
||||
}
|
||||
|
||||
private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
|
||||
val matrix = Matrix()
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
|
||||
else -> return bitmap
|
||||
}
|
||||
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
if (rotated != bitmap) bitmap.recycle()
|
||||
return rotated
|
||||
}
|
||||
}
|
||||
81
app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt
Normal file
81
app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt
Normal file
@@ -0,0 +1,81 @@
|
||||
package eu.geyskens.pdfscan.scan
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PointF
|
||||
import org.opencv.android.Utils
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.core.MatOfPoint
|
||||
import org.opencv.core.MatOfPoint2f
|
||||
import org.opencv.core.Size
|
||||
import org.opencv.imgproc.Imgproc
|
||||
|
||||
/**
|
||||
* Finds the largest document-like quadrilateral in an image using OpenCV.
|
||||
* All heavy work runs on a downscaled copy; corners are returned normalized
|
||||
* to 0..1 so callers can map them onto any resolution.
|
||||
*/
|
||||
object EdgeDetector {
|
||||
|
||||
private const val PROCESS_WIDTH = 500.0
|
||||
|
||||
/** @return four normalized corners (TL, TR, BR, BL), or null if nothing convincing was found. */
|
||||
fun detect(bitmap: Bitmap): List<PointF>? {
|
||||
val src = Mat()
|
||||
Utils.bitmapToMat(bitmap, src)
|
||||
try {
|
||||
val scale = PROCESS_WIDTH / src.cols()
|
||||
val work = Mat()
|
||||
Imgproc.resize(src, work, Size(PROCESS_WIDTH, src.rows() * scale))
|
||||
|
||||
val gray = Mat()
|
||||
Imgproc.cvtColor(work, gray, Imgproc.COLOR_RGBA2GRAY)
|
||||
Imgproc.GaussianBlur(gray, gray, Size(5.0, 5.0), 0.0)
|
||||
|
||||
val edges = Mat()
|
||||
Imgproc.Canny(gray, edges, 50.0, 150.0)
|
||||
// Close small gaps so contours form a continuous loop.
|
||||
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0))
|
||||
Imgproc.dilate(edges, edges, kernel)
|
||||
|
||||
val contours = ArrayList<MatOfPoint>()
|
||||
Imgproc.findContours(
|
||||
edges, contours, Mat(),
|
||||
Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
|
||||
val frameArea = work.rows() * work.cols()
|
||||
var best: List<PointF>? = null
|
||||
var bestArea = frameArea * 0.15 // ignore quads smaller than 15% of the frame
|
||||
|
||||
for (contour in contours) {
|
||||
val c2f = MatOfPoint2f(*contour.toArray())
|
||||
val peri = Imgproc.arcLength(c2f, true)
|
||||
val approx = MatOfPoint2f()
|
||||
Imgproc.approxPolyDP(c2f, approx, 0.02 * peri, true)
|
||||
|
||||
if (approx.total() == 4L && Imgproc.isContourConvex(MatOfPoint(*approx.toArray()))) {
|
||||
val area = Imgproc.contourArea(approx)
|
||||
if (area > bestArea) {
|
||||
bestArea = area
|
||||
best = orderCorners(approx.toArray().map {
|
||||
PointF((it.x / work.cols()).toFloat(), (it.y / work.rows()).toFloat())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
} finally {
|
||||
src.release()
|
||||
}
|
||||
}
|
||||
|
||||
/** Sort four points into TL, TR, BR, BL by their x+y / x-y sums. */
|
||||
fun orderCorners(points: List<PointF>): List<PointF> {
|
||||
require(points.size == 4)
|
||||
val tl = points.minByOrNull { it.x + it.y }!!
|
||||
val br = points.maxByOrNull { it.x + it.y }!!
|
||||
val tr = points.maxByOrNull { it.x - it.y }!!
|
||||
val bl = points.minByOrNull { it.x - it.y }!!
|
||||
return listOf(tl, tr, br, bl)
|
||||
}
|
||||
}
|
||||
105
app/src/main/java/eu/geyskens/pdfscan/scan/ImageProcessor.kt
Normal file
105
app/src/main/java/eu/geyskens/pdfscan/scan/ImageProcessor.kt
Normal file
@@ -0,0 +1,105 @@
|
||||
package eu.geyskens.pdfscan.scan
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PointF
|
||||
import eu.geyskens.pdfscan.data.ScanFilter
|
||||
import org.opencv.android.Utils
|
||||
import org.opencv.core.Mat
|
||||
import org.opencv.core.MatOfPoint2f
|
||||
import org.opencv.core.Point
|
||||
import org.opencv.core.Size
|
||||
import org.opencv.imgproc.Imgproc
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Turns a raw capture into a clean, deskewed page: perspective-corrects to the
|
||||
* given corners, rotates, then applies the chosen look. Fully on-device.
|
||||
*/
|
||||
object ImageProcessor {
|
||||
|
||||
/**
|
||||
* @param corners normalized (0..1) TL, TR, BR, BL in [source] space.
|
||||
* @param rotationDegrees multiple of 90 applied after warping.
|
||||
*/
|
||||
fun process(
|
||||
source: Bitmap,
|
||||
corners: List<PointF>,
|
||||
rotationDegrees: Int,
|
||||
filter: ScanFilter,
|
||||
): Bitmap {
|
||||
val src = Mat()
|
||||
Utils.bitmapToMat(source, src)
|
||||
try {
|
||||
val w = src.cols().toDouble()
|
||||
val h = src.rows().toDouble()
|
||||
val pts = corners.map { Point(it.x * w, it.y * h) }
|
||||
val (tl, tr, br, bl) = pts
|
||||
|
||||
val widthTop = hypot(tr.x - tl.x, tr.y - tl.y)
|
||||
val widthBottom = hypot(br.x - bl.x, br.y - bl.y)
|
||||
val heightLeft = hypot(bl.x - tl.x, bl.y - tl.y)
|
||||
val heightRight = hypot(br.x - tr.x, br.y - tr.y)
|
||||
val outW = max(widthTop, widthBottom).toInt().coerceAtLeast(1)
|
||||
val outH = max(heightLeft, heightRight).toInt().coerceAtLeast(1)
|
||||
|
||||
val srcQuad = MatOfPoint2f(tl, tr, br, bl)
|
||||
val dstQuad = MatOfPoint2f(
|
||||
Point(0.0, 0.0),
|
||||
Point(outW - 1.0, 0.0),
|
||||
Point(outW - 1.0, outH - 1.0),
|
||||
Point(0.0, outH - 1.0),
|
||||
)
|
||||
val transform = Imgproc.getPerspectiveTransform(srcQuad, dstQuad)
|
||||
val warped = Mat()
|
||||
Imgproc.warpPerspective(src, warped, transform, Size(outW.toDouble(), outH.toDouble()))
|
||||
|
||||
applyFilter(warped, filter)
|
||||
rotate(warped, rotationDegrees)
|
||||
|
||||
val out = Bitmap.createBitmap(warped.cols(), warped.rows(), Bitmap.Config.ARGB_8888)
|
||||
Utils.matToBitmap(warped, out)
|
||||
warped.release()
|
||||
return out
|
||||
} finally {
|
||||
src.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFilter(mat: Mat, filter: ScanFilter) {
|
||||
when (filter) {
|
||||
ScanFilter.COLOR -> {
|
||||
// Gentle contrast/brightness lift so paper reads as white.
|
||||
mat.convertTo(mat, -1, 1.15, 8.0)
|
||||
}
|
||||
ScanFilter.GRAYSCALE -> {
|
||||
val gray = Mat()
|
||||
Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY)
|
||||
Imgproc.cvtColor(gray, mat, Imgproc.COLOR_GRAY2RGBA)
|
||||
gray.release()
|
||||
}
|
||||
ScanFilter.BLACK_WHITE -> {
|
||||
val gray = Mat()
|
||||
Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY)
|
||||
val bw = Mat()
|
||||
Imgproc.adaptiveThreshold(
|
||||
gray, bw, 255.0,
|
||||
Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY,
|
||||
15, 10.0
|
||||
)
|
||||
Imgproc.cvtColor(bw, mat, Imgproc.COLOR_GRAY2RGBA)
|
||||
gray.release()
|
||||
bw.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotate(mat: Mat, degrees: Int) {
|
||||
when (((degrees % 360) + 360) % 360) {
|
||||
90 -> org.opencv.core.Core.rotate(mat, mat, org.opencv.core.Core.ROTATE_90_CLOCKWISE)
|
||||
180 -> org.opencv.core.Core.rotate(mat, mat, org.opencv.core.Core.ROTATE_180)
|
||||
270 -> org.opencv.core.Core.rotate(mat, mat, org.opencv.core.Core.ROTATE_90_COUNTERCLOCKWISE)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user