Skip to content
Ingestion & Parsing

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.”

Per-field Tesseract OCR flow for a DVIR inspection form A left-to-right data-flow diagram. A preprocessed inspection raster enters a region-of-interest crop step driven by a version-controlled layout map that holds one ROI tuple per field. Three example fields fan out into their own Tesseract call, each with its own page segmentation mode and character whitelist: the odometer uses psm 7 with a numeric whitelist, the VIN uses psm 7 with an uppercase alphanumeric whitelist that excludes the letters I, O and Q, and the defect code uses psm 12 with an uppercase alphanumeric whitelist. Every call runs oem 3 with dpi 300 and emits recognized text plus a mean word confidence read from the image_to_data TSV. The three results reach one confidence gate holding an 85 percent critical floor. Fields at or above the floor that also pass typing are coerced into the canonical DVIR schema and written to a write-once audit store. Fields below the floor, or that clear confidence but fail typing, route to a human-in-the-loop verification queue with the original raster crop attached — routed, never guessed. INPUT Preprocessed raster deskew · threshold CROP ROI crop version-controlled layout map · 1 ROI/field PER-FIELD TESSERACT · --oem 3 · --dpi 300 odometer --psm 7 whitelist: 0-9 . single line vin --psm 7 whitelist: A–Z 0-9, no I O Q single line defect_code --psm 12 whitelist: A–Z 0-9 sparse each emits text + mean word confidence (image_to_data TSV) CONFIDENCE FLOOR ≥ 85% ? + field typing Coerce to DVIR schema typed field contract WORM audit store append-only · pinned engine ver. reconstructable at DOT audit Human-in-the-loop queue original raster crop attached route, never guess pass below floor · bad type

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, structural match, and the X | Y union syntax.
  • Tesseract 5.x installed system-side with the eng traineddata pack; verify with tesseract --version and confirm the LSTM engine is present (tesseract --list-langs).
  • pytesseract>=0.3.10 — the thin wrapper exposing both image_to_string and the TSV-producing image_to_data.
  • opencv-python and numpy — 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 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 12sparse 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.

python
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:

python
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.

python
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.

python
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.

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.

python
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.

python
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/0 and I/1 misreads. The single most damaging DVIR OCR error, because it breaks the vehicle join silently. The VIN whitelist in Step 2 excludes I, O, and Q outright; 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.

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.