Configuring Tesseract OCR for Fleet Inspection Forms
Configure Tesseract wrong on a Driver Vehicle Inspection Report and the engine will not fail loudly — it will confidently emit a VIN with a letter O where a zero belongs, and that single misread silently breaks the vehicle-to-report join that 49 CFR § 396.11(a)(3) obligates the carrier to retain and reconstruct on demand. This page answers one focused question: which exact Tesseract 5.x invocation — page segmentation mode, engine mode, DPI, and per-field character whitelist — turns a grease-smudged, checkbox-heavy inspection scan into field-typed text with a per-field confidence you can gate on. It is the recognition stage of the PDF & Image OCR Pipeline Setup reference, and every field it emits must satisfy the canonical contract in the Standardized DVIR JSON Schema Design before any downstream stage is allowed to trust it.
Tesseract is a probabilistic engine and § 396.11 is not. The entire configuration below exists to make the probabilistic stage bounded and observable: constrain what characters each field may contain, emit a per-character confidence for every glyph, and refuse to admit any critical field that scores below the floor. A DVIR OCR run is not “read the page as best you can” — it is “read exactly these regions under exactly these constraints, and route anything you are unsure about instead of guessing.”
Prerequisites
Anchor link to "Prerequisites"This page assumes the raster already left the PDF & Image OCR Pipeline Setup preprocessing stage — grayscale, deskewed, adaptively thresholded, and grid-line-suppressed. Tesseract configuration is the step after the image is clean; feeding it a raw phone photo of a triplicate form and hoping the flags compensate is the single most common cause of low-confidence output. Before implementing this you need:
- Python 3.10+ — the code below uses
StrEnum, structuralmatch, and theX | Yunion syntax. - Tesseract 5.x installed system-side with the
engtraineddata pack; verify withtesseract --versionand confirm the LSTM engine is present (tesseract --list-langs). pytesseract>=0.3.10— the thin wrapper exposing bothimage_to_stringand the TSV-producingimage_to_data.opencv-pythonandnumpy— to carry the cropped region-of-interest arrays into Tesseract without a disk round-trip.pydantic(v2) — to coerce the extracted strings into the canonical field contract from the Standardized DVIR JSON Schema Design and reject anything mistyped.
A version-controlled ROI layout map (one (y1, y2, x1, x2) tuple per field, per carrier template) is a hard prerequisite. Tesseract’s field-level accuracy on a DVIR comes almost entirely from cropping to a single field before recognition, not from asking the engine to segment the whole page.
Step-by-Step Implementation
Anchor link to "Step-by-Step Implementation"Step 1 — Choose the page segmentation mode per field, not per page
Anchor link to "Step 1 — Choose the page segmentation mode per field, not per page"The --psm flag tells Tesseract how to partition the raster before recognition, and the correct value depends on what a cropped field contains, not on the page as a whole. The default --psm 3 (fully automatic page segmentation) treats pre-printed form rules as text boundaries and bleeds adjacent columns together — never use it on a DVIR. Crop to one field, then pick the mode that matches that field’s geometry:
--psm 7— treat the image as a single text line. This is the correct default for a cropped odometer box, VIN strip, or unit-number field.--psm 6— assume a single uniform block of text. Use it for a multi-line handwritten defect description.--psm 12— sparse text with orientation and script detection. Use it for scattered checkbox codes where glyphs do not share a baseline.
Engine mode is not optional: --oem 3 selects the LSTM neural engine (with legacy fallback), which reads both machine-printed odometer digits and cursive mechanic annotations that the legacy engine alone drops.
from enum import StrEnum
class Psm(StrEnum):
"""Page segmentation modes used across DVIR field types."""
SINGLE_LINE = "7" # odometer, VIN, unit number
UNIFORM_BLOCK = "6" # multi-line handwritten defect notes
SPARSE = "12" # scattered checkbox / code cells
OEM_LSTM = "3" # LSTM engine with legacy fallback; never drop below this.
Step 2 — Constrain each field with a character whitelist
Anchor link to "Step 2 — Constrain each field with a character whitelist"tessedit_char_whitelist is the highest-leverage configuration on a DVIR because it turns an open-vocabulary guess into a constrained decode. An odometer field that can only emit digits will never return the letter l for a 1; a VIN field that excludes I, O, and Q — characters the VIN standard prohibits precisely because they are confused with 1 and 0 — eliminates the most damaging misread class outright.
Build the config string from a per-field spec rather than passing a monolithic string:
from dataclasses import dataclass
VIN_ALPHABET = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789" # no I, O, Q — per VIN spec
UPPER_ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
NUMERIC = "0123456789."
@dataclass(frozen=True)
class FieldSpec:
"""One DVIR field's OCR contract: where it is and how to decode it."""
name: str
roi: tuple[int, int, int, int] # (y1, y2, x1, x2) from the layout map
psm: str
whitelist: str
def config(self) -> str:
return (
f"--psm {self.psm} --oem {OEM_LSTM} "
f"-c tessedit_char_whitelist={self.whitelist}"
)
ODOMETER = FieldSpec("odometer", (420, 470, 180, 520), Psm.SINGLE_LINE, NUMERIC)
VIN = FieldSpec("vin", (300, 350, 180, 640), Psm.SINGLE_LINE, VIN_ALPHABET)
DEFECT_CODE = FieldSpec("defect_code", (640, 690, 180, 420), Psm.SPARSE, UPPER_ALNUM)
Step 3 — Extract text and its confidence together, never separately
Anchor link to "Step 3 — Extract text and its confidence together, never separately"image_to_string returns text with no confidence, which is useless for a compliance gate. Use image_to_data with output_type=Output.DICT to get per-word confidence alongside the text, so every field carries the evidence needed to accept or route it. Pass --dpi 300 explicitly: mobile captures rarely embed a DPI tag, and without it Tesseract fragments characters on sub-300-DPI rasters.
import cv2
import numpy as np
import pytesseract
from pytesseract import Output
def read_field(page: np.ndarray, spec: FieldSpec) -> tuple[str, float]:
"""Recognize one cropped DVIR field, returning (text, mean_confidence).
page -- full preprocessed grayscale raster (deskewed, thresholded,
grid-suppressed) from the OCR pipeline's preprocessing stage.
"""
y1, y2, x1, x2 = spec.roi
roi = page[y1:y2, x1:x2]
data = pytesseract.image_to_data(
roi,
config=f"{spec.config()} --dpi 300",
output_type=Output.DICT,
)
# Keep only tokens Tesseract actually scored (conf == -1 means "no text").
scored = [
(word, float(conf))
for word, conf in zip(data["text"], data["conf"])
if word.strip() and float(conf) >= 0
]
if not scored:
return "", 0.0
text = " ".join(word for word, _ in scored).strip()
mean_conf = sum(conf for _, conf in scored) / len(scored)
return text, mean_conf
Step 4 — Gate on confidence and coerce to the DVIR schema
Anchor link to "Step 4 — Gate on confidence and coerce to the DVIR schema"The recognition stage does not decide compliance; it produces the evidence the gate acts on. Apply a hard 85% mean-confidence floor to critical fields (VIN, odometer, defect code), and route — never default — any field below it to human verification. Fields that clear the floor are coerced into the canonical DVIR contract; a field that clears confidence but fails typing (an odometer that parses to a non-integer, a VIN that is not 17 characters) is rejected exactly as hard as a low-confidence one.
from enum import StrEnum
CRITICAL_FLOOR = 85.0 # mean word confidence below which a critical field is routed, not admitted
class FieldStatus(StrEnum):
ADMITTED = "admitted"
ROUTED = "routed_for_review" # sent to human-in-the-loop, never guessed
def evaluate_field(spec: FieldSpec, text: str, confidence: float) -> FieldStatus:
"""Apply the confidence floor. Return the terminal status for this field."""
if confidence < CRITICAL_FLOOR:
return FieldStatus.ROUTED # route below the floor; do not admit a guess
match spec.name:
case "vin" if len(text) != 17:
return FieldStatus.ROUTED
case "odometer" if not text.replace(".", "", 1).isdigit():
return FieldStatus.ROUTED
case _:
return FieldStatus.ADMITTED
Any field returning FieldStatus.ROUTED must be handed to the human-in-the-loop verification queue with its original raster crop attached, so a reviewer re-derives it against the source rather than trusting a low-confidence decode. Defect codes that are admitted must still resolve against the controlled vocabulary in Defect Code Standardization Across Fleets before they mean anything downstream, and the full record must satisfy the field mapping defined in How to Map DVIR Fields to FMCSA 396.11 Requirements.
Verification and Testing
Anchor link to "Verification and Testing"Tesseract configuration is deterministic given a fixed image, traineddata version, and config string — pin all three and assert on the output. Two test patterns are non-negotiable before this stage ships.
First, a confidence-floor assertion on a known-good fixture: a clean sample scan must clear 85% on every critical field, so a regression in preprocessing or a traineddata swap surfaces as a failing test rather than a production routing storm.
def test_clean_scan_clears_confidence_floor():
page = cv2.imread("fixtures/clean_dvir.png", cv2.IMREAD_GRAYSCALE)
for spec in (ODOMETER, VIN, DEFECT_CODE):
text, conf = read_field(page, spec)
assert conf >= CRITICAL_FLOOR, f"{spec.name} scored {conf:.1f}, below floor"
assert evaluate_field(spec, text, conf) is FieldStatus.ADMITTED
Second, a whitelist-effectiveness assertion: feed a fixture engineered to bait a misread (a 0/O ambiguity in the VIN strip) and assert the constrained decode never emits the excluded glyph. This proves the whitelist is actually loaded and not silently ignored because of a malformed config string.
def test_vin_whitelist_excludes_confusable_letters():
page = cv2.imread("fixtures/vin_zero_oh_bait.png", cv2.IMREAD_GRAYSCALE)
text, _ = read_field(page, VIN)
assert not (set("IOQ") & set(text)), "VIN decode leaked a prohibited letter"
Pin the Tesseract and traineddata versions in your container image and record them in the audit envelope. An OCR result is only reconstructable at audit time if the exact engine that produced it can be re-run.
Common Failure Modes and Gotchas
Anchor link to "Common Failure Modes and Gotchas"- Sub-300-DPI mobile captures. A cab-mounted camera photographing a triplicate form often lands near 150 DPI, where Tesseract splits single characters into two low-confidence glyphs. Do not upscale to fake the DPI — that invents pixels the confidence score cannot see through. Reject below the readability floor at ingestion (the PDF & Image OCR Pipeline Setup gate owns this) so the raster never reaches Tesseract at all.
- VIN
O/0andI/1misreads. The single most damaging DVIR OCR error, because it breaks the vehicle join silently. The VIN whitelist in Step 2 excludesI,O, andQoutright; never widen it “to be safe” — the standard forbids those letters, so any candidate glyph must resolve to a digit. - Grid lines Tesseract reads as glyphs. If form rules survive preprocessing, Tesseract emits phantom hyphens, underscores, and
1s that corrupt defect codes and mileage. This is a preprocessing failure, not a configuration one — fix grid suppression upstream rather than pattern-scrubbing the output, which only hides the confidence you need to gate on. - A config string that silently no-ops. A typo in
tessedit_char_whitelist(a stray space, wrong key) makes Tesseract ignore the constraint without erroring, so decodes look normal until a confusable-character misread slips through. The whitelist-effectiveness test in the previous section is the only reliable guard.
Related
Anchor link to "Related"- PDF & Image OCR Pipeline Setup — the parent reference specifying the preprocessing, classification, and confidence-gate stages this Tesseract configuration plugs into.
- Standardized DVIR JSON Schema Design — the canonical field contract every OCR-extracted value must satisfy before it is trusted.
- Defect Code Standardization Across Fleets — the controlled vocabulary that admitted defect-code decodes must resolve against.
- How to Map DVIR Fields to FMCSA 396.11 Requirements — the field-level mapping the extracted record is checked against for § 396.11 completeness.
- Async Batching for High-Volume Ingestion — the downstream layer that persists batches of these extracted records under bounded concurrency.
Part of the PDF & Image OCR Pipeline Setup guide, within the DVIR Ingestion & Digital/Paper Parsing Workflows architecture. Back to DVIR Ingestion & Digital/Paper Parsing Workflows.