Files
PageScribe/app/src/main/java/com/bm/pagescribe/ocr/OcrModels.kt
2026-10-18 19:01:01 -07:00

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
}