diff --git a/src/parse_bench/evaluation/layout_adapters/adapters.py b/src/parse_bench/evaluation/layout_adapters/adapters.py index b9d2297..0d1dcd5 100644 --- a/src/parse_bench/evaluation/layout_adapters/adapters.py +++ b/src/parse_bench/evaluation/layout_adapters/adapters.py @@ -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: @@ -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, @@ -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, ) diff --git a/src/parse_bench/evaluation/layout_label_mappers/mappers.py b/src/parse_bench/evaluation/layout_label_mappers/mappers.py index 819e05d..4d23d1d 100644 --- a/src/parse_bench/evaluation/layout_label_mappers/mappers.py +++ b/src/parse_bench/evaluation/layout_label_mappers/mappers.py @@ -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", diff --git a/src/parse_bench/inference/providers/parse/pymupdf4llm.py b/src/parse_bench/inference/providers/parse/pymupdf4llm.py index 70cd85b..c459bca 100644 --- a/src/parse_bench/inference/providers/parse/pymupdf4llm.py +++ b/src/parse_bench/inference/providers/parse/pymupdf4llm.py @@ -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.""" @@ -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"), @@ -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 "
| a | b |