This project provides a complete workflow for processing ALTO XML π files. It takes raw ALTO XMLs and transforms them into structured statistics tables π, performs text classification, and filters low-quality OCR π results.
The core of the quality filtering relies on language identification π and a composite quality score π β combining structural detectors, perplexity π, and character-level metrics β to identify and categorize noisy or unreliable OCR π output.
- βοΈ Setup
- π€οΈ Workflow Stages
- Acknowledgements π
Before you begin, set up your environment.
- Create and activate a new virtual environment π₯οΈ in the project directory.
- Install the required Python π packages:
pip install -r setup/requirements.txt
- Download the FastText π model for language identification:
wget "[https://huggingface.co/facebook/fasttext-language-identification/resolve/main/model.bin](https://huggingface.co/facebook/fasttext-language-identification/resolve/main/model.bin)" -O lid.176.bin - Clone and install
alto-toolsπ§, which is used for statistics and text extraction in low memory environments:git clone [https://github.com/cneud/alto-tools.git](https://github.com/cneud/alto-tools.git) cd alto-tools pip install . cd ..
- Copy the
v3folder from the πlayoutreaderπ§ repository [^9] to the project directory for the LR-based text extraction method:git clone [https://github.com/ppaanngggg/layoutreader.git](https://github.com/ppaanngggg/layoutreader.git) cp -r layoutreader/v3/ ./ rm -rf layoutreader/
You are now ready to start the workflow.
The process is divided into sequential steps, starting from raw ALTO π files and ending with extracted linguistic and statistic data π.
You can run the entire pipeline end-to-end with a single command (see below), or run each stage individually as described in Steps 1β4.
The run_pipeline.py π orchestrator runs every stage sequentially (split β statistics β text extraction β classification β aggregation) and, at the end, merges all per-stage paradata ποΈ logs into a single run summary describing every stage, the intermediate file formats produced, and the effective end-to-end output license βοΈ (see Paradata logging).
python3 run_pipeline.py # all settings from config_langID.txt
python3 run_pipeline.py --method glm # override just the extraction backend
python3 run_pipeline.py --skip-split # PAGE_ALTO already populated
python3 run_pipeline.py --dry-run # print the resolved plan, run nothing- Configuration βοΈ: every setting is read from config_langID.txt π
(section
[PIPELINE], withINPUT_CSVtaken from[EXTRACT]). Precedence is CLI flag > config value > built-in default. Point at a different config with--configor theLANGID_CONFIGenvironment variable. - Extraction method π:
[PIPELINE] METHODselects the Step 3 backend βalto-tools,layoutreader(default), orglm. The choice flows through to the merged license: a LayoutReader π run resolves to CC BY-NC-SA 4.0, an alto-tools π§° run to CC BY-NC 4.0. - Output π€: a merged
<YYMMDD-HHmmss>_pipeline-run.jsonin the paradata π directory, alongside the individual per-stage logs.
Note
page_split.py (Step 1) does not emit paradata of its own, so a full run typically merges
four logged stages (Steps 2β4 plus aggregation). The merged license is re-derived from the
union of components used across all stages, so the end-to-end most-restrictive rule holds.
Tip
Prefer to inspect or re-run a single stage? The individual scripts below remain fully usable on their own β the orchestrator simply calls them in order.
First, ensure you have a directory π containing your document-level <file>.alto.xml files.
This script will split them into individual page-specific XML π files.
python3 page_split.py <input_dir> <output_dir>
Each page-specific file retains the header from its original source document π.
- Input π₯:
../ALTO/(input directory with ALTO XML π documents) - Output π€:
../PAGE_ALTO/(output directory with ALTO XML π files split into pages)
Example of the output directory with divided per-page XML files: PAGE_ALTO π.
PAGE_ALTO/
βββ <file1>
β βββ <file1>-<page>.alto.xml
β βββ ...
βββ <file2>
β βββ <file2>-<page>.alto.xml
β βββ ...
βββ ...
Next, use the output directory from Step 1 as the input for this script to generate a foundational CSV π statistics file.
python3 alto_stats_create.py <input_dir> -o output.csv
This script writes a CSV π file line-by-line, capturing metadata for each page:
file, page, textlines, illustrations, graphics, strings, path
CTX200205348, 1, 33, 1, 10, 163, /lnet/.../A-PAGE/CTX200205348/CTX200205348-1.alto.xml
CTX200205348, 2, 0, 1, 12, 0, /lnet/.../A-PAGE/CTX200205348/CTX200205348-2.alto.xml
...
The extraction is powered by the alto-tools π§ framework ^1.
- Input π₯:
../PAGE_ALTO/(input directory with ALTO XML π files split into pages from Step 1) - Output π€:
output.csv(table with page-level statistics and paths to ALTO files)
Important
This statistics table is the basis for subsequent processing steps. Example: test_alto_stats.csv π.
This script runs in parallel β‘ (using multiple CPU π» cores) to extract text from ALTO XMLs π into .txt π files.
It reads the CSV π from Step 2.
- Input 1 π₯:
output.csv(from Step 2) - Input 2 π₯:
../PAGE_ALTO/(input directory with ALTO XML π files split into pages from Step 1) - Output π€:
../PAGE_TXT/or../PAGE_TXT_LR/(directory containing raw text π files)
Caution
The model responsible for spatial layout π analysis requires a GPU π to run efficiently.
python3 extract_LytRdr_ALTO_2_TXT.py
Uses the LayoutReader π framework ^9 to extract text and bounding boxes of XML π elements
(specifically, <TextLine> elements containing Strings with CONTENT attribute),
process them to reconstruct the reading order of lines (columns-friendly), handle words split
between two lines (adding the full form of the word), and group page contents into paragraphs
based on the vertical spread of text lines.
Example of per-page text files: PAGE_TXT_LR π.
PAGE_TXT_LR/
βββ <file1>
β βββ <file1>-<page>.txt
β βββ ...
βββ <file2>
β βββ <file2>-<page>.txt
β βββ ...
βββ ...
Note
The method is CPU π»-bound and faster than the LayoutReader method, but the text lines may not be in the correct reading order, and full forms of hyphenated split words are not reconstructed.
python3 extract_ALTO_2_TXT.py
Uses the alto-tools π§ framework ^1 to extract text lines from XML π elements directly,
with no post-processing. Suitable for a quick overview of raw text content.
Example of per-page text files: PAGE_TXT π.
PAGE_TXT/
βββ <file1>
βββ <file2>
β βββ <file2>-<page>.txt
β βββ ...
βββ ...
Warning
The method is GPU π-bound, slower than the LayoutReader method, and requires a gpuram48G card.
python3 extract_LLM_ALTO_2_TXT.py
Uses the GLM-4v-9b π€ multimodal large language model ^10 to perform generative OCR π directly from
page images, prompted as Transcribe all text on this page exactly as it appears. The script
trims whitespace and resizes high-resolution images to fit model constraints.
Note
This method is significantly slower than parsing XML π but often yields higher quality text for complex layouts π or degraded scans. It patches the transformers configuration to run the GLM-4v architecture.
Example of per-page text files: PAGE_TXT_LLM π.
PAGE_TXT_LLM/
βββ <file1>
βββ <file2>
β βββ <file2>-<page>.txt
β βββ ...
βββ ...
This is a key β time-consuming step that analyzes the text quality π of each page line-by-line, assigning each line a quality category to filter out OCR π noise.
It uses the FastText language identification model π and perplexity π scores from Qwen2.5-0.5B π€ to detect noise ^2 ^6.
More post-processing of TXT π files can be found in the GitHub repository of the ATRIUM project, which covers NLP enrichment using Nametag for NER and UDPipe for CONLL-U files with lemmas & POS tags ^5.
As the script processes, it assigns each line one of five categories πͺ§:
| Category | Action | Description |
|---|---|---|
| β Clear | Ready to be processed by further NLP | Passes all structural checks; high composite quality score π. |
| Corrections of generally readable words are needed | Partially degraded: moderate quality score π indicating isolated symbol issues, fused tokens, mid-word uppercase, or elevated perplexity π. | |
| ποΈ Trash | Should be re-processed by another OCR π tool | Severely corrupted: composite quality score π below the Trash threshold, or routed here by an override (unreadable all-caps line, inverted-scan page block). |
| π£ Non-text | May be checked for identifiers of finds/sites | Filtered by the CPU π» pre-filter: line is too short, has too few unique symbols, contains fewer than 30% alphabetic characters, or consists mostly of digits and punctuation. |
| π« Empty | Can be ignored | Line contains only whitespace (paragraphs separator) |
Note
This script generates two primary output directories:
DOC_LINE_LANG_CLASS/ and DOC_LINE_STATS/, while the
raw text π files (primary input) are stored in ../PAGE_TXT/ generated from ../PAGE_ALTO/.
All input/output paths and tunable parameters are configured βοΈ in config_langID.txt π.
Parameters are organized into three sections: [CLASSIFY], [AGGREGATE], and [TEXT_UTILS].
[CLASSIFY]
BATCH_SIZE = 128 # Batch size for processing lines
WORKERS_MAX = 32 # Max CPU workers for parallel tasks
EXPECTED_LANGS = ces,deu,eng # Expected languages (ISO codes); first is default
TRUSTED_FOREIGN_LANGS = deu,eng,fra,pol,ita # Allowed foreign languages (ISO codes)
MODEL_NAME = Qwen/Qwen2.5-0.5B # Language model for perplexity scoring; English-only collections: distilgpt2
[TEXT_UTILS]
QS_WEIGHT_VALID_WORD = 0.35 # Weight for valid word ratio in QS
QS_WEIGHT_WEIRD = 0.18 # Weight for inverted word weirdness in QS
QS_WEIGHT_PERPLEXITY = 0.08 # Weight for inverted normalized perplexity in QS
QS_WEIGHT_LENGTH = 0.02 # Weight for length reward in QS
QS_WEIGHT_GARBAGE = 0.18 # Weight for inverted garbage density in QS
QS_WEIGHT_VOWEL = 0.07 # Weight for vowel quality in QS
QS_WEIGHT_LANG = 0.05 # Weight for language confidence in QS
QS_WEIGHT_GIBBERISH = 0.04 # Weight for inverted gibberish ratio in QS
QS_WEIGHT_FUSED = 0.03 # Weight for inverted fused word ratio in QS
QS_LENGTH_MAX = 100.0 # Max length for normalization
# NOTE: QS_WEIGHT_SYMBOL no longer exists β there is no symbol_ratio term in compute_quality_score().
CATEG_TRASH_SCORE_MAX = 0.55 # Max QS for Trash category
CATEG_NOISY_SCORE_MAX = 0.80 # Max QS for Noisy category (#3 2026-07-02: lowered 0.85 -> 0.80)
REPEATED_DOUBLE_MIN = 2 # Minimum occurrence count for doubled-char penalty
SHORT_NOISY_QS_PENALTY = 0.20 # Opt-in QS penalty for short strings exhibiting OCR oddities
# --- New since last revision: Phase-2 categoriser overrides ---
LOWPPL_CLEAR_MAX = 50.0 # ppl ceiling for Override 3 (was hardcoded)
HARD_SWEEP_LANG_MAX = 0.45 # orig_lang_score ceiling for the hard-sweep route
HARD_SWEEP_PPL_MIN = 1000.0 # ppl floor for the hard-sweep route
GHOST_DOMINATED_MIN_RATIO = 0.5 # min ghost-token share to flag ghost_dominated
WORD_W_PENALTY = 0.20 # per-word weirdness penalty for tokens containing 'w'
ROT_HIGH_LANG_CONF = 0.90 # lang_score ceiling for the page-level rotation arm
Parameters that scale with the perplexity π model:
These parameters must be re-tuned whenever you switch between multilingual Qwen2.5-0.5Bπ€ and English-adapted distilgpt2π€,
because the two models produce perplexity π on very different numerical scales β Qwen2.5-0.5Bπ€ assign scores roughly 3Γ lower
than distilgpt2π€ on the same Czech π¨πΏ text:
| Parameter | Qwen2.5-0.5B | distilgpt2 | What it controls |
|---|---|---|---|
PERPLEXITY_THRESHOLD_MAX |
1000.0 | 3000.0 | The ceiling used to normalise raw perplexity π into [0, 1] for the quality score π. A value at or above this ceiling contributes 0 to the score (worst); a value of 0 contributes 1 (best). |
SHORT_PPL_CAP |
850.0 | 2500.0 | Maximum perplexity π applied to 1β2 word lines before quality scoring. Short text fragments receive extreme perplexity π scores from any LM because there is no context to condition on; this cap prevents legitimate short labels and codes from being unfairly penalised. |
PPL_INVERTED_MIN |
200.0 | 500.0 | Perplexity π floor for the inverted-scan detection arm. A line is considered a candidate for the inverted-scan penalty only if the LM is also uncertain about it (perplexity π above this value). |
CLEAN_PROSE_PPL_MAX |
400.0 | 1000.0 | Maximum perplexity π a line may have to qualify for the near-boundary Clear promotion (Override 4). Lines with perplexity π above this value are not promoted even if all other conditions are met. |
Parameters that are model-independent π€ and stable across different choices of perplexity π model π€:
These parameters are expressed as ratios or quality-score fractions, not as perplexity π values, so their meaning does not change between models and their defaults are stable across either choice:
| Parameter | Default | What it controls |
|---|---|---|
ROT_RATIO_INVERTED_MIN |
0.55 | Minimum fraction of structurally rotatable characters (pbqdnuwmoxszeyv) among alphabetic characters that must be present before a rotation penalty is even considered. A value of 0.55 means more than half of all letters in the line must belong to this ambiguous set. |
WEIRD_RATIO_INVERTED_MIN |
0.35 | Minimum mean per-word weirdness score required to confirm an inverted scan when rot_ratio is already above the threshold. This second condition prevents Czech π¨πΏ sentences that happen to contain many p, d, b, q letters from being falsely penalised. |
CLEAN_PROSE_MIN_SCORE |
0.65 | Lower bound of the quality-score range within which the near-boundary promotion (Override 4) can fire. A line must score at least this well before it is a candidate for promotion from Noisy to Clear. |
CLEAN_PROSE_WEIRD_MAX |
0.08 | Maximum mean per-word weirdness a line may have to qualify for the near-boundary promotion. Even a single notably corrupted token disqualifies the line from being promoted. |
CLEAN_PROSE_WC_MIN |
4 | Minimum word count a line must have to qualify for near-boundary promotion. Very short lines (1β3 words) have unreliable perplexity π scores and are therefore never promoted regardless of their quality score π. |
MOSTLY_READABLE_VALID_MIN |
0.85 | Minimum ratio of structurally valid words required. Semi-readable lines dipping below this ratio are capped at Noisy and prevented from achieving Clear. |
Note
The CLEAN_PROSE_* rows above (CLEAN_PROSE_PPL_MAX, CLEAN_PROSE_MIN_SCORE, CLEAN_PROSE_WEIRD_MAX,
CLEAN_PROSE_WC_MIN) parameterise the near-boundary "Override 4" clean-prose promotion, which has been
removed from determine_category() (see the callout in Categorisation Logic). These
keys β together with the never-implemented CLEAR_BAND_WC_MIN guard β have now been removed from
config_langID.txt as well (#7 Phase 0 of the config-coverage audit); they are not read by any current
scoring or categorisation path. The rows are kept here only as historical documentation of the removed override.
Language- and collection-specific data π¨πΏ moved from hardcoded Python literals into the config (#7 Tier 1). Defaults are bit-identical to the previous in-code values, so the shipped config produces exactly the same categorisation:
| Parameter | Section | Default | What it controls |
|---|---|---|---|
DEU_DIACS |
[TEXT_UTILS] |
ÀâüΓΓΓΓ |
German diacritic glyphs π©πͺ; together with CZ_DIACS rebuilds the per-language diacritic map used by infer_lang_from_diacritics(). |
DIACRITIC_INFER_THRESHOLD |
[TEXT_UTILS] |
0.07 | Minimum diacritic share among alphabetic characters for diacritic-based language inference. |
WQX_CHARS |
[TEXT_UTILS] |
wqxWQX |
Letters rare in Czech π¨πΏ β wqx-heavy tokens signal OCR noise in score_word, score_words_in_line and determine_category. |
ROT_WHITELIST |
[TEXT_UTILS] |
po,pod,do,od,on,ony,by,bez,ne,nebo,ven,den,zde,se,ve,mez,pouze,bude |
Czech π¨πΏ function words recognisable upright; their mirror/rotation ghost images (ROT_GHOSTLIST) are derived at import time β changing this key requires re-import (override_constants() does not rebuild it). |
GHOST_WORD_COLLISIONS |
[TEXT_UTILS] |
no,bo |
Ghost images that collide with real words and must never count as ghost hits. |
TRAILING_FILL_CHARS |
[TEXT_UTILS] |
\x20._:-<\u2013\u2014 |
Trailing filler characters stripped before headline/short-line checks. Unicode-escape decoded β the leading space is written as \x20 because configparser strips leading whitespace from values. |
NONTEXT_MARKERS |
[TEXT_UTILS] |
IVerc |
Collection-specific literal markers (ARUP/B stamp) forcing the Non-text route in pre_filter_line(). |
FASTTEXT_MODEL |
[CLASSIFY] |
lid.176.bin |
Path to the FastText π language-ID weights loaded by each CPU worker. |
TRUST_TIER_TRUSTED |
[CLASSIFY] |
0.85 | Trust multiplier on the FastText π confidence for a known but unexpected language before it feeds the quality score π. |
TRUST_TIER_UNKNOWN |
[CLASSIFY] |
0.50 | Trust multiplier for an unknown language. |
REMAP_KEEP_SCORE_LANGS |
[CLASSIFY] |
slk |
Languages that keep their original FastText π confidence when remapped to the default language (Slovak β Czech π¨πΏ, so the confidence stays meaningful after the label swap). |
This script reads the extracted text π files, batches lines together π¦, and runs the FastText π and Qwen2.5-0.5B π€ models. It uses a CPU π»/GPU π split architecture:
- A single dedicated GPU π worker holds the only Qwen2.5-0.5B π€ instance and processes perplexity π batches to prevent VRAM OOM errors.
- Multiple CPU π» workers (up to
WORKERS_MAX, default 32) read files, run FastText π and structural detectors, and submit text batches to the GPU π worker via a shared queue. CPU π» workers poll the result dictionary while the GPU processes, running language identification π concurrently.
Warning
The first item of EXPECTED_LANGS list of languages π should be the most expected language in the processed
collection to work as a default replacement of ambiguous language recognition predictions.
python3 langID_classify.py
- Input 1 π₯:
../PAGE_TXT/from Step 3 - Input 2 π₯:
output.csvfrom Step 2 - Output π€:
DOC_LINE_LANG_CLASS/containing per-document CSVs π (e.g., DOC_LINE_CATEG π)
Tip
This script is resume-capable. If interrupted, run it again and already-present output files will be skipped.
<doc_name>.csv π: Detailed classification results for every single line within a document, columns:
fileβ document identifier πpage_numβ page number πline_numβ line number, starts from 1 for each page π’textβ cleaned text of the line πoriginal_textβ original pre-repair text of the line πsplit_wsβ hyphenated word prefix at the end of the line (split word start)split_weβ hyphenated word suffix at the start of the line (split word end)word_countβ count of whitespace-delimited tokens in the line (count of words)char_countβ count of total character in the cleaned linegarbage_densityβ ratio of non-alphanumeric characters to total line length (calculated onoriginal_text)upperβ count of words with unexpected mid-word uppercase lettersrepeatedβ count of words where a non-standard character makes up β₯ 30% of the word, or containing consecutive doubled garble charactersldl_fusesβ count of words with a letterβdigitβletter sandwich (e.g.,vyt1aΔenΓ‘), excluding valid measurements.fused_wordsβ count of tokens that appear to be fused words (abnormal consonant/vowel runs or extreme length)gibberishβ count of words flagged as gibberish (high vowel ratio)weird_wxβ count of words with an abnormal density of 'w' or 'x' glyphsword_weirdβ mean per-word weirdness score in [0, 1]; combines strange-symbol (0.40), repeated-char (0.35), LDL-fusion (0.15), mid-uppercase (0.10), and aWORD_W_PENALTY-weighted (default 0.20) signal for tokens containing the letterwβ rare in Czech and a strong inverted/mirror-OCR fingerprint β plus a separate caps-prefix penalty (0.20). The combined score is clamped to [0, 1]. Isolated single letters score 0.85 (OCR noise) or 0.25 (digit/measurement).vowel_ratioβ ratio of vowel characters to total alphabetic and symbol characters in theoriginal_textrot_ratioβ the ratio of structurally ambiguous/rotatable characters (pbqdnuwmoxszeyv) to the total number of alphabetic characters in the line.
<doc_name>.csv's key resulting output columns that depict the final classification and quality assessment:
quality_scoreβ composite quality score π in [0, 1] based on 9 combined signals; higher = cleanercategβ assigned category: Clear β , Noisyβ οΈ , Trash ποΈ, Non-text π£, or Empty π«
<doc_name>.csv's columns useful for archive managers information apart from the quality score π and category:
langβ predicted ISO language code from the FastText π model (remapped if unknown)lang_scoreβ FastText π confidence score for the predicted language (capped if remapped, #3)original_langβ predicted language before remapping logicorig_lang_scoreβ original FastText confidence before remappingperplexβ Qwen2.5-0.5B π€ perplexity π score of the line πcaps_headerβ boolean flag indicating whether all alphabetic words in the line are uppercase (typical of section headers)
Diagnostic flags (#3):
Ten boolean audit columns follow caps_header. Six name the categoriser rule that decided the line β
allcaps_novowel, lowppl_clear, cleanprose_clear, trash_threshold, noisy_threshold, clear_threshold
(exactly one True, or none for Empty). Two further internal reason codes β trash_hard_sweep (route 1a)
and trash_inverted (route 1b) β also exist but are folded into the trash_threshold column rather than
getting their own column, so the per-line reason granularity is coarser in the CSV than inside the categoriser.
Four name the document-level post-pass that later changed it β pp_dedup (header/footer mode-harmonisation),
pp_surrounded_trash (rolling-window smoothing), pp_inverted_run (page-level inverted-scan sweep), and
pp_page_context (page-context Trash/Noisy adjustment, see below).
A categoriser flag and a pp_ flag may both be True on one line: that is the intended trail from the
original decision to the override.
Before any GPU π or model inference, pre_filter_line() applies a fast CPU π»-side check and assigns Empty or Non-text
directly, bypassing the ML pipeline entirely. It also applies two lightweight OCR π text repairs to every line before
the rules are evaluated.
Firstly, two fixes correct the most common systematic OCR π substitution errors before any rule is checked. They modify the text that is passed forward but do not on their own affect what category a line receives.
- Digit-for-letter substitution: A
1surrounded by alphabetic characters on both sides is replaced withl(e.g.,poh1edβpohled); a2at the start of a token followed immediately by a lowercase letter is replaced withz. These substitutions reflect common OCR π confusions between visually similar characters. - Spaced-letter collapse: A sequence of individually spaced single uppercase letters (
P R A H A) is recognised as a prostrkΓ‘vΓ‘nΓ/spaced-text typographic style and collapsed back into a normally-cased word (Praha). Without this repair, spaced words fail the letter-ratio check and would be discarded asNon-text.
- Line is blank or contains only whitespace β
Empty - Line consists entirely of digits, arithmetic/date separators, and punctuation with no letters β
Non-text(e.g.1998,5.3.,- 14 -) - Line is a Roman numeral, optionally followed by a period β
Non-text(e.g.XIV.,iii) - Line is a standalone alphanumeric archive or inventory code β a short letter prefix of up to 3 characters
followed by 3 or more digits, with an optional slash-separated suffix β
Non-text(e.g.A1739,CTX200205348,A679/2015) - Line matches a stamp-like ratio pattern β a short alphanumeric string, optional non-alphanumeric characters,
two 2-to-4 digit numbers separated by a
/, and optional trailing non-alphanumeric characters βNon-text(e.g.,123/456,1998/01,NZ1998/01) - Fewer than 4 total characters, or fewer than 3 unique non-whitespace symbols β
Non-text(lines this short cannot carry meaningful archaeological text) - Alphabetic characters make up less than 30% of total characters β
Non-text(the line is dominated by digits, punctuation, or special characters) - Isolated Chars & Fusions: A line dominated by isolated alphanumeric tokens, or a single token fusing letters, digits, and symbols β
Non-text. - Otherwise β forwarded for ML classification as
Process
Finally, the following categories of exception send a line directly to Process even if it would otherwise be caught by a Non-text rule:
- Metadata marker bypass β If the line contains any of the following patterns (checked case-insensitively),
it is forwarded as Process regardless of how short it is or how few letters it contains. These strings are
structural metadata markers specific to Czech π¨πΏ archaeological report forms. Without this bypass they would be
discarded as
Non-textbecause they are typically very short, heavily abbreviated, or contain mostly punctuation β but their presence is meaningful for downstream NLP and archival cataloguing:
| Marker | Typical context in Czech π¨πΏ archaeological records |
|---|---|
Tb. |
Table reference abbreviation (Czech π¨πΏ: tabulka) |
Δ.neg, Δ. neg, Δ neg, Δ.neg., Δ. neg., Δ neg. |
Negative number reference (ΔΓslo negativu) |
neg., neg |
Negative reference shorthand |
obr., obr |
Figure reference (obrΓ‘zek) |
Δ. |
General Czech π¨πΏ number abbreviation (ΔΓslo) |
str. |
Page abbreviation (strana) |
Datum |
Date field label on standard report forms |
-
High digit-ratio bypass β If digits make up more than 40% of the line's total characters, the line is forwarded as Process regardless of its letter ratio. This preserves content-bearing strings that are intentionally numeric-heavy: measurement records (e.g.,
vΓ‘ha 90,9g,30β50 cm), date strings (e.g.,5.XI.1946), grid coordinates, and catalogue references that combine letters and numbers. Without this bypass, most measurement lines would be discarded by rule 7 above. -
Forgiven headline / abbreviation bypass (#3, 2026-07-02) β Lines recognised by
is_forgiven_headline()(short numbered headlines/captions such as2, Popis nΓ‘lezu i - 3, and bare domain abbreviations/units such asmm,cm,Tb.,Δ.neg.) are forwarded as Process even when they fall under the 4-character floor of rule 6, so they are scored and floored atNoisyby the categoriser instead of being discarded asNon-text. See theforgivennote in Categorisation Logic for the full definition of what qualifies. -
All-caps headline word (#3, 2026-07-02) β A standalone all-caps alphabetic word that carries real vowels (e.g.
LITERATURA,ARCHEOLOGIE) is treated as a section headline and forwarded as Process for scoring, rather than being caught as a code by the standalone-alphanumeric-token check insideis_non_text()(rules 4β5). Genuine garbled codes are stillNon-text: a token containingX(a classic garbled-OCR π-code signal), a vowel-starved all-caps run of 10+ characters, or any digit-bearing alphanumeric token remainsNon-text.
FastText π ^2 is run on the lowercased line text and returns a predicted ISO 639-3 language code
(e.g. ces for Czech π¨πΏ, deu for German π©πͺ) and a confidence score between 0 and 1. The pipeline then applies
a series of remapping rules (remap_lang() in text_util_langID.pyπ) before the lang and
lang_score fields are finalised for storage and before the score is used in quality computation.
Configuration keys (in [CLASSIFY]):
EXPECTED_LANGSβ comma-separated list of language π codes the collection is expected to contain (e.g.,ces,deu,eng). The first entry is the default fallback language used when FastText π predicts a language that is not in eitherEXPECTED_LANGSorTRUSTED_FOREIGN_LANGS. If your collection is primarily Czech π¨πΏ,cesshould be first. If your collection is primarily German π©πͺ archival material, putdeufirst and adjust the perplexity π thresholds accordingly.TRUSTED_FOREIGN_LANGSβ comma-separated list of foreign languages π whose presence in the collection is considered genuine and should be kept as-is. A language belongs in this list if you expect real documents or passages in that language (e.g., German-language summaries in a Czech π¨πΏ report, Latin citations, English π¬π§ abstracts). Languages on this list are not remapped to the default, regardless of confidence.
Language score thresholds (in [TEXT_UTILS]):
LANG_SCORE_REMAP = 0.75β the confidence value applied to unknown Latin-script lines force-remapped to the collection default.LANG_SCORE_REMAP_FAR = 0.50β the confidence value applied to unknown non-Latin-script lines (Hangul, Cyrillic, CJK, β¦) force-remapped to the collection default.LANG_SCORE_ROUGH = 0.45β a FastText π confidence below this is considered too unreliable to trust. This threshold is used both by the hard-sweep override and by the page-level inverted-scan sweep (see Post-Processing Smoothing) to identify lines/pages where FastText π cannot confidently assign any language β a strong signal that the content is not readable text.LANG_SCORE_CLEAR = 0.75β reserved for future use; not currently read by any categorisation or scoring path.
Remapping logic (applied per line, in order):
- If the predicted language π code appears in
EXPECTED_LANGSorTRUSTED_FOREIGN_LANGSβ the FastText π prediction and confidence score are kept unchanged. No remapping occurs. - If the predicted language is
slk(Slovak), it is considered a near-twin of Czech and is remapped to the collection default, but its original score is preserved. - If the predicted language π is not in either set and not Slovak (e.g., FastText π guesses Danish
danon a Czech π¨πΏ line) β the language π code is force-remapped to the first entry ofEXPECTED_LANGS(the collection default), and the storedlang_scoreis replaced according to theLANG_REMAP_ALWAYSswitch below.
Important
LANG_REMAP_ALWAYS ([TEXT_UTILS], default true) controls how the replacement score in step 3 is computed:
true(default): the storedlang_scoreis unconditionally set toLANG_SCORE_REMAP(0.75, Latin-script guess) orLANG_SCORE_REMAP_FAR(0.50, non-Latin-script guess), regardless of what FastText originally reported. A weak and a strong foreign guess both land on the same fixed value.false: restores the earlier cap-not-floor behaviour β the stored score becomesmin(original_score, cap), using the same two cap values. A weak original guess is left untouched below the cap; only a confident foreign guess is pulled down. A confident foreign guess on Czech archival data is evidence of inverted or garbled OCR π, not of trustworthy language ID, so capping (rather than flooring) keeps the stored score honestly low.
Either way, this switch only changes the stored lang_score and the QS_WEIGHT_LANG input. It has no effect
on orig_lang_score β the pre-remapping FastText π confidence β which is passed through unchanged and is what
actually drives the hard-sweep, wqx/rotation, and vowelless overrides in Categorisation Logic.
Lines that pass the pre-filter are analysed by structural detectors defined in text_util_langID.pyπ:
| Detector | What it counts |
|---|---|
detect_strange_symbols |
Occurrences of any character that is not alphanumeric and not in the allowed set { . - , + ( ) " ' β β : % ; ? ! / }. Edge punctuation is stripped before inspection. |
detect_letter_digit_letter |
Words with a letterβdigitβletter sandwich β the fingerprint of OCR π digit insertions mid-word (e.g., vyt1aΔenΓ‘). Legitimate measurements (30cm, 90,9g) do not trigger. |
detect_mid_uppercase |
Words with unexpected uppercase mid-word (dalSΓ). Academic titles (PhDr, MUDr) are excluded. |
detect_repeated_chars |
Words with triple character runs, or double runs occurring β₯3 times. Exempts vowels o, u and digits to protect legitimate Czech π¨πΏ doubles (e.g., dennΓ). |
detect_gibberish_words |
Words of length β₯ 4 with a vowel ratio above VOWEL_RATIO_HIGH (0.70). All-caps and mostly numeric words are excluded. Sub-tokens are split on internal punctuation first. |
compute_rotatable_ratio |
Measures the concentration of structurally ambiguous/rotatable letters (pbqdnuwmoxszeyv) to catch severe visual noise interpreting graphical textures as characters. |
detect_fused_words |
Counts tokens that are likely multiple words merged without a space (token length > 14, consonant run of 5+, or vowel run of 3+). Sub-tokens are split on internal punctuation first. |
detect_wx_words |
Tokens with an abnormal density of 'w'/'x' glyphs (β₯ 2 per sub-token). By default, this is folded into the gibberish ratio to punish severe mirror scans. |
After structural detection, each line receives a single floating-point quality_score π in [0, 1] computed by
compute_quality_score() in text_util_langID.pyπ. The score is a weighted sum of nine
normalised signals, dynamically divided by the total sum of weights to strictly bound the maximum
score to 1.0 (preventing score inflation):
base_score =
QS_WEIGHT_VALID_WORD (def: 0.35) Γ valid_word_ratio
+ QS_WEIGHT_WEIRD (def: 0.18) Γ (1 β min(word_weird_ratio, 1.0))
+ QS_WEIGHT_PERPLEXITY (def: 0.08) Γ (1 β min(perplexity / PERPLEXITY_THRESHOLD_MAX, 1.0))
+ QS_WEIGHT_LENGTH (def: 0.02) Γ min(char_count / QS_LENGTH_MAX, 1.0)
+ active_garbage_wt (def: 0.18) Γ (1 β min(garbage_density / QS_GARBAGE_NORM_MAX, 1.0))
+ QS_WEIGHT_VOWEL (def: 0.07) Γ vowel_quality_score
+ QS_WEIGHT_LANG (def: 0.05) Γ lang_score
+ QS_WEIGHT_GIBBERISH (def: 0.04) Γ (1 β min(gibberish_ratio, 1.0))
+ QS_WEIGHT_FUSED (def: 0.03) Γ (1 β min(fused_ratio, 1.0))
quality_score = max(0.0, (base_score / total_weight) β short_penalty)
Note
There is no symbol_ratio term and no rot_penalty subtraction in the current implementation β both
appeared in earlier revisions. Symbol density is no longer fed into the quality score (the symbol
per-line column has also been removed); rotation/inversion is now handled entirely by the
per-line lexicon override and the page-level sweep described elsewhere in this document. compute_quality_score()
still accepts an is_upright_czech parameter for signature compatibility, but it has no effect on the computed value.
Note
(B2) QS_GARBAGE_NORM_MAX vs. CATEG_GARBAGE_DENSITY_HIGH. The garbage-density term inside the quality-score
formula is now normalised against its own constant, QS_GARBAGE_NORM_MAX (default 0.35), separate from
CATEG_GARBAGE_DENSITY_HIGH (also default 0.35), which gates the hard Trash override in
Categorisation Logic. The two constants were previously the same value reused in both
places, which made their individual contribution to the importance sweep inseparable. At default configuration
both equal 0.35, so behaviour is bit-identical to before; they can now be tuned independently.
Dynamic adjustments inside compute_quality_score() formula:
1. Garbage Penalty Guard (short clean strings)
Trigger: char_count β€ 12, word_weird == 0.0, and garbage_density < QS_GARBAGE_NORM_MAX.
What happens: active_garbage_wt is halved from QS_WEIGHT_GARBAGE (default 0.18) to 0.09. A compensating
constant of the same amount is added back to base_score so the total effective weight sum is unchanged and the
maximum possible score remains 1.0.
Why: Short archival label strings β Lokalita:, Osada:, Okres:, Datum: β contain a colon or other
structural punctuation that is counted as "garbage". Since the line is short, completely structurally clean (no weirdness),
and mostly legible, the reduced weight prevents the label from being unfairly penalised.
2. Short Noisy Strings Penalty (SHORT_NOISY_QS_PENALTY)
Trigger: char_count β€ 12 and (word_weird > 0.0 or garbage_density β₯ QS_GARBAGE_NORM_MAX).
Applied: Subtracts the configurable SHORT_NOISY_QS_PENALTY (default 0.20) directly from the final score
(the result is floored at 0.0).
Why: An opt-in penalty to sink very short noisy strings that might otherwise artificially float into acceptable score ranges due to their minimal features.
3. Short Perplexity π Cap (SHORT_PPL_CAP)
Trigger: word_count β€ 2 and raw LM perplexity π > SHORT_PPL_CAP (default 850.0 for Qwen2.5-0.5B π€,
2500.0 for distilgpt2 π€).
Applied: in langID_classify.py, before compute_quality_score() is called. The perplexity π value
passed to scoring is clamped to SHORT_PPL_CAP. The stored perplex column in the output CSV π is not
changed.
Why: Language models assign perplexity π by predicting tokens. With only 1β2 words, there is almost no context available, so the model assigns extremely high perplexity π even to perfectly valid words. Without this cap, every single-word or two-word line would receive a near-zero perplexity π component in its quality score π.
Categorisation is a two-function split: determine_category() in text_util_langID.pyπ holds
all of the routing logic and returns (category, reason); categorize_line() is now a thin wrapper that calls
determine_category() and then clamps the stored quality_score to the range consistent with the returned category
so downstream analytics can rely on the score as a monotone proxy for category rank.
Note
An earlier revision applied cumulative penalty subtractions to quality_score directly inside categorize_line()
before threshold routing (visible as a commented-out block in the source). The current implementation replaces
that cumulative-subtraction approach with the strict, ordered gate list below β the quality score itself is never
mutated during categorisation, only read.
Checked in order β the first match wins and skips all remaining checks, including the quality-score band routing.
Every gate below is individually toggleable via the ablation kill-switch (DISABLED_RULES) and, when active, calls
_fire(<rule_name>) for the rule-fire coverage instrumentation (tools/rule_coverage_report.py).
| # | Rule name | Condition | Result | Rationale |
|---|---|---|---|---|
| 0 | (implicit) | word_count == 0 or line contains only whitespace |
Empty |
Structural blank β no content to evaluate. Assigned before any scoring. |
| 1 | rule_hard_sweep |
orig_lang_score < HARD_SWEEP_LANG_MAX (def: 0.45) and ppl > HARD_SWEEP_PPL_MIN (def: 1000.0) |
Trash |
Hard sweep. FastText couldn't place the line at all and the LM found it surprising. Recorded as trash_hard_sweep. |
| 1a | rule_extreme_ppl |
ppl β₯ PPL_EXTREME_MIN (def: 3000.0) and orig_lang_score < EXTREME_LANG_CONF (def: 0.85) |
Trash |
Very high perplexity π alone is enough once the language guess also isn't strongly confident. Recorded as trash_hard_sweep. |
| 1b | rule_absolute_ppl |
ppl β₯ PPL_GARBAGE_ABSOLUTE (def: 30000.0) and not is_upright_czech |
Trash |
Catches catastrophic perplexity blow-ups regardless of language confidence, unless the line is protected by a Czech diacritic or upright function word. Recorded as trash_hard_sweep. |
| 2 | rule_inverted |
not is_upright_czech and (ghost_dominated or (no Czech diacritics and rot_ratio β₯ SUSPICIOUS_ROT_RATIO (def: 0.65) and ppl β₯ PPL_INVERTED_MIN (def: 200.0) and ghost-word hits β₯ GHOST_HITS_INVERTED_MIN (def: 1))) |
Trash |
Inverted/mirrored scan (per-line). ghost_dominated: a majority of word tokens are flip-images of common Czech function words (analyze_rotation_signals). is_upright_czech (a Czech diacritic, or a real upright function word) bypasses this route. Recorded as trash_inverted. |
| 3 | rule_allcaps |
All alphabetic words are uppercase and vowel_ratio < 0.10 |
Trash |
Definitively unreadable: an all-caps block with almost no vowels is a visual scramble. Recorded as allcaps_novowel. |
| 4 | rule_garbage_density |
garbage_density β₯ CATEG_GARBAGE_DENSITY_HIGH (def: 0.35), unless rule_trailing_fill_rescue fires (see below) |
Trash |
Garbage-density hard override. A line whose raw non-alphanumeric density alone exceeds the ceiling is routed to Trash directly, bypassing the weighted score. Recorded as trash_threshold. |
| 5 | rule_short_garbage |
(skipped entirely if the line is forgiven, see below) β word_count β€ ISOLATED_CHAR_MIN_TOKENS (def: 3) and no Czech π¨πΏ diacritics and lang_score β€ LANG_SCORE_REMAP (def: 0.75) and (gibberish present or word_weird > 0) |
Trash |
Structural short-garbage route (e.g. olie). Recorded as trash_threshold. |
| 6 | rule_lowppl_clear |
ppl < LOWPPL_CLEAR_MAX (def: 50.0) and word_count β₯ 3 |
Clear or Noisy if valid_word_ratio < MOSTLY_READABLE_VALID_MIN |
The language model is near-certain about the text. Recorded as lowppl_clear, or noisy_threshold if capped by the mostly-readable guard. |
Note
forgiven (is_forgiven_headline()) is computed once, immediately after gate 4 and before gate 5, so genuine
garbage caught by gates 1β4 is never rescued β forgiveness only ever lifts a line that would otherwise fall to
Trash at gate 5 or later up to Noisy. It recognises short numbered headlines/captions ("2, Popis nΓ‘lezu i - 3")
and bare domain abbreviations (mm, Tb., Δ.neg.) that would otherwise mis-route purely because the digits/symbols
around one or two real words drag valid_word_ratio down. A line is forgiven only when it carries both real
content (a clean word, a listed abbreviation, or a SHORT_VALID_WORDS function word) and genuine numbering/abbreviation
context (a short digit run, a roman numeral, or a domain abbreviation token) β a bare unnumbered prose fragment is
never forgiven and must route on its own quality score.
Four late-stage penalty gates (checked after gate 6, before quality-score band routing). Each one, if triggered,
only forces a Trash/rescue outcome when quality_score < CATEG_TRASH_SCORE_MAX + 0.35 (def: 0.90) β a line that
already scored high enough is left alone even if one of these structural red flags fires:
| Rule name | Condition | Rationale |
|---|---|---|
rule_wqx_rot |
(rot_ratio > 0.50 or wqx_ratio > 0.10) and orig_lang_score < 0.75 and not is_upright_czech |
Rotated/mirrored-glyph density or w/q/x-heavy tokens combined with a weak original language guess. |
rule_vowelless |
word_count β€ 3 and vowel_ratio < 0.30 and not is_upright_czech and the line is all-caps |
Short, vowel-starved, all-caps fragments (WVL A). |
rule_ledger_fragmentation |
len(words) β₯ 4 and more than 60% of tokens are bare digits or β€ 2 characters |
Table/ledger fragmentation loophole β mostly numeric or 1β2 char tokens. |
rule_mid_uppercase |
word_count β€ 2 and any token has unexpected mid-word uppercase |
Isolated mid-uppercase fragments (ClAΕ). |
When one of these fires and quality_score < 0.90, the outcome is resolved by check_rescues(), in order:
- If
rule_trailing_fill_rescuefires (see below) βNoisy/noisy_threshold. - Else if the line is
forgivenβNoisy/noisy_threshold. - Else β
Trash/trash_threshold.
rule_trailing_fill_rescue (_trailing_fill_rescued()) β used both at gate 4 and inside check_rescues(): if
stripping trailing fill characters (spaces, ._:-ββ<) from the line leaves a non-empty, structurally clean core
(compute_garbage_density(core) < CATEG_GARBAGE_DENSITY_HIGH) that either contains a Czech diacritic or is short
(word_count β€ 4 and len β€ 25), and valid_word_ratio > 0.0, the line is rescued rather than dropped straight to
Trash. This protects genuine short entries that trail off with punctuation/dashes (common in tabular archival forms).
Quality-score band routing (reached only if none of the gates above returned):
quality_score < CATEG_TRASH_SCORE_MAX (def: 0.55) β check_rescues() (Trash, unless rescued to Noisy)
quality_score β₯ CATEG_TRASH_SCORE_MAX:
valid_word_ratio < MOSTLY_READABLE_VALID_MIN (def: 0.85) AND NOT lm_confident_czech β Noisy (noisy_threshold)
otherwise β Clear (clear_threshold)
lm_confident_czech (_lm_confident_czech()) is true when is_upright_czech and ppl < LOWPPL_CZECH_CLEAR_MAX
(def: 180.0) and garbage_density < CZECH_CLEAR_GARBAGE_MAX (def: 0.15) β a confidently-Czech, low-perplexity,
structurally clean line is allowed through to Clear even if valid_word_ratio dips below the mostly-readable floor.
Important
The "Near-Boundary Clean Prose Promotion" (Override 4) described in earlier revisions of this document has been
removed from the current implementation. There is no CLEAN_PROSE_MIN_SCORE / CLEAN_PROSE_WC_MIN /
CLEAN_PROSE_WEIRD_MAX / CLEAN_PROSE_PPL_MAX promotion path in the current determine_category() β a Noisy
line just below CATEG_NOISY_SCORE_MAX is no longer promoted to Clear by this mechanism. The closest surviving
path to a similar outcome is rule_lowppl_clear (gate 6) and the lm_confident_czech relaxation of the mostly-readable
guard described above.
Score clamping after category assignment. categorize_line() clamps the stored quality_score π to the range
corresponding to the assigned band, so the CSV π value is always internally consistent with the categ label:
Trashβ score clamped tomin(qs, CATEG_TRASH_SCORE_MAX β Ξ΅)β always below 0.55Noisyβ score clamped to[CATEG_TRASH_SCORE_MAX, CATEG_NOISY_SCORE_MAX β Ξ΅]β always in[0.55, 0.80)Clearβ score clamped tomax(qs, CATEG_NOISY_SCORE_MAX)β always β₯ 0.80
Important
CATEG_NOISY_SCORE_MAX defaults to 0.80, not 0.85 as stated in earlier revisions of this document. The Noisy
band is therefore [0.55, 0.80) and Clear is β₯ 0.80 at default configuration.
After all lines in a document are classified and written to CSV π, apply_document_postprocessing() in
langID_classify.pyπ runs three passes, in this order, before the file is finalized. This
same function is reused byte-for-byte by the offline re-scorer (tools/recategorize_from_csv.py), so production
output and offline re-measurement never drift.
1. Header/footer deduplication. All occurrences of the exact same text string across a document are identified.
If the same string has been assigned to different categories on different pages (e.g., Obr. 1. SKUHROV NAD BΔLOU
is Clear on page 3 but Noisy on page 4 due to slightly different surrounding context affecting the LM), all
occurrences are harmonised to the statistical mode β the category assigned most frequently to that string across
the document. Recorded as pp_dedup.
Why: Repeating strings are boilerplate β page headers, footers, running titles, standard form labels. The same physical text should receive the same label throughout a document, and the majority vote across its occurrences is the most reliable estimate of the correct category.
2. Rolling-window surrounded-Trash smoothing. Scans the document line-by-line (documents with fewer than 5 lines
are skipped entirely). If a Noisy Trash π on both sides in a 5-line window (positions
β2 and β1 are Trash π and positions +1 and +2 are Trash π), and the line's quality score is below
CATEG_TRASH_SCORE_MAX + SURROUNDED_TRASH_QS_MARGIN (default: 0.70), it is downgraded to Trash π. Recorded as pp_surrounded_trash.
Why: A single Noisy Trash ποΈ lines is almost certainly corrupted text
that narrowly escaped the Trash π threshold. The score guard ensures that only near-boundary Noisy Noisy Trash π neighbourhood.
3. Page-context rules. For each page, two symmetric page-level rules run on top of the categories left by passes
1β2, using median_qs, clear_ratio, and the fraction of lines in a trusted language (decent_lang_ratio, over
EXPECTED_LANGS βͺ TRUSTED_FOREIGN_LANGS):
- Heavily-garbage pages: if a page's
Clearratio isβ€ PAGE_GARBAGE_CLEAR_MAX(def: 0.05), its trusted-language ratio is< PAGE_GARBAGE_LANG_MAX(def: 0.50), and its median quality score is< PAGE_GARBAGE_MEDIAN_QS_MAX(def: 0.55), everyNoisyline on that page scoring belowPAGE_GARBAGE_NOISY_QS_MAX(def: 0.80) is downgraded toTrash. - Predominantly-clean pages: if a page's
Clearratio is> PAGE_CLEAN_CLEAR_MIN(def: 0.60) and its median quality score is> PAGE_CLEAN_MEDIAN_QS_MIN(def: 0.80), everyTrashline on that page scoringβ₯ PAGE_CLEAN_RECOVER_QS_MIN(def: 0.45) and in a trusted language is promoted toNoisy.
Recorded as pp_page_context.
Why: A page that is almost entirely garbage rarely contains a genuinely-recoverable Noisy line; a page that is
almost entirely clean rarely contains a genuinely-unrecoverable Trash line. These rules use the page as additional
context the per-line categoriser cannot see.
4. Page-level inverted-scan sweep. Run last, independently per page, over every line not already Empty/Non-text.
A line is suspicious when it meets any of three detection arms:
- Diacritic-absence arm: the line lacks Czech π¨πΏ diacritics and has a stored
lang_score < LANG_SCORE_ROUGH(def: 0.45). - Perplexity/weirdness arm:
perplex β₯ PPL_INVERTED_MIN(def: 200.0) andword_weird > 0.0andlang_score < ROT_HIGH_LANG_CONF(def: 0.90). Norot_ratiorequirement. - Rotation arm: the line lacks Czech π¨πΏ diacritics and
rot_ratio β₯ ROT_RATIO_INVERTED_MIN(def: 0.55) andperplex β₯ PPL_INVERTED_MINandlang_score < ROT_HIGH_LANG_CONF.
Note
Earlier revisions of this document described only two arms and explicitly flagged that rot_ratio was computed
but never actually gated the page-level sweep. That is no longer accurate: the current implementation adds a third,
rotation arm that does condition on rot_ratio β₯ ROT_RATIO_INVERTED_MIN, alongside a perplexity/weirdness arm
that (like before) does not use rot_ratio at all. There is no code/doc discrepancy to flag here anymore.
Suspicious lines are downgraded to Trash when either:
- they make up β₯
INVERTED_PAGE_MAJORITY(default 0.60) of the page's scoreable lines β the page-majority arm, checked first, applied to the whole page and skipping the run-based rule for that page; or - absent a page-majority, they form a contiguous run of
β₯ INVERTED_RUN_MIN(default 4) suspicious lines.
Recorded as pp_inverted_run.
Why a page-majority arm? Some inverted/garbage scans break up into many short, isolated fragments separated by
Emptyπ« lines, Non-textπ£ stamps, or single-token noise, so the suspicious lines never form a single run of four
and escape the run-based rule. When the majority of a page's scoreable lines are individually suspicious, the
page as a whole is treated as an inverted/garbage scan and every suspicious line on it is downgraded, regardless of
run length. Lines carrying Czech π¨πΏ diacritics or a confident FastText π score are never suspicious, so genuine
content interleaved on the page is preserved.
Why three arms? Inverted-scan pages sometimes produce partial Czech π¨πΏ diacritics: the OCR π engine recognises some upside-down glyphs as plausible Latin characters and occasionally matches diacritical forms. The diacritic-absence arm alone would miss these pages. The perplexity/weirdness arm and the rotation arm each catch them independently β one using LM uncertainty plus word-level weirdness, the other using the character-shape rotation signal together with LM uncertainty β without requiring the absence of diacritics on their own.
The table below consolidates every factor that influences quality_score π or the final category assignment, including
where each factor is controlled and any known edge cases. This replaces the previous version of this table, which
still referenced the now-removed "Override 4" clean-prose promotion and the old CATEG_NOISY_SCORE_MAX = 0.85 default.
| Factor | Where applied | Config key(s) | Edge cases / exceptions |
|---|---|---|---|
| Valid word ratio | compute_quality_score (35% weight) |
QS_WEIGHT_VALID_WORD |
All-caps OCR π prefix guard: tokens like AAMMNAbSSOAO are excluded from valid-word count even though they are alphabetically dominant. |
| Word weirdness ratio | compute_quality_score (18% weight) |
QS_WEIGHT_WEIRD |
Isolated single letters score 0.85 (OCR π spaced-out noise); isolated digits/measurements score 0.25 (tolerable). All-caps words and academic titles excluded from mid-uppercase detection. |
| Perplexity π (LM) | compute_quality_score (8% weight) |
QS_WEIGHT_PERPLEXITY, PERPLEXITY_THRESHOLD_MAX |
Short-text perplexity π is capped at SHORT_PPL_CAP before scoring. rule_lowppl_clear (ppl < 50) bypasses thresholds entirely for highly confident predictions. |
| Text length | compute_quality_score (2% weight) |
QS_WEIGHT_LENGTH, QS_LENGTH_MAX |
Full reward for lines β₯ 100 chars; no minimum penalty for short lines. |
| Garbage density | compute_quality_score (18% weight) |
QS_WEIGHT_GARBAGE, QS_GARBAGE_NORM_MAX |
Halved to 9% for lines β€ 12 characters with zero weirdness and low density (short-string guard). Evaluated on the original text string. Normalisation constant (QS_GARBAGE_NORM_MAX) is separate from the hard-gate constant (CATEG_GARBAGE_DENSITY_HIGH), see B2 note above. |
| Vowel quality | compute_quality_score (7% weight) |
QS_WEIGHT_VOWEL, VOWEL_RATIO_LOW, VOWEL_RATIO_HIGH |
Linear ramp: full score in [0.20, 0.70] vowel ratio, ramps to 0.0 outside that range. |
| Language π confidence | compute_quality_score (5% weight) |
QS_WEIGHT_LANG |
Uses the stored (post-remapping) lang_score, whose value depends on LANG_REMAP_ALWAYS (see Language Handling); defaults to 0.5 when unavailable. |
| Gibberish ratio | compute_quality_score (4% weight) |
QS_WEIGHT_GIBBERISH |
Words β₯ 60% digits/separators excluded. Detection only on words β₯ 4 characters. Folds in the w/x count. |
| Fused word ratio | compute_quality_score (3% weight) |
QS_WEIGHT_FUSED, FUSED_VOWEL_RUN_MIN |
Triggers on tokens > 14 chars, consonant runs of 5+, or vowel runs of 3+. |
| Hard sweep / extreme / absolute PPL (gates 1β1b) | determine_category |
HARD_SWEEP_LANG_MAX, HARD_SWEEP_PPL_MIN, PPL_EXTREME_MIN, EXTREME_LANG_CONF, PPL_GARBAGE_ABSOLUTE |
Three independent hard-Trash routes; all fold to trash_hard_sweep. Fire before any other check, including forgiveness. |
| Inverted/mirrored lexicon (gate 2) | determine_category, analyze_rotation_signals, ghost_word_share |
GHOST_DOMINATED_MIN_RATIO, SUSPICIOUS_ROT_RATIO, PPL_INVERTED_MIN, GHOST_HITS_INVERTED_MIN |
Bypassed by any Czech diacritic or upright whitelist word (is_upright_czech). Recorded as trash_inverted. |
| All-caps vowel-less (gate 3) | determine_category |
none (hardcoded 0.10 vowel-ratio floor) | Fires only if all alphabetic words are uppercase and vowel_ratio < 0.10. Recorded as allcaps_novowel. |
| Garbage-density hard override (gate 4) | determine_category |
CATEG_GARBAGE_DENSITY_HIGH |
Bypassed by rule_trailing_fill_rescue. Recorded as trash_threshold. |
| Forgiven headline/abbreviation | determine_category, is_forgiven_headline, also pre_filter_line |
SHORT_EXCEPTION_TOKENS, HEADLINE_MAX_WORDS, HEADLINE_MAX_DIGITS |
Computed once after gate 4; only ever lifts an otherwise-Trash outcome to Noisy, never bypasses gates 1β4. Also used directly in the CPU pre-filter to route straight to Process. |
| Structural short-garbage route (gate 5) | determine_category |
ISOLATED_CHAR_MIN_TOKENS, LANG_SCORE_REMAP |
Skipped entirely if the line is forgiven. Recorded as trash_threshold. |
| High LM confidence override (gate 6) | determine_category |
LOWPPL_CLEAR_MAX (NOT PERPLEXITY_THRESHOLD_MAX) |
Requires ppl < 50.0 and word_count β₯ 3. Capped at Noisy if valid_word_ratio < MOSTLY_READABLE_VALID_MIN. Recorded as lowppl_clear / noisy_threshold. |
| Late-stage structural penalty gates | determine_category |
none new β reuses rot_ratio, wqx density, fragmentation ratio, mid-uppercase |
rule_wqx_rot, rule_vowelless, rule_ledger_fragmentation, rule_mid_uppercase; each only forces a rescue/Trash outcome when quality_score < CATEG_TRASH_SCORE_MAX + 0.35 (def. 0.90). |
| Trailing-fill rescue | determine_category, _trailing_fill_rescued |
CATEG_GARBAGE_DENSITY_HIGH |
Rescues short/diacritic-bearing lines whose only issue is trailing punctuation/dashes. Used at gate 4 and inside check_rescues(). |
| Mostly readable valid cap | determine_category |
MOSTLY_READABLE_VALID_MIN |
Caps semi-readable strings at Noisy unless lm_confident_czech (below) relaxes it. |
| LM-confident-Czech relaxation | determine_category, _lm_confident_czech |
LOWPPL_CZECH_CLEAR_MAX, CZECH_CLEAR_GARBAGE_MAX |
A confidently-Czech, low-perplexity, structurally clean line can reach Clear even below the mostly-readable floor. |
| (removed) | (removed: CLEAN_PROSE_* constants no longer exist) |
Previously promoted borderline Noisy lines to Clear; this path has been removed from the current implementation. See the note in Categorisation Logic. |
|
| Short perplexity π cap | langID_classify.py (before scoring) |
SHORT_PPL_CAP |
Applied only to lines with β€ 2 words. Does not change the stored perplex column; affects only the value passed to quality scoring. |
| Language π remapping | langID_classify.py / remap_lang (before scoring) |
EXPECTED_LANGS, TRUSTED_FOREIGN_LANGS, LANG_SCORE_REMAP, LANG_SCORE_REMAP_FAR, LANG_REMAP_ALWAYS |
Unknown languages remapped to first entry of EXPECTED_LANGS. Stored score set unconditionally (LANG_REMAP_ALWAYS=true, default) or capped (=false), except slk which always retains its original score. orig_lang_score is untouched either way and drives gates 1/2/late-stage penalties. |
| Context smoothing (rolling window) | Post-processing pass 2, langID_classify.py |
CATEG_TRASH_SCORE_MAX, SURROUNDED_TRASH_QS_MARGIN |
Noisy line must be surrounded by 2 Trash lines on each side (4 total); score must be < trash threshold + 0.15. Recorded as pp_surrounded_trash. |
| Page-context rules | Post-processing pass 3, langID_classify.py |
PAGE_GARBAGE_CLEAR_MAX, PAGE_GARBAGE_LANG_MAX, PAGE_GARBAGE_MEDIAN_QS_MAX, PAGE_GARBAGE_NOISY_QS_MAX, PAGE_CLEAN_CLEAR_MIN, PAGE_CLEAN_MEDIAN_QS_MIN, PAGE_CLEAN_RECOVER_QS_MIN |
Symmetric garbage-page-pulls-down / clean-page-promotes-up rules, run after dedup and rolling-window smoothing, before the inverted-scan sweep. Recorded as pp_page_context. |
| Page-level inverted-scan sweep | Post-processing pass 4, langID_classify.py |
ROT_RATIO_INVERTED_MIN, PPL_INVERTED_MIN, LANG_SCORE_ROUGH, ROT_HIGH_LANG_CONF, INVERTED_RUN_MIN, INVERTED_PAGE_MAJORITY |
Three arms (diacritic-absence, perplexity/weirdness, rotation) β see above. Suspicious lines Trashed via page-majority (checked first) or a run of β₯ 4. Recorded as pp_inverted_run. |
| Header/footer deduplication | Post-processing pass 1, langID_classify.py |
none | Based on exact text match across the whole document; harmonises to modal category. Recorded as pp_dedup. Runs first, before the other three passes. |
Example of per-document CSV π files: DOC_LINE_CATEG π by Qwen2.5-0.5B π€ and DOC_LINE_CATEG_gpt π by distilgpt2 π€.
DOC_LINE_LANG_CLASS/
βββ <docname1>.csv
βββ <docname2>.csv
βββ ...
This script processes the DOC_LINE_LANG_CLASS/ directory with CSV π files in chunks π§© to produce
final page-level statistics. It is CPU π»-bound and parallelized with ProcessPoolExecutor.
python3 langID_aggregate_STAT.py
- Input π₯:
DOC_LINE_LANG_CLASS/(directory with CSV π files from the previous step) - Output 1 π€:
final_page_stats.csvπ (configurable viaOUTPUT_STATS) β global page-level summary across all documents - Output 2 π€:
DOC_LINE_STAT/(configurable viaOUTPUT_DOC_DIR) β per-document CSVs π with the same schema
For each page, the aggregation computes features outputted in the following strict schema order:
Totals & Counts:
num_linesβ the total number of valid lines processed on the pageClear,Noisy,Trash,Non-text,Emptyβ integer count of lines in each categorytotal_word_countβ total number of words across scoreable linestotal_char_countβ total number of characters across scoreable lines
Averages (mean over the same Clear β
and Noisy
avg_quality_scoreβ mean composite quality score π in [0, 1]; higher = cleaner OCR π outputavg_word_weirdβ mean per-word weirdness ratio in [0, 1]; 0 = fully clean, lower is better πavg_lang_scoreβ mean FastText π confidence scoreavg_perplexβ mean Qwen2.5-0.5B π€ perplexity π scoreavg_vowel_ratioβ mean vowel-to-alphabetic-character ratio per lineavg_rot_ratioβ mean rotatable character ratio per linech_ratioβ mean fraction of lines flagged as all-caps headers (caps_header = True)
Language profile:
main_langβ the statistical mode (most frequent) language π predicted for the page
Note
avg_* columns and main_lang will be NaN / None for pages whose only lines are
Empty or Non-text (i.e., pages with no scoreable text content).
Additional per-line diagnostic variables (e.g. weird_wx, original_lang, original_text) and flags added
and ignored for this page-level aggregation to ensure stability.
All numeric averages are rounded to 4 decimal places; totals are stored as integers.
- Examples: arup_page_stats_SHORT.csv π, arub_page_stats_SHORT.csv π
Example of per-document aggregate CSV π files: DOC_LINE_STATS π by Qwen2.5-0.5B π€ and DOC_LINE_STATS_gpt π by distilgpt2 π€:
DOC_LINE_STAT/
βββ stats_<docname1>.csv
βββ stats_<docname2>.csv
βββ ...
This is the end of the text quality classification and filtering step. You can now use arup_page_stats_SHORT.csv π to identify files that need another round of OCR π or manual correction based on the line type counts. Pages with the majority of Clear β lines can be marked for further processing. The absence of clear lines combined with a high proportion of Trash ποΈ lines may also indicate handwritten content, which can be excluded before Handwritten Text Recognition (HTR) is applied.
In addition to the batch pipeline, this repository ships with a FastAPI wrapper (service/text_api.py) that exposes
the core text_util_langID quality classification engine over HTTP.
The batch pipeline and the API service share the same text_util_langID categorization engine and config_langID.txt
settings β including the default Qwen2.5-0.5B π€ perplexity model β to ensure zero drift between local processing
and web uploads.
For deployment instructions, endpoint specifications (/process, /info), and frontend integration details,
please see the dedicated Service Documentation.
This project incorporates a unified provenance and paradata ποΈ logging system to seamlessly track the execution details of every pipeline stage. The logger automatically captures run-time metadata and saves it in a structured JSON π format.
What gets logged?
- Provenance ποΈ: Captures the tool name, a tool version π·οΈ tag, the repository/runner reference, the running
container image (when set), the Python π version, and assigns a unique
run_idto each execution. The repository reference is resolved dynamically β environment overrides (ATRIUM_RUNNER_REPO,ATRIUM_RUNNER_REF,ATRIUM_RUNNER_IMAGE) take precedence over the static fallback in para_config.txt π β so the log points at the image actually executing rather than a fixed fork. - Output license βοΈ: Computes the effective output license π of the run from the licensed components it actually
exercised, and records it as
license/license_urlplus a detailedlicense_detailblock (per-component licenses, which component(s)determined_bythe result,is_non_commercial/is_share_alikeflags, and any unknown licenses). See Output licensing below. - Configuration βοΈ: Stores run-time configuration βοΈ, including script names, input/output paths, and specific model choices.
- Timing β±οΈ: Records precise UTC start times, end times, and the total duration of the run in seconds.
- Statistics π: Tracks the total number of input files, successfully processed documents, and computes performance throughput (e.g., output files generated per minute).
- Error Tracking π: Maintains a
skipped_files_detaillist that logs the exact filename and specific error reason if a file fails to process.
Log Location
By default, JSON π logs are written to the paradata π directory following the naming convention
<YYMMDD-HHmmss>_<program>.json. Paradata is intended to live alongside the outputs π€ (not committed to the
repository); the paradata ποΈ JSON files themselves are distributed under the CC BY-NC 4.0 license.
Important
The license of the files a run produces is not fixed β it is computed per run as the most restrictive license among the components (models, data, APIs) that the run actually used. The mechanism is data-driven via para_config.txt π (component β license) and para_licenses.py π (restrictiveness ranking + share-alike / non-commercial rules), so the licensing owner can adjust it without touching the logger.
Each repository ships a para_config.txt π listing its components. Components flagged always count
toward every run (the worst-case baseline); components flagged conditional are only counted when the script that uses
them records it. For this repository the components and their effect on the effective output license π are:
| Component | License | Counted | Used by |
|---|---|---|---|
| alto-tools π§ ^1 | Apache-2.0 | always | page split, statistics, alto-tools text extraction |
| FastText π ^2 | CC BY-NC 4.0 | always | language identification (langID_classify.py) |
| Qwen2.5-0.5B π€ ^6 | Apache-2.0 | conditional | perplexity π scoring (default, langID_classify.py) |
| distilgpt2 π€ | Apache-2.0 | conditional | perplexity π scoring (English-only alternative) |
| LayoutLMv3 π ^9 | CC BY-NC-SA 4.0 | conditional | LayoutReader text extraction (extract_LytRdr_ALTO_2_TXT.py) |
| GLM-4v-9b π€ ^10 | glm-4 | conditional | generative OCR π extraction (extract_LLM_ALTO_2_TXT.py) |
Because the always-on FastText π weights are CC BY-NC 4.0, the baseline effective output license for this repository is CC BY-NC 4.0 (non-commercial). Runs that additionally use the LayoutReader π method escalate to CC BY-NC-SA 4.0 (non-commercial and share-alike), the most restrictive option here. A run that exercised only permissive components would resolve to Apache-2.0.
Note
The restrictiveness ordering encoded in para_licenses.py π is a mechanical engineering approximation, not legal advice; unrecognised licenses are treated conservatively as maximally restrictive so a missing entry can never silently relax the recorded output license.
For support write to: lutsai.k@gmail.com β responsible for this GitHub repository ^8 π
Β©οΈ 2026 UFAL & ATRIUM
[^1]: https://github.com/cneud/alto-tools
[^2]: https://huggingface.co/facebook/fasttext-language-identification
[^4]: https://atrium-research.eu/
[^5]: https://github.com/ufal/atrium-nlp-enrich
[^6]: https://huggingface.co/Qwen/Qwen2.5-0.5B
[^7]: https://ufal.mff.cuni.cz/home-page
[^8]: https://github.com/ufal/atrium-alto-postprocess
[^9]: https://github.com/ppaanngggg/layoutreader
[^10]: https://huggingface.co/THUDM/glm-4v-9b