Handling Low-DPI and Skewed DVIR Scans
A Driver Vehicle Inspection Report that arrives as a 130-DPI phone photo tilted nine degrees off-axis is not a minor inconvenience — it is a compliance liability waiting to surface at a DOT audit. Optical recognition on a low-resolution or skewed raster does not fail loudly; it confidently emits a VIN with the wrong check digit or an odometer off by a decade, silently breaking the vehicle-to-report join that 49 CFR § 396.11(a)(3) obligates the carrier to retain and reconstruct on demand. Worse, aggressive binarization on a faint scan can erase a hand-drawn defect checkmark entirely — and a defect that vanishes from the record is a defect the carrier can no longer prove it acted on. This page specifies the deterministic preprocessing chain that runs before recognition: detect the true resolution and reject anything below the readability floor, correct rotation, suppress sensor noise, binarize with a method that survives uneven illumination, and upsample undersized rasters so Tesseract sees clean, upright, high-contrast glyphs.
Preprocessing is the stage inside the PDF & Image OCR Pipeline Setup reference that stands between the ingestion gate and recognition. Get it right and the Tesseract configuration that follows it clears the confidence floor on the first pass; get it wrong and no page-segmentation flag or character whitelist can compensate for pixels that were never there. The same preprocessing quality also determines whether a managed engine is even worth the cost — the engine comparison assumes a clean upright raster as its baseline. Everything here is deterministic: identical input yields identical output, so any correction the pipeline applied is reproducible during an audit under the systematic-maintenance records requirement of 49 CFR § 396.3©.
Prerequisites
Anchor link to "Prerequisites"Target Python 3.10+ — the code below uses the X | Y union syntax and match statements. This stage owns image geometry and contrast; it hands its output to the Tesseract configuration stage, which then crops per-field regions and gates on confidence. Do not blur the boundary: preprocessing produces a clean upright binary raster, and recognition reads it — nothing here should attempt field extraction.
opencv-python— deskew, denoising, morphology, and Lanczos resampling.numpy— array math for the projection-profile variance search and the Sauvola integral-image computation.Pillow— read embedded DPI metadata from scanned TIFF/JPEG/PDF-rendered pages before falling back to a pixel-dimension estimate.
The 150-DPI readability floor and the 300-DPI recognition target are the same values the ingestion gate and recognition stage use; keep them defined once and imported, never re-hardcoded per module.
Step-by-Step Implementation
Anchor link to "Step-by-Step Implementation"Step 1 — Detect the true DPI and reject below the floor
Anchor link to "Step 1 — Detect the true DPI and reject below the floor"Resolution is the first thing to establish, because every later step is calibrated to it and a sub-floor page must never reach the deskew stage. Read the embedded DPI tag when the source provides one; when it does not — mobile captures almost never do — estimate it from the pixel dimensions against a known physical form size (a US Letter DVIR is 8.5 inches wide). Reject anything below 150 DPI and return it to the client for re-capture.
from dataclasses import dataclass
from PIL import Image
DPI_FLOOR = 150 # § 396.11 readability floor — reject below this
DPI_TARGET = 300 # recognition target — upscale toward this
FORM_WIDTH_INCHES = 8.5 # US Letter DVIR physical width
@dataclass(frozen=True)
class DpiReport:
dpi: int
accepted: bool # False -> reject payload, return for re-capture
def detect_dpi(path: str) -> DpiReport:
"""Resolve the true DPI from metadata, else estimate from pixel width."""
with Image.open(path) as im:
tagged = im.info.get("dpi")
if tagged and tagged[0] >= 1:
dpi = int(round(tagged[0]))
else:
# No DPI tag: estimate from pixel width vs the physical form width.
dpi = int(round(im.width / FORM_WIDTH_INCHES))
# A sub-150-DPI scan cannot yield a reliable VIN — reject, never guess.
return DpiReport(dpi=dpi, accepted=dpi >= DPI_FLOOR)
Step 2 — Deskew by maximizing projection-profile variance
Anchor link to "Step 2 — Deskew by maximizing projection-profile variance"A skewed page is the single biggest accuracy killer after low resolution, because Tesseract’s line finder assumes horizontal baselines. Estimate the rotation by rotating the binarized page through a small range of candidate angles and measuring the variance of the horizontal projection profile (the row-sum vector). The correct angle is the one where text rows and blank gaps are most sharply separated, which maximizes that variance. Rotate by the negative of the detected angle to correct it.
import cv2
import numpy as np
def _profile_variance(binary: np.ndarray, angle: float) -> float:
h, w = binary.shape
m = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
rotated = cv2.warpAffine(binary, m, (w, h),
flags=cv2.INTER_NEAREST,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
profile = rotated.sum(axis=1) # ink per row
return float(np.var(profile)) # sharp rows/gaps -> high variance
def deskew(gray: np.ndarray, limit: float = 12.0, step: float = 0.25) -> np.ndarray:
"""Correct rotation by searching for the variance-maximizing angle."""
binary = cv2.threshold(gray, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
angles = np.arange(-limit, limit + step, step)
best = max(angles, key=lambda a: _profile_variance(binary, a))
h, w = gray.shape
# Rotate by -best to undo the skew; replicate the border to avoid black wedges.
m = cv2.getRotationMatrix2D((w / 2, h / 2), best, 1.0)
return cv2.warpAffine(gray, m, (w, h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE)
A Hough-transform estimate is the common alternative: run cv2.HoughLinesP over the edge map, take the median angle of the near-horizontal line segments (the pre-printed form rules make excellent Hough evidence), and correct by its negative. Projection-profile search is more robust on forms with sparse text but heavy grid lines; Hough is faster when strong ruled lines dominate. Pick one and pin it — do not switch per page, or the correction becomes non-reproducible.
Step 3 — Denoise before you threshold
Anchor link to "Step 3 — Denoise before you threshold"Sensor speckle and JPEG blocking artifacts become isolated black pixels after binarization, which Tesseract reads as stray punctuation inside a VIN or odometer. Remove them while the image is still grayscale. A median filter erases salt-and-pepper speckle without softening edges; a bilateral filter smooths flat regions while preserving the sharp glyph boundaries that carry the character shape.
def denoise(gray: np.ndarray) -> np.ndarray:
"""Suppress speckle while preserving glyph edges."""
despeckled = cv2.medianBlur(gray, 3) # kills salt-and-pepper
return cv2.bilateralFilter(despeckled, d=5,
sigmaColor=60, sigmaSpace=60) # edge-preserving
Step 4 — Binarize with Sauvola, not global Otsu
Anchor link to "Step 4 — Binarize with Sauvola, not global Otsu"Otsu computes a single global threshold for the whole page. That works on an evenly lit flatbed scan, but a phone photo of a form on a bay workbench has a bright center and shadowed corners, and one global cutoff either floods the shadows black or bleaches the highlights white. Sauvola computes a local threshold per pixel from the mean m and standard deviation s of its neighborhood window:
T = m * (1 + k * ((s / R) - 1))
where R is the dynamic range of the standard deviation (128 for 8-bit images) and k is a sensitivity constant, typically 0.2–0.34. Because the threshold tracks local illumination, faint pencil checkmarks in a shadowed corner survive where Otsu would erase them.
def sauvola_threshold(gray: np.ndarray, window: int = 25,
k: float = 0.2, R: float = 128.0) -> np.ndarray:
"""Local adaptive binarization: T = m * (1 + k * ((s / R) - 1))."""
g = gray.astype(np.float64)
# Integral images give O(1) window mean and variance at every pixel.
mean = cv2.boxFilter(g, ddepth=-1, ksize=(window, window),
borderType=cv2.BORDER_REFLECT)
mean_sq = cv2.boxFilter(g * g, ddepth=-1, ksize=(window, window),
borderType=cv2.BORDER_REFLECT)
std = np.sqrt(np.clip(mean_sq - mean * mean, 0, None))
threshold = mean * (1 + k * ((std / R) - 1))
# Foreground (ink) is darker than the local threshold.
return np.where(g > threshold, 255, 0).astype(np.uint8)
Step 5 — Upscale sub-300-DPI pages with Lanczos
Anchor link to "Step 5 — Upscale sub-300-DPI pages with Lanczos"A page that cleared the 150-DPI floor but sits below the 300-DPI recognition target fragments thin strokes during recognition. Upsample it with Lanczos resampling, which reconstructs edges more faithfully than bilinear or nearest-neighbor interpolation. Upscaling never invents detail the floor check could not verify — it only gives the recognizer more pixels per glyph to work with, which is why the 150-DPI reject in Step 1 is non-negotiable.
from PIL import Image
def upscale_to_target(binary: np.ndarray, source_dpi: int) -> np.ndarray:
"""Lanczos-resample toward the 300-DPI recognition target."""
if source_dpi >= DPI_TARGET:
return binary
scale = DPI_TARGET / source_dpi
im = Image.fromarray(binary)
resized = im.resize(
(round(im.width * scale), round(im.height * scale)),
resample=Image.LANCZOS,
)
return np.asarray(resized)
Verification and Testing
Anchor link to "Verification and Testing"Every step here is deterministic given a fixed input, so assert on exact behavior. Two tests are non-negotiable before this stage ships: a deskew-accuracy test and a floor-rejection test.
import cv2
import numpy as np
def _rotate(img: np.ndarray, degrees: float) -> np.ndarray:
h, w = img.shape
m = cv2.getRotationMatrix2D((w / 2, h / 2), degrees, 1.0)
return cv2.warpAffine(img, m, (w, h), borderValue=255)
def test_deskew_corrects_known_angle():
# Synthesize a page of horizontal text bars, then skew it by a known angle.
page = np.full((600, 800), 255, np.uint8)
for y in range(80, 560, 40):
page[y:y + 12, 60:740] = 0
skewed = _rotate(page, 6.0) # inject a 6-degree skew
corrected = deskew(skewed)
# The corrected page must be flat within half a degree of true horizontal.
residual = _profile_variance(
cv2.threshold(corrected, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1], 0.0)
flat = _profile_variance(
cv2.threshold(page, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1], 0.0)
assert residual > 0.9 * flat, "deskew did not restore horizontal baselines"
def test_low_dpi_scan_is_rejected(tmp_path):
# A 120-DPI page (1020 px / 8.5 in) must be rejected at the floor.
from PIL import Image
p = tmp_path / "low.png"
Image.new("L", (1020, 1320), 255).save(p, dpi=(120, 120))
report = detect_dpi(str(p))
assert report.dpi == 120
assert report.accepted is False # below the 150-DPI floor -> reject
Pin the OpenCV and Pillow versions in the container image and record them in the audit envelope alongside the detected DPI and the correction angle applied. A preprocessing result is only reconstructable at audit time if the exact library versions that produced it can be re-run.
Common Failure Modes and Gotchas
Anchor link to "Common Failure Modes and Gotchas"- Over-thresholding erases faint checkmarks. Push Sauvola’s
ktoo high and a lightly penciled defect box turns to solid white — the very defect § 396.11(a)(3) requires the record to preserve disappears silently. Tunekdown (toward 0.2) on scans with faint annotations and validate against a fixture that contains a known low-contrast checkmark; assert the mark survives binarization. Never let a contrast parameter drop evidence. - Correcting rotation in the wrong direction. The sign of the detected angle is the most common bug in a deskew routine. A page skewed clockwise must be rotated counter-clockwise to correct it — invert the sign once and every page comes out more crooked, not less. The known-angle test above catches this: if the residual variance falls instead of rising, the sign is flipped.
- Mixed-DPI multi-page PDFs. A single PDF often bundles a crisp 300-DPI office scan with a 120-DPI cab photo appended later. Detect DPI per page, not per document — a document-level average passes the floor while burying an unreadable page inside it. Rasterize and gate each page independently, and route only the failing pages to re-capture.
Frequently Asked Questions
Anchor link to "Frequently Asked Questions"Why reject a sub-150-DPI scan instead of upscaling it?
Upscaling adds pixels but no information — Lanczos resampling cannot recover a stroke that the sensor never resolved, so a 120-DPI VIN interpolated to 300 DPI is still a guess wearing a higher resolution. The 150-DPI floor guarantees enough real detail for a reliable decode; below it, reject the payload and return it for re-capture rather than admit a plausible-looking misread that breaks the vehicle-to-report join under § 396.11(a)(3).
When should I use Sauvola thresholding instead of Otsu?
Use Sauvola whenever illumination is uneven across the page, which describes nearly every phone or bay-camera capture. Otsu picks one global threshold, so a bright center and shadowed corners force a compromise that either floods shadows black or bleaches highlights white. Sauvola computes a local threshold per pixel from the neighborhood mean and standard deviation, so faint checkmarks in a shadowed corner survive. Reserve Otsu for evenly lit flatbed scans where a single cutoff is genuinely correct.
How do I keep preprocessing reproducible for a DOT audit?
Make every step deterministic and pin its inputs. Fix the deskew method, the Sauvola window and k, and the library versions, then record the detected DPI, the correction angle, and those versions in the audit envelope beside the source hash. Because identical input yields identical output, an auditor can re-run the exact preprocessing chain from the retained raster and reproduce the cleaned image the recognizer saw, satisfying the records requirement of § 396.3©.
Related
Anchor link to "Related"- PDF & Image OCR Pipeline Setup — the parent reference specifying the ingestion gate, classification, and confidence-gate stages this preprocessing feeds.
- Configuring Tesseract OCR for Fleet Inspection Forms — the recognition stage that reads the clean upright raster this chain produces.
- Tesseract vs AWS Textract vs Azure Form Recognizer for DVIR OCR — the engine-selection decision that assumes a clean raster as its baseline.
- Async Batching for High-Volume Ingestion — the layer that runs this preprocessing under bounded concurrency during end-of-shift upload bursts.
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.