Coordinates¶
Uncial has one coordinate model, everywhere, on every platform and from both extraction paths:
Top-down pixels. The origin
(0, 0)is the top-left corner of the page,xgrows right,ygrows down.
Nothing in the public API reports anything else. You never have to ask which engine produced a box.
Why this is worth stating¶
The sources disagree, and they disagree in different ways:
| Source | Native convention |
|---|---|
| Tesseract | top-down pixels — already what Uncial uses |
| Apple Vision | normalized 0..1, origin at the bottom-left |
| PDF text layer | points (72 per inch), origin at the bottom-left of the crop box |
Each engine converts at its own edge. The Vision recognizer has exactly one place where
(1 - (y + height)) × pageHeight happens; the pdf-text module lifts boxes off the baseline
and scales points to pixels. A caller sees the result, never the conversion.
BoundingBox¶
Every box in the model — OcrLine.box, OcrWord.box — is an axis-aligned
BoundingBox in io.github.andrewmalitchuk.uncial.model.source.geometry:
public data class BoundingBox(
public val left: Float,
public val top: Float,
public val width: Float,
public val height: Float,
)
top is the distance from the page's top edge. Derived members:
| Member | Meaning |
|---|---|
right |
left + width |
bottom |
top + height — larger than top, because y grows downwards |
center |
A Point at the geometric centre; useful for reading-order and column heuristics |
area |
width * height |
union(other) |
The smallest box containing both |
BoundingBox.Zero |
A degenerate box at the origin, used where an engine reports no geometry |
Point(x, y) and Size(width, height) use the same units; Size.isEmpty is true when either
dimension is zero or negative.
The page is the frame of reference¶
Boxes are in page pixels, and OcrPage.size is that page's size in the same units:
val page = document.pages[0]
val line = page.lines.first()
// Normalized 0..1, if that is what your UI wants.
val x = line.box.left / page.size.width
val y = line.box.top / page.size.height
OcrPage.size is the rasterized size, which depends on the dpi the page was rendered at.
It is not the PDF's point size, and it is not the same across documents. Always normalize
against the page you got the box from.
Drawing an overlay on top of a rendered page is therefore a plain scale from page pixels to view pixels:
fun BoundingBox.toView(page: OcrPage, viewWidth: Float, viewHeight: Float): BoundingBox {
val sx = viewWidth / page.size.width
val sy = viewHeight / page.size.height
return BoundingBox(left * sx, top * sy, width * sx, height * sy)
}
Digital and OCR'd pages are directly comparable¶
pdf-text could have handed back a second coordinate system — PDF points — and left callers to
reconcile the two. It does not. It scales every coordinate by OcrOptions.renderScale
(renderDpi / 72), so a page read from the text layer lands in the same pixel space as a page
that went through an engine.
This matters most in a Mixed document, where a scanner OCR'd the cover page and the rest is
image-only. Both kinds of page carry comparable geometry, which is what lets
DocumentStructure take one median type size across the whole document — OcrLine.fontSize is
scaled by renderScale too, for exactly that reason.
It also means the number in fontSize is in page pixels, not points: at the default 200 dpi a
12 pt heading reads as roughly 33. Compare it against other lines of the same document, never
against an absolute point size.
Two frames that are not the same thing
renderScale is derived from renderDpi alone. The rasterizer additionally clamps the
dpi when a page would exceed maxPageSide (see
Options). For ordinary page sizes
at the default settings no clamping happens and the two frames coincide. On a page large
enough to be clamped, an OCR'd page is rendered smaller than the digital layer's scaled
points would suggest — which is another reason to normalize against OcrPage.size rather
than assuming a scale.
Rotation and skew¶
Two separate things, at two different granularities.
OcrPage.orientation is coarse page rotation in 90° steps — Orientation.Up, Right,
Down, Left, with .degrees giving 0, 90, 180, 270. Up is the default and is what
you get from engines that do not measure it; only the JVM Tesseract engine currently detects
orientation, which OcrCapabilities.orientationDetection reports.
OcrLine.skewDegrees is the fine rotation of a crooked scan, positive clockwise, 0f for a
straight line and for engines that do not measure it (OcrCapabilities.skewDetection). Note
that box stays axis-aligned regardless — a skewed line's box is its bounding rectangle, not a
rotated quad.
For PDF pages, Uncial renders the crop box, not the media box, and swaps the reported page
size for /Rotate 90 and /Rotate 270 — so OcrPage.size and the boxes on it are always in
the same frame as each other.
Confidence is not geometry, but it travels with it¶
OcrLine.confidence and OcrWord.confidence are a Confidence value class normalized to
0.0..1.0, whatever scale the engine used natively (Tesseract reports 0..100, Vision
0..1). An engine that reports none gives Confidence.Unknown, whose value is NaN — so
check before doing arithmetic:
val c = line.confidence
if (c.isKnown && c.value < 0.6f) flagForReview(line)
// or, with a default
val score = line.confidence.orElse(1f)
Text from the digital layer is not a guess and carries Confidence.Certain. Unknown sorts
below every known value, so minOf(...) over a line's words does not silently return NaN.