diff --git a/docling/.agents/skills/docling/references/cli.md b/docling/.agents/skills/docling/references/cli.md index 050b6a0b91..9dae2894c4 100644 --- a/docling/.agents/skills/docling/references/cli.md +++ b/docling/.agents/skills/docling/references/cli.md @@ -113,6 +113,7 @@ docling report.pdf --no-ocr --output /tmp/ # skip OCR (faster) docling scan.pdf --ocr-lang eng,deu --output /tmp/ # the engine's own codes docling scan.pdf --ocr-lang iso:en,iso:de --output /tmp/ # BCP-47 tags behind `iso:` docling scan.pdf --ocr-engine rapidocr --ocr-option model_size=tiny --output /tmp/ # engine-specific field +docling mixed.pdf --ocr-mode full_page --ocr-option page_bitmap_area_threshold=0.97 --output /tmp/ # OCR only scanned pages ``` `--ocr-option KEY=VALUE` (repeatable) sets any field of the selected OCR engine. diff --git a/docling/cli/main.py b/docling/cli/main.py index 756ad260fb..785ebf2a75 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -2232,7 +2232,7 @@ def show_engine_options( raise table = rich.table.Table(title=f"Options of the {stage.value} engine {engine!r}") table.add_column("Key", style="bold", no_wrap=True) - table.add_column("Type") + table.add_column("Type", overflow="fold") table.add_column("Default") table.add_column("Description") for row in describe_options_class(options_cls): diff --git a/docling/datamodel/pipeline_options.py b/docling/datamodel/pipeline_options.py index ac71da97d5..fbae63648c 100644 --- a/docling/datamodel/pipeline_options.py +++ b/docling/datamodel/pipeline_options.py @@ -193,12 +193,17 @@ class GraniteVisionTableStructureOptions(BaseTableStructureOptions): kind: ClassVar[str] = "granite_vision_table" +_BITMAP_AREA_THRESHOLD_DEPRECATION = ( + "`bitmap_area_threshold` is deprecated; set `page_bitmap_area_threshold` instead." +) + + class OcrOptions(BaseOptions): """Base configuration for Optical Character Recognition engines. Defines the common interface shared by all OCR engine implementations. Subclasses provide engine-specific parameters while inheriting the shared - language selection, full-page OCR toggle, and bitmap area threshold. + language selection, OCR mode, and page bitmap area threshold. See Also: `OcrAutoOptions`: Automatic engine selection based on availability. @@ -277,6 +282,24 @@ class OcrOptions(BaseOptions): ), ] = False + page_bitmap_area_threshold: Annotated[ + float | None, + Field( + description=( + "Minimum fraction of the page area (0.0-1.0) that bitmaps must cover " + "for the page to be processed with OCR. Pages below it skip OCR, " + "whatever the `mode`. With `mode=OcrMode.FULL_PAGE`, a high value " + "such as 0.97 OCRs only scanned pages and skips programmatic pages " + "that only carry logos or photos. `None` processes every page. A page " + "whose broken text layer `replace_broken_text_layer` replaces is " + "OCR'd even below the threshold." + ), + examples=[None, 0.97], + ge=0.0, + le=1.0, + ), + ] = None + model_config = ConfigDict( validate_assignment=True, validate_default=True, @@ -310,6 +333,22 @@ def _accept_force_full_page_ocr(cls, data: Any) -> Any: data["mode"] = OcrMode.FULL_PAGE return data + @model_validator(mode="before") + @classmethod + def _accept_bitmap_area_threshold(cls, data: Any) -> Any: + r""" + Accept the deprecated `bitmap_area_threshold` constructor keyword and + translate it into `page_bitmap_area_threshold`. + """ + if not isinstance(data, dict) or "bitmap_area_threshold" not in data: + return data + warnings.warn( + _BITMAP_AREA_THRESHOLD_DEPRECATION, DeprecationWarning, stacklevel=3 + ) + threshold = data.pop("bitmap_area_threshold") + data.setdefault("page_bitmap_area_threshold", threshold) + return data + # Deprecated: superseded by `OcrMode.FULL_PAGE`. Kept for backwards # compatibility as a view over `mode`, so the two can never drift apart. @computed_field( # type: ignore[prop-decorator] @@ -328,6 +367,23 @@ def force_full_page_ocr(self, value: bool) -> None: if value: self.mode = OcrMode.FULL_PAGE + # Deprecated: superseded by `page_bitmap_area_threshold`. A plain property, + # not a computed field, so it stays out of dumps. + @property + def bitmap_area_threshold(self) -> float | None: + warnings.warn( + _BITMAP_AREA_THRESHOLD_DEPRECATION, DeprecationWarning, stacklevel=2 + ) + return self.page_bitmap_area_threshold + + @bitmap_area_threshold.setter + def bitmap_area_threshold(self, value: float | None) -> None: + # stacklevel=4: pydantic calls the setter through two frames + warnings.warn( + _BITMAP_AREA_THRESHOLD_DEPRECATION, DeprecationWarning, stacklevel=4 + ) + self.page_bitmap_area_threshold = value + class OcrAutoOptions(OcrOptions): """Automatic OCR engine selection based on system availability. diff --git a/docling/models/base_ocr_model.py b/docling/models/base_ocr_model.py index 00307699b3..052d68240e 100644 --- a/docling/models/base_ocr_model.py +++ b/docling/models/base_ocr_model.py @@ -256,6 +256,9 @@ def get_ocr_rects(self, page: Page) -> list[BoundingBox]: """ assert page.size is not None + if self._skips_ocr(page): + return [] + # Compute the OCR rects according to the mode ocr_rects: list[BoundingBox] @@ -286,6 +289,39 @@ def get_ocr_rects(self, page: Page) -> list[BoundingBox]: ocr_rects = self._find_layout_ocr_rects(page) return ocr_rects + def _skips_ocr(self, page: Page) -> bool: + r""" + Whether `page_bitmap_area_threshold` keeps the page from OCR. A page whose + broken text layer `replace_broken_text_layer` replaces is always OCR'd. + """ + if self.options.replace_broken_text_layer and page._broken_text_layer: + return False + return not self._has_enough_bitmap_area(page) + + def _has_enough_bitmap_area(self, page: Page) -> bool: + r""" + Whether bitmaps cover at least `page_bitmap_area_threshold` of the page. + True without a threshold, or without a backend to measure the bitmaps. + """ + threshold = self.options.page_bitmap_area_threshold + if threshold is None or page._backend is None: + return True + assert page.size is not None + + coverage, _ = self._deduplicate_rects( + page.size, page._backend.get_bitmap_rects() + ) + if coverage < threshold: + _log.debug( + "Page %s: bitmaps cover %.3f of the page, below " + "page_bitmap_area_threshold=%.3f; skipping OCR.", + page.page_no, + coverage, + threshold, + ) + return False + return True + def _find_layout_ocr_rects(self, page: Page) -> list[BoundingBox]: r""" 1. Collect the bboxes of all layout clusters. @@ -630,6 +666,7 @@ def post_process_cells( broken (`is_broken_text_layer`): Any existing PDF cells are ignored and only the OCR cells are used. A page replaced for `replace_broken_text_layer` keeps its invisible cells. + A FULL_PAGE page that `page_bitmap_area_threshold` skipped keeps its PDF cells. - If LAYOUT_REGIONS or PDF_AWARE_LAYOUT_REGIONS and the priority parameter is None, the priority is auto-selected based on the OcrMode: - OCR_FIRST when LAYOUT_REGIONS @@ -640,7 +677,8 @@ def post_process_cells( existing_cells = page.cells # Combine existing and OCR cells with overlap filtering - full_page = _ocr_full_page(self.options, page) + # False on a page that `page_bitmap_area_threshold` kept from OCR + full_page = _ocr_full_page(self.options, page) and not self._skips_ocr(page) # A replaced broken text layer keeps its invisible cells, as in the merge keep_invisible = full_page and self.options.mode != OcrMode.FULL_PAGE if full_page: @@ -650,6 +688,8 @@ def post_process_cells( final_cells = self._merge_cells_by_priority( final_cells, invisible_cells ) + elif self.options.mode == OcrMode.FULL_PAGE: + final_cells = existing_cells else: if priority is None: priority = ( diff --git a/docling/models/stages/ocr/auto_ocr_model.py b/docling/models/stages/ocr/auto_ocr_model.py index 000a6ce2cd..94950efc47 100644 --- a/docling/models/stages/ocr/auto_ocr_model.py +++ b/docling/models/stages/ocr/auto_ocr_model.py @@ -77,6 +77,7 @@ def __init__( replace_broken_text_layer=( self.options.replace_broken_text_layer ), + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) @@ -105,6 +106,7 @@ def __init__( replace_broken_text_layer=( self.options.replace_broken_text_layer ), + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) @@ -139,6 +141,7 @@ def __init__( replace_broken_text_layer=( self.options.replace_broken_text_layer ), + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) @@ -171,6 +174,7 @@ def __init__( replace_broken_text_layer=( self.options.replace_broken_text_layer ), + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) @@ -199,6 +203,7 @@ def __init__( replace_broken_text_layer=( self.options.replace_broken_text_layer ), + page_bitmap_area_threshold=self.options.page_bitmap_area_threshold, ), accelerator_options=accelerator_options, ) diff --git a/docling/models/stages/page_preprocessing/page_preprocessing_model.py b/docling/models/stages/page_preprocessing/page_preprocessing_model.py index 23cb7154e1..98cf323584 100644 --- a/docling/models/stages/page_preprocessing/page_preprocessing_model.py +++ b/docling/models/stages/page_preprocessing/page_preprocessing_model.py @@ -32,9 +32,14 @@ def resolve_skip_cell_extraction(pipeline_options: PdfPipelineOptions) -> bool: vector-dense pages (CAD/wiring schematics drawn as 100k+ path segments) that decode costs multiple GiB regardless of cell content levels. See https://github.com/docling-project/docling/issues/4058. + + A `page_bitmap_area_threshold` keeps the decode: the pages below it skip OCR. """ - return bool(pipeline_options.do_ocr) and ( - pipeline_options.ocr_options.mode == OcrMode.FULL_PAGE + ocr_options = pipeline_options.ocr_options + return ( + bool(pipeline_options.do_ocr) + and ocr_options.mode == OcrMode.FULL_PAGE + and ocr_options.page_bitmap_area_threshold is None ) diff --git a/docs/concepts/OCR.md b/docs/concepts/OCR.md index 32e2774cad..eddb1541fb 100644 --- a/docs/concepts/OCR.md +++ b/docs/concepts/OCR.md @@ -193,6 +193,30 @@ Docling cannot detect a page whose text uses the right alphabet but the wrong le letters in the wrong order, for example `Cartifecita` instead of `Certificate`. Such pages keep their PDF text. To read every page with OCR, set `mode=OcrMode.FULL_PAGE` (`--ocr-mode full_page`). +## OCR only scanned pages + +`OcrOptions.page_bitmap_area_threshold` OCRs a page only when bitmaps cover at least that fraction +of it (0.0-1.0). The pages below it skip OCR and keep their native text, whatever the `mode`. The +default `None` processes every page. A page whose broken text layer `replace_broken_text_layer` +replaces is OCR'd even below the threshold. + +With `mode=OcrMode.FULL_PAGE` and a high value, a mixed document gets full-page OCR on its scanned +pages, while its programmatic pages, including the ones that carry logos or photos, are read from +the PDF text layer: + +```python +from docling.datamodel.pipeline_options import OcrMode, TesseractCliOcrOptions + +options = TesseractCliOcrOptions(mode=OcrMode.FULL_PAGE, page_bitmap_area_threshold=0.97) +``` + +```sh +docling convert --ocr-mode full_page --ocr-option page_bitmap_area_threshold=0.97 document.pdf +``` + +`bitmap_area_threshold`, the field this replaces, is still accepted with a `DeprecationWarning` and +sets `page_bitmap_area_threshold`. It does not set the `mode`: to OCR the scanned pages in full, as +it did before `OcrMode`, also set `mode=OcrMode.FULL_PAGE`. ## RapidOCR diff --git a/tests/test_ocr_page_bitmap_area_threshold.py b/tests/test_ocr_page_bitmap_area_threshold.py new file mode 100644 index 0000000000..71e0eb5e73 --- /dev/null +++ b/tests/test_ocr_page_bitmap_area_threshold.py @@ -0,0 +1,187 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""`page_bitmap_area_threshold` OCRs only the pages that bitmaps mostly cover. + +It restores the "OCR only scanned pages" use case of the `bitmap_area_threshold` +removed with the introduction of `OcrMode` (#4834). A page below the threshold +skips OCR, so it must keep its native text, line, word and character cells, also +in `FULL_PAGE` mode, where OCR cells normally replace the native cells and their +extraction is skipped. +""" + +from collections.abc import Iterable +from pathlib import Path +from types import SimpleNamespace + +import pytest +from docling_core.types.doc import BoundingBox + +from docling.backend.docling_parse_backend import ThreadedDoclingParseDocumentBackend +from docling.datamodel.accelerator_options import AcceleratorOptions +from docling.datamodel.base_models import ConfidenceReport, InputFormat, Page +from docling.datamodel.document import ConversionResult, InputDocument +from docling.datamodel.pipeline_options import ( + EasyOcrOptions, + OcrMode, + OcrOptions, + PdfPipelineOptions, + RapidOcrOptions, + TesseractOcrOptions, +) +from docling.datamodel.settings import DocumentLimits +from docling.models.base_ocr_model import BaseOcrModel +from docling.models.stages.page_preprocessing.page_preprocessing_model import ( + resolve_skip_cell_extraction, +) + +OCR_OPTION_CLASSES = [EasyOcrOptions, RapidOcrOptions, TesseractOcrOptions] + +# First pages: a scan (bitmaps cover all of it) and a programmatic page whose +# pictures cover about 15% of it. +SCANNED_PDF = Path("tests/data/ocr/sources/ocr_test.pdf") +PROGRAMMATIC_PDF = Path("tests/data/pdf/sources/picture_classification.pdf") + + +class _OcrRectsOnlyModel(BaseOcrModel): + """Minimal concrete `BaseOcrModel`: only the page selection is under test.""" + + def __call__( + self, conv_res: ConversionResult, page_batch: Iterable[Page] + ) -> Iterable[Page]: + raise NotImplementedError + + @classmethod + def get_options_type(cls) -> type[OcrOptions]: + return OcrOptions + + +def _model( + mode: OcrMode, threshold: float | None, replace_broken_text_layer: bool = False +) -> _OcrRectsOnlyModel: + return _OcrRectsOnlyModel( + enabled=True, + artifacts_path=None, + options=OcrOptions( + kind="test", + lang=["en"], + mode=mode, + page_bitmap_area_threshold=threshold, + replace_broken_text_layer=replace_broken_text_layer, + ), + accelerator_options=AcceleratorOptions(), + ) + + +def _first_page(pdf: Path) -> Page: + """The first page, parsed by the default backend, with its word cells.""" + in_doc = InputDocument( + path_or_stream=pdf, + format=InputFormat.PDF, + backend=ThreadedDoclingParseDocumentBackend, + limits=DocumentLimits(page_range=(1, 1)), + ) + doc_backend = in_doc._backend + page = Page(page_no=1) + page._backend = next(doc_backend.iter_pages()) + doc_backend.unload() + page.size = page._backend.get_size() + page.parsed_page = page._backend.get_segmented_page() + return page + + +def _ocr_rects(pdf: Path, threshold: float | None) -> list[BoundingBox]: + return _model(OcrMode.FULL_PAGE, threshold).get_ocr_rects(_first_page(pdf)) + + +@pytest.mark.parametrize("options_cls", OCR_OPTION_CLASSES) +def test_removed_bitmap_area_threshold_is_accepted_as_deprecated(options_cls): + """Configurations written before `OcrMode` must not fail validation.""" + with pytest.deprecated_call(match="page_bitmap_area_threshold"): + options = options_cls(bitmap_area_threshold=0.97) + + assert options.page_bitmap_area_threshold == 0.97 + + +@pytest.mark.parametrize("options_cls", OCR_OPTION_CLASSES) +def test_removed_bitmap_area_threshold_is_accepted_on_assignment(options_cls): + """Code written before `OcrMode` also set the field on a built object.""" + options = options_cls() + + with pytest.deprecated_call(match="page_bitmap_area_threshold"): + options.bitmap_area_threshold = 0.97 + + assert options.page_bitmap_area_threshold == 0.97 + assert "bitmap_area_threshold" not in options.model_dump() + + +def test_deprecation_warning_points_to_the_caller(): + """Python hides a DeprecationWarning that points to library code.""" + options = EasyOcrOptions() + + with pytest.deprecated_call() as constructor_warnings: + EasyOcrOptions(bitmap_area_threshold=0.97) + with pytest.deprecated_call() as assignment_warnings: + options.bitmap_area_threshold = 0.97 + + for caught in (constructor_warnings, assignment_warnings): + assert [w.filename for w in caught] == [__file__] + + +def test_explicit_threshold_wins_over_the_deprecated_one(): + with pytest.deprecated_call(): + options = EasyOcrOptions( + bitmap_area_threshold=0.05, page_bitmap_area_threshold=0.97 + ) + + assert options.page_bitmap_area_threshold == 0.97 + + +def test_scanned_page_is_ocred_above_the_threshold(): + assert len(_ocr_rects(SCANNED_PDF, threshold=0.97)) == 1 + + +def test_programmatic_page_skips_ocr_below_the_threshold(): + assert _ocr_rects(PROGRAMMATIC_PDF, threshold=0.97) == [] + + +def test_without_threshold_every_page_is_ocred(): + assert len(_ocr_rects(PROGRAMMATIC_PDF, threshold=None)) == 1 + + +def test_broken_text_layer_is_replaced_below_the_threshold(): + """A broken text layer comes from fonts, so its page is rarely a scan.""" + page = _first_page(PROGRAMMATIC_PDF) + page._broken_text_layer = True + model = _model(OcrMode.DEFAULT, threshold=0.97, replace_broken_text_layer=True) + + assert len(model.get_ocr_rects(page)) == 1 + + +def test_full_page_ocr_keeps_the_native_text_of_a_skipped_page(): + """With no OCR cells, a skipped page would otherwise lose all its text.""" + page = _first_page(PROGRAMMATIC_PDF) + assert page.parsed_page is not None + native_text = [cell.text for cell in page.cells] + native_words = [cell.text for cell in page.parsed_page.word_cells] + native_chars = [cell.text for cell in page.parsed_page.char_cells] + assert native_text and native_words + + model = _model(OcrMode.FULL_PAGE, threshold=0.97) + model.post_process_cells([], page, SimpleNamespace(confidence=ConfidenceReport())) + + assert [cell.text for cell in page.cells] == native_text + assert [cell.text for cell in page.parsed_page.word_cells] == native_words + assert [cell.text for cell in page.parsed_page.char_cells] == native_chars + + +def test_full_page_ocr_with_threshold_extracts_the_native_text(): + """The pages below the threshold need the native cells that FULL_PAGE skips.""" + options = PdfPipelineOptions( + do_ocr=True, + ocr_options=EasyOcrOptions( + mode=OcrMode.FULL_PAGE, page_bitmap_area_threshold=0.97 + ), + ) + + assert resolve_skip_cell_extraction(options) is False diff --git a/tests/test_skip_cell_extraction.py b/tests/test_skip_cell_extraction.py index eed0fdbf5c..bdaec12772 100644 --- a/tests/test_skip_cell_extraction.py +++ b/tests/test_skip_cell_extraction.py @@ -65,7 +65,10 @@ def test_post_process_cells_tolerates_missing_parsed_page() -> None: assert page.cells == [] cell = _ocr_cell("part 42-A", confidence=0.9) - ocr_model = SimpleNamespace(options=SimpleNamespace(mode=OcrMode.FULL_PAGE)) + ocr_model = SimpleNamespace( + options=SimpleNamespace(mode=OcrMode.FULL_PAGE), + _skips_ocr=lambda page: False, + ) conv_res = SimpleNamespace(confidence=ConfidenceReport()) BaseOcrModel.post_process_cells(ocr_model, [cell], page, conv_res)