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:
2026-07-12 22:43:09 +02:00
commit 037550afa9
37 changed files with 2051 additions and 0 deletions

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

View 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)
}
}

View 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 -> {}
}
}
}