Extracting Contractor License Numbers from Scanned PDFs with Tesseract
This page is a focused extension of parsing PDF permit applications with OCR and layout analysis, the document-parsing subsystem inside the broader automated permit ingestion and parsing workflows architecture. Where the parent guide builds a general five-stage extractor, this guide drills into the single hardest field on a municipal application: the contractor license number. It assumes you already have a deskewed, binarized page and a named zone for the contractor block, and it layers the constrained OCR, state-aware validation, and registry reconciliation that turn an error-prone read into compliance-grade data.
Permalink to this section Why the License Field Is the Riskiest Read on the Form
A misread parcel ID surfaces as a failed lookup; a misread contractor license number quietly authorizes an unlicensed, uninsured, or suspended contractor to pull a permit. The compliance stakes are concrete: inspection scheduling, bond verification, workers-compensation checks, and municipal liability all key off this one alphanumeric string. Three properties of the field make it uniquely hard.
First, license numbers carry no semantic redundancy — every character is significant, so the classic OCR substitutions (O/0, I/1/l, S/5, B/8, Z/2) each flip a valid license into a different valid-looking one. Second, the field appears in the most degraded part of the document: it is typically hand-stamped, rubber-stamped over signature lines, or printed by the contractor’s own dot-matrix software onto a faxed affidavit. Third, the format is not universal — California issues a one-letter-plus-six-digit class license, Florida uses prefixes like CGC/CBC/CRC, Texas TDLR numbers run six to seven digits with no letter, and Nevada zero-pads to a fixed width. A single national regex is worse than useless; it launders bad reads into apparently-valid output.
The input you are handed is a cropped, binarized grayscale region of one page plus the filing’s jurisdiction_code. The output is a LicenseResult carrying the normalized license string, the matched state format, an aggregate confidence, and a routing disposition (auto_accept, needs_review, or quarantine).
Permalink to this section Step 1: Constrain Tesseract to the License Character Space
The base extractor in the parent guide OCRs a zone with a generic alphanumeric whitelist. For the license field specifically, tighten every control: force the LSTM engine (--oem 1), treat the stamp as a single line (--psm 7), pin DPI to your render resolution, and restrict the whitelist to uppercase letters, digits, and the hyphen that some states embed. Critically, capture per-character data so a downstream step can detect ambiguity, not just read a flat string.
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pytesseract
from pytesseract import Output
# Uppercase + digits + hyphen only. Excluding lowercase and punctuation
# removes most stamp-smudge hallucinations before they reach validation.
LICENSE_WHITELIST = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
@dataclass(slots=True)
class RawLicense:
text: str
min_conf: float # weakest character's confidence (0-100)
chars: list[tuple[str, float]]
def ocr_license_zone(zone_img: np.ndarray, *, dpi: int = 300) -> RawLicense:
"""Read a single cropped license stamp under maximally constrained OCR."""
config = (
f"--oem 1 --psm 7 --dpi {dpi} "
f"-c tessedit_char_whitelist={LICENSE_WHITELIST}"
)
data = pytesseract.image_to_data(
zone_img, config=config, output_type=Output.DICT
)
chars: list[tuple[str, float]] = []
for token, conf in zip(data["text"], data["conf"]):
token, conf = token.strip(), float(conf)
if not token or conf < 0:
continue
chars.extend((ch, conf) for ch in token) # spread word conf to chars
text = "".join(ch for ch, _ in chars)
min_conf = min((c for _, c in chars), default=0.0)
return RawLicense(text=text, min_conf=min_conf, chars=chars)
--psm 7 (single text line) consistently beats --psm 6 on a one-line stamp because it stops Tesseract from inventing line breaks across a smudged baseline. The full set of engine flags is catalogued in the Tesseract command-line usage guide. If a contractor block spans two lines (license plus expiration date), OCR the license sub-zone separately rather than letting the date contaminate the read.
Permalink to this section Step 2: Validate Against State-Specific Formats
Resolve the expected pattern from the filing’s jurisdiction, not from a guess about the string’s shape. Keep the format table beside the same registry that drives the rest of the parser, and version it alongside the permit code taxonomies for annual updates so a state board’s renumbering and your regex change ship together.
import re
# Compiled per-state license formats. Anchored, so a partial match fails
# loudly instead of silently truncating a longer misread.
STATE_LICENSE_FORMATS: dict[str, re.Pattern[str]] = {
"CA": re.compile(r"^[A-Z]\d{6,7}$"), # class letter + 6-7 digits
"FL": re.compile(r"^C[GBR]C\d{6,7}$"), # CGC/CBC/CRC + digits
"TX": re.compile(r"^\d{6,7}$"), # TDLR numeric
"NV": re.compile(r"^\d{7}$"), # zero-padded to 7
}
def validate_format(license_text: str, jurisdiction_code: str) -> bool:
"""True only if the read matches the issuing state's documented format."""
state = jurisdiction_code.split("-", 1)[0].upper() # e.g. "CA-ALAMEDA"
pattern = STATE_LICENSE_FORMATS.get(state)
if pattern is None:
# Unknown jurisdiction: never auto-pass — force human review upstream.
return False
return bool(pattern.fullmatch(license_text))
Anchoring each pattern with ^/$ (or fullmatch) is what prevents a 13-character smear from matching a 7-character rule on a substring. The re module’s anchoring and grouping behavior is documented in the Python regular-expression reference. When jurisdiction_code is absent or unmapped, fail closed: an unknown format is a review trigger, never an auto-accept.
Permalink to this section Step 3: Resolve Ambiguous Characters Before Rejecting
A failed format match is often a recoverable read, not a wrong one — one ambiguous glyph away from valid. Rather than discard it, generate the small set of candidate strings reachable by swapping known confusable characters, and keep only candidates that satisfy the state format. This recovers a large share of stamp reads without ever inventing a license out of nothing.
from itertools import product
from math import prod
# Bidirectional swaps for the confusions that survive a tight whitelist.
CONFUSION_MAP: dict[str, tuple[str, ...]] = {
"0": ("0", "O"), "O": ("O", "0"),
"1": ("1", "I"), "I": ("I", "1"),
"5": ("5", "S"), "S": ("S", "5"),
"8": ("8", "B"), "B": ("B", "8"),
"2": ("2", "Z"), "Z": ("Z", "2"),
}
def recover_candidates(text: str, jurisdiction_code: str,
*, max_expansions: int = 64) -> list[str]:
"""Enumerate confusable-swap variants that match the state format."""
options = [CONFUSION_MAP.get(ch, (ch,)) for ch in text]
# Bound the combinatorics: if the search space is too large, the read is
# too noisy to auto-correct safely -- defer to a human instead of guessing.
if prod(len(o) for o in options) > max_expansions:
return []
seen: list[str] = []
for combo in product(*options):
candidate = "".join(combo)
if validate_format(candidate, jurisdiction_code) and candidate not in seen:
seen.append(candidate)
return seen
If exactly one candidate validates, you have a high-confidence correction; if several do, the read is genuinely ambiguous and must go to a clerk — picking one would be guessing. Cap the expansion (max_expansions) so a long noisy read cannot explode into thousands of combinations on a constrained worker.
Permalink to this section Step 4: Reconcile Against the State License Registry
Format-valid is not the same as real-and-active. The final gate confirms the number exists and is in good standing by querying the issuing board’s API or a nightly-cached mirror. These lookups are slow and rate-limited, so cache aggressively and treat the registry call as the network boundary it is.
import logging
from enum import StrEnum
logger = logging.getLogger("permit.license")
CONFIDENCE_FLOOR = 85.0
class Disposition(StrEnum):
AUTO_ACCEPT = "auto_accept"
NEEDS_REVIEW = "needs_review"
QUARANTINE = "quarantine"
@dataclass(slots=True)
class LicenseResult:
license: str | None
state: str
confidence: float
disposition: Disposition
def resolve_license(raw: RawLicense, jurisdiction_code: str,
registry) -> LicenseResult:
"""Validate, recover, and reconcile a raw read into a routed result."""
state = jurisdiction_code.split("-", 1)[0].upper()
if validate_format(raw.text, jurisdiction_code):
candidate = raw.text
else:
recovered = recover_candidates(raw.text, jurisdiction_code)
if len(recovered) != 1: # zero or ambiguous -> human
return LicenseResult(None, state, raw.min_conf,
Disposition.NEEDS_REVIEW)
candidate = recovered[0]
# registry.lookup returns ("active" | "expired" | "suspended" | None)
status = registry.lookup(state, candidate) # cached; see below
if status is None:
return LicenseResult(None, state, raw.min_conf, Disposition.QUARANTINE)
if status != "active" or raw.min_conf < CONFIDENCE_FLOOR:
return LicenseResult(candidate, state, raw.min_conf,
Disposition.NEEDS_REVIEW)
return LicenseResult(candidate, state, raw.min_conf, Disposition.AUTO_ACCEPT)
Score routing on the weakest character (min_conf), never the average — one uncertain digit invalidates the whole number. A format-valid read of an expired or suspended license is arguably the most important case to surface, so it routes to review rather than rejection: a clerk needs to see it, not have it silently dropped. Registry calls inherit the backoff and circuit-breaker discipline from error handling and retry logic for ingestion pipelines, and at submission-spike volume the lookups run inside the workers described in implementing async batch processing for high-volume submissions.
Permalink to this section Parameter and Flag Reference
| Setting | Recommended | Rationale for license stamps |
|---|---|---|
--oem |
1 |
LSTM engine; the legacy matcher collapses on rubber-stamped and faxed text. |
--psm |
7 |
Single-line mode reads a one-line stamp without inventing breaks across smudges. |
--dpi |
300 |
Must equal the render DPI; a mismatch desyncs Tesseract’s internal scaling and inflates errors. |
tessedit_char_whitelist |
A–Z0–9- |
Drops lowercase and punctuation hallucinations before validation ever runs. |
CONFIDENCE_FLOOR |
85.0 |
Below this the field routes to a clerk; tune against a fixture set per scanner fleet. |
max_expansions |
64 |
Caps confusable-swap combinatorics so a noisy read can’t exhaust a 2–4 GB worker. |
| registry cache TTL | ~24h |
State boards update nightly; a daily mirror balances freshness against rate limits. |
Permalink to this section Common Failure Patterns and Fixes
Permalink to this section Character substitution survives the whitelist
The whitelist removes impossible glyphs but cannot tell 0 from O when both are legal. Do not trust a single read of a length-ambiguous field. Always run Step 3’s confusable expansion against the state format, and if more than one candidate validates, route to review — the correct fix is escalation, not a coin flip.
Permalink to this section DPI mismatch between render and OCR
If render_page produced 300 DPI but you pass --dpi 200 (or omit it), Tesseract mis-scales its character model and confidence craters across the board. Thread one dpi value through rasterization and the OCR config from a single source of truth so the two can never drift apart.
Permalink to this section State schema drift after a board renumbering
A state that adds a class prefix or extends digit length will fail every read overnight if the regex is hard-coded. Keep STATE_LICENSE_FORMATS in the versioned taxonomy, add a contract test per state that asserts a known-good license still matches, and alert when a previously stable jurisdiction’s pass rate falls off a cliff — that is drift, not document quality.
Permalink to this section Stamp overlaps the signature or a printed line
Rubber stamps land on top of ruled lines, leaving horizontal strokes that OCR reads as extra - or 1 characters. Apply a thin horizontal-line removal (morphological opening with a wide, 1-pixel-tall kernel) to the license sub-zone before OCR, and strip leading/trailing hyphens that the whitelist would otherwise admit.
Permalink to this section Multiple licenses in one contractor block
General-plus-subcontractor filings stack several numbers in one zone. OCR with --psm 6 (uniform block) to preserve line structure, split on line breaks, and validate each line independently against the state format rather than concatenating them into one impossible string.
Permalink to this section Audit and Logging Guidance
Every license decision must be reconstructable years later, because a permit issued to a mis-validated contractor can become a records-retention and liability question. Emit one structured log record per field, written to the same immutable audit trail the rest of the pipeline uses. Capture at minimum:
- Source file hash, page number, and the zone bounding box the read came from.
- The raw OCR string, its per-character and aggregate confidence, and the OCR config string (engine, PSM, DPI, whitelist).
- Any confusable-swap correction applied, including all candidates that validated.
- The matched state format key and the registry lookup status (
active/expired/suspended/not_found). - The final disposition and, for
needs_review/quarantine, the specific reason code.
def audit_license(file_hash: str, page: int, raw: RawLicense,
result: LicenseResult, *, reason: str | None = None) -> None:
logger.info(
"license_decision",
extra={
"file_hash": file_hash, "page": page,
"raw_text": raw.text, "min_conf": raw.min_conf,
"resolved": result.license, "state": result.state,
"disposition": str(result.disposition), "reason": reason,
},
)
Log the raw read alongside the resolved value so a compliance officer can see why a correction was made, not just the corrected number. When false positives cluster around one document type — thermal-fax affidavits or third-generation photocopies — the telemetry tells you whether to retune preprocessing or commission a custom .traineddata model, and the normalized records flow downstream into the same store targeted when syncing legacy CSV exports to modern databases.
Permalink to this section Frequently Asked Questions
Permalink to this section Why not just use a single national license regex?
Because state formats overlap: a clean Texas read can satisfy a California pattern on a substring, laundering a wrong-state misread into apparently-valid output. Always resolve the expected format from the filing’s jurisdiction_code and fail closed when the jurisdiction is unknown.
Permalink to this section Should I auto-correct an ambiguous read or send it to a human?
Auto-correct only when exactly one confusable-swap candidate matches the state format. If zero or more than one validate, route to review — choosing among several valid candidates is guessing, and a guessed license number is the failure mode this whole pipeline exists to prevent.
Permalink to this section What confidence threshold is right for license fields?
Start at 85 on the weakest character, with anything under 50 treated as effectively illegible. Score the minimum, not the average, because a single uncertain digit invalidates the entire number. Tune the floor against a fixture set of real degraded stamps from your own scanner fleet.
Permalink to this section How do I handle a format-valid license that is expired or suspended?
Route it to review, never to silent rejection. An expired or suspended license that reads cleanly is exactly what a clerk and compliance officer need to act on, so the registry status — not just the format match — drives the disposition.
Permalink to this section Related
- Parsing PDF permit applications with OCR and layout analysis — the parent guide whose base extractor, zone maps, and routing this page extends.
- Error handling and retry logic for ingestion pipelines — backoff and circuit-breaker patterns for the state-registry lookups in Step 4.
- Designing JSON schemas for building permits — the canonical field model the validated license number conforms to.
- Automated permit ingestion and parsing workflows — the full ingestion architecture this extraction step lives inside.