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.
Files changed (227) hide show
  1. parse_bench/__init__.py +3 -0
  2. parse_bench/analysis/__init__.py +6 -0
  3. parse_bench/analysis/aggregation_report.py +582 -0
  4. parse_bench/analysis/cli.py +472 -0
  5. parse_bench/analysis/comparison.py +382 -0
  6. parse_bench/analysis/comparison_core.py +357 -0
  7. parse_bench/analysis/comparison_report.py +2066 -0
  8. parse_bench/analysis/detailed_report.py +2254 -0
  9. parse_bench/analysis/leaderboard_report.py +852 -0
  10. parse_bench/analysis/metric_definitions.py +771 -0
  11. parse_bench/cli.py +267 -0
  12. parse_bench/data/__init__.py +1 -0
  13. parse_bench/data/cli.py +118 -0
  14. parse_bench/data/download.py +127 -0
  15. parse_bench/evaluation/__init__.py +11 -0
  16. parse_bench/evaluation/cli.py +435 -0
  17. parse_bench/evaluation/evaluators/__init__.py +17 -0
  18. parse_bench/evaluation/evaluators/base.py +34 -0
  19. parse_bench/evaluation/evaluators/extract.py +429 -0
  20. parse_bench/evaluation/evaluators/layoutdet.py +1682 -0
  21. parse_bench/evaluation/evaluators/parse.py +1353 -0
  22. parse_bench/evaluation/evaluators/qa.py +199 -0
  23. parse_bench/evaluation/layout_adapters/__init__.py +21 -0
  24. parse_bench/evaluation/layout_adapters/adapters.py +3180 -0
  25. parse_bench/evaluation/layout_adapters/base.py +105 -0
  26. parse_bench/evaluation/layout_adapters/registry.py +109 -0
  27. parse_bench/evaluation/layout_label_mappers/__init__.py +22 -0
  28. parse_bench/evaluation/layout_label_mappers/base.py +66 -0
  29. parse_bench/evaluation/layout_label_mappers/mappers.py +332 -0
  30. parse_bench/evaluation/layout_label_mappers/projection.py +74 -0
  31. parse_bench/evaluation/layout_label_mappers/registry.py +119 -0
  32. parse_bench/evaluation/metric_aggregation.py +56 -0
  33. parse_bench/evaluation/metrics/__init__.py +5 -0
  34. parse_bench/evaluation/metrics/attribution/__init__.py +35 -0
  35. parse_bench/evaluation/metrics/attribution/constants.py +12 -0
  36. parse_bench/evaluation/metrics/attribution/core.py +1108 -0
  37. parse_bench/evaluation/metrics/attribution/evaluate.py +446 -0
  38. parse_bench/evaluation/metrics/attribution/geometry.py +161 -0
  39. parse_bench/evaluation/metrics/attribution/text_utils.py +233 -0
  40. parse_bench/evaluation/metrics/base.py +33 -0
  41. parse_bench/evaluation/metrics/downstream/__init__.py +0 -0
  42. parse_bench/evaluation/metrics/extract/__init__.py +29 -0
  43. parse_bench/evaluation/metrics/extract/json_subset_match.py +473 -0
  44. parse_bench/evaluation/metrics/extract/json_subset_match_metric.py +81 -0
  45. parse_bench/evaluation/metrics/extract/list_unwrap.py +340 -0
  46. parse_bench/evaluation/metrics/extract/rule_based_metric.py +90 -0
  47. parse_bench/evaluation/metrics/extract/test_rules.py +409 -0
  48. parse_bench/evaluation/metrics/extract/test_types.py +11 -0
  49. parse_bench/evaluation/metrics/field_grounding/__init__.py +21 -0
  50. parse_bench/evaluation/metrics/field_grounding/core.py +437 -0
  51. parse_bench/evaluation/metrics/field_grounding/extract_adapter.py +1224 -0
  52. parse_bench/evaluation/metrics/field_grounding/parse_adapter.py +697 -0
  53. parse_bench/evaluation/metrics/field_grounding/rule_filters.py +19 -0
  54. parse_bench/evaluation/metrics/field_grounding/value_compare.py +190 -0
  55. parse_bench/evaluation/metrics/layoutdet/__init__.py +17 -0
  56. parse_bench/evaluation/metrics/layoutdet/classification_utils.py +300 -0
  57. parse_bench/evaluation/metrics/layoutdet/iou.py +76 -0
  58. parse_bench/evaluation/metrics/parse/__init__.py +5 -0
  59. parse_bench/evaluation/metrics/parse/_vendor_grits_reference.py +531 -0
  60. parse_bench/evaluation/metrics/parse/cross_page_table_consistency.py +165 -0
  61. parse_bench/evaluation/metrics/parse/emphasis_spans.py +242 -0
  62. parse_bench/evaluation/metrics/parse/fast_tree_edit.py +282 -0
  63. parse_bench/evaluation/metrics/parse/grits_metric.py +1125 -0
  64. parse_bench/evaluation/metrics/parse/grits_reference_metric.py +142 -0
  65. parse_bench/evaluation/metrics/parse/header_accuracy_metric.py +1662 -0
  66. parse_bench/evaluation/metrics/parse/llm_normalization/__init__.py +51 -0
  67. parse_bench/evaluation/metrics/parse/llm_normalization/base.py +125 -0
  68. parse_bench/evaluation/metrics/parse/llm_normalization/config.py +44 -0
  69. parse_bench/evaluation/metrics/parse/llm_normalization/postprocess.py +322 -0
  70. parse_bench/evaluation/metrics/parse/llm_normalization/strategy_judge.py +541 -0
  71. parse_bench/evaluation/metrics/parse/mermaid_graph.py +682 -0
  72. parse_bench/evaluation/metrics/parse/rule_based_judge_metric.py +56 -0
  73. parse_bench/evaluation/metrics/parse/rule_based_metric.py +434 -0
  74. parse_bench/evaluation/metrics/parse/rules_bag.py +1161 -0
  75. parse_bench/evaluation/metrics/parse/rules_base.py +751 -0
  76. parse_bench/evaluation/metrics/parse/rules_chart.py +1556 -0
  77. parse_bench/evaluation/metrics/parse/rules_diagram.py +591 -0
  78. parse_bench/evaluation/metrics/parse/rules_form.py +2274 -0
  79. parse_bench/evaluation/metrics/parse/rules_formatting.py +1500 -0
  80. parse_bench/evaluation/metrics/parse/rules_heading.py +228 -0
  81. parse_bench/evaluation/metrics/parse/rules_list.py +226 -0
  82. parse_bench/evaluation/metrics/parse/rules_page_decoration.py +276 -0
  83. parse_bench/evaluation/metrics/parse/rules_table.py +1666 -0
  84. parse_bench/evaluation/metrics/parse/rules_text.py +340 -0
  85. parse_bench/evaluation/metrics/parse/rules_watermark.py +105 -0
  86. parse_bench/evaluation/metrics/parse/structural_consistency_metric.py +251 -0
  87. parse_bench/evaluation/metrics/parse/table_extraction.py +152 -0
  88. parse_bench/evaluation/metrics/parse/table_merging.py +195 -0
  89. parse_bench/evaluation/metrics/parse/table_pairing.py +87 -0
  90. parse_bench/evaluation/metrics/parse/table_parsing.py +955 -0
  91. parse_bench/evaluation/metrics/parse/table_record_match_metric.py +1453 -0
  92. parse_bench/evaluation/metrics/parse/table_splitting.py +301 -0
  93. parse_bench/evaluation/metrics/parse/table_title_stripping.py +530 -0
  94. parse_bench/evaluation/metrics/parse/teds_metric.py +600 -0
  95. parse_bench/evaluation/metrics/parse/test_rules.py +120 -0
  96. parse_bench/evaluation/metrics/parse/test_types.py +103 -0
  97. parse_bench/evaluation/metrics/parse/text_content_projection.py +175 -0
  98. parse_bench/evaluation/metrics/parse/text_similarity_metric.py +61 -0
  99. parse_bench/evaluation/metrics/parse/utils.py +885 -0
  100. parse_bench/evaluation/metrics/qa/__init__.py +5 -0
  101. parse_bench/evaluation/metrics/qa/answer_comparison.py +380 -0
  102. parse_bench/evaluation/qa/__init__.py +5 -0
  103. parse_bench/evaluation/qa/llm_service.py +335 -0
  104. parse_bench/evaluation/reports/__init__.py +8 -0
  105. parse_bench/evaluation/reports/csv.py +64 -0
  106. parse_bench/evaluation/reports/html.py +338 -0
  107. parse_bench/evaluation/reports/markdown.py +98 -0
  108. parse_bench/evaluation/reports/rule_csv.py +22 -0
  109. parse_bench/evaluation/runner.py +1864 -0
  110. parse_bench/evaluation/stats.py +104 -0
  111. parse_bench/extensions.py +72 -0
  112. parse_bench/inference/__init__.py +33 -0
  113. parse_bench/inference/chunkr_layout_extraction.py +160 -0
  114. parse_bench/inference/cli.py +484 -0
  115. parse_bench/inference/layout_extraction.py +422 -0
  116. parse_bench/inference/pipelines/__init__.py +59 -0
  117. parse_bench/inference/pipelines/extract.py +39 -0
  118. parse_bench/inference/pipelines/layout.py +142 -0
  119. parse_bench/inference/pipelines/parse.py +2603 -0
  120. parse_bench/inference/pipelines.py +0 -0
  121. parse_bench/inference/providers/__init__.py +28 -0
  122. parse_bench/inference/providers/base.py +196 -0
  123. parse_bench/inference/providers/cancellation.py +137 -0
  124. parse_bench/inference/providers/extract/__init__.py +22 -0
  125. parse_bench/inference/providers/extract/citations.py +549 -0
  126. parse_bench/inference/providers/extract/extend.py +851 -0
  127. parse_bench/inference/providers/extract/llamaextract_v2_api.py +583 -0
  128. parse_bench/inference/providers/layoutdet/__init__.py +25 -0
  129. parse_bench/inference/providers/layoutdet/adapters.py +946 -0
  130. parse_bench/inference/providers/layoutdet/base.py +203 -0
  131. parse_bench/inference/providers/layoutdet/chandra.py +449 -0
  132. parse_bench/inference/providers/layoutdet/docling.py +125 -0
  133. parse_bench/inference/providers/layoutdet/dots_ocr.py +606 -0
  134. parse_bench/inference/providers/layoutdet/layout_v3.py +137 -0
  135. parse_bench/inference/providers/layoutdet/layout_v3_byoc.py +204 -0
  136. parse_bench/inference/providers/layoutdet/paddle.py +117 -0
  137. parse_bench/inference/providers/layoutdet/qwen3vl.py +360 -0
  138. parse_bench/inference/providers/layoutdet/surya.py +250 -0
  139. parse_bench/inference/providers/layoutdet/yolo.py +109 -0
  140. parse_bench/inference/providers/parse/__init__.py +64 -0
  141. parse_bench/inference/providers/parse/_docling_common.py +233 -0
  142. parse_bench/inference/providers/parse/_layout_utils.py +611 -0
  143. parse_bench/inference/providers/parse/amazon_nova.py +515 -0
  144. parse_bench/inference/providers/parse/anthropic.py +882 -0
  145. parse_bench/inference/providers/parse/azure_document_intelligence.py +700 -0
  146. parse_bench/inference/providers/parse/chandra2.py +633 -0
  147. parse_bench/inference/providers/parse/chunkr.py +268 -0
  148. parse_bench/inference/providers/parse/databricks_ai_parse.py +724 -0
  149. parse_bench/inference/providers/parse/datalab.py +370 -0
  150. parse_bench/inference/providers/parse/deepseekocr2.py +382 -0
  151. parse_bench/inference/providers/parse/docling.py +281 -0
  152. parse_bench/inference/providers/parse/docling_serve.py +289 -0
  153. parse_bench/inference/providers/parse/dots_ocr.py +574 -0
  154. parse_bench/inference/providers/parse/extend_parse.py +710 -0
  155. parse_bench/inference/providers/parse/falconocr.py +436 -0
  156. parse_bench/inference/providers/parse/florin_parser_nano.py +559 -0
  157. parse_bench/inference/providers/parse/gemma4.py +472 -0
  158. parse_bench/inference/providers/parse/glm_zai.py +229 -0
  159. parse_bench/inference/providers/parse/google.py +1125 -0
  160. parse_bench/inference/providers/parse/google_agentic_vision.py +819 -0
  161. parse_bench/inference/providers/parse/google_docai.py +776 -0
  162. parse_bench/inference/providers/parse/google_docai_layout_normalization.py +573 -0
  163. parse_bench/inference/providers/parse/granite_vision.py +515 -0
  164. parse_bench/inference/providers/parse/infinity_parser2.py +704 -0
  165. parse_bench/inference/providers/parse/kdl_frontier_nano.py +3327 -0
  166. parse_bench/inference/providers/parse/landingai.py +452 -0
  167. parse_bench/inference/providers/parse/liteparse.py +350 -0
  168. parse_bench/inference/providers/parse/llamaparse.py +677 -0
  169. parse_bench/inference/providers/parse/llamaparse_v2_normalization.py +1013 -0
  170. parse_bench/inference/providers/parse/markitdown.py +138 -0
  171. parse_bench/inference/providers/parse/mineru25.py +405 -0
  172. parse_bench/inference/providers/parse/mineru2605pro.py +432 -0
  173. parse_bench/inference/providers/parse/mineru_diffusion.py +371 -0
  174. parse_bench/inference/providers/parse/mistral_ocr.py +546 -0
  175. parse_bench/inference/providers/parse/nemotron_omni.py +473 -0
  176. parse_bench/inference/providers/parse/oi_parser.py +222 -0
  177. parse_bench/inference/providers/parse/openai.py +740 -0
  178. parse_bench/inference/providers/parse/opendataloader.py +152 -0
  179. parse_bench/inference/providers/parse/paddleocr.py +624 -0
  180. parse_bench/inference/providers/parse/pdf_inspector.py +142 -0
  181. parse_bench/inference/providers/parse/pulse.py +785 -0
  182. parse_bench/inference/providers/parse/pymupdf.py +207 -0
  183. parse_bench/inference/providers/parse/pymupdf4llm.py +356 -0
  184. parse_bench/inference/providers/parse/pypdf.py +179 -0
  185. parse_bench/inference/providers/parse/qwen.py +678 -0
  186. parse_bench/inference/providers/parse/rakedoc_nano.py +70 -0
  187. parse_bench/inference/providers/parse/reducto.py +546 -0
  188. parse_bench/inference/providers/parse/surya2.py +372 -0
  189. parse_bench/inference/providers/parse/tesseract.py +301 -0
  190. parse_bench/inference/providers/parse/textract.py +694 -0
  191. parse_bench/inference/providers/parse/unlimitedocr.py +346 -0
  192. parse_bench/inference/providers/parse/unstructured.py +485 -0
  193. parse_bench/inference/providers/parse/warp_ingest.py +199 -0
  194. parse_bench/inference/providers/registry.py +49 -0
  195. parse_bench/inference/renormalize.py +170 -0
  196. parse_bench/inference/runner.py +2023 -0
  197. parse_bench/layout_label_mapping.py +424 -0
  198. parse_bench/layout_projection.py +179 -0
  199. parse_bench/pipeline/__init__.py +1 -0
  200. parse_bench/pipeline/cli.py +549 -0
  201. parse_bench/schemas/__init__.py +33 -0
  202. parse_bench/schemas/evaluation.py +93 -0
  203. parse_bench/schemas/extract_output.py +36 -0
  204. parse_bench/schemas/layout_detection_output.py +545 -0
  205. parse_bench/schemas/layout_ontology.py +315 -0
  206. parse_bench/schemas/metrics.py +69 -0
  207. parse_bench/schemas/parse_output.py +152 -0
  208. parse_bench/schemas/pipeline.py +22 -0
  209. parse_bench/schemas/pipeline_io.py +106 -0
  210. parse_bench/schemas/product.py +97 -0
  211. parse_bench/test_cases/__init__.py +25 -0
  212. parse_bench/test_cases/bbox_value_strict_comparator.py +880 -0
  213. parse_bench/test_cases/extract_field_paths.py +164 -0
  214. parse_bench/test_cases/layout_attribution_generation.py +287 -0
  215. parse_bench/test_cases/loader.py +652 -0
  216. parse_bench/test_cases/parse_rule_schemas.py +1071 -0
  217. parse_bench/test_cases/rule_filters.py +32 -0
  218. parse_bench/test_cases/rule_ids.py +107 -0
  219. parse_bench/test_cases/schema.py +427 -0
  220. parse_bench/utils/__init__.py +15 -0
  221. parse_bench/utils/gemini_layout_utils.py +670 -0
  222. parse_bench/utils/text_aggregation.py +100 -0
  223. parse_bench-1.0.0.dist-info/METADATA +476 -0
  224. parse_bench-1.0.0.dist-info/RECORD +227 -0
  225. parse_bench-1.0.0.dist-info/WHEEL +4 -0
  226. parse_bench-1.0.0.dist-info/entry_points.txt +2 -0
  227. 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
+ )