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,360 @@
|
|
|
1
|
+
"""Provider for Qwen3-VL layout detection via Modal OpenAI-compatible API."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from openai import OpenAI
|
|
12
|
+
from PIL import Image
|
|
13
|
+
|
|
14
|
+
from parse_bench.inference.providers.base import (
|
|
15
|
+
Provider,
|
|
16
|
+
ProviderPermanentError,
|
|
17
|
+
ProviderTransientError,
|
|
18
|
+
)
|
|
19
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
20
|
+
from parse_bench.schemas.layout_detection_output import (
|
|
21
|
+
QWEN3VL_STR_TO_LABEL,
|
|
22
|
+
LayoutDetectionModel,
|
|
23
|
+
LayoutOutput,
|
|
24
|
+
LayoutPrediction,
|
|
25
|
+
)
|
|
26
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
27
|
+
from parse_bench.schemas.pipeline_io import (
|
|
28
|
+
InferenceRequest,
|
|
29
|
+
InferenceResult,
|
|
30
|
+
RawInferenceResult,
|
|
31
|
+
)
|
|
32
|
+
from parse_bench.schemas.product import ProductType
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@register_provider("qwen3vl_layout")
|
|
38
|
+
class Qwen3VLLayoutProvider(Provider):
|
|
39
|
+
"""
|
|
40
|
+
Layout detection using Qwen3-VL-8B via Modal OpenAI-compatible API.
|
|
41
|
+
|
|
42
|
+
This provider sends images to the Qwen3-VL model and parses the JSON
|
|
43
|
+
response containing layout predictions with normalized coordinates.
|
|
44
|
+
|
|
45
|
+
Response format:
|
|
46
|
+
[
|
|
47
|
+
{"label": "text", "bbox_2d": [x1, y1, x2, y2], "score": 0.95},
|
|
48
|
+
...
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
Coordinates are normalized to [0-1000] and converted to pixel coords.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
BASE_URL = "https://llamaindex--example-vllm-inference-qwen3vl-serve.modal.run/v1"
|
|
55
|
+
model_type = LayoutDetectionModel.QWEN3_VL_8B
|
|
56
|
+
|
|
57
|
+
# Image pixel constraints (from Qwen3-VL reference)
|
|
58
|
+
MIN_PIXELS = 512 * 32 * 32 # 524,288
|
|
59
|
+
MAX_PIXELS = 2048 * 32 * 32 # 2,097,152
|
|
60
|
+
|
|
61
|
+
SYSTEM_PROMPT = """You are a document layout detector.
|
|
62
|
+
Output ONLY valid JSON (no markdown / html, no prose).
|
|
63
|
+
Use bbox_2d with normalized coordinates in [0, 1000] as [x1, y1, x2, y2]."""
|
|
64
|
+
|
|
65
|
+
USER_PROMPT = """<image>
|
|
66
|
+
Locate every instance that belongs to the following document layout categories:
|
|
67
|
+
"caption", "footnote", "formula", "list_item", "page_footer", "page_header",
|
|
68
|
+
"picture", "section_header", "table", "text", "title".
|
|
69
|
+
|
|
70
|
+
Report bbox coordinates in JSON format.
|
|
71
|
+
|
|
72
|
+
Return ONLY a JSON array. Each element MUST be:
|
|
73
|
+
{
|
|
74
|
+
"label": one of ["caption","footnote","formula",
|
|
75
|
+
"list_item","page_footer","page_header","picture",
|
|
76
|
+
"section_header","table","text","title"],
|
|
77
|
+
"bbox_2d": [x1, y1, x2, y2],
|
|
78
|
+
"score": number between 0.0 and 1.0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Rules:
|
|
82
|
+
- bbox_2d uses normalized 0-1000 coordinates [x1,y1,x2,y2]. (No pixel coords.)
|
|
83
|
+
- Detect DocLayNet-style BLOCKS (regions), not word/line boxes.
|
|
84
|
+
- Prefer a single box per logical region. Merge adjacent
|
|
85
|
+
lines into one text block when they form a paragraph.
|
|
86
|
+
- Avoid duplicates: if two boxes overlap heavily
|
|
87
|
+
(IoU > 0.7) and have the same label, keep only the
|
|
88
|
+
one with the higher score.
|
|
89
|
+
- Output in approximate reading order (top-to-bottom, left-to-right).
|
|
90
|
+
- If no instances exist, return []."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
provider_name: str,
|
|
95
|
+
base_config: dict[str, Any] | None = None,
|
|
96
|
+
):
|
|
97
|
+
"""Initialize the Qwen3VL layout detection provider."""
|
|
98
|
+
super().__init__(provider_name, base_config)
|
|
99
|
+
|
|
100
|
+
# Initialize OpenAI client for Modal endpoint
|
|
101
|
+
self._client = OpenAI(
|
|
102
|
+
base_url=self.BASE_URL,
|
|
103
|
+
api_key="not-needed",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Get timeout (default 120 seconds for VLM)
|
|
107
|
+
self._timeout = self.base_config.get("timeout", 120)
|
|
108
|
+
|
|
109
|
+
def _image_to_base64(self, image: Image.Image) -> str:
|
|
110
|
+
"""Convert PIL Image to base64 string."""
|
|
111
|
+
buffer = io.BytesIO()
|
|
112
|
+
image.save(buffer, format="PNG")
|
|
113
|
+
buffer.seek(0)
|
|
114
|
+
return base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
115
|
+
|
|
116
|
+
def _extract_json(self, content: str) -> list[dict]:
|
|
117
|
+
"""
|
|
118
|
+
Extract JSON array from LLM response, handling markdown fences.
|
|
119
|
+
|
|
120
|
+
:param content: Raw response content from the model
|
|
121
|
+
:return: Parsed JSON array
|
|
122
|
+
:raises ValueError: If JSON cannot be extracted
|
|
123
|
+
"""
|
|
124
|
+
# Try direct parse first
|
|
125
|
+
try:
|
|
126
|
+
result = json.loads(content)
|
|
127
|
+
if isinstance(result, list):
|
|
128
|
+
return result
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
# Try to extract from markdown code block
|
|
133
|
+
match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
|
|
134
|
+
if match:
|
|
135
|
+
try:
|
|
136
|
+
result = json.loads(match.group(1))
|
|
137
|
+
if isinstance(result, list):
|
|
138
|
+
return result
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
# Try to find array in content
|
|
143
|
+
match = re.search(r"\[[\s\S]*\]", content)
|
|
144
|
+
if match:
|
|
145
|
+
try:
|
|
146
|
+
result = json.loads(match.group(0))
|
|
147
|
+
if isinstance(result, list):
|
|
148
|
+
return result
|
|
149
|
+
except json.JSONDecodeError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
raise ValueError(f"Could not extract JSON from response: {content[:500]}")
|
|
153
|
+
|
|
154
|
+
def _normalize_bbox(
|
|
155
|
+
self,
|
|
156
|
+
bbox_normalized: list[float],
|
|
157
|
+
image_width: int,
|
|
158
|
+
image_height: int,
|
|
159
|
+
) -> list[float]:
|
|
160
|
+
"""
|
|
161
|
+
Convert [0-1000] normalized coords to pixel coords.
|
|
162
|
+
|
|
163
|
+
:param bbox_normalized: Bounding box in [0-1000] normalized coords
|
|
164
|
+
:param image_width: Actual image width in pixels
|
|
165
|
+
:param image_height: Actual image height in pixels
|
|
166
|
+
:return: Bounding box in pixel coordinates [x1, y1, x2, y2]
|
|
167
|
+
"""
|
|
168
|
+
x1, y1, x2, y2 = bbox_normalized
|
|
169
|
+
|
|
170
|
+
# Clamp to valid range
|
|
171
|
+
x1 = max(0, min(1000, x1))
|
|
172
|
+
y1 = max(0, min(1000, y1))
|
|
173
|
+
x2 = max(0, min(1000, x2))
|
|
174
|
+
y2 = max(0, min(1000, y2))
|
|
175
|
+
|
|
176
|
+
return [
|
|
177
|
+
x1 * image_width / 1000,
|
|
178
|
+
y1 * image_height / 1000,
|
|
179
|
+
x2 * image_width / 1000,
|
|
180
|
+
y2 * image_height / 1000,
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
def _call_endpoint(self, image: Image.Image) -> tuple[list[dict], str]:
|
|
184
|
+
"""
|
|
185
|
+
Call Qwen3VL via OpenAI API and return parsed predictions.
|
|
186
|
+
|
|
187
|
+
:param image: PIL Image to analyze
|
|
188
|
+
:return: Tuple of (parsed predictions list, raw response content)
|
|
189
|
+
:raises ProviderError: For API errors
|
|
190
|
+
"""
|
|
191
|
+
img_base64 = self._image_to_base64(image)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
response = self._client.chat.completions.create( # type: ignore[call-overload]
|
|
195
|
+
model=None, # Not needed for Modal
|
|
196
|
+
messages=[
|
|
197
|
+
{"role": "system", "content": self.SYSTEM_PROMPT},
|
|
198
|
+
{
|
|
199
|
+
"role": "user",
|
|
200
|
+
"content": [
|
|
201
|
+
{
|
|
202
|
+
"type": "image_url",
|
|
203
|
+
"min_pixels": self.MIN_PIXELS,
|
|
204
|
+
"max_pixels": self.MAX_PIXELS,
|
|
205
|
+
"image_url": {"url": f"data:image/png;base64,{img_base64}"},
|
|
206
|
+
},
|
|
207
|
+
{"type": "text", "text": self.USER_PROMPT},
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
max_tokens=12384,
|
|
212
|
+
temperature=0.7,
|
|
213
|
+
extra_body={
|
|
214
|
+
"top_k": 20,
|
|
215
|
+
"top_p": 0.8,
|
|
216
|
+
"repetition_penalty": 1.05,
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
error_msg = str(e).lower()
|
|
221
|
+
if "timeout" in error_msg or "connection" in error_msg:
|
|
222
|
+
raise ProviderTransientError(f"API call failed: {e}") from e
|
|
223
|
+
raise ProviderPermanentError(f"API call failed: {e}") from e
|
|
224
|
+
|
|
225
|
+
content = response.choices[0].message.content
|
|
226
|
+
if not content:
|
|
227
|
+
raise ProviderPermanentError("Empty response from model")
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
predictions = self._extract_json(content)
|
|
231
|
+
except ValueError as e:
|
|
232
|
+
raise ProviderPermanentError(str(e)) from e
|
|
233
|
+
|
|
234
|
+
return predictions, content
|
|
235
|
+
|
|
236
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
237
|
+
"""
|
|
238
|
+
Run layout detection inference on an image.
|
|
239
|
+
|
|
240
|
+
:param pipeline: Pipeline specification
|
|
241
|
+
:param request: Inference request (source_file_path should be an image)
|
|
242
|
+
:return: Raw inference result
|
|
243
|
+
:raises ProviderError: For any provider-related failures
|
|
244
|
+
"""
|
|
245
|
+
if request.product_type != ProductType.LAYOUT_DETECTION:
|
|
246
|
+
raise ProviderPermanentError(
|
|
247
|
+
f"{self.__class__.__name__} only supports LAYOUT_DETECTION product type, got {request.product_type}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
started_at = datetime.now()
|
|
251
|
+
|
|
252
|
+
# Load the image
|
|
253
|
+
try:
|
|
254
|
+
image = Image.open(request.source_file_path)
|
|
255
|
+
# Ensure image is in RGB mode
|
|
256
|
+
if image.mode not in ("RGB", "RGBA"):
|
|
257
|
+
image = image.convert("RGB") # type: ignore[assignment]
|
|
258
|
+
except Exception as e:
|
|
259
|
+
raise ProviderPermanentError(f"Failed to load image: {e}") from e
|
|
260
|
+
|
|
261
|
+
# Get image dimensions
|
|
262
|
+
image_width, image_height = image.size
|
|
263
|
+
|
|
264
|
+
# Call the endpoint
|
|
265
|
+
predictions, raw_content = self._call_endpoint(image)
|
|
266
|
+
|
|
267
|
+
completed_at = datetime.now()
|
|
268
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
269
|
+
|
|
270
|
+
# Store in raw output for normalization
|
|
271
|
+
raw_output = {
|
|
272
|
+
"response": predictions,
|
|
273
|
+
"raw_content": raw_content,
|
|
274
|
+
"image_width": image_width,
|
|
275
|
+
"image_height": image_height,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return RawInferenceResult(
|
|
279
|
+
request=request,
|
|
280
|
+
pipeline=pipeline,
|
|
281
|
+
pipeline_name=pipeline.pipeline_name,
|
|
282
|
+
product_type=request.product_type,
|
|
283
|
+
raw_output=raw_output,
|
|
284
|
+
started_at=started_at,
|
|
285
|
+
completed_at=completed_at,
|
|
286
|
+
latency_in_ms=latency_ms,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
290
|
+
"""
|
|
291
|
+
Normalize raw inference result to produce LayoutOutput.
|
|
292
|
+
|
|
293
|
+
Converts normalized [0-1000] coordinates to pixel coordinates and
|
|
294
|
+
maps string labels to canonical labels.
|
|
295
|
+
|
|
296
|
+
:param raw_result: Raw inference result from run_inference()
|
|
297
|
+
:return: Inference result with both raw and normalized outputs
|
|
298
|
+
:raises ProviderError: For any normalization failures
|
|
299
|
+
"""
|
|
300
|
+
if raw_result.product_type != ProductType.LAYOUT_DETECTION:
|
|
301
|
+
raise ProviderPermanentError(
|
|
302
|
+
f"{self.__class__.__name__} only supports LAYOUT_DETECTION product type, got {raw_result.product_type}"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Get image dimensions
|
|
306
|
+
image_width = raw_result.raw_output.get("image_width", 0)
|
|
307
|
+
image_height = raw_result.raw_output.get("image_height", 0)
|
|
308
|
+
|
|
309
|
+
# Parse the response into predictions
|
|
310
|
+
response = raw_result.raw_output.get("response", [])
|
|
311
|
+
|
|
312
|
+
raw_predictions: list[LayoutPrediction] = []
|
|
313
|
+
|
|
314
|
+
for item in response:
|
|
315
|
+
label_str = item.get("label", "")
|
|
316
|
+
bbox_normalized = item.get("bbox_2d", [0, 0, 0, 0])
|
|
317
|
+
score = item.get("score", 1.0)
|
|
318
|
+
|
|
319
|
+
# Convert string label to enum
|
|
320
|
+
label_enum = QWEN3VL_STR_TO_LABEL.get(label_str.lower())
|
|
321
|
+
if label_enum is None:
|
|
322
|
+
# Unknown label, skip
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
# Clamp score to valid range
|
|
326
|
+
score = max(0.0, min(1.0, float(score)))
|
|
327
|
+
|
|
328
|
+
# Convert normalized coords to pixel coords
|
|
329
|
+
bbox_pixels = self._normalize_bbox(bbox_normalized, image_width, image_height)
|
|
330
|
+
|
|
331
|
+
# Create raw prediction
|
|
332
|
+
raw_predictions.append(
|
|
333
|
+
LayoutPrediction(
|
|
334
|
+
bbox=bbox_pixels,
|
|
335
|
+
score=score,
|
|
336
|
+
label=str(int(label_enum)),
|
|
337
|
+
provider_metadata={"label_name": label_enum.name},
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
output = LayoutOutput(
|
|
342
|
+
task_type="layout_detection",
|
|
343
|
+
example_id=raw_result.request.example_id,
|
|
344
|
+
pipeline_name=raw_result.pipeline_name,
|
|
345
|
+
model=self.model_type,
|
|
346
|
+
image_width=max(int(image_width), 1),
|
|
347
|
+
image_height=max(int(image_height), 1),
|
|
348
|
+
predictions=raw_predictions,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return InferenceResult(
|
|
352
|
+
request=raw_result.request,
|
|
353
|
+
pipeline_name=raw_result.pipeline_name,
|
|
354
|
+
product_type=raw_result.product_type,
|
|
355
|
+
raw_output=raw_result.raw_output,
|
|
356
|
+
output=output,
|
|
357
|
+
started_at=raw_result.started_at,
|
|
358
|
+
completed_at=raw_result.completed_at,
|
|
359
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
360
|
+
)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Provider for Surya OCR layout detection via Modal HTTP API."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from parse_bench.inference.providers.base import (
|
|
13
|
+
Provider,
|
|
14
|
+
ProviderPermanentError,
|
|
15
|
+
ProviderTransientError,
|
|
16
|
+
)
|
|
17
|
+
from parse_bench.inference.providers.registry import register_provider
|
|
18
|
+
from parse_bench.schemas.layout_detection_output import (
|
|
19
|
+
SURYA_STR_TO_LABEL,
|
|
20
|
+
LayoutDetectionModel,
|
|
21
|
+
LayoutOutput,
|
|
22
|
+
LayoutPrediction,
|
|
23
|
+
)
|
|
24
|
+
from parse_bench.schemas.pipeline import PipelineSpec
|
|
25
|
+
from parse_bench.schemas.pipeline_io import (
|
|
26
|
+
InferenceRequest,
|
|
27
|
+
InferenceResult,
|
|
28
|
+
RawInferenceResult,
|
|
29
|
+
)
|
|
30
|
+
from parse_bench.schemas.product import ProductType
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@register_provider("surya_layout")
|
|
36
|
+
class SuryaLayoutProvider(Provider):
|
|
37
|
+
"""
|
|
38
|
+
Layout detection using Surya OCR via Modal HTTP API.
|
|
39
|
+
|
|
40
|
+
This provider sends images to the Surya layout detection model
|
|
41
|
+
deployed on Modal and parses the JSON response.
|
|
42
|
+
|
|
43
|
+
Response format from Modal endpoint:
|
|
44
|
+
{
|
|
45
|
+
"predictions": [
|
|
46
|
+
{
|
|
47
|
+
"bbox": [x1, y1, x2, y2],
|
|
48
|
+
"label": "Text",
|
|
49
|
+
"score": 0.95,
|
|
50
|
+
"position": 0
|
|
51
|
+
},
|
|
52
|
+
...
|
|
53
|
+
],
|
|
54
|
+
"image_width": 612,
|
|
55
|
+
"image_height": 792
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
Coordinates are already in pixel coordinates.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
# Default Modal endpoint URL - can be overridden via config
|
|
62
|
+
DEFAULT_ENDPOINT_URL = "https://llamaindex--slayout-detection-kfjewo192-suryalayoutserver-serve.modal.run"
|
|
63
|
+
model_type = LayoutDetectionModel.SURYA_LAYOUT
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
provider_name: str,
|
|
68
|
+
base_config: dict[str, Any] | None = None,
|
|
69
|
+
):
|
|
70
|
+
"""Initialize the Surya layout detection provider."""
|
|
71
|
+
super().__init__(provider_name, base_config)
|
|
72
|
+
|
|
73
|
+
# Get endpoint URL from config or use default
|
|
74
|
+
self.endpoint_url = self.base_config.get("endpoint_url", self.DEFAULT_ENDPOINT_URL)
|
|
75
|
+
|
|
76
|
+
# Get timeout (default 120 seconds)
|
|
77
|
+
self._timeout = self.base_config.get("timeout", 120)
|
|
78
|
+
|
|
79
|
+
def _image_to_base64(self, image: Image.Image) -> str:
|
|
80
|
+
"""Convert PIL Image to base64 string."""
|
|
81
|
+
buffer = io.BytesIO()
|
|
82
|
+
image.save(buffer, format="PNG")
|
|
83
|
+
buffer.seek(0)
|
|
84
|
+
return base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
85
|
+
|
|
86
|
+
def _call_endpoint(self, image: Image.Image) -> dict[str, Any]:
|
|
87
|
+
"""
|
|
88
|
+
Call Surya Modal endpoint with base64 image.
|
|
89
|
+
|
|
90
|
+
:param image: PIL Image to analyze
|
|
91
|
+
:return: Parsed JSON response
|
|
92
|
+
:raises ProviderError: For API errors
|
|
93
|
+
"""
|
|
94
|
+
img_base64 = self._image_to_base64(image)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
response = requests.post(
|
|
98
|
+
f"{self.endpoint_url}/predict",
|
|
99
|
+
json={"image": img_base64},
|
|
100
|
+
headers={"Content-Type": "application/json"},
|
|
101
|
+
timeout=self._timeout,
|
|
102
|
+
)
|
|
103
|
+
except requests.exceptions.Timeout as e:
|
|
104
|
+
raise ProviderTransientError(f"Request timed out: {e}") from e
|
|
105
|
+
except requests.exceptions.ConnectionError as e:
|
|
106
|
+
raise ProviderTransientError(f"Connection error: {e}") from e
|
|
107
|
+
except Exception as e:
|
|
108
|
+
raise ProviderPermanentError(f"Request failed: {e}") from e
|
|
109
|
+
|
|
110
|
+
# Handle HTTP errors
|
|
111
|
+
if response.status_code == 429:
|
|
112
|
+
raise ProviderTransientError("Rate limited (429)")
|
|
113
|
+
if response.status_code >= 500:
|
|
114
|
+
raise ProviderTransientError(f"Server error ({response.status_code}): {response.text[:500]}")
|
|
115
|
+
if response.status_code >= 400:
|
|
116
|
+
raise ProviderPermanentError(f"Client error ({response.status_code}): {response.text[:500]}")
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
result: dict[str, Any] = response.json()
|
|
120
|
+
except Exception as e:
|
|
121
|
+
raise ProviderPermanentError(f"Failed to parse JSON response: {e}") from e
|
|
122
|
+
|
|
123
|
+
# Check for error in response
|
|
124
|
+
if "error" in result:
|
|
125
|
+
raise ProviderPermanentError(f"API error: {result['error']}")
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
|
130
|
+
"""
|
|
131
|
+
Run layout detection inference on an image.
|
|
132
|
+
|
|
133
|
+
:param pipeline: Pipeline specification
|
|
134
|
+
:param request: Inference request (source_file_path should be an image)
|
|
135
|
+
:return: Raw inference result
|
|
136
|
+
:raises ProviderError: For any provider-related failures
|
|
137
|
+
"""
|
|
138
|
+
if request.product_type != ProductType.LAYOUT_DETECTION:
|
|
139
|
+
raise ProviderPermanentError(
|
|
140
|
+
f"{self.__class__.__name__} only supports LAYOUT_DETECTION product type, got {request.product_type}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
started_at = datetime.now()
|
|
144
|
+
|
|
145
|
+
# Load the image
|
|
146
|
+
try:
|
|
147
|
+
image: Image.Image = Image.open(request.source_file_path)
|
|
148
|
+
# Ensure image is in RGB mode
|
|
149
|
+
if image.mode not in ("RGB", "RGBA"):
|
|
150
|
+
image = image.convert("RGB")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
raise ProviderPermanentError(f"Failed to load image: {e}") from e
|
|
153
|
+
|
|
154
|
+
# Call the endpoint
|
|
155
|
+
result = self._call_endpoint(image)
|
|
156
|
+
|
|
157
|
+
completed_at = datetime.now()
|
|
158
|
+
latency_ms = int((completed_at - started_at).total_seconds() * 1000)
|
|
159
|
+
|
|
160
|
+
# Store in raw output for normalization
|
|
161
|
+
raw_output = {
|
|
162
|
+
"response": result.get("predictions", []),
|
|
163
|
+
"image_width": result.get("image_width", image.size[0]),
|
|
164
|
+
"image_height": result.get("image_height", image.size[1]),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return RawInferenceResult(
|
|
168
|
+
request=request,
|
|
169
|
+
pipeline=pipeline,
|
|
170
|
+
pipeline_name=pipeline.pipeline_name,
|
|
171
|
+
product_type=request.product_type,
|
|
172
|
+
raw_output=raw_output,
|
|
173
|
+
started_at=started_at,
|
|
174
|
+
completed_at=completed_at,
|
|
175
|
+
latency_in_ms=latency_ms,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
|
179
|
+
"""
|
|
180
|
+
Normalize raw inference result to produce LayoutOutput.
|
|
181
|
+
|
|
182
|
+
Maps string labels to canonical labels using the adapter.
|
|
183
|
+
|
|
184
|
+
:param raw_result: Raw inference result from run_inference()
|
|
185
|
+
:return: Inference result with both raw and normalized outputs
|
|
186
|
+
:raises ProviderError: For any normalization failures
|
|
187
|
+
"""
|
|
188
|
+
if raw_result.product_type != ProductType.LAYOUT_DETECTION:
|
|
189
|
+
raise ProviderPermanentError(
|
|
190
|
+
f"{self.__class__.__name__} only supports LAYOUT_DETECTION product type, got {raw_result.product_type}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Get image dimensions
|
|
194
|
+
image_width = raw_result.raw_output.get("image_width", 0)
|
|
195
|
+
image_height = raw_result.raw_output.get("image_height", 0)
|
|
196
|
+
|
|
197
|
+
# Parse the response into predictions
|
|
198
|
+
response = raw_result.raw_output.get("response", [])
|
|
199
|
+
|
|
200
|
+
raw_predictions: list[LayoutPrediction] = []
|
|
201
|
+
|
|
202
|
+
for item in response:
|
|
203
|
+
label_str = item.get("label", "")
|
|
204
|
+
bbox = item.get("bbox", [0, 0, 0, 0])
|
|
205
|
+
score = item.get("score", 1.0)
|
|
206
|
+
position = item.get("position", 0)
|
|
207
|
+
|
|
208
|
+
# Convert string label to enum
|
|
209
|
+
label_enum = SURYA_STR_TO_LABEL.get(label_str)
|
|
210
|
+
if label_enum is None:
|
|
211
|
+
# Unknown label, skip
|
|
212
|
+
logger.warning(f"Unknown Surya label: {label_str}")
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
# Clamp score to valid range
|
|
216
|
+
score = max(0.0, min(1.0, float(score)))
|
|
217
|
+
|
|
218
|
+
# Create raw prediction (bbox is already in pixel coordinates)
|
|
219
|
+
raw_predictions.append(
|
|
220
|
+
LayoutPrediction(
|
|
221
|
+
bbox=bbox,
|
|
222
|
+
score=score,
|
|
223
|
+
label=str(int(label_enum)),
|
|
224
|
+
provider_metadata={
|
|
225
|
+
"label_name": label_enum.name,
|
|
226
|
+
"position": position,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
output = LayoutOutput(
|
|
232
|
+
task_type="layout_detection",
|
|
233
|
+
example_id=raw_result.request.example_id,
|
|
234
|
+
pipeline_name=raw_result.pipeline_name,
|
|
235
|
+
model=self.model_type,
|
|
236
|
+
image_width=max(int(image_width), 1),
|
|
237
|
+
image_height=max(int(image_height), 1),
|
|
238
|
+
predictions=raw_predictions,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return InferenceResult(
|
|
242
|
+
request=raw_result.request,
|
|
243
|
+
pipeline_name=raw_result.pipeline_name,
|
|
244
|
+
product_type=raw_result.product_type,
|
|
245
|
+
raw_output=raw_result.raw_output,
|
|
246
|
+
output=output,
|
|
247
|
+
started_at=raw_result.started_at,
|
|
248
|
+
completed_at=raw_result.completed_at,
|
|
249
|
+
latency_in_ms=raw_result.latency_in_ms,
|
|
250
|
+
)
|