134 lines
4.4 KiB
Kotlin
134 lines
4.4 KiB
Kotlin
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package com.bm.pagescribe.ocr
|
|
|
|
import kotlin.math.roundToInt
|
|
|
|
/**
|
|
* One recognised text line, in pixel coordinates of the photo that was OCR'd.
|
|
*
|
|
* Deliberately free of any `android.*` type so the layout, ordering and
|
|
* filtering logic can be unit tested on the JVM.
|
|
*/
|
|
data class OcrLine(
|
|
val text: String,
|
|
val left: Int,
|
|
val top: Int,
|
|
val right: Int,
|
|
val bottom: Int,
|
|
) {
|
|
val width: Int get() = (right - left).coerceAtLeast(0)
|
|
val height: Int get() = (bottom - top).coerceAtLeast(0)
|
|
val centerX: Int get() = (left + right) / 2
|
|
val centerY: Int get() = (top + bottom) / 2
|
|
val trimmed: String get() = text.trim()
|
|
}
|
|
|
|
/** Which half of an open spread a line was photographed on. */
|
|
enum class PageSide { LEFT, RIGHT }
|
|
|
|
/**
|
|
* The rectangle of one half of the spread in photo coordinates. The vertical
|
|
* range is the whole photo; the horizontal range is image edge -> gutter (LEFT)
|
|
* or gutter -> image edge (RIGHT).
|
|
*/
|
|
data class PageBounds(
|
|
val left: Int,
|
|
val top: Int,
|
|
val right: Int,
|
|
val bottom: Int,
|
|
) {
|
|
val width: Int get() = (right - left).coerceAtLeast(1)
|
|
val height: Int get() = (bottom - top).coerceAtLeast(1)
|
|
|
|
fun clampTo(imageWidth: Int, imageHeight: Int): PageBounds = PageBounds(
|
|
left.coerceIn(0, imageWidth),
|
|
top.coerceIn(0, imageHeight),
|
|
right.coerceIn(0, imageWidth),
|
|
bottom.coerceIn(0, imageHeight),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Grayscale view of a photo: 0 = black, 255 = white. Coordinates outside the
|
|
* image clamp to the edge so callers never have to bounds-check.
|
|
*/
|
|
class GrayImage(private val pixels: ByteArray, val width: Int, val height: Int) {
|
|
|
|
init {
|
|
require(pixels.size >= width * height) {
|
|
"pixel buffer ${pixels.size} is smaller than ${width}x${height}"
|
|
}
|
|
}
|
|
|
|
operator fun get(x: Int, y: Int): Int {
|
|
val cx = if (x < 0) 0 else if (x >= width) width - 1 else x
|
|
val cy = if (y < 0) 0 else if (y >= height) height - 1 else y
|
|
return pixels[cy * width + cx].toInt() and 0xFF
|
|
}
|
|
|
|
/** Fraction of pixels in the box that are clearly not paper white. */
|
|
fun darkFraction(left: Int, top: Int, right: Int, bottom: Int): Double = stats(left, top, right, bottom).dark
|
|
|
|
/** Standard deviation of luminance in the box: ~0 for blank paper. */
|
|
fun deviation(left: Int, top: Int, right: Int, bottom: Int): Double = stats(left, top, right, bottom).std
|
|
|
|
private data class Stats(val dark: Double, val std: Double)
|
|
|
|
private fun stats(left: Int, top: Int, right: Int, bottom: Int): Stats {
|
|
val l = left.coerceAtMost(right)
|
|
val r = right.coerceAtLeast(left)
|
|
val t = top.coerceAtMost(bottom)
|
|
val b = bottom.coerceAtLeast(top)
|
|
if (r - l < 3 || b - t < 3) return Stats(0.0, 0.0)
|
|
|
|
// Sample at most ~48x24 points: enough for a stable estimate and cheap.
|
|
val stepX = ((r - l) / 48).coerceAtLeast(1)
|
|
val stepY = ((b - t) / 24).coerceAtLeast(1)
|
|
var count = 0
|
|
var sum = 0.0
|
|
var sumSq = 0.0
|
|
var dark = 0
|
|
var y = t
|
|
while (y < b) {
|
|
var x = l
|
|
while (x < r) {
|
|
val v = get(x, y)
|
|
count++
|
|
sum += v
|
|
sumSq += v.toDouble() * v
|
|
if (v < 200) dark++
|
|
x += stepX
|
|
}
|
|
y += stepY
|
|
}
|
|
if (count == 0) return Stats(0.0, 0.0)
|
|
val mean = sum / count
|
|
val variance = (sumSq / count) - (mean * mean)
|
|
return Stats(dark.toDouble() / count, kotlin.math.sqrt(variance.coerceAtLeast(0.0)))
|
|
}
|
|
}
|
|
|
|
/** Median of an int list; `fallback` when the list is empty. */
|
|
internal fun List<Int>.medianInt(fallback: Double = 0.0): Double {
|
|
if (isEmpty()) return fallback
|
|
val sorted = sorted()
|
|
val mid = size / 2
|
|
return if (size % 2 == 1) sorted[mid].toDouble() else (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
}
|
|
|
|
/** Median of a double list; `fallback` when the list is empty. */
|
|
internal fun List<Double>.medianDouble(fallback: Double = 0.0): Double {
|
|
if (isEmpty()) return fallback
|
|
val sorted = sorted()
|
|
val mid = size / 2
|
|
return if (size % 2 == 1) sorted[mid] else (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
}
|
|
|
|
/** Rounds a double to an int, clamped into `[0, max]`. */
|
|
internal fun Double.clampToInt(max: Int): Int {
|
|
val v = roundToInt()
|
|
return if (v < 0) 0 else if (v > max) max else v
|
|
}
|