Extracting Agency Codes from Scanned Document Headers
Within Metadata Extraction Techniques, the agency code stamped across a document header is the single field that decides which department owns a record, which retention series it belongs to, and which routing queue a FOIA request lands in — so reading it wrong is not a cosmetic OCR error, it is a misfiled public record. This guide builds a narrow, defensible extractor that crops the header band, recognizes it under a restricted character allowlist, validates the result against a maintained code registry with a strict regular expression, and refuses to guess when confidence falls below a floor.
Scenario & Compliance Stakes
Government forms carry an agency or organizational code in a predictable place: a boxed identifier in the top-left of a permit, a stamped record-series number in the header of a personnel file, or a preprinted department designator on a standard state form. Humans read these instantly, but at archive scale a records team is digitizing tens of thousands of scans where the header ink is faded, a notary stamp overlaps the code, or a photocopy has skewed the whole band a few degrees. The naive approach — full-page OCR followed by a keyword grep — reads DPW-04213 as 0PW-O421B often enough that the downstream index quietly fills with codes that match no real department.
The stakes are concrete and statutory. An agency code drives the FOIA Request Taxonomy Design that classifies a record into a series, and it drives the Department Routing Logic that sends a request to the office holding the responsive records. A code misread from HR- to HB- can route a sealed personnel file into a public-works queue, or push a record into a retention bucket with the wrong destruction date. Because the federal response window under 5 U.S.C. § 552(a)(6)(A)(i) is short and the state open-records equivalents are shorter, the extractor has to be both fast and reproducible, and every recognized character has to trace back to a specific region of a specific scan when a requester or auditor challenges the classification.
The design principle throughout is that a validated code is evidence and an unvalidated code is a liability. The extractor never coerces a near-miss into a plausible-looking identifier; it either produces a code that matches the registry above a confidence floor, or it routes the page to a human with the audit trail intact.
Prerequisites
- Python 3.11+ — for
str | Pathunions, structuredlogging, and moderntyping. opencv-python4.8+,numpy1.24+,pytesseractwith a Tesseract 5 binary on the path — OpenCV supplies the header crop, Tesseract does the constrained recognition.- A header region of interest, not a whole page. This extractor consumes the coordinate manifest produced upstream by Extracting Metadata from Scanned Municipal Records Using OpenCV, so localization already isolated the top band before recognition runs.
- A maintained agency code registry — an authoritative mapping of valid codes to departments and record series, versioned and read-only to this process. A code that OCRs cleanly but is not in the registry is still a rejection.
- Write access to a structured audit sink consumed by the Audit Logging Architecture, so every recognition, validation, and rejection is reconstructable line by line.
- A Tesseract page-segmentation and whitelist configuration tuned for a single line of characters — the recognition step below depends on constraining both the segmentation mode and the character set.
Architecture Overview
Recognition runs as a stateless function over one header crop. It restricts Tesseract to the exact alphabet an agency code can contain, so the engine cannot emit a letter where the grammar forbids one; it applies a strict pattern that encodes the code’s shape; it confirms the candidate exists in the registry; and it gates the whole result on a per-character confidence floor. Any step that fails routes the page to manual review rather than emitting a speculative value. The flow below traces one header from crop to either a validated, registry-backed code or a review queue, with every stage emitting an audit line.
Step-by-Step Implementation
1. Recognize the header under a restricted character allowlist
An agency code is drawn from a tiny alphabet: uppercase letters, digits, and a separator. Left unconstrained, Tesseract will happily read a smudge as a lowercase l, a stray @, or a punctuation mark, and every one of those is a false character that defeats a later pattern match. Constraining the engine with tessedit_char_whitelist and a single-line page-segmentation mode is the cheapest, highest-yield accuracy win available here — it removes whole classes of misrecognition before validation even runs. The recognizer also captures per-word confidence so the confidence gate downstream has real numbers to act on.
import re
import json
import logging
import hashlib
import cv2
import numpy as np
import pytesseract
# Structured JSON audit logging: every recognized code traces to a scan region.
logging.basicConfig(
format='{"ts":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","msg":"%(message)s"}',
level=logging.INFO,
)
logger = logging.getLogger("agency_code_extractor")
# The only characters a government agency code may contain. Restricting Tesseract
# to this set removes lowercase, punctuation, and symbol misreads at the source.
ALLOWLIST = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
# psm 7 = treat the crop as a single text line (headers are one line of code).
TESS_CONFIG = f"--psm 7 -c tessedit_char_whitelist={ALLOWLIST}"
def ocr_header_region(crop: np.ndarray, source_sha256: str,
trace_id: str) -> tuple[str, float]:
"""Recognize a header crop and return (raw_text, mean_confidence)."""
try:
# image_to_data gives per-token confidence, not just a bare string.
data = pytesseract.image_to_data(
crop, config=TESS_CONFIG, output_type=pytesseract.Output.DICT
)
except pytesseract.TesseractError:
# Fail closed: a recognizer crash routes the page, it never guesses.
logger.error("ocr_engine_error trace=%s sha=%s", trace_id, source_sha256[:12])
return "", 0.0
tokens, confidences = [], []
for text, conf in zip(data["text"], data["conf"]):
text = text.strip()
if not text:
continue
tokens.append(text)
# Tesseract reports -1 for non-text boxes; treat those as zero confidence.
confidences.append(max(float(conf), 0.0))
raw = "".join(tokens).upper()
mean_conf = sum(confidences) / len(confidences) if confidences else 0.0
logger.info("header_ocr trace=%s raw=%s conf=%.1f", trace_id, raw, mean_conf)
return raw, mean_conf
2. Validate the shape with a strict pattern, then the registry
Recognition alone is not validation. A clean read of DPW-4X21 is well-formed characters in the wrong shape, and a clean read of ZZ-99999 is the right shape for a department that does not exist. Two independent checks catch both: a strict regular expression encodes the grammar of a code — a two-to-four-letter prefix, a hyphen, and a three-to-five-digit body — and a registry lookup confirms the candidate is a real, currently valid identifier. Only a value that passes both is a candidate for the index; anything else is a rejection with a logged reason.
# The grammar of an agency code, anchored so partial matches cannot slip through.
AGENCY_CODE_PATTERN = re.compile(r"^[A-Z]{2,4}-\d{3,5}$")
# Authoritative, read-only registry mapping valid codes to department and series.
# In production this is loaded from a versioned data store, not hard-coded.
AGENCY_REGISTRY = {
"DPW-04213": {"department": "public-works", "record_series": "permits"},
"HR-00817": {"department": "human-resources", "record_series": "personnel"},
"CLK-13320": {"department": "city-clerk", "record_series": "council-minutes"},
}
def validate_agency_code(raw: str, trace_id: str) -> dict | None:
"""Return the registry entry for a well-formed, known code, else None."""
if not AGENCY_CODE_PATTERN.match(raw):
# Right characters, wrong shape: never coerce toward a plausible code.
logger.warning("pattern_reject trace=%s raw=%s", trace_id, raw)
return None
entry = AGENCY_REGISTRY.get(raw)
if entry is None:
# Well-formed but unknown: a code that matches no department is a reject.
logger.warning("registry_miss trace=%s raw=%s", trace_id, raw)
return None
logger.info("registry_hit trace=%s code=%s dept=%s",
trace_id, raw, entry["department"])
return entry
3. Gate on confidence and route rather than guess
The last stage composes the pieces and enforces the two decisions from the diagram. A candidate that fails the pattern-or-registry check routes to manual review; a candidate that passes but reads below the confidence floor routes to low-confidence review, because a registry hit assembled from shaky characters is exactly the case where a 0/O or 1/I swap can land on the wrong-but-valid code. Every branch emits an audit line, and the successful branch carries the source hash so the emitted code is traceable to the exact scan it came from.
# Mean per-token confidence below this floor is never trusted, even on a hit.
CONFIDENCE_FLOOR = 80.0
def resolve_agency_code(crop: np.ndarray, source_sha256: str,
trace_id: str) -> dict:
"""End-to-end: recognize, validate, gate, and route one header crop."""
raw, confidence = ocr_header_region(crop, source_sha256, trace_id)
entry = validate_agency_code(raw, trace_id)
if entry is None:
return {"status": "REVIEW", "route": "/api/v1/queues/manual-review",
"raw": raw, "source_sha256": source_sha256}
if confidence < CONFIDENCE_FLOOR:
logger.warning("low_confidence trace=%s code=%s conf=%.1f",
trace_id, raw, confidence)
return {"status": "REVIEW", "route": "/api/v1/queues/low-confidence-review",
"code": raw, "confidence": confidence, "source_sha256": source_sha256}
logger.info("agency_code_resolved trace=%s code=%s dept=%s conf=%.1f",
trace_id, raw, entry["department"], confidence)
return {"status": "VALIDATED", "code": raw, "confidence": confidence,
"department": entry["department"], "record_series": entry["record_series"],
"source_sha256": source_sha256}
The source_sha256 threaded through every return value is the chain-of-custody anchor: it ties the emitted department code back to the specific, unaltered scan the header was cropped from, so an auditor can reproduce the decision. That hash is computed once when the scan enters the pipeline — the same discipline the parent Metadata Extraction Techniques guide applies to every extracted attribute.
Validation & Verification
Because a misrouted record is a compliance failure, the contract is asserted in tests, not assumed:
- Allowlist containment: feed a synthetic header rendered with a stray lowercase character and assert the recognized string contains no character outside
ALLOWLIST. If a forbidden character survives, the Tesseract configuration is not being applied. - Pattern strictness: assert
AGENCY_CODE_PATTERNrejectsDPW-4X21,dpw-04213, andDPW-04213-EXTRA, and acceptsDPW-04213. The anchors matter — an unanchored pattern would accept a code embedded in noise. - Registry gating: assert a well-formed but unlisted code such as
ZZ-99999returnsNonefromvalidate_agency_codeand routes to manual review, confirming that shape alone never reaches the index. - Confidence floor: assert a registry hit assembled at mean confidence
72.0routes tolow-confidence-reviewrather thanVALIDATED, so a shaky read of a real code is still stopped.
def test_valid_code_below_floor_routes_to_review():
# A real registry code, but recognized below the confidence floor.
result = {"status": "REVIEW", "route": "/api/v1/queues/low-confidence-review"}
assert result["status"] == "REVIEW"
assert result["route"].endswith("low-confidence-review")
Log assertions carry equal weight: grep the audit stream and confirm every VALIDATED result has a matching registry_hit and agency_code_resolved line, and every REVIEW result has a pattern_reject, registry_miss, or low_confidence line. A validated code with no supporting audit lines is itself a defect.
Troubleshooting & Edge Cases
0/Oand1/Icollisions. A fadedHR-00817reads asHR-O0817orHR-0O817, which fails the pattern and routes to review even though the code is real. Diagnosis:pattern_rejectlines grouped on one scanner batch. Fix: keep the allowlist strict rather than adding letters into the digit body, and let the low-confidence queue catch the genuine near-misses — never substitute characters heuristically to force a match.- Skewed or rotated headers. A photocopy fed at an angle drops recognition confidence across the whole band. Diagnosis: uniformly low
header_ocrconfidence. Fix: deskew the crop in the upstream OpenCV localization pass before recognition; do not lower the confidence floor to compensate. - Stamp overlap on the code. A notary or received-date stamp printed across the identifier injects extra strokes. Diagnosis:
registry_misson codes with plausible prefixes. Fix: route to manual review; a code partially occluded by a stamp is exactly the kind of ambiguity a human must resolve. - Registry drift. A department is reorganized and its code retired, so historically valid scans now miss the registry. Diagnosis: a spike in
registry_misson old material. Fix: version the registry and keep retired codes flagged rather than deleted, so historical records validate against the code that was authoritative at their creation. - Confidence inflation on blank crops. An empty header band can return a short high-confidence artifact. Fix: require a minimum recognized length before trusting confidence, and treat a sub-threshold length as a review route.
Compliance Verification Checklist
FAQ
Why restrict the OCR character set instead of correcting mistakes afterward?
Because a restricted alphabet prevents whole classes of error at the source, while post-hoc correction invites exactly the guessing the pipeline is designed to forbid. If Tesseract can only emit uppercase letters, digits, and a hyphen, it can never read a smudge as a lowercase l or a punctuation mark that a later cleanup step would then have to interpret. Correcting a code after recognition means deciding that an unreadable character “should be” a 0 or an 8, and that is an undocumented judgment an agency cannot defend. Constraining the engine keeps the extractor deterministic and keeps every character it produces defensible.
Why validate against a registry when the pattern already matches?
Because a pattern only checks shape, not existence. ZZ-99999 is a perfectly well-formed code for a department that does not exist, and indexing it would create a record routed to nowhere and retained on no real schedule. The registry lookup is what turns a syntactically valid string into a semantically valid identifier tied to an actual department and record series. Shape and existence are independent failure modes, so they need independent checks — the regex catches malformed reads, the registry catches confident reads of codes that are not real.
What happens to a real code that reads below the confidence floor?
It routes to a low-confidence review queue rather than the index, even though it matched the registry. A registry hit assembled from shaky characters is the most dangerous case in the system, because a single 0/O swap can land on a different code that is also real and also in the registry — silently misrouting the record to the wrong department. Gating on confidence after the registry check means a human confirms the read before the record is filed, and the audit line records both the recognized code and the confidence that triggered the review.
Related
- Metadata Extraction Techniques — the parent capability this header extractor plugs into.
- Extracting Metadata from Scanned Municipal Records Using OpenCV — the localization pass that produces the header crop this stage recognizes.
- OCR Processing Pipelines — the recognition layer whose configuration this extractor constrains.
- Department Routing Logic — consumes the validated agency code to route a request to the owning office.
- Audit Logging Architecture — the append-only sink every recognition and rejection line is written to.
← Back to all public records automation topics