Skip to content

fix: avoid lossy supersample of raster-only (scanned) pages in PDFium-rendering backends#3803

Open
wittjeff wants to merge 2 commits into
docling-project:mainfrom
wittjeff:fix/pypdfium-raster-supersample
Open

fix: avoid lossy supersample of raster-only (scanned) pages in PDFium-rendering backends#3803
wittjeff wants to merge 2 commits into
docling-project:mainfrom
wittjeff:fix/pypdfium-raster-supersample

Conversation

@wittjeff

@wittjeff wittjeff commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

get_page_image() in the PDFium-rendering backends renders every page at 1.5× the requested scale and PIL-resizes down. For vector content that supersampling sharpens the output, but for image-only (scanned) pages it is a lossy resample round-trip of already-rasterized pixels that can destroy borderline OCR text detections — see #3587 for isolated evidence (an entire sentence silently lost end-to-end on a degraded scan; page CER 0.184 vs ~0.05 for direct rasterization).

This implements suggestions 1 and 2 from the issue:

  1. Raster-only pages render directly at the requested scale. A page whose content consists solely of image objects (the common scanned-document case) skips the supersample round-trip. Detection walks page.get_objects() (traversing form XObjects, since scans are often form-wrapped — e.g. tests/data_scanned/ocr_test.pdf) and is conservative: pages with annotations, or with content nested beyond the inspection depth, keep supersampling. Pages with any text/vector content are unaffected.
  2. PdfBackendOptions.supersample_factor (default 1.5, i.e. no behavior change for non-raster pages) makes the factor configurable; 1.0 restores direct rendering everywhere.

Implementation notes:

  • The previously duplicated get_page_image() in PyPdfiumPageBackend and DoclingParsePageBackend is hoisted into their shared base ManagedPdfiumPageBackend, so the fix and the new option apply consistently to both the default DoclingParseDocumentBackend and PyPdfiumDocumentBackend (rather than the option being silently ignored by the default backend).
  • pdfium sizes bitmaps with ceil() per side while the requested pixel dimensions use round(); on the direct-render path the rounding excess is now cropped instead of resampled. This matters in practice: real scans commonly have fractional media boxes (e.g. 595.2×841.9 pt), where a naive size check would have re-triggered the lossy resize.
  • The threaded docling-parse (v4) backend renders through docling-parse's own path and is not affected.

Verification

  • New tests: a raster-only page (generated deterministically in-memory, integer and fractional page sizes) renders pixel-identical to a direct pdfium render at the same scale; a vector page keeps supersampling by default; supersample_factor=1.0 yields a direct render on vector pages through both backends.
  • The self-contained repro from Scanned-page OCR loses text: get_page_image's 1.5x supersample-then-downsample degrades raster pages #3587 now recovers the lost line through get_page_image:
    direct pdfium render @3x:        9 text lines detected
    docling get_page_image(scale=3): 9 text lines detected
    line 'A second paragraph...' -> direct: True, docling render: True
    
  • No reference-data churn: the scanned fixtures (ocr_test*.pdf) are raster-only, so their render path changes; I replicated test_e2e_ocr_conversion locally for EasyOCR and RapidOCR (onnxruntime, standard + force_full_page_ocr) and the conversions verify against the stored groundtruth unchanged. Tesseract variants could not run in my environment (no tessdata) — CI should confirm.
  • tests/test_backend_pdfium.py, tests/test_backend_docling_parse.py, tests/test_backend_docling_parse_legacy.py, tests/test_pdf_password.py pass; make validate (ruff, ty, tach) is clean.

Issue resolved by this Pull Request:
Resolves #3587

Checklist:

  • Documentation has been updated, if necessary. (new section in docs/usage/advanced_options.md)
  • Examples have been added, if necessary.
  • Tests have been added, if necessary.

🤖 Generated with Claude Code

PyPdfiumPageBackend.get_page_image() renders every page at 1.5x the
requested scale and PIL-resizes down. For vector content this sharpens
the output, but for image-only (scanned) pages it is a lossy resample
round-trip of already-rasterized pixels that can destroy borderline OCR
text detections (issue docling-project#3587).

- Render pages whose content is raster-only (image objects only, the
  common scanned-document case) directly at the requested scale.
- Add PdfBackendOptions.supersample_factor (default 1.5, preserving
  current behavior for pages with text/vector content); 1.0 disables
  supersampling entirely.
- Skip the PIL resize when the rendered bitmap already has the target
  pixel dimensions.

Resolves docling-project#3587

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <152964771+witt3rd@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

DCO Check Passed

Thanks @wittjeff, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 Merge protection satisfied — ready to merge.

Show 1 satisfied protection

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

…t backend

Address review findings on the initial change:

- Hoist the duplicated get_page_image() implementation from
  PyPdfiumPageBackend and DoclingParsePageBackend into
  ManagedPdfiumPageBackend, so the raster-only skip and the
  supersample_factor option apply to the default DoclingParse backend
  too instead of being silently ignored.
- Trim pdfium's per-side ceil() rounding excess by cropping instead of
  resampling: real scans commonly have fractional media boxes
  (e.g. 595.2 x 841.9), where a round()-based size gate would have
  re-triggered the lossy resize the fix is meant to avoid.
- Treat pages with annotations, or with form XObjects nested beyond the
  inspection depth, as not raster-only (annotations are drawn on render
  but are not page content objects).
- Skip the page-object walk entirely when supersample_factor is 1.0.
- Document the new option in docs/usage/advanced_options.md.

Verified that scanned-fixture conversion output is unchanged vs the
stored groundtruth for EasyOCR and RapidOCR (fuzzy e2e verification),
so no reference data regeneration is required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <152964771+witt3rd@users.noreply.github.com>
@wittjeff wittjeff changed the title fix: avoid lossy supersample of raster-only pages in pypdfium2 backend fix: avoid lossy supersample of raster-only (scanned) pages in PDFium-rendering backends Jul 14, 2026
@wittjeff wittjeff marked this pull request as ready for review July 14, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scanned-page OCR loses text: get_page_image's 1.5x supersample-then-downsample degrades raster pages

2 participants