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
37 changes: 25 additions & 12 deletions src/parse_bench/evaluation/layout_adapters/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2792,8 +2792,6 @@ def to_layout_output(
class PyMuPDF4LLMLayoutAdapter(LayoutAdapter):
"""Extract canonical layout predictions from PyMuPDF4LLM parse output."""

_SCALE = 1000

@classmethod
def matches(cls, inference_result: InferenceResult) -> bool:
if not isinstance(inference_result.output, ParseOutput) or not inference_result.output.layout_pages:
Expand All @@ -2819,29 +2817,44 @@ def to_layout_output(
if not isinstance(inference_result.output, ParseOutput):
raise ValueError("PyMuPDF4LLMLayoutAdapter requires ParseOutput or LayoutOutput")

layout_pages = inference_result.output.layout_pages
# Segments are normalized to [0, 1] against their own page. Scale each by
# its real page dimensions and expose the (reference) page's real
# dimensions as image_width/height so the metric's normalize_bbox_xyxy
# round-trips back to [0, 1] (a numeric no-op vs. the old synthetic 1000
# scale). Mirrors DoclingParseLayoutAdapter's real-dimension convention.
selected_pages = [lp for lp in layout_pages if page_filter is None or lp.page_number == page_filter]
reference_page = selected_pages[0] if selected_pages else (layout_pages[0] if layout_pages else None)
output_width = int(reference_page.width or 1) if reference_page is not None else 1
output_height = int(reference_page.height or 1) if reference_page is not None else 1

predictions: list[LayoutPrediction] = []
for page in inference_result.output.layout_pages:
for page in layout_pages:
if page_filter is not None and page.page_number != page_filter:
continue
page_width = float(page.width or output_width)
page_height = float(page.height or output_height)
for item in page.items:
segments = item.layout_segments or ([item.bbox] if item.bbox is not None else [])
for segment in segments:
if segment is None:
continue
# Emit the raw provider label; canonicalization happens in the
# label-mapper layer during projection.
label = segment.label or item.type or "Text"
content_text = item.html if label == "Table" and item.html else item.md or item.value
is_table = item.type == "table"
content_text = item.html if is_table and item.html else item.md or item.value
content = _build_docling_parse_content(
"table" if label == "Table" else "text",
"table" if is_table else "text",
content_text,
)
scale = self._SCALE
predictions.append(
LayoutPrediction(
bbox=[
segment.x * scale,
segment.y * scale,
(segment.x + segment.w) * scale,
(segment.y + segment.h) * scale,
segment.x * page_width,
segment.y * page_height,
(segment.x + segment.w) * page_width,
(segment.y + segment.h) * page_height,
],
score=segment.confidence if segment.confidence is not None else 1.0,
label=label,
Expand All @@ -2861,8 +2874,8 @@ def to_layout_output(
example_id=inference_result.request.example_id,
pipeline_name=inference_result.pipeline_name,
model=LayoutDetectionModel.PYMUPDF4LLM_LAYOUT,
image_width=self._SCALE,
image_height=self._SCALE,
image_width=max(output_width, 1),
image_height=max(output_height, 1),
predictions=predictions,
markdown=inference_result.output.markdown,
)
58 changes: 58 additions & 0 deletions src/parse_bench/evaluation/layout_label_mappers/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,64 @@ def to_canonical(
return canonical


@register_layout_label_mapper(
"pymupdf4llm",
"model:pymupdf4llm_layout",
priority=95,
)
class PyMuPDF4LLMLabelMapper(LayoutLabelMapper):
"""Mapper for raw PyMuPDF4LLM boxclass labels emitted in layout_pages.

The ``pymupdf4llm`` PARSE provider forwards the raw pymupdf4llm boxclass
string on each layout segment; this mapper owns the canonical semantics and
raises on any genuinely unknown class so a new upstream label cannot silently
become incorrect benchmark ground truth.
"""

_MAPPING: dict[str, CanonicalLabel] = {
"caption": CanonicalLabel.CAPTION,
"footnote": CanonicalLabel.FOOTNOTE,
"formula": CanonicalLabel.FORMULA,
"list-item": CanonicalLabel.LIST_ITEM,
"listitem": CanonicalLabel.LIST_ITEM,
"page-footer": CanonicalLabel.PAGE_FOOTER,
"pagefooter": CanonicalLabel.PAGE_FOOTER,
"page-header": CanonicalLabel.PAGE_HEADER,
"pageheader": CanonicalLabel.PAGE_HEADER,
"picture": CanonicalLabel.PICTURE,
"image": CanonicalLabel.PICTURE,
"section-header": CanonicalLabel.SECTION_HEADER,
"sectionheader": CanonicalLabel.SECTION_HEADER,
"heading": CanonicalLabel.SECTION_HEADER,
"table": CanonicalLabel.TABLE,
"text": CanonicalLabel.TEXT,
"title": CanonicalLabel.TITLE,
"code": CanonicalLabel.CODE,
"document-index": CanonicalLabel.DOCUMENT_INDEX,
"documentindex": CanonicalLabel.DOCUMENT_INDEX,
"form": CanonicalLabel.FORM,
"key-value-region": CanonicalLabel.KEY_VALUE_REGION,
"keyvalueregion": CanonicalLabel.KEY_VALUE_REGION,
"checkbox-selected": CanonicalLabel.CHECKBOX_SELECTED,
"checkboxselected": CanonicalLabel.CHECKBOX_SELECTED,
"checkbox-unselected": CanonicalLabel.CHECKBOX_UNSELECTED,
"checkboxunselected": CanonicalLabel.CHECKBOX_UNSELECTED,
}

def to_canonical(
self,
label: str,
prediction: LayoutPrediction,
context: MappingContext,
) -> CanonicalLabel:
del prediction, context
normalized = label.strip().lower().replace("_", "-").replace(" ", "-")
mapped = self._MAPPING.get(normalized)
if mapped is None:
raise UnknownRawLayoutLabelError(f"Unknown PyMuPDF4LLM raw layout label '{label}'")
return mapped


@register_layout_label_mapper(
"model:yolo_doclaynet",
"model:docling_layout_old",
Expand Down
51 changes: 15 additions & 36 deletions src/parse_bench/inference/providers/parse/pymupdf4llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,6 @@
logger = logging.getLogger(__name__)


# PyMuPDF Layout 1.28 emits exactly the DocLayNet/Core11 classes. Keep the
# mapping strict so a new upstream class does not silently become incorrect
# benchmark ground truth.
_PYMUPDF_CLASS_TO_CANONICAL = {
"caption": "Caption",
"footnote": "Footnote",
"formula": "Formula",
"list-item": "List-item",
"page-footer": "Page-footer",
"page-header": "Page-header",
"picture": "Picture",
"section-header": "Section-header",
"table": "Table",
"text": "Text",
"title": "Title",
}


@register_provider("pymupdf4llm")
class PyMuPDF4LLMProvider(Provider):
"""Provider for PyMuPDF4LLM (markdown). AGPL — runtime dep only."""
Expand Down Expand Up @@ -309,17 +291,18 @@ def _build_layout_page(
return None

items: list[LayoutItemIR] = []
unknown_classes: set[str] = set()
for page_box in page_data.get("page_boxes", []):
if not isinstance(page_box, dict):
continue

raw_class = str(page_box.get("class", "")).strip().lower().replace("_", "-")
canonical_label = _PYMUPDF_CLASS_TO_CANONICAL.get(raw_class)
if canonical_label is None:
if raw_class:
unknown_classes.add(raw_class)
# Emit the raw pymupdf4llm boxclass label untouched. Canonicalization
# and failing loud on genuinely unknown classes are owned by the
# evaluation label-mapper layer (PyMuPDF4LLMLabelMapper), not the
# provider, so no class is silently dropped here.
raw_label = str(page_box.get("class", "")).strip()
if not raw_label:
continue
normalized_class = raw_label.lower().replace("_", "-")

bbox = cls._coerce_bbox(
page_box.get("bbox"),
Expand Down Expand Up @@ -354,20 +337,23 @@ def _build_layout_page(
w=bbox[2],
h=bbox[3],
confidence=confidence,
label=canonical_label,
label=raw_label,
start_index=start_index,
end_index=end_index,
)

if canonical_label == "Table":
if normalized_class == "table":
item_type = "table"
item_html = cls._convert_md_tables_to_html(content)
elif canonical_label == "Picture":
# If the sliced content is already native HTML (e.g. a pipeline
# opted into table_output="html"), keep it verbatim; otherwise
# convert Markdown pipe tables via markdown2.
item_html = content if "<table" in content.lower() else cls._convert_md_tables_to_html(content)
elif normalized_class == "picture":
item_type = "image"
item_html = ""
else:
# Field-grounding evaluation consumes text-like items while the
# canonical category remains on the segment label.
# raw provider category remains on the segment label.
item_type = "text"
item_html = ""

Expand All @@ -382,13 +368,6 @@ def _build_layout_page(
)
)

if unknown_classes:
logger.warning(
"Skipping unknown PyMuPDF4LLM layout classes on page %s: %s",
page_number,
", ".join(sorted(unknown_classes)),
)

return ParseLayoutPageIR(
page_number=page_number,
width=page_width,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Tests for the PyMuPDF4LLM layout label mapper and its registry wiring."""

import pytest

from parse_bench.evaluation.layout_label_mappers.base import MappingContext
from parse_bench.evaluation.layout_label_mappers.mappers import PyMuPDF4LLMLabelMapper
from parse_bench.evaluation.layout_label_mappers.registry import (
list_layout_label_mappers,
resolve_layout_label_mapper,
)
from parse_bench.layout_label_mapping import UnknownRawLayoutLabelError
from parse_bench.schemas.layout_detection_output import LayoutDetectionModel, LayoutOutput
from parse_bench.schemas.layout_ontology import CanonicalLabel


def _context(*, provider_name: str | None) -> MappingContext:
layout_output = LayoutOutput(
example_id="ex",
pipeline_name="pymupdf4llm_markdown",
model=LayoutDetectionModel.PYMUPDF4LLM_LAYOUT,
image_width=100,
image_height=100,
predictions=[],
)
return MappingContext(
provider_name=provider_name,
pipeline_name="pymupdf4llm_markdown",
model=LayoutDetectionModel.PYMUPDF4LLM_LAYOUT,
raw_output={},
layout_output=layout_output,
)


def test_mapper_registered_for_provider_and_model_keys() -> None:
keys = list_layout_label_mappers()
assert "pymupdf4llm" in keys
assert "model:pymupdf4llm_layout" in keys


@pytest.mark.parametrize(
"raw,expected",
[
("table", CanonicalLabel.TABLE),
("text", CanonicalLabel.TEXT),
("title", CanonicalLabel.TITLE),
("section-header", CanonicalLabel.SECTION_HEADER),
("Section_Header", CanonicalLabel.SECTION_HEADER),
("list-item", CanonicalLabel.LIST_ITEM),
("list_item", CanonicalLabel.LIST_ITEM),
("picture", CanonicalLabel.PICTURE),
("image", CanonicalLabel.PICTURE),
("caption", CanonicalLabel.CAPTION),
],
)
def test_maps_known_raw_labels(raw: str, expected: CanonicalLabel) -> None:
assert PyMuPDF4LLMLabelMapper().to_canonical(raw, None, None) == expected # type: ignore[arg-type]


def test_unknown_label_raises() -> None:
with pytest.raises(UnknownRawLayoutLabelError):
PyMuPDF4LLMLabelMapper().to_canonical("totally-unknown-class", None, None) # type: ignore[arg-type]


def test_resolver_selects_mapper_by_provider_name() -> None:
mapper = resolve_layout_label_mapper(_context(provider_name="pymupdf4llm"))
assert isinstance(mapper, PyMuPDF4LLMLabelMapper)


def test_resolver_selects_mapper_by_model_key() -> None:
# With no provider name, resolution must fall back to the model:<value> key.
mapper = resolve_layout_label_mapper(_context(provider_name=None))
assert isinstance(mapper, PyMuPDF4LLMLabelMapper)
Loading