Track page coverage from folios and flag pages to re-shoot

The printed page number is read anyway for ordering, so use it to keep
count of what has actually been captured.

PageCoverage turns the folios in pages/ into a span [low, high] with one
state per page: captured, needs re-capture (a page file that exists but is
blank - the folio was read, the body was not), or missing (no page file at
all). The shelf, book and capture screens surface it, and the book list
interleaves the gaps in folio order, so a page that was skipped or came out
blank sits exactly where the reader would look for it. A photo holding no
text at all has no folio to attribute; it stays in failed/ and is listed
separately as unreadable.

A one- or two-line half is not a text block, so PageNumberDetector's
top/bottom band test could never recognise a folio on the very page this
feature cares about - a body that failed to OCR, leaving only its page
number. Sparse halves now accept an isolated bare number sitting in the
outer margin.

98 unit tests, 0 failures (was 83). Verified end to end on the Pixel 7:
"Pages 42-51 - 7 of 10 captured - 3 to shoot", with page 47 flagged for
re-capture and 48/49 reported missing.
This commit is contained in:
2026-10-15 11:34:39 -07:00
parent fc486d87e9
commit 684c65b025
13 changed files with 422 additions and 38 deletions

View File

@@ -18,6 +18,13 @@ one is still being read.
- **A text file per book.** Pages are ordered by the folio (printed page
number) they carry, not by the order you happened to photograph them. The
finished file is `<book title>.txt`.
- **Coverage you can see.** Because the folios are read anyway, the app knows
which pages have been captured. The book screen shows `Pages 42–51 · 7 of 10
captured · 3 to shoot`, listing every folio in order — the ones already read
with their opening lines, the ones never photographed as *not captured yet*,
and the ones whose photo came out blank as *no readable text — photograph this
page again*. The page numbers steer the process but are never written into
the text.
- **Figures and captions are dropped.** Isolated short lines that sit under
picture content (with their one or two credit lines) are filtered out, while a
short line over blank paper is kept.
@@ -49,7 +56,9 @@ Each queued photo goes through `SpreadProcessor`:
3. Run ML Kit text recognition over the whole spread (bundled Latin model, so
it works offline).
4. Split the recognised lines by the gutter and read each half's **folio** — the
bare page number near the outer edge, top or bottom.
bare page number near the outer edge, top or bottom. A page whose body failed
to OCR often leaves only its folio readable, so even a near-empty half is
searched for one.
5. Drop **captions**: short, narrow lines isolated below picture content.
6. **Reflow** the body into paragraphs, gluing hyphenated line breaks and
trimming running heads.
@@ -58,6 +67,25 @@ Each queued photo goes through `SpreadProcessor`:
At the end, `assembleTextFile` sorts the page files by folio, joins each page to
the next when a sentence runs across the spread, and writes `<title>.txt`.
### Tracking coverage and knowing what to re-shoot
Every stored page file is named after its folio, so the set of folios in
`pages/` is a record of which pages have been seen. `PageCoverage` turns that
into, for the span `[low, high]` of folios seen so far:
- **captured** — a page file with body text;
- **needs re-capture** — a page file that exists but is blank (the folio was
read, the body was not), written by `SpreadProcessor` as the one-byte file
`"\n"`;
- **missing** — a folio in the span with no page file at all.
The book screen lists every folio in that span in order and marks the two kinds
of gap, so a page that was never shot and a page that came out blank are both
pointed at by number. A book can never reveal how long it really is, so pages
beyond the highest folio seen are unknown rather than missing. A photo that
contains no text at all has no folio to attribute, so it is parked in `failed/`
and listed separately as *could not be read*.
### Storage layout
```
@@ -81,7 +109,7 @@ retried up to `MAX_JOB_ATTEMPTS` before being parked in `failed/`.
```bash
./gradlew :app:assembleDebug # APK at app/build/outputs/apk/debug/
./gradlew :app:testDebugUnitTest # 83 unit tests (JVM + Robolectric)
./gradlew :app:testDebugUnitTest # 98 unit tests (JVM + Robolectric)
```
Install on a connected device:
@@ -108,20 +136,21 @@ AppCompat action bar is no longer inset correctly, so each screen uses a
## Tests
`app/src/test/` holds 83 JVM/Robolectric tests covering the parts that do not
`app/src/test/` holds 98 JVM/Robolectric tests covering the parts that do not
need a camera:
| Suite | What it covers |
| --- | --- |
| `PageCoverageTest` | folio spans, missing pages vs pages that need re-shooting |
| `GutterDetectorTest` | tilted-gutter fit and centre fallback |
| `PageNumberDetectorTest` | folio geometry and margin isolation |
| `PageNumberDetectorTest` | folio geometry, margin isolation, sparse pages |
| `PageNumberResolverTest` | choosing the right folio per half |
| `CaptionFilterTest` | dropping captions under figures, keeping orphans |
| `ParagraphReflowTest` | hyphen gluing, running heads, across-page join |
| `BitmapsTest` | EXIF-aware downsample and scaling |
| `ImageArchiverTest` | archive scale and JPEG/WebP quality ladder |
| `BookRepositoryTest` | create/list/delete and navigation |
| `BookTest` | queue claim/fail/complete and text assembly |
| `BookTest` | queue claim/fail/complete, page files, text assembly, coverage |
## Known limitations

View File

@@ -16,10 +16,13 @@ import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bm.pagescribe.book.Book
import com.bm.pagescribe.book.BookRepository
import com.bm.pagescribe.book.Coverage
import com.bm.pagescribe.book.PageState
import com.bm.pagescribe.ocr.WorkScheduler
import com.bm.pagescribe.util.EdgeToEdge
import com.google.android.material.appbar.MaterialToolbar
@@ -112,14 +115,72 @@ class BookActivity : AppCompatActivity() {
val pages = book.pageFiles()
val failed = book.failedJobs()
metaText.text = getString(R.string.book_pages_label, pages.size, book.queuedCount())
val coverage = book.coverage()
metaText.text = buildString {
append(
when {
coverage == null -> getString(R.string.book_no_pages)
coverage.complete -> getString(
R.string.book_coverage_complete, coverage.low, coverage.high, coverage.captured,
)
else -> getString(
R.string.book_coverage_todo,
coverage.low, coverage.high, coverage.captured, coverage.expected, coverage.toShoot,
)
}
)
if (failed.isNotEmpty()) {
append(" · ").append(getString(R.string.book_unreadable, failed.size))
}
}
emptyText.visibility = if (pages.isEmpty() && failed.isEmpty()) View.VISIBLE else View.GONE
adapter.submit(pages, failed)
adapter.submit(bookRows(coverage, pages, failed))
logText.text = book.logText().lines().takeLast(60).joinToString("\n")
logScroll.post { (logScroll as? android.widget.ScrollView)?.fullScroll(View.FOCUS_DOWN) }
}
/**
* The page list: every folio in the book's range in order, so a page that
* was never shot or needs another shot appears exactly where the reader
* would look for it. Photos that could not be read at all come last, since
* their page number is unknown.
*/
private fun bookRows(
coverage: Coverage?,
pages: List<Pair<Int, File>>,
failed: List<File>,
): List<PageRow> {
val rows = ArrayList<PageRow>()
val byNumber = pages.toMap()
if (coverage != null) {
for (slot in coverage.slots) {
when (slot.state) {
PageState.CAPTURED ->
rows.add(PageRow(slot.number.toString(), snippet(byNumber.getValue(slot.number)), alert = false))
PageState.NEEDS_RECAPTURE ->
rows.add(PageRow(slot.number.toString(), getString(R.string.page_need_recapture), alert = true))
PageState.MISSING ->
rows.add(PageRow(slot.number.toString(), getString(R.string.page_missing), alert = true))
}
}
}
for (file in failed) {
rows.add(PageRow("?", getString(R.string.page_unreadable, file.name), alert = true))
}
return rows
}
private fun snippet(file: File): String = runCatching {
file.readText().lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.take(2)
.joinToString(" ")
.let { if (it.length > 160) it.take(160) + "…" else it }
}.getOrDefault("")
private fun export() {
val book = repository.find(bookKey) ?: return
if (book.pageNumbers().isEmpty()) {
@@ -164,33 +225,20 @@ class BookActivity : AppCompatActivity() {
}
}
/** Page rows, plus any photo that gave up. */
/** A row of the page list: a folio and either its opening text or a warning. */
private data class PageRow(val label: String, val detail: String, val alert: Boolean)
/** Page rows, including the folios still missing and photos that gave up. */
private class PageAdapter : RecyclerView.Adapter<PageAdapter.Holder>() {
private data class Row(val label: String, val detail: String, val primary: Boolean)
private val rows = ArrayList<PageRow>()
private val rows = ArrayList<Row>()
fun submit(pages: List<Pair<Int, File>>, failed: List<File>) {
fun submit(newRows: List<PageRow>) {
rows.clear()
for (file in failed) {
rows.add(Row("!", file.name, primary = false))
}
for ((number, file) in pages) {
rows.add(Row(number.toString(), snippet(file), primary = true))
}
rows.addAll(newRows)
notifyDataSetChanged()
}
private fun snippet(file: File): String = runCatching {
file.readText().lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.take(2)
.joinToString(" ")
.let { if (it.length > 160) it.take(160) + "…" else it }
}.getOrDefault("")
class Holder(view: View) : RecyclerView.ViewHolder(view) {
val number: TextView = view.findViewById(R.id.pageNumber)
val text: TextView = view.findViewById(R.id.pageSnippet)
@@ -203,8 +251,13 @@ private class PageAdapter : RecyclerView.Adapter<PageAdapter.Holder>() {
override fun onBindViewHolder(holder: Holder, position: Int) {
val row = rows[position]
val color = ContextCompat.getColor(
holder.itemView.context,
if (row.alert) R.color.warn else R.color.ink,
)
holder.number.text = row.label
holder.number.alpha = if (row.primary) 1f else 0.5f
holder.number.setTextColor(color)
holder.text.text = row.detail
holder.text.setTextColor(color)
}
}

View File

@@ -205,11 +205,15 @@ class CaptureActivity : AppCompatActivity() {
private fun refreshStatus() {
val current = book ?: return
statusText.text = getString(
R.string.capture_status,
current.queuedCount(),
current.pageNumbers().size
)
val coverage = current.coverage()
val done = coverage?.captured ?: 0
val queued = current.queuedCount()
val todo = coverage?.toShoot ?: 0
statusText.text = if (todo > 0) {
getString(R.string.capture_status_todo, queued, done, todo)
} else {
getString(R.string.capture_status, queued, done)
}
}
companion object {

View File

@@ -165,8 +165,13 @@ private class BookAdapter(
val book = books[position]
val pages = book.pageNumbers().size
val queued = book.queuedCount()
val todo = book.coverage()?.toShoot ?: 0
holder.title.text = book.title
holder.subtitle.text = holder.itemView.context.getString(R.string.book_pages_label, pages, queued)
holder.subtitle.text = if (todo > 0) {
holder.itemView.context.getString(R.string.book_pages_todo, pages, queued, todo)
} else {
holder.itemView.context.getString(R.string.book_pages_label, pages, queued)
}
holder.itemView.setOnClickListener { onClick(book) }
}
}

View File

@@ -17,6 +17,8 @@ import java.util.Properties
* queue/<id>.jpg photos waiting for OCR (id keeps capture order)
* work/<id>.jpg the one photo currently being read
* pages/0042.txt the text of page 42 (file name = the printed folio)
* a one-byte file ("\n") means page 42 was seen but its
* body could not be read, so it should be re-shot
* images/0042.jpg the archived, shrunk page image (or .webp)
* failed/<id>.jpg photos that failed OCR repeatedly
* failures.properties per-photo attempt counter
@@ -121,6 +123,20 @@ class Book private constructor(
fun pageText(number: Int): String? = pageFile(number).takeIf { it.exists() }?.readText()
/**
* folio -> whether its page file holds body text.
*
* [writePage] stores `text.trimEnd() + "\n"`, so a page whose whole body
* failed to OCR is the one-byte file `"\n"`. Checking the length is enough
* to tell a read page from a blank one without parsing every page again on
* each refresh.
*/
fun pageHasText(): Map<Int, Boolean> =
pageFiles().associate { (number, file) -> number to (file.length() > 1L) }
/** Which folios are captured, missing, or need re-shooting. */
fun coverage(): Coverage? = PageCoverage.of(pageHasText())
fun pageFile(number: Int): File = pagesDir.resolve(pageFileName(number))
fun writePage(number: Int, text: String) {

View File

@@ -0,0 +1,76 @@
package com.bm.pagescribe.book
/**
* The state of one page within a book's folio range.
*
* The printed page number is the one piece of a photographed page that is both
* easy to read and stable, so it is used to keep track of which pages are still
* missing — even though it is never written into the text itself.
*/
enum class PageState {
/** The page was photographed and its body text stored. */
CAPTURED,
/** The folio was read but the page body was not, so the picture should be retaken. */
NEEDS_RECAPTURE,
/** No picture covers this folio at all. */
MISSING,
}
/** One folio and how it is doing. */
data class PageSlot(val number: Int, val state: PageState)
/**
* How complete a book is.
*
* A photo of a book cannot reveal how long the book is, so "every page has been
* captured" can only mean every page between the lowest and highest folio seen
* so far. Pages outside that span are unknown rather than missing.
*/
data class Coverage(
val low: Int,
val high: Int,
val slots: List<PageSlot>,
) {
/** How many pages the span `[low, high]` holds. */
val expected: Int get() = high - low + 1
/** Pages whose text was actually read. */
val captured: Int get() = slots.count { it.state == PageState.CAPTURED }
/** Folios inside the span with no picture. */
val missing: List<Int> get() = slots.filter { it.state == PageState.MISSING }.map { it.number }
/** Folios that were photographed but produced no text. */
val recapture: List<Int> get() = slots.filter { it.state == PageState.NEEDS_RECAPTURE }.map { it.number }
/** Pages that still need a usable picture: either type of gap. */
val toShoot: Int get() = missing.size + recapture.size
val complete: Boolean get() = toShoot == 0
}
object PageCoverage {
/**
* @param pageHasText folio -> whether its page file holds body text. A folio
* that is present but blank *was* read (the number is known) yet the body
* could not be transcribed, which is exactly the "re-shoot this page" case.
*/
fun of(pageHasText: Map<Int, Boolean>): Coverage? {
if (pageHasText.isEmpty()) return null
val low = pageHasText.keys.min()
val high = pageHasText.keys.max()
val slots = ArrayList<PageSlot>(high - low + 1)
for (number in low..high) {
val state = when (pageHasText[number]) {
null -> PageState.MISSING
false -> PageState.NEEDS_RECAPTURE
true -> PageState.CAPTURED
}
slots.add(PageSlot(number, state))
}
return Coverage(low, high, slots)
}
}

View File

@@ -43,6 +43,12 @@ object PageNumberDetector {
val bodyGap = medianGap(ordered)
val pageWidth = bounds.width
// A page whose body failed to OCR often leaves only its folio readable.
// One or two lines are not a text block, so the top/bottom bands cannot
// be measured against them; any isolated bare number in the outer margin
// is taken for the folio instead.
val sparse = ordered.size <= 2
var best: Detected? = null
var bestScore = Double.NEGATIVE_INFINITY
@@ -51,9 +57,16 @@ object PageNumberDetector {
if (line.height > MAX_HEIGHT_FACTOR * bodyHeight) continue
val cy = line.centerY
val inBottom = cy >= textBottom - BOTTOM_BAND * span
val inTop = cy <= textTop + TOP_BAND * span
if (!inBottom && !inTop) continue
val inBottom: Boolean
val inTop: Boolean
if (sparse) {
inBottom = cy >= (bounds.top + bounds.bottom) / 2
inTop = !inBottom
} else {
inBottom = cy >= textBottom - BOTTOM_BAND * span
inTop = cy <= textTop + TOP_BAND * span
if (!inBottom && !inTop) continue
}
// The folio lives in the margin: a body line that is just a number
// has no such gap around it.

View File

@@ -94,7 +94,14 @@ class SpreadProcessor(context: Context) {
if (captionsDropped > 0) notes.add("Ignored $captionsDropped caption line(s).")
for ((number, text) in pageTexts) {
if (number >= 1) book.writePage(number, text)
if (number < 1) continue
// The page file is written even when the body is empty: it records
// the folio, which is what tells the book screen that this page was
// seen but needs another photo.
book.writePage(number, text)
if (text.isBlank()) {
book.appendLog("Page $number has a folio but no readable text; it should be re-shot.")
}
}
book.assembleTextFile()

View File

@@ -4,4 +4,5 @@
<color name="accent">#5B8DEF</color>
<color name="paper">#FBF7EF</color>
<color name="ink">#1B1B1B</color>
<color name="warn">#B3261E</color>
</resources>

View File

@@ -17,6 +17,13 @@
<string name="title_book">Book</string>
<string name="book_no_pages">No pages yet. Tap Capture to photograph the first spread.</string>
<string name="book_pages_label">%1$d pages · %2$d queued</string>
<string name="book_pages_todo">%1$d pages · %2$d queued · %3$d to shoot</string>
<string name="book_coverage_complete">Pages %1$d–%2$d · all %3$d captured</string>
<string name="book_coverage_todo">Pages %1$d–%2$d · %3$d of %4$d captured · %5$d to shoot</string>
<string name="page_missing">not captured yet — photograph this spread</string>
<string name="page_need_recapture">no readable text — photograph this page again</string>
<string name="page_unreadable">could not be read (%1$s) — photograph this spread again</string>
<string name="book_unreadable">%1$d unreadable</string>
<string name="book_capture">Capture</string>
<string name="book_export">Export text</string>
<string name="book_delete">Delete book</string>
@@ -40,6 +47,7 @@
<string name="capture_busy">Still saving the previous picture…</string>
<string name="capture_pages">Pages</string>
<string name="capture_status">Queued: %1$d · Done: %2$d</string>
<string name="capture_status_todo">Queued: %1$d · Done: %2$d · To shoot: %3$d</string>
<string name="capture_hint">Photograph the whole open spread, square to the page.</string>
<!-- Notification -->

View File

@@ -215,6 +215,58 @@ class BookTest {
assertEquals("Second attempt, clean.\n", book.pageText(12))
}
// ------------------------------------------------------ coverage
@Test
fun `a blank page is recorded as read but without text`() {
val book = Book.create(root, "Blank")
book.writePage(20, "")
book.writePage(21, "Real text.")
assertEquals(mapOf(20 to false, 21 to true), book.pageHasText())
}
@Test
fun `coverage flags blank pages for re-shooting and gaps as missing`() {
val book = Book.create(root, "Coverage")
book.writePage(40, "Page forty.")
book.writePage(41, "") // folio read, body blank -> re-shoot
book.writePage(44, "Page forty-four.")
val coverage = book.coverage()!!
assertEquals(40, coverage.low)
assertEquals(44, coverage.high)
assertEquals(listOf(42, 43), coverage.missing)
assertEquals(listOf(41), coverage.recapture)
assertEquals(2, coverage.captured)
assertEquals(5, coverage.expected)
assertEquals(3, coverage.toShoot)
assertFalse(coverage.complete)
}
@Test
fun `a blank page is left out of the assembled text`() {
val book = Book.create(root, "GapText")
book.writePage(4, "The start.")
book.writePage(5, "")
book.writePage(6, "The end.")
assertEquals("The start.\n\nThe end.\n", book.assembleTextFile().readText())
}
@Test
fun `re-shooting a blank page clears the recapture flag`() {
val book = Book.create(root, "FixBlank")
book.writePage(9, "")
assertEquals(listOf(9), book.coverage()!!.recapture)
// A clean second attempt replaces the blank file.
book.writePage(9, "Now it reads properly.")
assertEquals(emptyList<Int>(), book.coverage()!!.recapture)
assertTrue(book.coverage()!!.complete)
}
// -------------------------------------------------------- misc
@Test

View File

@@ -0,0 +1,101 @@
package com.bm.pagescribe.book
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PageCoverageTest {
@Test
fun `a book with no pages has no coverage`() {
assertNull(PageCoverage.of(emptyMap()))
}
@Test
fun `a single captured page is a complete one-page range`() {
val coverage = PageCoverage.of(mapOf(7 to true))!!
assertEquals(7, coverage.low)
assertEquals(7, coverage.high)
assertEquals(1, coverage.expected)
assertEquals(1, coverage.captured)
assertTrue(coverage.complete)
assertEquals(emptyList<Int>(), coverage.missing)
assertEquals(emptyList<Int>(), coverage.recapture)
}
@Test
fun `a contiguous run is complete`() {
val coverage = PageCoverage.of(mapOf(42 to true, 43 to true, 44 to true, 45 to true))!!
assertEquals(42, coverage.low)
assertEquals(45, coverage.high)
assertEquals(4, coverage.expected)
assertEquals(4, coverage.captured)
assertEquals(0, coverage.toShoot)
assertTrue(coverage.complete)
}
@Test
fun `folios skipped between two spreads are reported missing`() {
// The user shot 42/43 then 46/47 and flipped past two pages.
val coverage = PageCoverage.of(
mapOf(42 to true, 43 to true, 46 to true, 47 to true),
)!!
assertEquals(listOf(44, 45), coverage.missing)
assertEquals(emptyList<Int>(), coverage.recapture)
assertEquals(2, coverage.toShoot)
assertFalse(coverage.complete)
}
@Test
fun `a folio with no text needs to be re-shot, not marked missing`() {
// Page 44's number was read but its body came out blank.
val coverage = PageCoverage.of(
mapOf(42 to true, 43 to true, 44 to false, 45 to true),
)!!
assertEquals(emptyList<Int>(), coverage.missing)
assertEquals(listOf(44), coverage.recapture)
assertEquals(3, coverage.captured)
assertEquals(4, coverage.expected)
assertEquals(1, coverage.toShoot)
}
@Test
fun `a blank final page still widens the range`() {
// The last spread produced only a folio on page 47.
val coverage = PageCoverage.of(mapOf(46 to true, 47 to false))!!
assertEquals(46, coverage.low)
assertEquals(47, coverage.high)
assertEquals(2, coverage.expected)
assertEquals(listOf(47), coverage.recapture)
}
@Test
fun `missing and blank pages are both counted as to shoot`() {
val coverage = PageCoverage.of(
mapOf(10 to true, 11 to false, 13 to true, 14 to false),
)!!
// 12 was never photographed; 11 and 14 were, but blank.
assertEquals(listOf(12), coverage.missing)
assertEquals(listOf(11, 14), coverage.recapture)
assertEquals(2, coverage.captured)
assertEquals(5, coverage.expected)
assertEquals(3, coverage.toShoot)
}
@Test
fun `slots run in folio order and carry their state`() {
val coverage = PageCoverage.of(mapOf(3 to true, 4 to false, 6 to true))!!
assertEquals(listOf(3, 4, 5, 6), coverage.slots.map { it.number })
assertEquals(
listOf(
PageState.CAPTURED,
PageState.NEEDS_RECAPTURE,
PageState.MISSING,
PageState.CAPTURED,
),
coverage.slots.map { it.state },
)
}
}

View File

@@ -100,4 +100,23 @@ class PageNumberDetectorTest {
assertEquals(42, PageNumberDetector.folioNumber(" 42 "))
assertEquals(7, PageNumberDetector.folioNumber("7."))
}
@Test
fun `a lone folio on an otherwise blank page is still read`() {
// The body failed to OCR and only the printed number came out; it is
// still the key that says which page this was.
val detected = PageNumberDetector.detect(listOf(folio(350, "47", 30, 62)), leftBounds, PageSide.LEFT)
assertEquals(47, detected?.number)
}
@Test
fun `a lone line of prose on a sparse page is not a folio`() {
val lone = OcrLine("The end of the chapter", 30, 350, 300, 376)
assertNull(PageNumberDetector.detect(listOf(lone), leftBounds, PageSide.LEFT))
}
@Test
fun `a sparse page still rejects a number in the inner margin`() {
assertNull(PageNumberDetector.detect(listOf(folio(350, "47", 340, 370)), leftBounds, PageSide.LEFT))
}
}