Straighten the page before the model sees it
Vision models tolerate rotation better than OCR ever did. They still read a straight page more accurately, and coordinates only mean something on one.
Classical OCR falls apart on a page rotated three degrees, because it segments text by scanning horizontal bands and a skewed line crosses several of them. Deskewing was mandatory, and every OCR pipeline had one.
Vision models do not have that failure mode. They read a tilted page, and a page rotated ninety degrees, and a photograph taken at an angle, without any preprocessing at all.
So the deskew step gets deleted. Then two things go wrong.
Rotation tolerance is not rotation indifference. A straight page still reads more accurately, and coordinates only mean anything on a page whose orientation you know.
The two things you lose
Accuracy degrades, gradually and invisibly. A model reading a page at eight degrees of skew is worse than the same model reading it straight — not catastrophically, but measurably, and most on the fields that were marginal anyway: small print, dense tables, handwriting. Because the degradation is smooth rather than a cliff, nobody notices it as a category. It shows up as "photographs are less accurate", which gets attributed to focus or lighting.
Coordinates stop meaning anything. A bounding box on a rotated page is a rectangle in the rotated frame. Crop it to show a reviewer and you get a sideways or diagonal sliver. Any downstream use of geometry — cropping, redaction, highlighting — requires knowing the page's orientation.
Start with EXIF, which is free
A large share of "rotated" images are not rotated at all. They are correctly oriented images carrying an EXIF orientation tag that says how to display them, and a pipeline that reads raw pixels without applying it sees the wrong thing.
import sharp from "sharp"
// .rotate() with no argument applies the EXIF orientation and strips
// the tag. One line, and it fixes the majority of phone uploads.
const normalised = await sharp(input).rotate().toBuffer()
This is the highest-value line in the whole preprocessing stage. Phone cameras set the tag constantly, image libraries disagree about honouring it, and the symptom — some photographs are sideways, inconsistently — looks like a much harder problem than it is.
Then detect coarse rotation
Ninety, one-eighty, two-seventy. Usually a scanner fed a page the wrong way, or a multi-page PDF where one page went in rotated.
The cheapest reliable detector is to OCR at low resolution in each of the four orientations and keep the one with the highest mean word confidence. Text reads as confident nonsense upside down and as confident words the right way up, and the gap is large:
async function coarseOrientation(page: Buffer): Promise<0 | 90 | 180 | 270> {
const scores = await Promise.all(
([0, 90, 180, 270] as const).map(async (deg) => {
const rotated = await sharp(page).rotate(deg).resize({ width: 800 }).toBuffer()
const ocr = await ocrEngine.recognise(rotated)
// Mean word confidence, weighted by word count so a page that
// yields three words does not win on a fluke.
return { deg, score: ocr.meanConfidence * Math.log1p(ocr.wordCount) }
}),
)
return scores.sort((a, b) => b.score - a.score)[0].deg
}
At 800px wide this is fast and cheap. If you are not running OCR at all, asking the vision model once — "what rotation, in degrees, would make this page upright?" — works nearly as well and costs one small call.
Then deskew the small angle
Coarse rotation handles multiples of ninety. The residual — one to ten degrees from a page fed slightly crooked — needs a different method.
The projection profile approach is the classic and it still works: rotate through a range of candidate angles, project the pixels onto the vertical axis, and pick the angle where the profile has the sharpest peaks. Text lines produce strong periodic peaks when horizontal and smear when tilted.
function deskewAngle(gray: Uint8Array, w: number, h: number): number {
let best = { angle: 0, score: -Infinity }
// ±10 degrees covers essentially all scanner skew. Coarse pass first,
// then refine around the winner if you need sub-degree precision.
for (let angle = -10; angle <= 10; angle += 0.5) {
const rows = projectRows(gray, w, h, angle)
// Variance of the row-sum profile. High variance = crisp lines.
const score = variance(rows)
if (score > best.score) best = { angle, score }
}
return best.angle
}
Most image libraries and OCR toolkits ship an implementation. The reason to know what it does is to know when it fails — pages that are mostly image with little text have no line structure to find, and the detector returns noise. Bound the correction and skip it when the confidence is low:
// A detected angle near the search boundary usually means detection
// failed rather than that the page is severely skewed.
if (Math.abs(angle) > 9 || best.score < MIN_CONFIDENCE) return 0
Keep the transform
The part that gets forgotten. Once you rotate the image, every coordinate the model returns is in the corrected frame, and the original document is in the original frame.
Store the transform so you can map back:
type PagePrep = {
exifApplied: boolean
coarseRotation: 0 | 90 | 180 | 270
deskewDegrees: number
/** Dimensions before and after, for scaling boxes. */
original: { w: number; h: number }
corrected: { w: number; h: number }
}
Without it you can show a reviewer a crop from the corrected image — fine — but you cannot highlight a region on the original PDF, and you cannot redact reliably, because redaction has to happen on the file of record.
Keep both the corrected image and the transform. Storage is cheap and recomputing a deskew angle later is not deterministic across library versions.
When to skip it
Preprocessing is not free — it is CPU time and a step that can itself fail.
Native PDFs are already straight. Detect the source type first and skip the whole stage; running deskew on a born-digital page occasionally introduces skew that was not there.
No coordinate requirements and mild skew. If nothing downstream needs geometry and pages are within a couple of degrees, the accuracy difference is small enough to ignore.
Photographs with perspective distortion. Deskew corrects rotation, not perspective. A photograph taken at an angle needs a four-point transform to flatten, which is a different and more fragile operation. Measure whether it helps before adding it — vision models handle moderate perspective better than they handle a bad dewarp.
More in when extraction accuracy collapses, where traditional OCR still wins, and how we build extraction pipelines.