parse-bench 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- parse_bench/__init__.py +3 -0
- parse_bench/analysis/__init__.py +6 -0
- parse_bench/analysis/aggregation_report.py +582 -0
- parse_bench/analysis/cli.py +472 -0
- parse_bench/analysis/comparison.py +382 -0
- parse_bench/analysis/comparison_core.py +357 -0
- parse_bench/analysis/comparison_report.py +2066 -0
- parse_bench/analysis/detailed_report.py +2254 -0
- parse_bench/analysis/leaderboard_report.py +852 -0
- parse_bench/analysis/metric_definitions.py +771 -0
- parse_bench/cli.py +267 -0
- parse_bench/data/__init__.py +1 -0
- parse_bench/data/cli.py +118 -0
- parse_bench/data/download.py +127 -0
- parse_bench/evaluation/__init__.py +11 -0
- parse_bench/evaluation/cli.py +435 -0
- parse_bench/evaluation/evaluators/__init__.py +17 -0
- parse_bench/evaluation/evaluators/base.py +34 -0
- parse_bench/evaluation/evaluators/extract.py +429 -0
- parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
- parse_bench/evaluation/evaluators/parse.py +1353 -0
- parse_bench/evaluation/evaluators/qa.py +199 -0
- parse_bench/evaluation/layout_adapters/__init__.py +21 -0
- parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
- parse_bench/evaluation/layout_adapters/base.py +105 -0
- parse_bench/evaluation/layout_adapters/registry.py +109 -0
- parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
- parse_bench/evaluation/layout_label_mappers/base.py +66 -0
- parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
- parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
- parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
- parse_bench/evaluation/metric_aggregation.py +56 -0
- parse_bench/evaluation/metrics/__init__.py +5 -0
- parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
- parse_bench/evaluation/metrics/attribution/constants.py +12 -0
- parse_bench/evaluation/metrics/attribution/core.py +1108 -0
- parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
- parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
- parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
- parse_bench/evaluation/metrics/base.py +33 -0
- parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
- parse_bench/evaluation/metrics/extract/__init__.py +29 -0
- parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
- parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
- parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
- parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
- parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
- parse_bench/evaluation/metrics/extract/test_types.py +11 -0
- parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
- parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
- parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
- parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
- parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
- parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
- parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
- parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
- parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
- parse_bench/evaluation/metrics/parse/__init__.py +5 -0
- parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
- parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
- parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
- parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
- parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
- parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
- parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
- parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
- parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
- parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
- parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
- parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
- parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
- parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
- parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
- parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
- parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
- parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
- parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
- parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
- parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
- parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
- parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
- parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
- parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
- parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
- parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
- parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
- parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
- parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
- parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
- parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
- parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
- parse_bench/evaluation/metrics/parse/test_types.py +103 -0
- parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
- parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
- parse_bench/evaluation/metrics/parse/utils.py +885 -0
- parse_bench/evaluation/metrics/qa/__init__.py +5 -0
- parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
- parse_bench/evaluation/qa/__init__.py +5 -0
- parse_bench/evaluation/qa/llm_service.py +335 -0
- parse_bench/evaluation/reports/__init__.py +8 -0
- parse_bench/evaluation/reports/csv.py +64 -0
- parse_bench/evaluation/reports/html.py +338 -0
- parse_bench/evaluation/reports/markdown.py +98 -0
- parse_bench/evaluation/reports/rule_csv.py +22 -0
- parse_bench/evaluation/runner.py +1864 -0
- parse_bench/evaluation/stats.py +104 -0
- parse_bench/extensions.py +72 -0
- parse_bench/inference/__init__.py +33 -0
- parse_bench/inference/chunkr_layout_extraction.py +160 -0
- parse_bench/inference/cli.py +484 -0
- parse_bench/inference/layout_extraction.py +422 -0
- parse_bench/inference/pipelines/__init__.py +59 -0
- parse_bench/inference/pipelines/extract.py +39 -0
- parse_bench/inference/pipelines/layout.py +142 -0
- parse_bench/inference/pipelines/parse.py +2603 -0
- parse_bench/inference/pipelines.py +0 -0
- parse_bench/inference/providers/__init__.py +28 -0
- parse_bench/inference/providers/base.py +196 -0
- parse_bench/inference/providers/cancellation.py +137 -0
- parse_bench/inference/providers/extract/__init__.py +22 -0
- parse_bench/inference/providers/extract/citations.py +549 -0
- parse_bench/inference/providers/extract/extend.py +851 -0
- parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
- parse_bench/inference/providers/layoutdet/__init__.py +25 -0
- parse_bench/inference/providers/layoutdet/adapters.py +946 -0
- parse_bench/inference/providers/layoutdet/base.py +203 -0
- parse_bench/inference/providers/layoutdet/chandra.py +449 -0
- parse_bench/inference/providers/layoutdet/docling.py +125 -0
- parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
- parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
- parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
- parse_bench/inference/providers/layoutdet/paddle.py +117 -0
- parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
- parse_bench/inference/providers/layoutdet/surya.py +250 -0
- parse_bench/inference/providers/layoutdet/yolo.py +109 -0
- parse_bench/inference/providers/parse/__init__.py +64 -0
- parse_bench/inference/providers/parse/_docling_common.py +233 -0
- parse_bench/inference/providers/parse/_layout_utils.py +611 -0
- parse_bench/inference/providers/parse/amazon_nova.py +515 -0
- parse_bench/inference/providers/parse/anthropic.py +882 -0
- parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
- parse_bench/inference/providers/parse/chandra2.py +633 -0
- parse_bench/inference/providers/parse/chunkr.py +268 -0
- parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
- parse_bench/inference/providers/parse/datalab.py +370 -0
- parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
- parse_bench/inference/providers/parse/docling.py +281 -0
- parse_bench/inference/providers/parse/docling_serve.py +289 -0
- parse_bench/inference/providers/parse/dots_ocr.py +574 -0
- parse_bench/inference/providers/parse/extend_parse.py +710 -0
- parse_bench/inference/providers/parse/falconocr.py +436 -0
- parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
- parse_bench/inference/providers/parse/gemma4.py +472 -0
- parse_bench/inference/providers/parse/glm_zai.py +229 -0
- parse_bench/inference/providers/parse/google.py +1125 -0
- parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
- parse_bench/inference/providers/parse/google_docai.py +776 -0
- parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
- parse_bench/inference/providers/parse/granite_vision.py +515 -0
- parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
- parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
- parse_bench/inference/providers/parse/landingai.py +452 -0
- parse_bench/inference/providers/parse/liteparse.py +350 -0
- parse_bench/inference/providers/parse/llamaparse.py +677 -0
- parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
- parse_bench/inference/providers/parse/markitdown.py +138 -0
- parse_bench/inference/providers/parse/mineru25.py +405 -0
- parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
- parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
- parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
- parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
- parse_bench/inference/providers/parse/oi_parser.py +222 -0
- parse_bench/inference/providers/parse/openai.py +740 -0
- parse_bench/inference/providers/parse/opendataloader.py +152 -0
- parse_bench/inference/providers/parse/paddleocr.py +624 -0
- parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
- parse_bench/inference/providers/parse/pulse.py +785 -0
- parse_bench/inference/providers/parse/pymupdf.py +207 -0
- parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
- parse_bench/inference/providers/parse/pypdf.py +179 -0
- parse_bench/inference/providers/parse/qwen.py +678 -0
- parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
- parse_bench/inference/providers/parse/reducto.py +546 -0
- parse_bench/inference/providers/parse/surya2.py +372 -0
- parse_bench/inference/providers/parse/tesseract.py +301 -0
- parse_bench/inference/providers/parse/textract.py +694 -0
- parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
- parse_bench/inference/providers/parse/unstructured.py +485 -0
- parse_bench/inference/providers/parse/warp_ingest.py +199 -0
- parse_bench/inference/providers/registry.py +49 -0
- parse_bench/inference/renormalize.py +170 -0
- parse_bench/inference/runner.py +2023 -0
- parse_bench/layout_label_mapping.py +424 -0
- parse_bench/layout_projection.py +179 -0
- parse_bench/pipeline/__init__.py +1 -0
- parse_bench/pipeline/cli.py +549 -0
- parse_bench/schemas/__init__.py +33 -0
- parse_bench/schemas/evaluation.py +93 -0
- parse_bench/schemas/extract_output.py +36 -0
- parse_bench/schemas/layout_detection_output.py +545 -0
- parse_bench/schemas/layout_ontology.py +315 -0
- parse_bench/schemas/metrics.py +69 -0
- parse_bench/schemas/parse_output.py +152 -0
- parse_bench/schemas/pipeline.py +22 -0
- parse_bench/schemas/pipeline_io.py +106 -0
- parse_bench/schemas/product.py +97 -0
- parse_bench/test_cases/__init__.py +25 -0
- parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
- parse_bench/test_cases/extract_field_paths.py +164 -0
- parse_bench/test_cases/layout_attribution_generation.py +287 -0
- parse_bench/test_cases/loader.py +652 -0
- parse_bench/test_cases/parse_rule_schemas.py +1071 -0
- parse_bench/test_cases/rule_filters.py +32 -0
- parse_bench/test_cases/rule_ids.py +107 -0
- parse_bench/test_cases/schema.py +427 -0
- parse_bench/utils/__init__.py +15 -0
- parse_bench/utils/gemini_layout_utils.py +670 -0
- parse_bench/utils/text_aggregation.py +100 -0
- parse_bench-1.0.0.dist-info/METADATA +476 -0
- parse_bench-1.0.0.dist-info/RECORD +227 -0
- parse_bench-1.0.0.dist-info/WHEEL +4 -0
- parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
- parse_bench-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""Shared layout parsing utilities for LLM-based parse_with_layout providers.
|
|
2
|
+
|
|
3
|
+
These utilities are used by Google, OpenAI, and Anthropic providers that
|
|
4
|
+
produce layout-annotated output using <div data-bbox="..." data-label="...">
|
|
5
|
+
HTML wrappers with the Core11 label set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from parse_bench.schemas.parse_output import (
|
|
17
|
+
LayoutItemIR,
|
|
18
|
+
LayoutSegmentIR,
|
|
19
|
+
ParseLayoutPageIR,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Layout-annotated prompts (Core11 label set)
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
SYSTEM_PROMPT_LAYOUT = (
|
|
29
|
+
"You are a document parser. Your task is to convert "
|
|
30
|
+
"document images to clean, well-structured markdown."
|
|
31
|
+
"\n\nGuidelines:\n"
|
|
32
|
+
"- Preserve the document structure "
|
|
33
|
+
"(headings, paragraphs, lists, tables)\n"
|
|
34
|
+
"- Convert tables to HTML format "
|
|
35
|
+
"(<table>, <tr>, <th>, <td>)\n"
|
|
36
|
+
"- For existing tables in the document: use colspan "
|
|
37
|
+
"and rowspan attributes to preserve merged cells "
|
|
38
|
+
"and hierarchical headers\n"
|
|
39
|
+
"- For charts/graphs being converted to tables: use "
|
|
40
|
+
"flat combined column headers (e.g., "
|
|
41
|
+
'"Primary 2015" not separate rows) so each data '
|
|
42
|
+
"cell's row contains all its labels\n"
|
|
43
|
+
"- Describe images/figures briefly in square brackets "
|
|
44
|
+
"like [Figure: description]\n"
|
|
45
|
+
"- Preserve any code blocks with appropriate syntax "
|
|
46
|
+
"highlighting\n"
|
|
47
|
+
"- Maintain reading order (left-to-right, "
|
|
48
|
+
"top-to-bottom for Western documents)\n"
|
|
49
|
+
"- Do not add commentary or explanations "
|
|
50
|
+
"- only output the parsed content"
|
|
51
|
+
"\n\n"
|
|
52
|
+
"Additionally, wrap each layout element in a <div> tag with:\n"
|
|
53
|
+
'- data-bbox="[x1, y1, x2, y2]" — bounding box in normalized 0-1000 '
|
|
54
|
+
"coordinates where x is horizontal (left edge = 0, right edge = 1000) "
|
|
55
|
+
"and y is vertical (top = 0, bottom = 1000). "
|
|
56
|
+
"x1,y1 is the top-left corner and x2,y2 is the bottom-right corner.\n"
|
|
57
|
+
'- data-label="<category>" — one of: Caption, Footnote, Formula, '
|
|
58
|
+
"List-item, Page-footer, Page-header, Picture, Section-header, "
|
|
59
|
+
"Table, Text, Title\n\n"
|
|
60
|
+
"Place elements in reading order. Every piece of content must be "
|
|
61
|
+
"inside exactly one <div> wrapper."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
USER_PROMPT_LAYOUT = (
|
|
65
|
+
"Parse this document page and output its content as "
|
|
66
|
+
"clean markdown, with each layout element wrapped in a "
|
|
67
|
+
'<div data-bbox="[x1,y1,x2,y2]" data-label="Category"> tag. '
|
|
68
|
+
"Use HTML tables for any tabular data. "
|
|
69
|
+
"For charts/graphs, use flat combined column headers. "
|
|
70
|
+
"Output ONLY the parsed content with div wrappers, "
|
|
71
|
+
"no explanations."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Gemini-specific layout prompts — use native [y_min, x_min, y_max, x_max]
|
|
76
|
+
# format to avoid intermittent coordinate inversion when asking for [x1,y1,x2,y2].
|
|
77
|
+
# Callers must convert with swap_gemini_bbox() after parse_layout_blocks().
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
SYSTEM_PROMPT_LAYOUT_GEMINI = SYSTEM_PROMPT_LAYOUT.replace(
|
|
81
|
+
'"[x1, y1, x2, y2]" — bounding box in normalized 0-1000 '
|
|
82
|
+
"coordinates where x is horizontal (left edge = 0, right edge = 1000) "
|
|
83
|
+
"and y is vertical (top = 0, bottom = 1000). "
|
|
84
|
+
"x1,y1 is the top-left corner and x2,y2 is the bottom-right corner.",
|
|
85
|
+
'"[y_min, x_min, y_max, x_max]" — bounding box in normalized 0-1000 '
|
|
86
|
+
"coordinates where x is horizontal (left edge = 0, right edge = 1000) "
|
|
87
|
+
"and y is vertical (top = 0, bottom = 1000). "
|
|
88
|
+
"The order is [y_min, x_min, y_max, x_max].",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
USER_PROMPT_LAYOUT_GEMINI = USER_PROMPT_LAYOUT.replace(
|
|
92
|
+
"[x1,y1,x2,y2]",
|
|
93
|
+
"[y_min,x_min,y_max,x_max]",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Absolute-pixel prompt variants (bbox_scale=None in the provider config).
|
|
99
|
+
# Some models ground well but are unreliable at re-normalizing their bboxes
|
|
100
|
+
# into the 0-1000 range, which conflates grounding quality with coordinate
|
|
101
|
+
# compliance. These variants ask for the model's native pixel frame instead;
|
|
102
|
+
# build_layout_pages(bbox_scale=None) then normalizes by the image
|
|
103
|
+
# dimensions. Built by string replacement so the variants can never drift
|
|
104
|
+
# from the base prompts on anything except the coordinate bullet.
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
_DATA_BBOX_BULLET_NORM = (
|
|
108
|
+
'"[x1, y1, x2, y2]" — bounding box in normalized 0-1000 '
|
|
109
|
+
"coordinates where x is horizontal (left edge = 0, right edge = 1000) "
|
|
110
|
+
"and y is vertical (top = 0, bottom = 1000). "
|
|
111
|
+
"x1,y1 is the top-left corner and x2,y2 is the bottom-right corner."
|
|
112
|
+
)
|
|
113
|
+
_DATA_BBOX_BULLET_ABS = (
|
|
114
|
+
'"[x1, y1, x2, y2]" — bounding box in ABSOLUTE PIXEL coordinates '
|
|
115
|
+
"of the image, where x is horizontal (left edge = 0) and y is "
|
|
116
|
+
"vertical (top = 0). "
|
|
117
|
+
"x1,y1 is the top-left corner and x2,y2 is the bottom-right corner."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
SYSTEM_PROMPT_LAYOUT_ABS = SYSTEM_PROMPT_LAYOUT.replace(_DATA_BBOX_BULLET_NORM, _DATA_BBOX_BULLET_ABS)
|
|
121
|
+
assert SYSTEM_PROMPT_LAYOUT_ABS != SYSTEM_PROMPT_LAYOUT, (
|
|
122
|
+
"data-bbox bullet replacement did not match SYSTEM_PROMPT_LAYOUT"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
USER_PROMPT_LAYOUT_ABS = USER_PROMPT_LAYOUT.replace(
|
|
126
|
+
'data-label="Category"> tag. ',
|
|
127
|
+
'data-label="Category"> tag (data-bbox in absolute pixel coordinates). ',
|
|
128
|
+
)
|
|
129
|
+
assert USER_PROMPT_LAYOUT_ABS != USER_PROMPT_LAYOUT, "user-prompt replacement did not match USER_PROMPT_LAYOUT"
|
|
130
|
+
|
|
131
|
+
_DATA_BBOX_BULLET_GEMINI_NORM = (
|
|
132
|
+
'"[y_min, x_min, y_max, x_max]" — bounding box in normalized 0-1000 '
|
|
133
|
+
"coordinates where x is horizontal (left edge = 0, right edge = 1000) "
|
|
134
|
+
"and y is vertical (top = 0, bottom = 1000). "
|
|
135
|
+
"The order is [y_min, x_min, y_max, x_max]."
|
|
136
|
+
)
|
|
137
|
+
_DATA_BBOX_BULLET_GEMINI_ABS = (
|
|
138
|
+
'"[y_min, x_min, y_max, x_max]" — bounding box in ABSOLUTE PIXEL '
|
|
139
|
+
"coordinates of the image, where x is horizontal (left edge = 0) "
|
|
140
|
+
"and y is vertical (top = 0). "
|
|
141
|
+
"The order is [y_min, x_min, y_max, x_max]."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
SYSTEM_PROMPT_LAYOUT_GEMINI_ABS = SYSTEM_PROMPT_LAYOUT_GEMINI.replace(
|
|
145
|
+
_DATA_BBOX_BULLET_GEMINI_NORM, _DATA_BBOX_BULLET_GEMINI_ABS
|
|
146
|
+
)
|
|
147
|
+
assert SYSTEM_PROMPT_LAYOUT_GEMINI_ABS != SYSTEM_PROMPT_LAYOUT_GEMINI, (
|
|
148
|
+
"data-bbox bullet replacement did not match SYSTEM_PROMPT_LAYOUT_GEMINI"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
USER_PROMPT_LAYOUT_GEMINI_ABS = USER_PROMPT_LAYOUT_GEMINI.replace(
|
|
152
|
+
'data-label="Category"> tag. ',
|
|
153
|
+
'data-label="Category"> tag (data-bbox in absolute pixel coordinates). ',
|
|
154
|
+
)
|
|
155
|
+
assert USER_PROMPT_LAYOUT_GEMINI_ABS != USER_PROMPT_LAYOUT_GEMINI, (
|
|
156
|
+
"user-prompt replacement did not match USER_PROMPT_LAYOUT_GEMINI"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_layout_prompts(
|
|
161
|
+
bbox_scale: float | None,
|
|
162
|
+
mode: str | None,
|
|
163
|
+
*,
|
|
164
|
+
gemini: bool = False,
|
|
165
|
+
pixel_disallowed_modes: tuple[str, ...] = ("parse_with_layout_file",),
|
|
166
|
+
pixel_frame_supported: bool = False,
|
|
167
|
+
) -> tuple[str, str]:
|
|
168
|
+
"""Validate *bbox_scale* and return the (system, user) layout prompts.
|
|
169
|
+
|
|
170
|
+
``bbox_scale`` follows the same convention as the layoutdet providers:
|
|
171
|
+
a number means the model's coordinates are on that grid (only 1000 is
|
|
172
|
+
supported here, since the prompt text hardcodes the range), and ``None``
|
|
173
|
+
means absolute pixel coordinates, normalized by the image dimensions.
|
|
174
|
+
|
|
175
|
+
Shared by the div-layout providers so the scale validation, the
|
|
176
|
+
scale/mode constraint, and the prompt-pair selection cannot drift
|
|
177
|
+
between them.
|
|
178
|
+
|
|
179
|
+
The pixel frame is normalized by the recorded page dimensions, so it
|
|
180
|
+
assumes the model perceives the image at the sent resolution. Provider
|
|
181
|
+
APIs downscale images past their native limits, and the model then
|
|
182
|
+
reports pixels in that downscaled frame while ``normalize()`` divides by
|
|
183
|
+
the dimensions we recorded — so a provider may only opt in
|
|
184
|
+
(``pixel_frame_supported=True``) once it pre-resizes each page to the
|
|
185
|
+
size the model actually perceives. Anthropic does this in
|
|
186
|
+
``_resize_to_perceived_size``; the other div-layout providers do not,
|
|
187
|
+
and are rejected rather than allowed to report a silently shifted frame.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
ProviderConfigError: for an unsupported scale; for ``bbox_scale=None``
|
|
191
|
+
on a provider that cannot pin the perceived frame; or for
|
|
192
|
+
``bbox_scale=None`` combined with a mode in
|
|
193
|
+
*pixel_disallowed_modes* (absolute pixel coordinates are
|
|
194
|
+
undefined when the model is sent the PDF file rather than a
|
|
195
|
+
rendered image).
|
|
196
|
+
"""
|
|
197
|
+
from parse_bench.inference.providers.base import ProviderConfigError
|
|
198
|
+
|
|
199
|
+
if bbox_scale is not None and bbox_scale != 1000:
|
|
200
|
+
raise ProviderConfigError(
|
|
201
|
+
f"Unsupported bbox_scale {bbox_scale!r}. Use 1000 (normalized 0-1000, "
|
|
202
|
+
"the default) or None (absolute pixel coordinates)."
|
|
203
|
+
)
|
|
204
|
+
if bbox_scale is None and not pixel_frame_supported:
|
|
205
|
+
raise ProviderConfigError(
|
|
206
|
+
"bbox_scale=None (pixel coordinates) is not supported by this provider. "
|
|
207
|
+
"The provider API may downscale the page before the model sees it, so "
|
|
208
|
+
"the model's pixel coordinates would be in a different frame than the "
|
|
209
|
+
"recorded page dimensions. Use bbox_scale=1000 (the default), or add a "
|
|
210
|
+
"perceived-size pre-resize to the provider first."
|
|
211
|
+
)
|
|
212
|
+
if bbox_scale is None and mode in pixel_disallowed_modes:
|
|
213
|
+
raise ProviderConfigError(
|
|
214
|
+
f"bbox_scale=None (pixel coordinates) is not supported with mode "
|
|
215
|
+
f"'{mode}'; absolute pixel coordinates are undefined when the model "
|
|
216
|
+
"is sent the PDF file."
|
|
217
|
+
)
|
|
218
|
+
if bbox_scale is None:
|
|
219
|
+
if gemini:
|
|
220
|
+
return SYSTEM_PROMPT_LAYOUT_GEMINI_ABS, USER_PROMPT_LAYOUT_GEMINI_ABS
|
|
221
|
+
return SYSTEM_PROMPT_LAYOUT_ABS, USER_PROMPT_LAYOUT_ABS
|
|
222
|
+
if gemini:
|
|
223
|
+
return SYSTEM_PROMPT_LAYOUT_GEMINI, USER_PROMPT_LAYOUT_GEMINI
|
|
224
|
+
return SYSTEM_PROMPT_LAYOUT, USER_PROMPT_LAYOUT
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def swap_gemini_bbox(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
228
|
+
"""Convert Gemini native [y_min, x_min, y_max, x_max] to [x1, y1, x2, y2]."""
|
|
229
|
+
for item in items:
|
|
230
|
+
bbox = item.get("bbox", [])
|
|
231
|
+
if len(bbox) == 4:
|
|
232
|
+
y_min, x_min, y_max, x_max = bbox
|
|
233
|
+
item["bbox"] = [x_min, y_min, x_max, y_max]
|
|
234
|
+
return items
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Label mapping (case-insensitive raw label -> canonical label string)
|
|
238
|
+
LABEL_MAP: dict[str, str] = {
|
|
239
|
+
"caption": "Caption",
|
|
240
|
+
"footnote": "Footnote",
|
|
241
|
+
"formula": "Formula",
|
|
242
|
+
"list-item": "List-item",
|
|
243
|
+
"list_item": "List-item",
|
|
244
|
+
"page-footer": "Page-footer",
|
|
245
|
+
"page_footer": "Page-footer",
|
|
246
|
+
"page-header": "Page-header",
|
|
247
|
+
"page_header": "Page-header",
|
|
248
|
+
"picture": "Picture",
|
|
249
|
+
"figure": "Picture",
|
|
250
|
+
"section-header": "Section-header",
|
|
251
|
+
"section_header": "Section-header",
|
|
252
|
+
"table": "Table",
|
|
253
|
+
"text": "Text",
|
|
254
|
+
"title": "Title",
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def split_pdf_to_pages(pdf_path: str) -> list[tuple[bytes, int, int]]:
|
|
259
|
+
"""Split a PDF into single-page PDF bytes.
|
|
260
|
+
|
|
261
|
+
Returns a list of (pdf_bytes, width_px, height_px) tuples, one per page.
|
|
262
|
+
Width/height are at 72 DPI (PDF points).
|
|
263
|
+
"""
|
|
264
|
+
import fitz # PyMuPDF
|
|
265
|
+
|
|
266
|
+
src = fitz.open(pdf_path)
|
|
267
|
+
results: list[tuple[bytes, int, int]] = []
|
|
268
|
+
for page_num in range(len(src)):
|
|
269
|
+
page = src[page_num]
|
|
270
|
+
rect = page.rect
|
|
271
|
+
# Create a single-page PDF in memory
|
|
272
|
+
dst = fitz.open()
|
|
273
|
+
dst.insert_pdf(src, from_page=page_num, to_page=page_num)
|
|
274
|
+
pdf_bytes = dst.tobytes()
|
|
275
|
+
dst.close()
|
|
276
|
+
results.append((pdf_bytes, int(rect.width), int(rect.height)))
|
|
277
|
+
src.close()
|
|
278
|
+
return results
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_WRAPPER_TAG_RE = re.compile(r"<(/?)(\w+)((?:[^>\"']|\"[^\"]*\"|'[^']*')*)>")
|
|
282
|
+
_BBOX_ATTR_RE = re.compile(r"data-bbox=[\"'](\[[^\]]+\])[\"']", re.IGNORECASE)
|
|
283
|
+
_LABEL_ATTR_RE = re.compile(r"data-label=[\"']([^\"']+)[\"']", re.IGNORECASE)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def extract_layout_blocks_lenient(content: str) -> list[dict[str, Any]]:
|
|
287
|
+
"""Parse layout elements tolerating wrapper tags other than ``<div>``.
|
|
288
|
+
|
|
289
|
+
``parse_layout_blocks`` implements the contract the prompt asks for: a flat
|
|
290
|
+
list of ``<div data-bbox=... data-label=...>`` elements, each closed. Some
|
|
291
|
+
models answer with the same two attributes on a different tag and leave it
|
|
292
|
+
unclosed — Amazon Nova 2 emits ``<TABLE data-bbox=...>`` around a real
|
|
293
|
+
``<table>`` with no ``</TABLE>`` at all, and wraps the page in an outer
|
|
294
|
+
``<div data-bbox=...>``. Those are formatting differences, not missing
|
|
295
|
+
information: bbox, label and content are all present, so the element should
|
|
296
|
+
be scored on what it says rather than on which tag carried it.
|
|
297
|
+
|
|
298
|
+
An element runs from its opening tag until the first of: its own closing tag
|
|
299
|
+
(depth-aware over same-named tags, so an inner ``</table>`` does not end a
|
|
300
|
+
``<TABLE>`` wrapper), the next element's opening tag, or end of content.
|
|
301
|
+
Taking the earliest of the three means an unclosed wrapper simply ends where
|
|
302
|
+
its sibling begins, which is how Nova's contiguous bands read anyway, and no
|
|
303
|
+
text can be dropped or attributed to two elements.
|
|
304
|
+
|
|
305
|
+
Elements whose span holds only whitespace are containers (a page-level
|
|
306
|
+
wrapper around the real elements) and are skipped.
|
|
307
|
+
"""
|
|
308
|
+
tags = list(_WRAPPER_TAG_RE.finditer(content))
|
|
309
|
+
opens = [t for t in tags if not t.group(1)]
|
|
310
|
+
wrappers = [t for t in opens if "data-bbox=" in t.group(3).lower()]
|
|
311
|
+
if not wrappers:
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
blocks: list[dict[str, Any]] = []
|
|
315
|
+
for position, wrapper in enumerate(wrappers):
|
|
316
|
+
bbox_match = _BBOX_ATTR_RE.search(wrapper.group(3))
|
|
317
|
+
label_match = _LABEL_ATTR_RE.search(wrapper.group(3))
|
|
318
|
+
if not bbox_match:
|
|
319
|
+
continue
|
|
320
|
+
try:
|
|
321
|
+
bbox = json.loads(bbox_match.group(1))
|
|
322
|
+
except json.JSONDecodeError:
|
|
323
|
+
logger.warning(f"Failed to parse bbox: {bbox_match.group(1)}")
|
|
324
|
+
continue
|
|
325
|
+
if not isinstance(bbox, list) or len(bbox) != 4:
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
name = wrapper.group(2).lower()
|
|
329
|
+
end = len(content)
|
|
330
|
+
depth = 0
|
|
331
|
+
for tag in tags:
|
|
332
|
+
if tag.start() < wrapper.end():
|
|
333
|
+
continue
|
|
334
|
+
if tag.group(2).lower() != name:
|
|
335
|
+
continue
|
|
336
|
+
if tag.group(1):
|
|
337
|
+
if depth == 0:
|
|
338
|
+
end = tag.start()
|
|
339
|
+
break
|
|
340
|
+
depth -= 1
|
|
341
|
+
else:
|
|
342
|
+
depth += 1
|
|
343
|
+
if position + 1 < len(wrappers):
|
|
344
|
+
end = min(end, wrappers[position + 1].start())
|
|
345
|
+
|
|
346
|
+
text = content[wrapper.end() : end].strip()
|
|
347
|
+
if not text:
|
|
348
|
+
continue
|
|
349
|
+
blocks.append({"bbox": bbox, "label": label_match.group(1) if label_match else "Text", "text": text})
|
|
350
|
+
|
|
351
|
+
return blocks
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def close_open_ended_bands(items: list[dict[str, Any]], page_extent: float = 1000.0) -> list[dict[str, Any]]:
|
|
355
|
+
"""Give an element a bottom edge when the model pinned it to the page bottom.
|
|
356
|
+
|
|
357
|
+
Amazon Nova 2 frequently emits ``[0, y1, 1000, 1000]`` for every element: the
|
|
358
|
+
left edge, right edge and top are real, but the bottom is left at the page
|
|
359
|
+
bottom, so each box swallows the whole page below it. A box like that fails
|
|
360
|
+
localisation (``IoA(pred, gt)`` collapses) even though its ``y1`` is correct,
|
|
361
|
+
which scores the model at zero for an output that does carry position.
|
|
362
|
+
|
|
363
|
+
The model states its own convention whenever it fills the field in: the boxes
|
|
364
|
+
come back as contiguous full-width bands, ``[0, 301, 1000, 456]`` followed by
|
|
365
|
+
``[0, 456, 1000, 480]`` — one band's bottom is the next band's top. This
|
|
366
|
+
applies exactly that reading, so an open-ended band ends where its successor
|
|
367
|
+
begins. A full-width band with a correct vertical extent localises fine
|
|
368
|
+
(``IoA(pred, gt)`` is the ground-truth box's width fraction, comfortably over
|
|
369
|
+
the 0.20 floor), so this recovers real credit rather than manufacturing it.
|
|
370
|
+
|
|
371
|
+
Only boxes whose bottom sits at the page extent are touched, and only when a
|
|
372
|
+
later element starts strictly lower. Boxes with a real bottom edge, the final
|
|
373
|
+
element, and degenerate output where every element shares one top edge are all
|
|
374
|
+
left exactly as they are.
|
|
375
|
+
"""
|
|
376
|
+
for index, item in enumerate(items):
|
|
377
|
+
bbox = item.get("bbox")
|
|
378
|
+
if not isinstance(bbox, list) or len(bbox) != 4:
|
|
379
|
+
continue
|
|
380
|
+
y1, y2 = bbox[1], bbox[3]
|
|
381
|
+
if y2 < page_extent:
|
|
382
|
+
continue
|
|
383
|
+
for following in items[index + 1 :]:
|
|
384
|
+
next_bbox = following.get("bbox")
|
|
385
|
+
if not isinstance(next_bbox, list) or len(next_bbox) != 4:
|
|
386
|
+
continue
|
|
387
|
+
if next_bbox[1] > y1:
|
|
388
|
+
bbox[3] = next_bbox[1]
|
|
389
|
+
break
|
|
390
|
+
return items
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def parse_layout_blocks(content: str) -> list[dict[str, Any]]:
|
|
394
|
+
"""Parse <div data-bbox="..." data-label="...">content</div> blocks.
|
|
395
|
+
|
|
396
|
+
Handles both attribute orderings. Returns list of dicts with
|
|
397
|
+
'bbox' (list[float]), 'label' (str), and 'text' (str) keys.
|
|
398
|
+
"""
|
|
399
|
+
blocks: list[dict[str, Any]] = []
|
|
400
|
+
|
|
401
|
+
# Match opening div with both attribute orders
|
|
402
|
+
pattern_bbox_first = re.compile(
|
|
403
|
+
r'<div\s+[^>]*?data-bbox=["\'](\[[^\]]+\])["\'][^>]*?data-label=["\']([^"\']+)["\'][^>]*?>'
|
|
404
|
+
r"([\s\S]*?)</div>",
|
|
405
|
+
re.IGNORECASE,
|
|
406
|
+
)
|
|
407
|
+
pattern_label_first = re.compile(
|
|
408
|
+
r'<div\s+[^>]*?data-label=["\']([^"\']+)["\'][^>]*?data-bbox=["\'](\[[^\]]+\])["\'][^>]*?>'
|
|
409
|
+
r"([\s\S]*?)</div>",
|
|
410
|
+
re.IGNORECASE,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Collect all matches with their start positions, then sort by
|
|
414
|
+
# position so mixed attribute orderings preserve document order.
|
|
415
|
+
raw_matches: list[tuple[int, str, str, str]] = [] # (pos, bbox_str, label, text)
|
|
416
|
+
|
|
417
|
+
for match in pattern_bbox_first.finditer(content):
|
|
418
|
+
raw_matches.append((match.start(), match.group(1), match.group(2), match.group(3)))
|
|
419
|
+
|
|
420
|
+
for match in pattern_label_first.finditer(content):
|
|
421
|
+
raw_matches.append((match.start(), match.group(2), match.group(1), match.group(3)))
|
|
422
|
+
|
|
423
|
+
raw_matches.sort(key=lambda m: m[0])
|
|
424
|
+
|
|
425
|
+
seen_positions: set[int] = set()
|
|
426
|
+
for pos, bbox_str, label, text in raw_matches:
|
|
427
|
+
if pos in seen_positions:
|
|
428
|
+
continue # skip duplicate from overlapping patterns
|
|
429
|
+
seen_positions.add(pos)
|
|
430
|
+
try:
|
|
431
|
+
bbox = json.loads(bbox_str)
|
|
432
|
+
if isinstance(bbox, list) and len(bbox) == 4:
|
|
433
|
+
blocks.append({"bbox": bbox, "label": label, "text": text.strip()})
|
|
434
|
+
except json.JSONDecodeError:
|
|
435
|
+
logger.warning(f"Failed to parse bbox: {bbox_str}")
|
|
436
|
+
|
|
437
|
+
return blocks
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# A markdown ATX heading marker: optional indent, 1-6 hashes, then whitespace.
|
|
441
|
+
# The trailing whitespace is required (as in CommonMark), so literal text like
|
|
442
|
+
# "#1 Best Seller" is not mistaken for a marker.
|
|
443
|
+
_LEADING_HEADING_MARKER = re.compile(r"^[ \t]*(#{1,6})[ \t]+")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _strip_heading_marker(text: str) -> str:
|
|
447
|
+
"""Strip a leading markdown heading marker, if any, from item text."""
|
|
448
|
+
match = _LEADING_HEADING_MARKER.match(text)
|
|
449
|
+
if not match:
|
|
450
|
+
return text
|
|
451
|
+
bare = text[match.end() :].strip()
|
|
452
|
+
return bare if bare else text
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _strip_outer_formula_delimiters(text: str) -> str:
|
|
456
|
+
"""Strip one outer pair of ``$$``/``$`` delimiters when unambiguous.
|
|
457
|
+
|
|
458
|
+
Only strips when the interior contains no further ``$``, so multi-formula
|
|
459
|
+
items (``$a$ + $b$``) and text with interior dollar signs pass through
|
|
460
|
+
verbatim rather than being corrupted.
|
|
461
|
+
"""
|
|
462
|
+
stripped = text.strip()
|
|
463
|
+
for delim in ("$$", "$"):
|
|
464
|
+
if len(stripped) > 2 * len(delim) and stripped.startswith(delim) and stripped.endswith(delim):
|
|
465
|
+
interior = stripped[len(delim) : -len(delim)]
|
|
466
|
+
if "$" not in interior and interior.strip():
|
|
467
|
+
return interior.strip()
|
|
468
|
+
return text
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def items_to_markdown(items: list[dict[str, Any]]) -> str:
|
|
472
|
+
"""Assemble clean markdown from parsed layout items.
|
|
473
|
+
|
|
474
|
+
The layout prompt asks for clean markdown inside each div, so models
|
|
475
|
+
frequently emit heading markers (``# Heading``) and formula delimiters
|
|
476
|
+
(``$$...$$``) in the item text already. Any existing markers are stripped
|
|
477
|
+
before the label-derived ones are applied, so they don't double up
|
|
478
|
+
(``# # Heading``, nested ``$$`` blocks) — doubled markers defeat the
|
|
479
|
+
heading/formula detection in downstream scoring. Title still always
|
|
480
|
+
renders as H1 and Section-header as H2.
|
|
481
|
+
"""
|
|
482
|
+
parts: list[str] = []
|
|
483
|
+
for item in items:
|
|
484
|
+
label = item.get("label", "").lower()
|
|
485
|
+
text = item.get("text", "")
|
|
486
|
+
if not text:
|
|
487
|
+
continue
|
|
488
|
+
if label == "title":
|
|
489
|
+
parts.append(f"# {_strip_heading_marker(text)}")
|
|
490
|
+
elif label in ("section-header", "section_header"):
|
|
491
|
+
parts.append(f"## {_strip_heading_marker(text)}")
|
|
492
|
+
elif label == "formula":
|
|
493
|
+
parts.append(f"$$\n{_strip_outer_formula_delimiters(text)}\n$$")
|
|
494
|
+
else:
|
|
495
|
+
parts.append(text)
|
|
496
|
+
return "\n\n".join(parts)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# Coordinates may overshoot their grid by rounding noise; values beyond
|
|
500
|
+
# scale * this tolerance indicate the model did not follow the coordinate
|
|
501
|
+
# instruction (see the compliance warning in ``build_layout_pages``).
|
|
502
|
+
_BBOX_ROUNDING_TOLERANCE = 1.05
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _is_finite_bbox(bbox: Any) -> bool:
|
|
506
|
+
"""True when *bbox* is four finite numeric (non-bool) values."""
|
|
507
|
+
return (
|
|
508
|
+
isinstance(bbox, list)
|
|
509
|
+
and len(bbox) == 4
|
|
510
|
+
and all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) for v in bbox)
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def build_layout_pages(
|
|
515
|
+
items: list[dict[str, Any]],
|
|
516
|
+
image_width: int,
|
|
517
|
+
image_height: int,
|
|
518
|
+
markdown: str,
|
|
519
|
+
page_number: int = 1,
|
|
520
|
+
bbox_scale: float | None = 1000.0,
|
|
521
|
+
) -> list[ParseLayoutPageIR]:
|
|
522
|
+
"""Convert parsed layout blocks to ParseLayoutPageIR.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
items: Parsed layout blocks from ``parse_layout_blocks``.
|
|
526
|
+
image_width: Page image width in pixels.
|
|
527
|
+
image_height: Page image height in pixels.
|
|
528
|
+
markdown: Page markdown content.
|
|
529
|
+
page_number: 1-indexed page number.
|
|
530
|
+
bbox_scale: The grid the bboxes are on (same convention as the
|
|
531
|
+
layoutdet providers): a number divides coordinates by that
|
|
532
|
+
scale; ``None`` means absolute pixel coordinates (the
|
|
533
|
+
``bbox_scale=None`` pipelines, prompted with the ``*_ABS``
|
|
534
|
+
variants), normalized by the image dimensions.
|
|
535
|
+
"""
|
|
536
|
+
if not items or not image_width or not image_height:
|
|
537
|
+
return []
|
|
538
|
+
|
|
539
|
+
if bbox_scale is None:
|
|
540
|
+
scale_x = float(image_width)
|
|
541
|
+
scale_y = float(image_height)
|
|
542
|
+
else:
|
|
543
|
+
scale_x = scale_y = float(bbox_scale)
|
|
544
|
+
|
|
545
|
+
compliance_warned = False
|
|
546
|
+
layout_items: list[LayoutItemIR] = []
|
|
547
|
+
for item in items:
|
|
548
|
+
bbox = item.get("bbox", [])
|
|
549
|
+
label_raw = item.get("label", "text")
|
|
550
|
+
text = item.get("text", "")
|
|
551
|
+
|
|
552
|
+
# Clamping a non-finite value would silently place it at the page
|
|
553
|
+
# edge (min(scale, nan) returns scale), so skip boxes the clamp
|
|
554
|
+
# cannot handle honestly.
|
|
555
|
+
if not _is_finite_bbox(bbox):
|
|
556
|
+
if len(bbox) == 4:
|
|
557
|
+
logger.warning(f"Skipping layout item with non-numeric bbox: {bbox!r}")
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
if (
|
|
561
|
+
bbox_scale is not None
|
|
562
|
+
and not compliance_warned
|
|
563
|
+
and any(v > bbox_scale * _BBOX_ROUNDING_TOLERANCE for v in bbox)
|
|
564
|
+
):
|
|
565
|
+
logger.warning(
|
|
566
|
+
f"Page {page_number}: bbox coordinates exceed the 0-{bbox_scale:g} "
|
|
567
|
+
"range; the model may be emitting absolute pixels (consider "
|
|
568
|
+
"bbox_scale=None)"
|
|
569
|
+
)
|
|
570
|
+
compliance_warned = True
|
|
571
|
+
|
|
572
|
+
x1, y1, x2, y2 = bbox
|
|
573
|
+
|
|
574
|
+
# Clamp to the page so out-of-range values cannot produce segments
|
|
575
|
+
# outside the unit square.
|
|
576
|
+
x1 = max(0.0, min(scale_x, x1))
|
|
577
|
+
x2 = max(0.0, min(scale_x, x2))
|
|
578
|
+
y1 = max(0.0, min(scale_y, y1))
|
|
579
|
+
y2 = max(0.0, min(scale_y, y2))
|
|
580
|
+
|
|
581
|
+
# Convert [x1,y1,x2,y2] to normalized [0,1] COCO [x,y,w,h]
|
|
582
|
+
nx = x1 / scale_x
|
|
583
|
+
ny = y1 / scale_y
|
|
584
|
+
nw = (x2 - x1) / scale_x
|
|
585
|
+
nh = (y2 - y1) / scale_y
|
|
586
|
+
|
|
587
|
+
label = LABEL_MAP.get(label_raw.lower(), "Text")
|
|
588
|
+
seg = LayoutSegmentIR(x=nx, y=ny, w=nw, h=nh, confidence=1.0, label=label)
|
|
589
|
+
|
|
590
|
+
norm_label = label_raw.lower()
|
|
591
|
+
if norm_label == "table":
|
|
592
|
+
item_type = "table"
|
|
593
|
+
elif norm_label in ("picture", "figure"):
|
|
594
|
+
item_type = "image"
|
|
595
|
+
else:
|
|
596
|
+
item_type = "text"
|
|
597
|
+
|
|
598
|
+
layout_items.append(LayoutItemIR(type=item_type, value=text, bbox=seg, layout_segments=[seg]))
|
|
599
|
+
|
|
600
|
+
if not layout_items:
|
|
601
|
+
return []
|
|
602
|
+
|
|
603
|
+
return [
|
|
604
|
+
ParseLayoutPageIR(
|
|
605
|
+
page_number=page_number,
|
|
606
|
+
width=float(image_width),
|
|
607
|
+
height=float(image_height),
|
|
608
|
+
md=markdown,
|
|
609
|
+
items=layout_items,
|
|
610
|
+
)
|
|
611
|
+
]
|