Scroll Text Reader uses scrolls-letterread or python -m kit letterread to propose Greek letters with LineNet or words with installed local Tesseract language packs, with model provenance and controls.
| Field | Value |
|---|---|
| Id | letterread |
| Status | Current |
| Kind | kit command, kit module |
| Area | Letters and titles |
| Run | python -m kit letterread, scrolls-letterread (the installed package's console script), from kit import letterread, from kit import letterexport |
| Code | kit/letterread.py, kit/letterexport.py, kit/letterweights.py, kit/data/letter-weights.json, kit/cli.py (cmd_letterread, letterread_main, import_advice) |
| Tests | tests/test_letterweights.py, tests/test_reader_backends.py, tests/test_letterread.py, tests/test_letterexport.py, tests/test_letterlines.py, tests/test_letter_scripts.py, tests/test_letterdemo.py, tests/test_packaging.py |
| Needs | Python 3.10 or newer and numpy. Pillow for PNG and JPEG input and for --overlay; tifffile (and imagecodecs for compressed TIFFs) for TIFF input; zarr for zarr input and for reading a compressed tiled TIFF band by band. The reader extra of the scrolls-workbench package brings numpy, Pillow, tifffile and imagecodecs; zarr comes with pip install zarr. The weights: a released model (letters-v2-ccby, 5,933,674 bytes, or letters-v2-real), downloaded once, or a linenet-v1 .npz file. No torch, no GPU |
| Runs on | Any CPU. A 301 x 1,006 px synthetic page with a control map, the four orientation reads, the three exports and an overlay took 4.0 s wall and 13.4 s of CPU (measured 2026-10-16, 4-core 2.8 GHz Xeon cloud container, weights cached). The guide measured a whole 2.4 um PHerc. 1667 w013 map (a 269 MB tiled TIFF) block-averaged by 43 at 35 to 36 s and about 250 MB peak with zarr installed (14 October 2026, 4-core cloud container; Big maps) |
| Network | Only when a released weights name is not yet in the cache: the download from github.com, which redirects to GitHub's release file host (release-assets.githubusercontent.com or objects.githubusercontent.com). It follows HTTPS_PROXY and NO_PROXY. Reading an image and the exports make no connection |
| Writes | The weights file into the cache folder on a released name's first use (~/.cache/scrolls-kit/letters/, or the folder KIT_LETTER_WEIGHTS names). With --out, --csv, --page-xml and --overlay, those files, overwritten on a rerun. Otherwise it prints only |
| Go-ahead | Not needed to read public data. Publishing a read, an overlay or an export of a prize-eligible or unpublished scroll is never allowed before the prize is announced (the no-findings rule, hard rule 1); posting any read needs Chase's yes (the go-ahead rule, hard rule 8) |
| Released | Public main since 2026-10-14 (PR #57); in the installable package scrolls-workbench since PR #58 the same day; listed in the README's "Released tools" as letter-reader, since 2026-10-20 a line of the Scrolls kit entry under the same id |
| Release id | letter-reader |
| License | Code: Apache-2.0 (LICENSE). Weights: letters-v2-ccby CC BY 4.0, letters-v2-real CC BY-NC 4.0 (which weights) |
| Guide | letter-reader.md (install, first reads, model cards, benchmarks, the result field by field) |
| Checked | 2026-10-17 against commit 869d621 and the reader routing change |
Scroll Text Reader was called Greek Letter Reader until 17 October 2026. The name covers both backends; commands, Python APIs and model identifiers keep their existing names. Outputs remain unverified hypotheses for a person to check.
The default backend is the Greek LineNet described below. The separate reader-backends page documents --backend tesseract: Latin and other installed language packs, capability discovery, word output and its limitations. Greek weights cannot be relabeled as another language. The command checks a local LineNet checkpoint's actual alphabet before calling it Greek.
Greek weights are selected before reading the image. Explicit --weights is preserved; otherwise --weight-profile selects a documented profile, defaulting to ink-lines when --lines is supplied and permissive for automatic layout. The profile and license are printed and recorded in JSON. This follows the released given-line evidence, not a claim of one universally better checkpoint. The Python letterread.read() API still requires explicit weights.
Ink detection turns a scroll's CT scan into a 2D ink map, and people then look for letters in it. letterread proposes those letters. It is a convolutional line reader trained with CTC (connectionist temporal classification: the network learns from each line's letter string alone, without letter boxes) on synthetic lines of random Greek capitals and real letter crops, exported to a .npz file in the format linenet-v1 and run in plain numpy. For each text line it finds, it returns the letters it decodes, each with a confidence p, a box in the image's pixels and whether it cleared the read's confidence floor (kept).
Every read carries its controls. The nulls are the same reader run on copies of the image that keep its gray levels but break its letters (tile-shuffled and phase-randomized), and the confidence floor p_min is set so the nulls keep few letters. An optional control map, such as the reverse-depth ink map of the same surface, is read on the same lines at the same floor. From those counts the read, each column and each line get a status: strong, weak, none, too_few or no_control. Turned and mirrored reads add weak evidence about orientation. An optional letter language model (built by greeklm) gives a second decode beside the ink-only one and never replaces it.
Around the network it does the page work: it loads big maps block-averaged in bands (never a float copy of the whole map), normalizes the image once (inverting dark ink on a light ground so the network always sees bright ink), estimates the letter height from the line spacing when it is not given, splits columns at empty gutters, deskews each column, finds line rows and reads each line band. kit/letterexport.py writes the result as a CSV table of letters and as PAGE XML for tools such as eScriptorium.
It does not read a scroll. Every string, box and overlay it returns is Model output: letter hypotheses for a person to check, never a reading or a transcription. Never write that letters were found or read on the strength of it. A papyrologist decides what is legible.
- Proposing letters, with positions and confidences, on a public ink map, a drawn ink label or a photograph of Greek capitals, for a person to check against the overlay.
- Deciding whether a map region shows letter-like structure beyond chance: the status against the nulls and, for an ink map, the reverse-depth control map.
- Exporting letter hypotheses to CSV or PAGE XML for a review tool.
- From Python, as a step of a pipeline that acts on the status code.
Do not use it:
- To check that the install works: run letterdemo, the offline synthetic check.
- To score how well it reads your own transcribed lines, or to cut training lines from them: use letterlines.
- To train or fine-tune the weights, or rerun the benchmarks: use the scripts in letters-training.
- To score an ink map against published ink labels: use auc (pixel AUC with the reverse-depth control) or hpscore.
- On a prize-eligible or unpublished scroll in public work: its letters are a possible finding and stay private (the no-findings rule in
AGENTS.md). Reading the ink labels of an unpublished text counts too.
python -m kit letterread IMAGE --weights letters-v2-ccby --letter-px PX \
--control CONTROL --out RESULT.json --overlay OVERLAY.pngIMAGE: the image to read:.npy,.npz,.tif,.png,.jpgor a zarr store, ink bright (give--polarity darkorautofor a photograph).PX: the letter height in image pixels (or--letter-mm MM --um-per-px UM). Leave it out to estimate it from the line spacing; the read stops when the image shows none.CONTROL: a map of the same surface on the same grid that should show no letters, such as the reverse-depth ink map; optional.RESULT.json,OVERLAY.png: the full result and a picture of the letters on the image, for a person to check.
The first use of letters-v2-ccby downloads it once. On a synthetic page of four lines of 24 drawn capitals, 32 px tall (made with kit.letterdemo.render_page, 301 x 1,006 px), with a noise image of the same ground as the control map, it printed:
Model output: letter hypotheses from a learned line reader, not a reading. A papyrologist decides what is legible.
weights letters-v2-ccby (letters-v2-ccby-step026000.npz), sha256 b14988852fc7; reader 0.2.0, seed 0, result schema 1
letter height 32.0 px (given), deskew 0.0 deg, confidence floor 0.952
line 0 y= 54.0 [ 24 kept, chance 1.38, control map 0]: ΦΟΜΥΕΡΘΞΒΧΛΔΚΤΨΩΗΠΓΑΖϹΝΙ
...
control: 96 letters kept vs nulls [9, 0, 11, 2]: confident letters well above every null
control map noise.npy: 0 letters kept on the same lines at the same confidence floor
status: strong (96 letters kept; nulls 9, 0, 11, 2, ratio 8.73 to the largest; control map 0, ratio 96). Confident letters more than three times every control: worth a person's check, still model output and not a reading.
orientation: {'as_is': 96, 'mirrored': 75, 'rot180': 56, 'rot90': 23, 'rot270': 20} (best as_is)
It worked when the exit code is 0 and the summary ends with a status: line. Only strong is worth a person's check; see Checks and controls. Progress messages (reading at letter height ..., wrote FILE) go to stderr; --quiet hides them.
- Nulls (
--nulls, default 4). Alternating tile-shuffled copies (letter-sized tiles moved and flipped at random: gray levels and local texture kept, letters and lines broken) and phase-randomized copies (the same power spectrum, so the same line spacing, shapes broken), each read like the image. The floorp_minlets the nulls keep at most 5 % of the line's letter slots (null_rate0.05), never below 0.5. - Control map (
--control). For an ink map, the reverse-depth map of the same surface: it shows the same fibers and cracks with the ink signal at the wrong depth. It is read on the image's columns, deskew and lines at the image's letter size and floor. A control map that keeps as many letters as the image makes the readnone. A control map with no finite values, or a constant one, is refused (check_control): it normalizes to zeros and keeps no letters, so its count of 0 could never stop a pass; before 16 October 2026 such a control let a read endstrong. - Status (
control.status, in this order,control_status): no nulls run isno_control; letters kept not above the largest null, or not above the control map, isnone; fewer than--min-letters(10,MIN_LETTERS) istoo_few; more than 3 times (STRONG_FACTOR) every control, a count of 0 counting as 1, isstrong; the rest isweak. Each column gets its own status, and each line itschancecount (the column's mean null count over its lines). The rule and what to do for each code: The status, and what to do. - A pass and a fail, synthetic (2026-10-16, this page's run). The synthetic page above is
strong: 96 letters against nulls of 9, 0, 11 and 2 and a control map of 0. The noise image alone, nothing given, istoo_few: 4 letters against nulls of 0, 0, 3 and 2, with a deskew warning, exit code 0. - On a public map (Model output, 14 October 2026, guide). PHerc. 1667 w013's public map averaged to about 38 um,
--letter-px 86,letters-v2-ccby: 120 letters against nulls of 31, 20, 33 and 47, statusweak, ratio 2.55. With the map itself as its control map the status isnone, as it must be. - The controls can fail on text-free input (Model output, 14 October 2026,
letters-v2-real,letter_reader_checksinresults.json). On 24 synthetic pages with no letters read twice each, 47 of 48 reads werenoneand one wasweak(ratio 1.06), soweakis not a pass. On 60 windows of tile-shuffled copies of the w013 map, the old verdict passed 17, each on 1 to 5 letters; the 10-letter floor makes all 17too_few. - Accuracy (Model output, 14 October 2026, benchmarks).
letters-v2-ccbynames drawn PHerc. 1667 letters from the official ink labels at a character error of 0.043 with the line and letter height given (117 letters), and the same places on the 2.4 um ink maps at a free-ends error of 0.308 to 0.479. Those segments are inink_9um's training labels, so their maps may be cleaner than maps of new text. - Orientation (
orientation.letters_kept). Text the right way round should keep the most lettersas_is. This is weak for Greek capitals, many of which are symmetric, so it does not enter the status.
Claim label: Model output, for every letter, box, count and status the reader returns; each read is a hypothesis for a person to check, reported with its weights, letter size, nulls, control map and status.
The console script scrolls-letterread takes the same arguments, with scrolls-letterread in place of python -m kit letterread in its usage line.
usage: python -m kit letterread [-h] [--backend {linenet,tesseract}]
[--language LANGUAGE]
[--weights WEIGHTS | --weight-profile {ink-lines,permissive}]
[--tessdata TESSDATA]
[--ocr-psm {3,6,7,11,13}]
[--ocr-timeout OCR_TIMEOUT]
[--ocr-min-confidence OCR_MIN_CONFIDENCE]
[--key KEY] [--crop Y0 Y1 X0 X1]
[--letter-px LETTER_PX]
[--letter-mm LETTER_MM]
[--um-per-px UM_PER_PX] [--max-mpx MAX_MPX]
[--polarity {bright,dark,auto}]
[--rotate {0,90,180,270}] [--mirror]
[--deskew MAX_DEG] [--lines LINES [LINES ...]]
[--columns COLUMNS] [--nulls NULLS]
[--control MAP_REVERSE]
[--min-letters MIN_LETTERS] [--seed SEED]
[--p-min P_MIN] [--lm LM]
[--lm-weight LM_WEIGHT] [--beam BEAM]
[--no-orientation] [--out OUT] [--csv OUT.csv]
[--page-xml OUT.xml] [--overlay OVERLAY]
[--overlay-max-side OVERLAY_MAX_SIDE]
[--quiet] [--json]
image
| Option | Default | Meaning |
|---|---|---|
image |
required | The image: .npy, .npz, .tif/.tiff, .png, .jpg (any format Pillow opens) or a zarr store. Ink bright by default. A PNG or JPEG is turned by its EXIF orientation as it loads |
--weights WEIGHTS |
none | A released model name (letters-v2-ccby or letters-v2-real, downloaded once and SHA-256 checked; a name is looked up before any file of that name), or a path to a linenet-v1 .npz file |
--backend BACKEND |
linenet |
linenet for the Greek network, tesseract for explicitly installed local OCR packs; no fallback |
--language LANGUAGE |
grc |
Explicit language; LineNet supports Greek only. Tesseract accepts installed codes such as lat, grc, heb, or an ordered combination such as grc+lat |
--weight-profile WEIGHT_PROFILE |
none | LineNet: ink-lines chooses letters-v2-real (CC BY-NC 4.0), permissive chooses letters-v2-ccby (CC BY 4.0). With neither weights nor profile, supplied --lines selects ink-lines and automatic line finding selects permissive. Mutually exclusive with --weights |
--tessdata TESSDATA |
none | Tesseract local language-pack directory; otherwise the engine's configured system directory. No downloads |
--ocr-psm OCR_PSM |
3 |
Tesseract segmentation: 3 automatic, 6 block, 7 line, 11 sparse text, 13 raw line |
--ocr-timeout OCR_TIMEOUT |
60 |
Tesseract timeout in seconds per call, including each control, positive and at most 600 |
--ocr-min-confidence OCR_MIN_CONFIDENCE |
50 |
Tesseract word confidence floor from 0 to 100, not a calibrated probability |
--key KEY |
none | .npz: the array name (default the first). zarr: the array path or OME-Zarr level (default level 0, full resolution); pixel values are then in that level's pixels. Also applied to --control |
--crop Y0 Y1 X0 X1 |
none | Read only rows Y0 to Y1 and columns X0 to X1 (numpy slicing, ends excluded); every row, box and --lines value is then relative to Y0, X0. The CSV and PAGE exports add the offset back |
--letter-px LETTER_PX |
none | Letter height in image pixels, at least 4 (MIN_LETTER_PX, a quarter of the network's 16 px letters). Without it (and without --letter-mm) it is estimated from the line pitch with a scale search, and the read stops when the image shows no line pitch |
--letter-mm LETTER_MM |
none | Letter height in millimeters; needs --um-per-px. Not with --letter-px. The height it gives in pixels must be at least 4 |
--um-per-px UM_PER_PX |
none | Pixel size in micrometers, with --letter-mm; letter px = mm x 1000 / um |
--max-mpx MAX_MPX |
64 |
Pixel budget in millions of pixels (MAX_PIXELS / 1e6): a bigger image is block-averaged by an integer factor while it loads |
--polarity POLARITY |
bright |
bright: ink brighter than the ground, as in ink maps. dark: dark ink on a light ground, as in photographs. auto: a quick read both ways, keeping the polarity whose letters stand further above its nulls (bright on a tie) |
--rotate ROTATE |
0 |
Turn the image 0, 90, 180 or 270 degrees clockwise before reading (after --mirror); letter boxes stay in input pixels |
--mirror |
off | Flip the image left to right before reading |
--deskew MAX_DEG |
6.0 |
Largest line tilt searched, degrees either way, in steps of 0.5 (DESKEW_DEG, DESKEW_STEP); 0 turns deskew off; under 45 |
--lines LINES ... |
none | Center rows of the lines in image pixels (in the turned image with --rotate or --mirror), each its row at the image's horizontal middle, which the deskew turns about (give --deskew 0 for level bands). Given lines mean one column. Default: found from the row profile |
--columns COLUMNS |
auto |
auto: split at empty vertical gutters at least 2 letter heights wide; off: the whole width as one column; a number: that many columns, split at the widest gutters |
--nulls NULLS |
4 |
Null copies to read, 0 to 64 (MAX_NULLS), alternating tile shuffle and phase randomization; 0 runs no control (status no_control) |
--control MAP_REVERSE |
none | A second map of the same surface on the same grid, such as its reverse-depth ink map, read on the image's lines at the same letter size and floor. Same shape after --crop and --key; normalized in the image's polarity. A map with no finite values, or whose finite values are all the same, is refused, since it could never fail |
--min-letters MIN_LETTERS |
10 |
Fewest kept letters a read or a column needs before its controls can pass (MIN_LETTERS); fewer is too_few |
--seed SEED |
0 |
Seed of the null copies (column i uses seed + i; the polarity trial uses seed + 1). The same seed, image, weights and options give the same result |
--p-min P_MIN |
none | Letter confidence floor from 0 to 1. Default: set from the nulls so they keep at most 5 % of letter slots, at least 0.5 |
--lm LM |
none | A letter language model .npz (python -m kit greeklm build); its beam decode is reported per line as with_lm, beside the ink-only string |
--lm-weight LM_WEIGHT |
0.5 |
Weight of the language model in its decode (LM_WEIGHT); 0 or more |
--beam BEAM |
32 |
Beam width of the language-model decode (CTC prefix beam search); at least 1. Unused without --lm |
--no-orientation |
off | Skip the mirrored and turned reads (orientation is then absent) |
--out OUT |
none | Also write the full result as JSON (UTF-8) |
--csv OUT.csv |
none | Also write one row per letter: column, line, index, letter, p, kept and its box, in the image file's pixels (the crop's Y0 and X0 added) |
--page-xml OUT.xml |
none | Also write PAGE XML (2019-07-15 schema): a TextRegion per column, a TextLine per line, a Glyph per letter with its confidence, in the image file's pixels; marked as model output |
--overlay OVERLAY |
none | Also write an image (PNG or any extension Pillow writes) of the image with the letters drawn on it, under a banner with the model-output note and the status; needs Pillow |
--overlay-max-side OVERLAY_MAX_SIDE |
4000 |
Longest side of the overlay in pixels (OVERLAY_MAX_SIDE); at least 16. The JSON records the scale under overlay |
--quiet |
off | No progress messages on stderr |
--json |
off | Print the full result as JSON on stdout instead of the text summary |
The command checks every option, the output folders and the input paths before it loads the weights, and loads the weights before the image, so a bad option or a failed download costs no load.
| Variable | Default | Meaning |
|---|---|---|
KIT_LETTER_WEIGHTS |
unset: the cache folder is ~/.cache/scrolls-kit/letters |
A folder (~ expanded) to download and keep released weights in, or a weights file (an existing file, or a path ending in .npz) to use for a released name when its SHA-256 matches; its folder is then the cache |
The standard-library helper kit.letterweights selects weights without loading models or reading images:
| Function | Returns | Raises | Notes |
|---|---|---|---|
profile_info(profile=None, *, lines_given=False, registry=None) |
Profile name, weights, license, scope and evidence | ValueError for unknown profiles or unregistered weights; file/JSON errors for an invalid registry |
Given lines default to ink-lines; automatic layout to permissive |
select_weights(weights=None, profile=None, *, lines_given=False, registry=None) |
Released weight name or unchanged explicit path | ValueError for both weights and profile |
Selection never downloads; the existing loader still checks the release's hash |
For explicit reusable backend selection use reader_backends.read() as described on its page.
from kit import letterread as R
from kit import letterexport as E
img, k, shape = R.load_reduced("MAP.tif", letter_px=86)
res = R.read(img, "letters-v2-ccby", letter_px=86, input_scale=k)
print(R.text_summary(res)); E.write_csv(res, "letters.csv")read() is the entry point; it takes an array, never a file name. Every result starts with note, schema, reader, weights and seed and ends with options.
| Function | Returns | Raises | Notes |
|---|---|---|---|
WeightsError() |
An OSError subclass |
Nothing | Raised by resolve_weights when a download fails; the message names the model, the URL and the cache folder |
weights_cache() |
Path |
Nothing | Folder for downloaded weights: $KIT_LETTER_WEIGHTS (~ expanded), else ~/.cache/scrolls-kit/letters; the file's folder when the variable names a file |
released_weights(registry=None) |
dict {name: entry} with file, sha256, url, licence, params, letter_px, card |
OSError, ValueError or KeyError for a missing or malformed registry |
Reads kit/data/letter-weights.json by default |
released_name(sha256, registry=None) |
str, or None |
Nothing | The released model name whose SHA-256 is sha256; None also when the registry cannot be read |
resolve_weights(spec, cache=None, registry=None, download=True) |
Path of a checked weights file |
TypeError for a spec that is not a string or path; ValueError for a missing file, a folder, a SHA-256 mismatch, a model without a URL, or a missing cached file with download=False; WeightsError for a failed download |
Names come first. A download goes to a temporary file, is kept (mode 0644) only when its SHA-256 matches, and times out after 120 s without data (DOWNLOAD_TIMEOUT) |
weights_info(net) |
dict name, file, sha256, format |
Nothing | File name only, never a folder |
load_weights(weights) |
LineNet |
As resolve_weights; ValueError for a file that is not linenet-v1 |
A LineNet is returned as it is |
utf8_console(*streams) |
list of the streams changed | Nothing | Default sys.stdout and sys.stderr; a pipe, a file or a console that cannot encode Greek becomes UTF-8 with errors="backslashreplace" |
LineNet(path) |
Object with alphabet, letter_px, band, stride, meta, file, sha256, name, band_rows, logits(band) and log_probs(band) ((T, K) arrays, T = ceil(W / stride)) |
ValueError for a file that is not linenet-v1, or unknown layer op |
numpy inference of the exported CTC line reader |
ctc_greedy(lp, alphabet, blank=0) |
list of (letter, p, t0, t1) |
Nothing | Best-path decode; p is the highest frame probability of the letter |
ctc_beam(lp, alphabet, lm=None, lm_weight=0.0, beam=32, blank=0, insert_bonus=0.0, top_k=8) |
(best string, log score) |
Nothing of its own | Prefix beam search with optional shallow fusion; lm needs logp_next(prefix) |
force_align(lp, text, alphabet, blank=0) |
list of (letter, t0, t1, mean p), or None when the line has too few frames |
ValueError for a letter not in the alphabet |
Viterbi alignment of a known text |
locate(query, text, top=3) |
list of (start, end, edit distance), end exclusive, best first |
Nothing | Semi-global alignment of a short string in a long text |
edit_distance(a, b) |
int | Nothing | Levenshtein distance (insertions, deletions and substitutions cost 1) |
exif_orientation(path) |
int 1 to 8 | Pillow's errors for an unreadable PNG or JPEG | 1 for other formats and for a damaged EXIF block |
image_shape(path, key=None) |
(H, W) |
As load_image |
Without decoding pixels where the format allows; EXIF orientation applied |
display_image(path, crop=None, k=1) |
RGB uint8 array, or None for a gray image or a format other than PNG and JPEG |
ValueError for an empty image or crop |
The overlay's color base, cropped and reduced by k |
load_image(path, key=None, crop=None) |
2D float32 array | FileNotFoundError for a missing zarr; ValueError for a shape that is not 2D or color, an empty crop, or a zarr key problem; KeyError for a missing .npz array; ImportError without the format's package |
Color becomes gray: Pillow's luma for PNG and JPEG, the mean of three channels otherwise |
reduce_factor(shape, letter_px=None, net_letter_px=16, max_pixels=MAX_PIXELS) |
int, 1 or more | Nothing | 1 within the budget; with a letter height, the largest factor that keeps letters at least twice net_letter_px tall |
load_reduced(path, key=None, crop=None, letter_px=None, net_letter_px=16, max_pixels=MAX_PIXELS, progress=None) |
(float32 image, k, (H, W) of the input window) |
As load_image |
Block-averages in row bands that follow the file's tiles; pass k to read as input_scale |
normalize(a, polarity='bright', dtype=np.float64) |
float32 array in 0 to 1, ink bright | ValueError for an image with no finite values or a bad polarity |
Robust scaling between the 0.5th and 99.8th percentiles; dark inverts |
resolve_polarity(lo, med, hi, polarity='auto') |
"bright" or "dark" |
ValueError for a polarity other than bright, dark or auto |
The gray-level guess POLARITY_RULE, used only without a reader |
normalize_info(a, polarity='bright', dtype=np.float64) |
(image, info); info has asked, used, background and, for auto, rule |
As normalize |
|
normalize_for_reading(a, polarity='bright', max_pixels=MAX_PIXELS, with_info=False) |
image, or (image, info) |
As normalize |
float64 arithmetic within the budget, float32 above |
polarity_trial(a, weights, polarity='bright', *, letter_px=None, input_scale=1, seed=0, columns='auto', deskew_deg=DESKEW_DEG, lines=None, rotate=0, mirror=False, letter_px_fallback=True, min_letters=MIN_LETTERS) |
dict rule, letter_px, bright, dark, choice |
ValueError for a polarity other than bright or dark, or no letter size with letter_px_fallback=False |
Quick reads both ways: up to 8 lines, 2 nulls; per polarity letters_kept, null_letters_kept, margin, status |
normalize_auto(img, weights, max_pixels=MAX_PIXELS, **trial_kw) |
(image, info), info with trial |
As polarity_trial and normalize |
Polarity auto for a whole read |
polarity_hint(trial, used) |
str, or None |
Nothing | A hint when the other polarity reads strong and better |
letter_px_from_units(letter_mm, um_per_px) |
float, letter height in pixels | ValueError when either is missing or not positive |
mm x 1000 / um |
resize(a, shape) |
float32 array of shape, each side at least 1 px |
Nothing | Area average by an integer factor, then bilinear |
line_pitch(a, lo=8) |
int pixels, or None |
Nothing | First autocorrelation peak above 0.15 of the row profile |
shear(a, deg, fill=0.0) |
array of a's shape | Nothing | Vertical shear about the middle column |
deskew_angles(max_deg=DESKEW_DEG, step=DESKEW_STEP) |
numpy array of angles in degrees | Nothing | Symmetric about 0, both limits included |
deskew_at_limit(angle, max_deg=DESKEW_DEG, step=DESKEW_STEP) |
bool | Nothing | True when the chosen angle is an end of the search |
deskew(a, h, max_deg=DESKEW_DEG, step=DESKEW_STEP, fill=0.0) |
(angle, sheared image) |
Nothing | The angle whose smoothed row profile has the largest variance |
orient(a, rotate=0, mirror=False) |
array (a itself when neither is asked) | ValueError for a rotate other than 0, 90, 180, 270 |
Mirror first, then turn clockwise |
view_box_to_input(box, rotate, mirror, shape) |
[y0, y1, x0, x1] rounded to 0.1 |
Nothing | A box in the turned image mapped back to the input |
compose_view(rotate, mirror, name) |
(rotate, mirror) |
KeyError for a name not in ORIENTATIONS |
The turn that reads the input as an orientation read did |
find_lines(a, h, pitch) |
sorted list of rows | Nothing | Row-profile peaks at least 0.7 pitch apart |
find_columns(a, h, min_gap=2.0, n=None, level=0.05, min_width=3.0) |
list of (x0, x1) |
Nothing | Gutters near empty in every row; no span narrower than 3 letter heights |
null_images(a, h, n=4, seed=0) |
list of (kind, image), kind tile_shuffle or phase |
Nothing | Both keep the image's gray levels |
read_bands(net, a, ys, h) |
list of (T, K) log-probability arrays | Nothing | One band per line center |
as_image(img, name='image') |
2D array | TypeError for a file name; ValueError for non-numbers, a wrong shape or an empty image |
Size-1 axes dropped; (H, W, 3 or 4) averaged to gray |
check_control(c, name='the control image') |
None |
ValueError when c has no finite values or they are all the same |
Checks in row bands of about 4 million pixels, without copying a big map; read and analyze call it on control, the command line on --control |
check_options(*, letter_px=None, polarity='bright', columns='auto', nulls=4, seed=0, min_letters=MIN_LETTERS, deskew_deg=DESKEW_DEG, rotate=0, input_scale=1, p_min=None, null_rate=0.05, lm_weight=None, beam=32, lines=None, shear_fill=None, **_) |
None |
ValueError naming the option and what to give |
What read and analyze check first; letter_px / input_scale must be at least MIN_LETTER_PX (4) and nulls at most MAX_NULLS (64) |
lm_info(lm) |
dict file, sha256, order, or None |
Nothing | |
read_options(kw, **extra) |
dict of every name in OPTION_NAMES with its value used |
Nothing | The result's options |
provenance(net, seed=0) |
dict schema, reader, weights, seed |
Nothing | |
estimate_letter_px(a) |
(px, how), how "line pitch" or "image height / 4" |
Nothing | Line pitch over PITCH_PER_H (6.6 / 3.5) |
analyze(img, weights, *, letter_px=None, polarity='bright', nulls=4, seed=0, deskew_deg=DESKEW_DEG, lines=None, p_min=None, null_rate=0.05, lm=None, lm_weight=None, beam=32, scale_search=True, columns='auto', normalized=False, input_scale=1, letter_px_fallback=True, progress=None, control=None, min_letters=MIN_LETTERS, shear_fill=None) |
The result dict without orientation | ValueError for a bad option, no letter size (with letter_px_fallback=False) or a control of another shape, with no finite values or constant; TypeError for a file name; as load_weights |
One read with nulls, columns, deskew, scale search and control map |
deskew_warning(res) |
str, or None |
Nothing | |
verdict(n, null_n) |
str, one of four fixed sentences | Nothing | The old wording kept in control.verdict; more than 3 times is "well above" |
control_status(n, null_n, control_n=None, min_letters=MIN_LETTERS) |
"strong", "weak", "none", "too_few" or "no_control" |
Nothing | The rule under Checks and controls |
control_report(n, null_n, control_n=None, min_letters=MIN_LETTERS) |
dict letters_kept, null_letters_kept, min_letters, ratio, status, status_text, and control_map with a control |
Nothing | |
status_line(c, sentence=True) |
str | Nothing | The status: line of the summary |
letter_px_check(a0, letter_px, input_scale=1) |
dict line_pitch_estimate, ratio and, beyond 2 times either way, warning; or None without a pitch |
Nothing | |
read(img, weights, *, orientation=True, normalized=False, progress=None, control=None, rotate=0, mirror=False, polarity_info=None, **kw) |
The full result dict (the fields under Outputs) | TypeError for an unknown option or a file name; as analyze |
analyze plus the turned reads, the polarity check after a failed read and boxes mapped back to input pixels |
trial_text(trial) |
str | Nothing | |
text_summary(res) |
str | Nothing | What the command prints without --json |
provenance_line(res) |
str | Nothing | |
view_options(rotate, mirror) |
str, such as "--rotate 90 --mirror", or "" |
Nothing | |
banner_text(res) |
str | Nothing | NOTE, then Controls: and the status line |
banner_layout(width, text=NOTE) |
(font, lines, line height, top offset, canvas width) |
ImportError without Pillow |
|
overlay_geometry(shape, res, max_side=OVERLAY_MAX_SIDE) |
dict scale, banner_rows, size, max_side |
ImportError without Pillow |
A box edge at input row y lands at overlay row banner_rows + y x scale |
overlay(img, res, path, polarity='bright', max_side=OVERLAY_MAX_SIDE, normalized=False, base=None) |
path | ValueError for a base of another size; ImportError without Pillow; OSError on write |
Kept letters green at p 0.8 or more, yellow below; letters under the floor gray boxes without glyphs |
image_frame(res, image_shape=None) |
(oy, ox, H, W) |
ValueError for a crop read without the whole image's size |
In kit/letterexport.py, as are the rows below |
letter_rows(res, image_shape=None) |
list of dicts with CSV_FIELDS |
As image_frame |
Boxes in the image file's pixels |
letters_csv(res, image_shape=None) |
str | As image_frame |
|
write_csv(res, path, image_shape=None) |
path | As image_frame; OSError |
UTF-8, no byte-order mark |
view_points(res, line) |
(band corners, baseline) as (y, x) points |
Nothing | One letter height around the line, following the column's deskew |
to_image_points(res, pts, frame, turned=True) |
list of integer (x, y) clipped to the image |
Nothing | |
page_xml(res, image_filename=None, image_shape=None, created=None) |
str | As image_frame |
PAGE XML 2019-07-15; created defaults to now, UTC |
write_page_xml(res, path, image_filename=None, image_shape=None, created=None) |
path | As page_xml; OSError |
UTF-8 |
| Input | Format | Notes |
|---|---|---|
| Image | 2D array in .npy, .npz, TIFF, PNG, JPEG or zarr (an array, a group with one array, or an OME-Zarr group, level 0 by default) |
Any numeric dtype. Color becomes gray. Exactly one non-singleton 2D plane, or a color plane with 3 or 4 channels last. A .npy and an uncompressed TIFF are memory-mapped; a zarr, or a compressed tiled TIFF with zarr installed, is read by chunk; a .npz, PNG or JPEG is decoded whole |
| Ink polarity | --polarity |
Ink maps and labels are bright ink (the default); photographs of papyrus are dark ink (dark or auto) |
| Letter height | --letter-px, or --letter-mm with --um-per-px |
In input pixels. The guide's Letter size matters most gives sizes for public maps, such as 86 px on PHerc. 1667's map averaged to about 38 um |
| Control map | Same formats, same shape after --crop and --key |
Such as the reverse-depth map villa writes with --direction both |
| Weights | A released name, or a linenet-v1 .npz |
Released: letters-v2-ccby and letters-v2-real, both 1,483,241 parameters at a working letter height of 16 px (letter-weights.json) |
| Language model | .npz from python -m kit greeklm build |
Optional |
Coordinates: rows down, columns right, in input pixels (the image file's pixels after EXIF orientation, relative to the crop's Y0 and X0). Boxes are [y0, y1, x0, x1]. Size: an image over --max-mpx (64 million pixels by default) is block-averaged while it loads, and everything is reported back in input pixels.
These fields describe the default LineNet backend. It adds backend with id/language/script and, when a task profile selects the weights, weight_profile with the profile's license and evidence. Tesseract instead emits schema: "scrolls-ocr-v1", word confidences and separate controls; see its output contract. Its result must not be parsed as Greek letter confidences or treated as control.status: strong.
Without --json it prints the text summary (see Quick start): the model-output note, a provenance line, the letter height and floor, the scale-search table when the size was estimated, any warnings, one line per text line (line N y=ROW [K kept, chance C]: LETTERS, · for a letter under the floor), per-column statuses when there are several columns, then the control:, control map, status: and orientation: lines. With --json it prints the full result instead. --out writes the same JSON. Main fields (every field: The result, field by field):
| Field | Type | Meaning |
|---|---|---|
note |
str | NOTE: model output, not a reading |
schema |
int | Result schema, 1 (SCHEMA) |
reader.module, reader.version |
str | kit.letterread and the package version |
weights.name, .file, .sha256, .format |
str or null | The released name (null for other weights), file name, SHA-256 and linenet-v1 |
seed |
int | Seed of the nulls |
engine |
str | linenet |
letter_px |
float | Letter height read at, input pixels |
letter_px_from, letter_px_estimated |
str, bool | given, or line pitch, then scale search (or image height / 4, ... from Python) |
letter_px_first_guess, scale_search |
float, list | When estimated: the first guess and per scale letter_px, letters_kept, null_letters_kept, margin |
letter_px_check |
object | With a given size: line_pitch_estimate, ratio, and warning beyond 2 times |
working_scale |
float | Network pixels per input pixel |
p_min |
float | Confidence floor used |
lines[] |
list | Per line: line, column, y (row at the column's middle), ink_only, n_kept, chance, control_map_kept (with a control map), with_lm (with --lm), and letters[] |
lines[].letters[] |
list | Per letter: glyph, p (0 to 1), kept (p at or above p_min), box [y0, y1, x0, x1] in input pixels, and view_box after a turned read |
columns[] |
list | Per column: x0, x1, deskew_deg, deskew_at_limit, lines, and its own control block with chance |
control |
object | letters_kept, null_letters_kept, nulls (kind, letters_kept), verdict, min_letters, ratio (to the largest null, null when no nulls), status, status_text, and control_map (letters_kept, ratio, file) with --control |
orientation |
object | letters_kept for as_is, mirrored, rot180, rot90, rot270; best; best_view (rotate, mirror) |
polarity |
object | asked, used, background, and trial (with hint) when auto decided or the controls did not pass |
deskew_deg, deskew_max_deg, deskew_warning, shear_fill |
float, str | First column's angle, the search limit, a warning at the limit, the gray of sheared-in rows |
input_shape, image_shape, input_scale, reduced_shape |
list, int | The input window, the whole image, the block size k, and what was read when k is above 1 |
source |
object | file (name only, never a folder) and key |
crop, letter_mm, um_per_px, exif_orientation, view |
various | Present when used |
overlay |
object | With --overlay: file, scale (overlay pixels per input pixel), banner_rows, size [width, height], max_side |
options |
object | Every option used, with max_mpx, rotate, mirror, orientation, control_map and lm |
--csv writes the header column,line,index,letter,p,kept,y0,y1,x0,x1 and one row per decoded letter (kept is true or false), in the image file's pixels. --page-xml writes a PAGE 2019-07-15 file whose Metadata comments carry the model-output note and the status; a glyph under the floor has · as its text and its best letter in its comments. --overlay writes the picture.
| Exit code | Meaning |
|---|---|
0 |
The read finished and every file asked for was written. Any status, including none and too_few, exits 0: check control.status |
1 |
An error the command does not catch, with a Python traceback |
2 |
A bad option or input, a missing package, a failed weights download, or out of memory, with the message on stderr and no read; or a usage error from the argument parser. Also when the read finished but an output file could not be written: the result is still printed, and the error follows on stderr |
Routing and weight selection can also fail before reading:
| Message | Cause and action |
|---|---|
--tessdata and --ocr-* options require --backend tesseract |
Select Tesseract explicitly or remove its options |
unknown weight profile 'NAME'; choose ink-lines, permissive |
--weight-profile names neither registered profile; choose ink-lines or permissive |
weight profile ... names unregistered weights |
The registry is invalid; do not substitute a different model |
choose --weights or --weight-profile, not both |
Make one explicit selection |
Tesseract's capability, language, input and timeout failures are on the backend page. Nondefault LineNet options unsupported by Tesseract fail; word output cannot use the glyph exporters.
| What you see | Why | What to do |
|---|---|---|
usage: ... then error: ... on stderr, exit code 2 |
A missing or wrong argument, such as no --weights |
Check the command against Commands and options |
give the letter height once: --letter-px, or --letter-mm with --um-per-px |
Both size forms given | Give one |
give the letter height in millimeters and the pixel size in micrometers together (--letter-mm MM --um-per-px UM) |
--letter-mm without --um-per-px, or the reverse |
Give both |
letter height (MM mm) and pixel size (UM um) must both be positive |
A zero or negative unit | Fix the values |
--letter-px PX: give the letter height in image pixels, at least 4 (the network reads letters 16 px tall, so smaller letters have no shape left to read) |
--letter-px under 4 (MIN_LETTER_PX), refused before any work |
Give the height of a letter in image pixels; a block-averaged map's letters are still given in input pixels |
--letter-mm MM at --um-per-px UM is a letter PX px tall, under the 4 px the reader can read; check the units (the letter height in millimeters, the pixel size in micrometers) |
The two values give a letter under 4 px, usually a unit mix-up (such as 0.1 mm at 38.4 um, 2.6 px, seen 2026-10-16) | Give the letter height in millimeters and the pixel size in micrometers |
--max-mpx must be positive and --overlay-max-side at least 16 |
A bad budget or overlay size | Use the defaults, 64 and 4000 |
--min-letters N: give at least 1 |
--min-letters 0 or less |
Keep 10; do not lower it to get a pass |
--deskew DEG: give an angle from 0 (off) to under 45 degrees |
Out of range | Use 0 to 44 |
--nulls must be 0 to 64, --beam at least 1 and --lm-weight 0 or more |
A negative count or weight, or more than 64 nulls (MAX_NULLS) |
Fix the value; 4 nulls is the default |
--p-min P: give a confidence from 0 to 1 |
Out of range | Leave it out to set it from the nulls |
--columns C: give auto, off or a number of columns, at least 1 |
Not auto, off or a whole number |
Fix it |
--overlay needs Pillow: ... |
Pillow is not installed | python -m pip install pillow, or the reader extra |
--overlay FILE: give an image file name such as letters.png |
An extension Pillow does not write | Use .png |
no such folder for FILE |
An output's folder does not exist (checked before the read) | Make the folder or fix the path |
IMAGE: no such file or folder |
The image, the control map or a zarr path does not exist | Fix the path |
SPEC: no such file, and not a released model (letters-v2-ccby, letters-v2-real) |
--weights is neither a released name nor a file |
Use a released name, or the path of a linenet-v1 file |
SPEC: a folder, not a linenet-v1 weights file |
--weights names a folder |
Give the file |
SPEC: has the file name of the released model NAME but not its SHA-256 SHA, so it is damaged or another file; download it again (python -m kit letterread IMAGE --weights NAME does), or rename it if it is weights of your own |
A local file named like a release with other contents | Pass the release's name instead, or rename your own file |
PATH: not a linenet-v1 weight file |
An .npz that is not exported reader weights |
Give weights from scripts/letters/train_linenet.py --export |
NAME: no download address yet; download FILE by hand ... |
A registry entry without a URL | Copy the file into the cache folder the message names |
could not download the weights NAME from URL into FOLDER: ERROR. Check the connection and retry, or download FILE by hand ... |
No network, a proxy or a certificate problem (a WeightsError) |
Retry, set HTTPS_PROXY or SSL_CERT_FILE, or download by hand and set KIT_LETTER_WEIGHTS (guide) |
NAME: download from URL does not match its SHA-256; nothing kept. Retry, or download FILE by hand ... |
A damaged or swapped download | Retry; nothing was kept |
NAME: PATH does not match its SHA-256 SHA; delete it and retry, or download FILE by hand ... |
The cached file or the KIT_LETTER_WEIGHTS folder's file is damaged |
Delete it and run again |
NAME: not in FOLDER |
Python only: resolve_weights(..., download=False) with nothing cached |
Allow the download or copy the file in |
weights SPEC: give a released model name (letters-v2-ccby, letters-v2-real), a path to a linenet-v1 .npz file, or a LineNet |
Python only: weights of another type | Pass a name, a path or a LineNet |
unknown layer op 'OP' |
A weights file from a newer exporter | Update the package |
no letter size: the image shows no regular line spacing to estimate it from. Give the letter height with --letter-px H (image pixels), or with --letter-mm MM and --um-per-px UM (for example --letter-mm 3.3 --um-per-px 38.4), or read a window that holds several lines with --crop Y0 Y1 X0 X1 |
No line pitch and no size given (the command line never falls back to a quarter of the height) | Give the size, or crop to several lines |
--control FILE: H x W px, but the image is H x W; give a map of the same surface on the same grid (such as its reverse-depth map) |
The control map has another shape (seen on synthetic inputs, 2026-10-16) | Use the map of the same surface and grid |
the control image is H x W px but the image is H x W; give a map of the same surface on the same grid (such as its reverse-depth map), cropped the same way |
Python only: a control of another shape | Same as above |
--control FILE is constant (every finite value is V): a constant map keeps no letters, so as a control it could never fail. Give a map of the same surface with its own content, such as its reverse-depth map, or read without a control map (from Python, the control image is constant ...) |
Every finite value of the control map is the same | Give the real reverse-depth map of the surface, or read without --control |
--control FILE has no finite values: it can keep no letters, so as a control it could never fail (from Python, the control image has no finite values ...) |
The control map is all NaN or infinite | Same as above |
IMAGE: expected a 2D image, got shape (...) |
A file with more than one 2D plane, such as a layer stack | Pass one 2D map |
image: expected a 2D image (H, W), or a color image (H, W, 3 or 4) with its channels last; got shape (...) |
Python only: an array of another shape | Pass a 2D array |
image: expected an array of numbers, got one of DTYPE |
Python only: strings or objects | Pass numbers |
image 'PATH': read() and analyze() take an image array; read a file with load_image(path) or, for a big map, load_reduced(path) (its block size goes in as input_scale) |
Python only: a file name passed to read |
Load it first |
read(): unknown option NAME; the options are ..., orientation, normalized, progress, control, rotate, mirror and polarity_info |
Python only: a misspelled keyword | Use a listed option |
OPTION VALUE: WHAT TO GIVE (such as nulls -1: give a whole number of null copies from 0 to 64 (4 is the default; 0 runs no control), or letter_px 3: letters under 4 px tall in the image read are too small to read (the network reads letters 16 px tall); give the height of a letter in image pixels, with , here at least N input pixels with input_scale K for a block-averaged image) |
Python only: check_options refuses a value for letter_px, polarity, columns, nulls, seed, min_letters, deskew_deg, rotate, input_scale, p_min, null_rate, lm_weight, beam, lines or shear_fill |
Give what the message asks |
IMAGE: the image is empty (0 x W px) |
An empty image or array | Check the file |
IMAGE: crop [...] is empty for an image of shape (H, W) |
--crop outside the image |
Fix the crop |
rotate R: give 0, 90, 180 or 270 (degrees clockwise) |
Python only: another angle | Use a quarter turn |
polarity 'P': give bright, dark or auto |
Python only: another polarity name | Use one of the three |
polarity 'P': the image's own polarity, bright or dark |
Python only: polarity_trial given auto |
Give bright or dark |
image has no finite values |
An all-NaN or all-infinite image | Check the map |
IMAGE: reading zarr needs the zarr package (python -m pip install zarr) |
zarr is not installed | Install zarr |
IMAGE: a single zarr array, so --key KEY does not apply |
--key on a zarr array |
Drop --key |
IMAGE: a zarr group with arrays [...]; pick one with --key (an array path, or an OME-Zarr level such as 0) |
A group with several arrays and no OME metadata | Give --key |
IMAGE: no array 'KEY' in this zarr (arrays: ...) |
A wrong --key |
Use one of the listed arrays |
IMAGE: 'KEY' is a zarr group, not an array; give its level, such as KEY/0 |
--key names a group |
Add the level |
'KEY is not a file in the archive' |
--key names no array of the .npz |
Use an array name of the file |
No module named 'numpy'; python -m pip install numpy pillow tifffile imagecodecs (the reader extra's packages) |
numpy, Pillow, tifffile or imagecodecs is missing (import_advice adds the install line) |
Install them, or the reader extra from a clone (python -m pip install -e ".[reader]") |
...; python -m pip install zarr (Python 3.11 or newer) |
zarr is missing for a zarr input | Install zarr |
out of memory reading IMAGE. Give the letter size (--letter-px, or --letter-mm with --um-per-px) so a big image is block-averaged while it loads, lower the budget with --max-mpx, or read a window with --crop Y0 Y1 X0 X1. |
A big image without a letter size | Do what it says |
out of memory reading IMAGE. Its letters (PX px tall) are too small to block-average (that needs letters at least 64 px tall), so the image is loaded whole: read a window with --crop Y0 Y1 X0 X1, or check the letter size. |
Letters under twice the network's height cannot be averaged | Crop a window, or check the size |
this result was read from a --crop window and does not record the whole image's size (image_shape); give image_shape=(H, W) of the image file |
Python only: exporting a crop read without its image size | Pass image_shape |
overlay base is H x W px but the image is H x W |
Python only: an overlay base of another size | Pass a base of the read image's size |
Status none or too_few, exit code 0, maybe with hint: this image reads better as ... |
Wrong letter size or polarity, too small an area, or no text the model can see | Follow the status table: check the size, the polarity, read a larger area |
warning: the deskew angle ... is the limit of its search ... |
The lines may be tilted more than --deskew |
Read again with a larger --deskew, or --rotate |
- Check every option, the output folders and the input paths; load the weights (a released name resolves to the cache file, downloaded once and SHA-256 checked).
- Load the image (
load_reduced). Over the budget (MAX_PIXELS= 64,000,000) it is block-averaged by an integer k in row bands ofBAND_PIXELS(64,000,000 input pixels) that follow the file's tiles; with a letter size, k keeps letters at least 32 px tall (twice the network's 16 px). - Normalize once (
normalize_for_reading): clip between the 0.5th and 99.8th percentiles to 0..1, inverted for dark ink. Withauto,polarity_trialreads both ways once (up to 8 lines, 2 nulls) and keeps the larger margin over its nulls (AUTO_RULE). - Letter size. Given (at least
MIN_LETTER_PX, 4 px, in the image read), or estimated from the first autocorrelation peak of the row profile divided byPITCH_PER_H(6.6 / 3.5, about 1.89 letter heights per line) and raised to 4 px when smaller, then a scale search atSCALE_STEPS(0.5, 0.63, 0.8, 1.0, 1.25, 1.6) times the guess, each size raised to 4 px and each distinct size read once, with 2 nulls on up to 8 lines, keeping the largest margin of letters over the largest null. The polarity trial also never reads under 4 px. - Layout. Resize to the network's 16 px letters, split columns at gutters (
find_columns), deskew each column by the shear in steps of 0.5 degrees up to--deskewthat makes its row profile most peaked, and find line rows as row-profile peaks at least 0.7 pitch apart. - Read. Each line band (a fixed number of letter heights around the row,
band_rows) goes through the numpy network: 2D convolutions with BatchNorm folded in, a collapse to one row, dilated 1D residual convolutions and a 25-way CTC head (24 capitals with lunate sigma, plus the blank). Best-path decoding gives letters, confidences and frame spans; boxes widen each span by 0.3 letter heights and undo the shear. - Controls. Read the nulls (
null_images) on the same lines, set the floor, count kept letters per line, column and read, read the control map on the same lines and floor, and set each status (control_status). Then, inread, the four turned reads at the same floor with no nulls, and, after anoneortoo_fewread in a fixed polarity, the quick read both ways with a hint. - Report every row, box and size in input pixels (through k and any turn), with the provenance first and the options last.
The method and its reasons: How it works and Why these rules in the guide. The PAGE export follows PRImA's 2019-07-15 schema (PAGE_NS in kit/letterexport.py).
- Synthetic page of 301 x 1,006 px with
--letter-px 32, a control map, the orientation reads, three exports and an overlay: 4.0 s wall, 13.4 s CPU (measured 2026-10-16, 4-core 2.8 GHz Xeon cloud container, weights cached). - Whole 2.4 um PHerc. 1667 w013 map, 41,600 x 79,600 px, block-averaged by 43 with
--letter-px 1376and an overlay: 35 to 36 s and about 250 MB peak with zarr; about 3.7 GB peak without zarr. The same map averaged 16 by 16, 2,600 x 4,975 px: 20 to 23 s and 420 MB (14 October 2026, 4-core cloud container, 2 threads; Big maps). - The weights download is 5,933,674 bytes, once.
It runs on a laptop CPU and needs no GPU and no Modal. Peak memory for a large image without a letter size: Not measured beyond the cases above.
The guidance below concerns LineNet. For Tesseract use its agent contract; above_controls never means a verified reading. Always preserve explicit caller weight choices, and include the selected profile's license when reporting an automatic choice.
- Command:
python -m kit letterread IMAGE --weights letters-v2-ccby --letter-px PX --control MAP_REVERSE --json --quiet; add--out RESULT.json --csv LETTERS.csv --page-xml LETTERS.xml --overlay LETTERS.pngfor files. SetKIT_LETTER_WEIGHTSto a folder or file when the cache must live elsewhere. - Parse:
control.status(strong,weak,none,too_few,no_control),control.letters_kept,control.null_letters_kept,control.control_map.letters_kept, per columncolumns[].control.status, per linelines[].ink_only,n_keptandchance, per letterglyph,p,kept,box;weights.sha256andoptionsfor provenance. - Rerun: Safe and deterministic for the same image, weights, options and seed. It overwrites
--out,--csv,--page-xmland--overlay; the weights download happens once. - Success: Exit code 0 and
control.statusofstrong. Exit code 0 alone only means the read finished. - Not a pass:
weak(a text-free page can beweak),none,too_few,no_control; any read with--nulls 0; an ink map read without its reverse-depth--control; astrongread on a segment whose labels or maps trained the ink model, which may be cleaner than new text; lines chosen afterward because they beatchance; any read at an estimated letter size that the scale-search table does not support. - Go-ahead: Nothing to read public data. Posting a read needs Chase's yes, and a read of a prize-eligible or unpublished scroll never goes public before the prize is announced.
- Never: Call its output a reading or say letters were found or read; put an overlay in a prize image (they carry machine letter annotations); commit weights, maps or reads of target scrolls to this repository; lower
--min-lettersor--p-minto get a pass.
python -m unittest tests.test_letterread tests.test_letterexport -vtests/test_letterread.py covers the numpy network against a torch export (skipped without torch), CTC decoding with and without a language model, forced alignment, nulls, line and column finding, deskew, normalization and polarity (auto, the trial, the hint), big images block-averaged in every format including zarr and OME-Zarr levels, the letter size in units and its check, the status codes and the control map, turned, mirrored and EXIF-turned reads, the overlay, the released weights (download once, SHA-256 refusal, nothing kept on a bad or interrupted download, names before paths, KIT_LETTER_WEIGHTS), the Python API's checks and provenance, the command line's errors and file order, and UTF-8 output. They use small random weights, not the released ones. tests/test_letterexport.py (6 tests, 2.0 s on 2026-10-16) checks the CSV and PAGE XML in input pixels, turned and two-column reads, crops and block-averaged reads. tests/test_letter_scripts.py, tests/test_letterlines.py, tests/test_letterdemo.py and tests/test_packaging.py use the reader from the scripts, letterlines, the demo and the console scripts. They skip without numpy. They do not measure accuracy on real maps; the benchmark scripts do (see letters-training). The run time of tests/test_letterread.py was Not measured here.
-
2026-10-17: working change against
869d621, explicit local multilingual backend routing with word provenance and controls; task-aware Greek weight profiles while preserving explicit weights and released model hashes. -
2026-10-16: speed-ups with bit-identical output: LineNet's max pooling by strided slices (one 36 x 1200 band 148.2 ms to 77.8 ms, per the commit),
shearby runs of columns (deskew of a 630 x 1200 image 384 ms to 40 ms, per the commit), andnormalize_infoskipping the median fill when every pixel is finite (b48258d,4a31ee5,45269ca,9e2a16e). -
2026-10-16: the missing-package hint names numpy, pillow, tifffile and imagecodecs instead of
scrolls-workbench[reader], which is not on PyPI (00c25f5). -
2026-10-16: refuses a constant control map or one with no finite values (
check_control), which could never fail and let a read endstrong; letter heights under 4 px (MIN_LETTER_PX) and more than 64 nulls (MAX_NULLS); the scale search and polarity trial never read under 4 px;resizekeeps each side at least 1 px, so a strip a few pixels tall no longer stops with a division by zero (c58cdab). -
2026-10-14:
letters-v2-ccby(CC BY 4.0) becomes the default weights of the demo and guide;letters-v2-realstays released (0366ed7). -
2026-10-14: fixes from the usability review (
ca7b143). -
2026-10-14: CSV and PAGE XML exports (
kit/letterexport.py,--csv,--page-xml) (33a0ad3). -
2026-10-14: a Python API with checked options, provenance on every result, sturdier weights downloads and UTF-8 output (
203018a). -
2026-10-14: turned, mirrored and tilted text, and photographs (
--rotate,--mirror,--deskew,--polarity auto, EXIF orientation) (c502445). -
2026-10-14: a status code per read and column, the control map (
--control) and per-line chance counts (05c59cc). -
2026-10-14: whole native maps block-averaged while loading, letter size in millimeters, a clearer overlay (
beb0ad8). -
2026-10-14: first public release with guide, model card, benchmarks and demo (
19d2f95, PR #57).
- letter-reader.md: the guide, with install, first reads, model cards, benchmarks and every result field.
- letterdemo: the offline install check on synthetic letters.
- letterlines: training lines from your own transcriptions, and scores on lines kept apart.
- greeklm: builds the optional letter language model for
--lm. - letters-training: the training, fine-tuning and benchmark scripts behind the weights and the numbers.
- workbench-package: the installable package and its
scrolls-letterreadconsole script. - The letter reader log and
letter_reader_checksinresults.json: the measured numbers and their conditions.