Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docling/.agents/skills/docling/references/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,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.
Expand Down
2 changes: 1 addition & 1 deletion docling/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2216,7 +2216,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):
Expand Down
56 changes: 55 additions & 1 deletion docling/datamodel/pipeline_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -260,6 +265,22 @@ class OcrOptions(BaseOptions):
),
] = 3.0

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."
),
examples=[None, 0.97],
ge=0.0,
le=1.0,
),
] = None

model_config = ConfigDict(
validate_assignment=True,
validate_default=True,
Expand Down Expand Up @@ -293,6 +314,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]
Expand All @@ -311,6 +348,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.
Expand Down
42 changes: 39 additions & 3 deletions docling/models/base_ocr_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ def get_ocr_rects(self, page: Page) -> list[BoundingBox]:
"""
assert page.size is not None

if not self._has_enough_bitmap_area(page):
return []

# Compute the OCR rects according to the mode
ocr_rects: list[BoundingBox]

Expand All @@ -264,6 +267,30 @@ def get_ocr_rects(self, page: Page) -> list[BoundingBox]:
]
return ocr_rects

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.
Expand Down Expand Up @@ -604,7 +631,8 @@ def post_process_cells(
r"""
Post-process the OCR cells and update the page object according to the algorithm:

- If FULL_PAGE: Any existing PDF cells are ignored and only the OCR cells are used.
- If FULL_PAGE: Any existing PDF cells are ignored and only the OCR cells are used,
unless `page_bitmap_area_threshold` skipped OCR on the page: it 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
Expand All @@ -614,9 +642,17 @@ def post_process_cells(
# Get existing cells from the read-only property
existing_cells = page.cells

# False on a FULL_PAGE page that `page_bitmap_area_threshold` kept from OCR
full_page_ocr = (
self.options.mode == OcrMode.FULL_PAGE
and self._has_enough_bitmap_area(page)
)

# Combine existing and OCR cells with overlap filtering
if self.options.mode == OcrMode.FULL_PAGE:
if full_page_ocr:
final_cells = ocr_cells
elif self.options.mode == OcrMode.FULL_PAGE:
final_cells = existing_cells
else:
if priority is None:
priority = (
Expand Down Expand Up @@ -645,7 +681,7 @@ def post_process_cells(
page.parsed_page.has_lines = len(final_cells) > 0

# In OcrMode.FULL_PAGE, PDF-extracted word/char cells are unreliable. Keep only OCR cells
if self.options.mode == OcrMode.FULL_PAGE:
if full_page_ocr:
page.parsed_page.word_cells = [
c for c in page.parsed_page.word_cells if c.from_ocr
]
Expand Down
5 changes: 5 additions & 0 deletions docling/models/stages/ocr/auto_ocr_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def __init__(
options=OcrMacOptions(
mode=self.options.mode,
lang=self.options.lang,
page_bitmap_area_threshold=self.options.page_bitmap_area_threshold,
),
accelerator_options=accelerator_options,
)
Expand All @@ -99,6 +100,7 @@ def __init__(
options=NemotronOcrOptions(
mode=self.options.mode,
lang=self.options.lang,
page_bitmap_area_threshold=self.options.page_bitmap_area_threshold,
),
accelerator_options=accelerator_options,
)
Expand Down Expand Up @@ -130,6 +132,7 @@ def __init__(
backend="onnxruntime",
mode=self.options.mode,
lang=self.options.lang,
page_bitmap_area_threshold=self.options.page_bitmap_area_threshold,
),
accelerator_options=accelerator_options,
)
Expand Down Expand Up @@ -159,6 +162,7 @@ def __init__(
options=EasyOcrOptions(
mode=self.options.mode,
lang=self.options.lang,
page_bitmap_area_threshold=self.options.page_bitmap_area_threshold,
),
accelerator_options=accelerator_options,
)
Expand All @@ -184,6 +188,7 @@ def __init__(
backend="torch",
mode=self.options.mode,
lang=self.options.lang,
page_bitmap_area_threshold=self.options.page_bitmap_area_threshold,
),
accelerator_options=accelerator_options,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,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
)


Expand Down
24 changes: 24 additions & 0 deletions docs/concepts/OCR.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,30 @@ Engines that run one language at a time (RapidOCR, Nemotron-OCR) take the **firs
about the rest. The KServe client also sends only the first entry.


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

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

The engine's own vocabulary, in its own codes, is listed in
Expand Down
5 changes: 4 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from typer.main import get_command
from typer.testing import CliRunner

import docling.cli.main as cli_main
from docling.cli.export_utils import (
_parse_page_range,
_should_generate_export_images,
Expand Down Expand Up @@ -1743,7 +1744,9 @@ def test_cli_deprecated_psm_still_sets_tesseract_psm(tmp_path, monkeypatch):
assert ocr_options.psm == 4


def test_cli_options_lists_engine_fields():
def test_cli_options_lists_engine_fields(monkeypatch):
# The rich console reads its width at import, so terminal_width does not reach it
monkeypatch.setattr(cli_main.console, "width", 200)
result = runner.invoke(app, ["options", "ocr", "rapidocr"], terminal_width=200)
assert result.exit_code == 0, result.output
assert "model_size" in result.output
Expand Down
Loading
Loading