Image-Text-to-Text
Transformers
Safetensors
qwen3_5
ocr
document-parsing
multimodal
markdown
tables
formulas
key-information-extraction
figure-captioning
vllm
conversational
Eval Results
Instructions to use ATH-MaaS/OvisOCR3 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use ATH-MaaS/OvisOCR3 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-text-to-text", model="ATH-MaaS/OvisOCR3") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] pipe(text=messages)# pip install -U transformers accelerate # Load model directly from transformers import AutoProcessor, AutoModelForMultimodalLM processor = AutoProcessor.from_pretrained("ATH-MaaS/OvisOCR3") model = AutoModelForMultimodalLM.from_pretrained("ATH-MaaS/OvisOCR3", device_map="auto") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=256) print(processor.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use ATH-MaaS/OvisOCR3 with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "ATH-MaaS/OvisOCR3" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "ATH-MaaS/OvisOCR3", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker
docker model run hf.co/ATH-MaaS/OvisOCR3
- SGLang
How to use ATH-MaaS/OvisOCR3 with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "ATH-MaaS/OvisOCR3" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "ATH-MaaS/OvisOCR3", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "ATH-MaaS/OvisOCR3" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "ATH-MaaS/OvisOCR3", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }' - Docker Model Runner
How to use ATH-MaaS/OvisOCR3 with Docker Model Runner:
docker model run hf.co/ATH-MaaS/OvisOCR3
Initial release of OvisOCR3
Browse files- .gitattributes +2 -0
- LICENSE +202 -0
- README.md +534 -0
- assets/crosspage_recursive.png +3 -0
- assets/mathjax.js +0 -0
- assets/ovisocr3_performance_overview.png +3 -0
- chat_template.jinja +154 -0
- config.json +96 -0
- document_crosspage.py +1747 -0
- merges.txt +0 -0
- model.safetensors +3 -0
- preprocessor_config.json +21 -0
- prompts/chart_parsing.txt +8 -0
- prompts/crosspage_table.txt +5 -0
- prompts/document_layout.txt +9 -0
- prompts/document_markdown.txt +1 -0
- prompts/document_structured.txt +15 -0
- prompts/figure_captioning.txt +17 -0
- prompts/formula_recognition.txt +8 -0
- prompts/image_classification.txt +5 -0
- prompts/kie_fields.txt +1 -0
- prompts/kie_open.txt +1 -0
- prompts/kie_schema.txt +3 -0
- prompts/plain_ocr.txt +7 -0
- prompts/position_aware_vqa.txt +6 -0
- prompts/reference_grounding.txt +19 -0
- prompts/region_ocr.txt +9 -0
- prompts/text_translation.txt +9 -0
- prompts/visual_question_answering.txt +3 -0
- prompts/word_spotting.txt +13 -0
- tokenizer.json +3 -0
- tokenizer_config.json +305 -0
- video_preprocessor_config.json +21 -0
- vocab.json +0 -0
.gitattributes
CHANGED
|
@@ -19,6 +19,7 @@
|
|
| 19 |
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 22 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
*.rar filter=lfs diff=lfs merge=lfs -text
|
|
@@ -33,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 19 |
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 23 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 24 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 25 |
*.rar filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 34 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 36 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
tokenizer.json filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Apache License
|
| 3 |
+
Version 2.0, January 2004
|
| 4 |
+
http://www.apache.org/licenses/
|
| 5 |
+
|
| 6 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 7 |
+
|
| 8 |
+
1. Definitions.
|
| 9 |
+
|
| 10 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 11 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 12 |
+
|
| 13 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 14 |
+
the copyright owner that is granting the License.
|
| 15 |
+
|
| 16 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 17 |
+
other entities that control, are controlled by, or are under common
|
| 18 |
+
control with that entity. For the purposes of this definition,
|
| 19 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 20 |
+
direction or management of such entity, whether by contract or
|
| 21 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 22 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 23 |
+
|
| 24 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 25 |
+
exercising permissions granted by this License.
|
| 26 |
+
|
| 27 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 28 |
+
including but not limited to software source code, documentation
|
| 29 |
+
source, and configuration files.
|
| 30 |
+
|
| 31 |
+
"Object" form shall mean any form resulting from mechanical
|
| 32 |
+
transformation or translation of a Source form, including but
|
| 33 |
+
not limited to compiled object code, generated documentation,
|
| 34 |
+
and conversions to other media types.
|
| 35 |
+
|
| 36 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 37 |
+
Object form, made available under the License, as indicated by a
|
| 38 |
+
copyright notice that is included in or attached to the work
|
| 39 |
+
(an example is provided in the Appendix below).
|
| 40 |
+
|
| 41 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 42 |
+
form, that is based on (or derived from) the Work and for which the
|
| 43 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 44 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 45 |
+
of this License, Derivative Works shall not include works that remain
|
| 46 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 47 |
+
the Work and Derivative Works thereof.
|
| 48 |
+
|
| 49 |
+
"Contribution" shall mean any work of authorship, including
|
| 50 |
+
the original version of the Work and any modifications or additions
|
| 51 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 52 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 53 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 54 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 55 |
+
means any form of electronic, verbal, or written communication sent
|
| 56 |
+
to the Licensor or its representatives, including but not limited to
|
| 57 |
+
communication on electronic mailing lists, source code control systems,
|
| 58 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 59 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 60 |
+
excluding communication that is conspicuously marked or otherwise
|
| 61 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 62 |
+
|
| 63 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 64 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 65 |
+
subsequently incorporated within the Work.
|
| 66 |
+
|
| 67 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 68 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 69 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 70 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 71 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 72 |
+
Work and such Derivative Works in Source or Object form.
|
| 73 |
+
|
| 74 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 75 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 76 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 77 |
+
(except as stated in this section) patent license to make, have made,
|
| 78 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 79 |
+
where such license applies only to those patent claims licensable
|
| 80 |
+
by such Contributor that are necessarily infringed by their
|
| 81 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 82 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 83 |
+
institute patent litigation against any entity (including a
|
| 84 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 85 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 86 |
+
or contributory patent infringement, then any patent licenses
|
| 87 |
+
granted to You under this License for that Work shall terminate
|
| 88 |
+
as of the date such litigation is filed.
|
| 89 |
+
|
| 90 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 91 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 92 |
+
modifications, and in Source or Object form, provided that You
|
| 93 |
+
meet the following conditions:
|
| 94 |
+
|
| 95 |
+
(a) You must give any other recipients of the Work or
|
| 96 |
+
Derivative Works a copy of this License; and
|
| 97 |
+
|
| 98 |
+
(b) You must cause any modified files to carry prominent notices
|
| 99 |
+
stating that You changed the files; and
|
| 100 |
+
|
| 101 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 102 |
+
that You distribute, all copyright, patent, trademark, and
|
| 103 |
+
attribution notices from the Source form of the Work,
|
| 104 |
+
excluding those notices that do not pertain to any part of
|
| 105 |
+
the Derivative Works; and
|
| 106 |
+
|
| 107 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 108 |
+
distribution, then any Derivative Works that You distribute must
|
| 109 |
+
include a readable copy of the attribution notices contained
|
| 110 |
+
within such NOTICE file, excluding those notices that do not
|
| 111 |
+
pertain to any part of the Derivative Works, in at least one
|
| 112 |
+
of the following places: within a NOTICE text file distributed
|
| 113 |
+
as part of the Derivative Works; within the Source form or
|
| 114 |
+
documentation, if provided along with the Derivative Works; or,
|
| 115 |
+
within a display generated by the Derivative Works, if and
|
| 116 |
+
wherever such third-party notices normally appear. The contents
|
| 117 |
+
of the NOTICE file are for informational purposes only and
|
| 118 |
+
do not modify the License. You may add Your own attribution
|
| 119 |
+
notices within Derivative Works that You distribute, alongside
|
| 120 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 121 |
+
that such additional attribution notices cannot be construed
|
| 122 |
+
as modifying the License.
|
| 123 |
+
|
| 124 |
+
You may add Your own copyright statement to Your modifications and
|
| 125 |
+
may provide additional or different license terms and conditions
|
| 126 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 127 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 128 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 129 |
+
the conditions stated in this License.
|
| 130 |
+
|
| 131 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 132 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 133 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 134 |
+
this License, without any additional terms or conditions.
|
| 135 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 136 |
+
the terms of any separate license agreement you may have executed
|
| 137 |
+
with Licensor regarding such Contributions.
|
| 138 |
+
|
| 139 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 140 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 141 |
+
except as required for reasonable and customary use in describing the
|
| 142 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 143 |
+
|
| 144 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 145 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 146 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 147 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 148 |
+
implied, including, without limitation, any warranties or conditions
|
| 149 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 150 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 151 |
+
appropriateness of using or redistributing the Work and assume any
|
| 152 |
+
risks associated with Your exercise of permissions under this License.
|
| 153 |
+
|
| 154 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 155 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 156 |
+
unless required by applicable law (such as deliberate and grossly
|
| 157 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 158 |
+
liable to You for damages, including any direct, indirect, special,
|
| 159 |
+
incidental, or consequential damages of any character arising as a
|
| 160 |
+
result of this License or out of the use or inability to use the
|
| 161 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 162 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 163 |
+
other commercial damages or losses), even if such Contributor
|
| 164 |
+
has been advised of the possibility of such damages.
|
| 165 |
+
|
| 166 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 167 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 168 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 169 |
+
or other liability obligations and/or rights consistent with this
|
| 170 |
+
License. However, in accepting such obligations, You may act only
|
| 171 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 172 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 173 |
+
defend, and hold each Contributor harmless for any liability
|
| 174 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 175 |
+
of your accepting any such warranty or additional liability.
|
| 176 |
+
|
| 177 |
+
END OF TERMS AND CONDITIONS
|
| 178 |
+
|
| 179 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 180 |
+
|
| 181 |
+
To apply the Apache License to your work, attach the following
|
| 182 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 183 |
+
replaced with your own identifying information. (Don't include
|
| 184 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 185 |
+
comment syntax for the file format. We also recommend that a
|
| 186 |
+
file or class name and description of purpose be included on the
|
| 187 |
+
same "printed page" as the copyright notice for easier
|
| 188 |
+
identification within third-party archives.
|
| 189 |
+
|
| 190 |
+
Copyright 2026 Alibaba Cloud
|
| 191 |
+
|
| 192 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 193 |
+
you may not use this file except in compliance with the License.
|
| 194 |
+
You may obtain a copy of the License at
|
| 195 |
+
|
| 196 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 197 |
+
|
| 198 |
+
Unless required by applicable law or agreed to in writing, software
|
| 199 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 200 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 201 |
+
See the License for the specific language governing permissions and
|
| 202 |
+
limitations under the License.
|
README.md
CHANGED
|
@@ -1,3 +1,537 @@
|
|
| 1 |
---
|
| 2 |
license: apache-2.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
license: apache-2.0
|
| 3 |
+
library_name: transformers
|
| 4 |
+
pipeline_tag: image-text-to-text
|
| 5 |
+
base_model: Qwen/Qwen3.5-0.8B
|
| 6 |
+
tags:
|
| 7 |
+
- ocr
|
| 8 |
+
- document-parsing
|
| 9 |
+
- multimodal
|
| 10 |
+
- markdown
|
| 11 |
+
- tables
|
| 12 |
+
- formulas
|
| 13 |
+
- key-information-extraction
|
| 14 |
+
- figure-captioning
|
| 15 |
+
- vllm
|
| 16 |
+
- qwen3_5
|
| 17 |
---
|
| 18 |
+
|
| 19 |
+
# OvisOCR3
|
| 20 |
+
|
| 21 |
+
<p align="center">
|
| 22 |
+
<img src="https://cdn-uploads.huggingface.co/production/uploads/658a8a837959448ef5500ce5/vRCIu5QD8VuIJolkC_ZHQ.png" alt="Ovis" width="30%" />
|
| 23 |
+
</p>
|
| 24 |
+
|
| 25 |
+
## Introduction
|
| 26 |
+
|
| 27 |
+
OvisOCR3 is a **unified 0.8B multimodal model for OCR and document understanding**. It integrates **document parsing, general OCR, key information extraction, document figure captioning, and cross-page table reconstruction** into a single compact model. These capabilities are selected through task-specific prompts, bringing recognition, extraction, and understanding together without requiring separate model deployments for each task.
|
| 28 |
+
|
| 29 |
+
Built on Qwen3.5-0.8B, OvisOCR3 is trained in four stages: continued pretraining, mid-training, on-policy distillation, and reinforcement learning. This training process builds a strong foundation in text recognition and document structure understanding, then refines these capabilities through domain-specific supervision and rewards.
|
| 30 |
+
|
| 31 |
+
OvisOCR3 achieves **state-of-the-art performance among models of comparable size across all five domains**, demonstrating that a single compact model can combine strong document parsing with a broad range of OCR and document-understanding capabilities.
|
| 32 |
+
|
| 33 |
+

|
| 34 |
+
|
| 35 |
+
## Capabilities
|
| 36 |
+
|
| 37 |
+
| Capability | Input | Output |
|
| 38 |
+
| --- | --- | --- |
|
| 39 |
+
| Document parsing | A page image and a parsing-mode prompt | Structured JSON, layout JSON, or Markdown with LaTeX formulas and HTML tables |
|
| 40 |
+
| General OCR | An image, optionally with a region, references, a question, a target language, or candidate labels | Text, bounding boxes, LaTeX, chart data, a label, or an answer |
|
| 41 |
+
| Key information extraction | An image, optionally with specified fields or a JSON output template | Key-value records or JSON following the supplied template |
|
| 42 |
+
| Document figure captioning | A page image and a document-figure-caption-mode prompt | Structured page content with localized visual captions and Mermaid representations for flowcharts |
|
| 43 |
+
| Cross-page table reconstruction | Consecutive page images and their parsed Markdown | Complete HTML tables with source-page associations, or a no-continuation prediction |
|
| 44 |
+
|
| 45 |
+
The [task prompts](./prompts/) specify the output contracts. The examples below use these files directly.
|
| 46 |
+
|
| 47 |
+
## Performance
|
| 48 |
+
|
| 49 |
+
Performance comparisons across five task domains are reported below. `--` denotes an unreported value, not zero. Bold scores indicate the best result in each column.
|
| 50 |
+
|
| 51 |
+
### Document Parsing
|
| 52 |
+
|
| 53 |
+
| Model | OmniDocBench1.6 | MDPBench | PureDocBench | Real5-OmniDocBench |
|
| 54 |
+
|---|---:|---:|---:|---:|
|
| 55 |
+
| Qwen3.5-OCR | 91.27 | 60.60 | 68.38 | 82.48 |
|
| 56 |
+
| Qwen3.8-Max | 93.70 | 86.40 | 80.56 | 86.86 |
|
| 57 |
+
| GLM-OCR | 95.22 | 67.30 | 63.34 | 90.32 |
|
| 58 |
+
| PaddleOCR-VL-1.6 | 96.34 | 78.90 | 65.44 | 93.19 |
|
| 59 |
+
| MonkeyOCRv2-B-Parsing | 91.57 | 83.30 | 64.91 | 89.22 |
|
| 60 |
+
| OvisOCR2 | 96.58 | 69.00 | 75.10 | 92.29 |
|
| 61 |
+
| HunyuanOCR-1.5 | 94.74 | 76.80 | 67.58 | 88.53 |
|
| 62 |
+
| Xiaomi-OCR-0 | **96.83** | 73.00 | 74.60 | 92.73 |
|
| 63 |
+
| **OvisOCR3** | 96.74 | **88.80** | **82.31** | **93.63** |
|
| 64 |
+
|
| 65 |
+
### General OCR
|
| 66 |
+
|
| 67 |
+
| Model | OCRBench | OCRBench v2 EN | OCRBench v2 ZH |
|
| 68 |
+
| --- | ---: | ---: | ---: |
|
| 69 |
+
| Qwen3.5-0.8B | 79.1 | 41.5 | 43.9 |
|
| 70 |
+
| Qwen3.5-2B | 85.4 | 48.1 | 55.4 |
|
| 71 |
+
| Qwen3.5-4B | 85.0 | 54.1 | 61.0 |
|
| 72 |
+
| Qwen3.5-OCR | 76.8 | 44.5 | 43.8 |
|
| 73 |
+
| PaddleOCR-VL-1.6 | 55.9 | 23.8 | 30.4 |
|
| 74 |
+
| HunyuanOCR-1.5 | 86.1 | 50.7 | 57.7 |
|
| 75 |
+
| GLM-OCR | 83.8 | 32.9 | 48.1 |
|
| 76 |
+
| Xiaomi-OCR-0 | 84.6 | 40.1 | 48.4 |
|
| 77 |
+
| **OvisOCR3** | **88.8** | **65.9** | **61.3** |
|
| 78 |
+
|
| 79 |
+
### Key Information Extraction
|
| 80 |
+
|
| 81 |
+
| Model | OCRBench KIE | OCRBench v2 KIE | CC-OCR KIE | In-house KIE Bench |
|
| 82 |
+
| --- | ---: | ---: | ---: | ---: |
|
| 83 |
+
| Qwen3.5-OCR | 78.50 | 72.90 | 88.43 | 59.90 |
|
| 84 |
+
| GLM-OCR | 79.00 | 74.22 | 68.36 | 75.25 |
|
| 85 |
+
| HunyuanOCR-1.5 | 81.00 | 68.63 | 66.08 | 75.96 |
|
| 86 |
+
| Xiaomi-OCR-0 | 76.50 | 74.66 | 79.53 | 75.68 |
|
| 87 |
+
| **OvisOCR3** | **90.50** | **83.01** | **88.69** | **77.73** |
|
| 88 |
+
|
| 89 |
+
### Document Figure Captioning
|
| 90 |
+
|
| 91 |
+
| Model | DocFigCapBench Localization F1 | DocFigCapBench Caption Score |
|
| 92 |
+
| --- | ---: | ---: |
|
| 93 |
+
| HunyuanOCR-1.5 | 48.24 | -- |
|
| 94 |
+
| Qwen3.5-OCR | 78.47 | 4.74 |
|
| 95 |
+
| OvisOCR2 | 82.18 | -- |
|
| 96 |
+
| MinerU2.5-Pro | 85.00 | 4.61 |
|
| 97 |
+
| PaddleOCR-VL-1.6 | 85.32 | 4.29 |
|
| 98 |
+
| Qwen3.8-Max | 86.67 | **7.30** |
|
| 99 |
+
| **OvisOCR3** | **90.97** | 6.93 |
|
| 100 |
+
|
| 101 |
+
### Cross-Page Table Reconstruction
|
| 102 |
+
|
| 103 |
+
| Model | CrossTabBench Relation F1 (P/R) | CrossTabBench Reconstruction TEDS | MPDocBench-Parse Truncated Table TEDS |
|
| 104 |
+
| :--- | ---: | ---: | ---: |
|
| 105 |
+
| PaddleOCR-VL-1.6 | 42.61 (90.13/27.90) | 24.24 | 83.39 |
|
| 106 |
+
| Logics-Parsing-V3 | 44.55 (90.63/29.53) | 26.10 | 88.05 |
|
| 107 |
+
| MinerU2.5-Pro | 59.14 (93.81/43.18) | 41.02 | 91.44 |
|
| 108 |
+
| MinerU2.5-Pro + MinerU-PoPo | 54.76 (97.40/38.09) | 36.42 | 91.87 |
|
| 109 |
+
| Qwen3.8-Max | 94.47 (90.19/99.19) | **91.71** | 89.57 |
|
| 110 |
+
| **OvisOCR3** | **97.07** (96.20/97.96) | 89.66 | **92.04** |
|
| 111 |
+
|
| 112 |
+
## Inference
|
| 113 |
+
|
| 114 |
+
The example uses a local model directory containing the weights, configuration, tokenizer, processor files, and `prompts/`. Run it from that directory, or set `MODEL_PATH` to its location. A GPU environment supported by vLLM is required.
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
pip install "vllm==0.22.1" pillow
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
```python
|
| 121 |
+
import warnings
|
| 122 |
+
from pathlib import Path
|
| 123 |
+
|
| 124 |
+
from PIL import Image
|
| 125 |
+
from vllm import LLM, SamplingParams
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
MODEL_PATH = Path(".")
|
| 129 |
+
|
| 130 |
+
model = LLM(
|
| 131 |
+
model=str(MODEL_PATH.resolve()),
|
| 132 |
+
tensor_parallel_size=1,
|
| 133 |
+
gpu_memory_utilization=0.8,
|
| 134 |
+
gdn_prefill_backend="triton",
|
| 135 |
+
limit_mm_per_prompt={"image": 2},
|
| 136 |
+
)
|
| 137 |
+
tokenizer = model.get_tokenizer()
|
| 138 |
+
sampling_params = SamplingParams(max_tokens=16384, temperature=0.0)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def load_prompt(name: str) -> str:
|
| 142 |
+
return (MODEL_PATH / "prompts" / f"{name}.txt").read_text(encoding="utf-8")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def infer(
|
| 146 |
+
images: list[Image.Image], task_prompt: str, *,
|
| 147 |
+
return_metadata=False,
|
| 148 |
+
):
|
| 149 |
+
if not 1 <= len(images) <= 2:
|
| 150 |
+
raise ValueError("This example accepts one or two images per request.")
|
| 151 |
+
|
| 152 |
+
content = [{"type": "image"} for _ in images]
|
| 153 |
+
content.append({"type": "text", "text": task_prompt})
|
| 154 |
+
prompt = tokenizer.apply_chat_template(
|
| 155 |
+
[{"role": "user", "content": content}],
|
| 156 |
+
tokenize=False,
|
| 157 |
+
add_generation_prompt=True,
|
| 158 |
+
enable_thinking=False,
|
| 159 |
+
)
|
| 160 |
+
request = {
|
| 161 |
+
"prompt": prompt,
|
| 162 |
+
"multi_modal_data": {"image": [image.convert("RGB") for image in images]},
|
| 163 |
+
"mm_processor_kwargs": {
|
| 164 |
+
"images_kwargs": {
|
| 165 |
+
"min_pixels": 448 * 448,
|
| 166 |
+
"max_pixels": 2880 * 2880,
|
| 167 |
+
}
|
| 168 |
+
},
|
| 169 |
+
}
|
| 170 |
+
output = model.generate([request], sampling_params)[0].outputs[0]
|
| 171 |
+
if output.finish_reason == "length":
|
| 172 |
+
warnings.warn("Generation reached a token limit; the output may be incomplete.")
|
| 173 |
+
return (output.text, output.finish_reason) if return_metadata else output.text
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
page = Image.open("test1.jpg").convert("RGB")
|
| 177 |
+
text = infer([page], load_prompt("plain_ocr"))
|
| 178 |
+
Path("output.txt").write_text(text, encoding="utf-8")
|
| 179 |
+
print(text)
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
`infer` returns the raw generated text by default; `return_metadata=True` also returns the finish reason. It does not remove image tags, trim repetitions, repair JSON, or otherwise rewrite the response. All task examples share the inference settings above. These are usage settings, not benchmark reproduction settings.
|
| 183 |
+
|
| 184 |
+
An image list passed to `infer` belongs to **one request**. For independent pages, call `infer([page], prompt)` for each page. The two-image form is used for cross-page reconstruction below.
|
| 185 |
+
|
| 186 |
+
## Task-Specific Usage
|
| 187 |
+
|
| 188 |
+
The following snippets reuse `page`, `infer`, and `load_prompt` from the inference example. Replace placeholder values with the fields, regions, or questions needed for your document. Use literal placeholder replacement rather than Python `str.format`, since prompts also contain JSON and LaTeX braces.
|
| 189 |
+
|
| 190 |
+
### Document Parsing
|
| 191 |
+
|
| 192 |
+
Compared with OvisOCR2's Markdown parsing, OvisOCR3 adds structured document parsing and document layout detection. Structured parsing returns region-level records with bounding boxes, categories, and content; Markdown parsing returns a continuous document without explicit bounding boxes or category labels for individual text regions.
|
| 193 |
+
|
| 194 |
+
| Mode | Prompt | Output |
|
| 195 |
+
| --- | --- | --- |
|
| 196 |
+
| Markdown | [document_markdown.txt](./prompts/document_markdown.txt) | A document in natural reading order, with LaTeX formulas, HTML tables, and image-region references |
|
| 197 |
+
| Structured | [document_structured.txt](./prompts/document_structured.txt) | A JSON array with `bbox`, `category`, and `content` for each element |
|
| 198 |
+
| Layout only | [document_layout.txt](./prompts/document_layout.txt) | A JSON array with `bbox` and `category`, without transcription |
|
| 199 |
+
|
| 200 |
+
For `document_markdown`, follow the [OvisOCR2](https://huggingface.co/ATH-MaaS/OvisOCR2) postprocessing order: strip leading and trailing whitespace, optionally filter image-tag blocks (enabled by default), and then apply repeated-tail cleanup to every response regardless of the finish reason. Set `filter_imgtags=False` to retain visual-region references for rendering. Keep `raw_markdown` for inspection; structured and layout JSON outputs are left unchanged. The helper checks its own length and repetition thresholds. It does not recover missing content or make a truncated response complete.
|
| 201 |
+
|
| 202 |
+
```python
|
| 203 |
+
def _clean_truncated_repeats(
|
| 204 |
+
text: str,
|
| 205 |
+
min_text_len: int = 8000,
|
| 206 |
+
max_period: int = 200,
|
| 207 |
+
min_period: int = 1,
|
| 208 |
+
min_repeat_chars: int = 100,
|
| 209 |
+
min_repeat_times: int = 5,
|
| 210 |
+
) -> str:
|
| 211 |
+
n = len(text)
|
| 212 |
+
if n < min_text_len:
|
| 213 |
+
return text
|
| 214 |
+
|
| 215 |
+
max_period = min(max_period, n - 1)
|
| 216 |
+
for unit_len in range(min_period, max_period + 1):
|
| 217 |
+
if text[n - 1] != text[n - 1 - unit_len]:
|
| 218 |
+
continue
|
| 219 |
+
|
| 220 |
+
match_len = 1
|
| 221 |
+
idx = n - 2
|
| 222 |
+
while idx >= unit_len and text[idx] == text[idx - unit_len]:
|
| 223 |
+
match_len += 1
|
| 224 |
+
idx -= 1
|
| 225 |
+
|
| 226 |
+
total_len = match_len + unit_len
|
| 227 |
+
repeat_times = total_len // unit_len
|
| 228 |
+
tail_len = total_len % unit_len
|
| 229 |
+
|
| 230 |
+
if repeat_times >= min_repeat_times and total_len >= min_repeat_chars:
|
| 231 |
+
return text[: n - total_len + unit_len] + text[n - tail_len :]
|
| 232 |
+
|
| 233 |
+
return text
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# Format 1: Markdown, with optional image-tag filtering and repeated-tail cleanup.
|
| 237 |
+
raw_markdown = infer([page], load_prompt("document_markdown"))
|
| 238 |
+
filter_imgtags = True
|
| 239 |
+
text = raw_markdown.strip()
|
| 240 |
+
if filter_imgtags:
|
| 241 |
+
text = "\n\n".join(
|
| 242 |
+
block
|
| 243 |
+
for block in text.split("\n\n")
|
| 244 |
+
if not block.strip().startswith('<img src="images/bbox_')
|
| 245 |
+
)
|
| 246 |
+
markdown = _clean_truncated_repeats(text)
|
| 247 |
+
|
| 248 |
+
# Format 2: Structured JSON with bbox, category, and content.
|
| 249 |
+
structured_json = infer([page], load_prompt("document_structured"))
|
| 250 |
+
|
| 251 |
+
# Format 3: Layout JSON with bbox and category only, without transcription.
|
| 252 |
+
layout_json = infer([page], load_prompt("document_layout"))
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
Structured and layout outputs follow natural reading order. The allowed category lists and category-specific content formats are defined in their respective prompts.
|
| 256 |
+
|
| 257 |
+
#### Coordinates and Visual Regions
|
| 258 |
+
|
| 259 |
+
Bounding boxes use `[left, top, right, bottom]` order with integer coordinates in `[0, 1000)`, relative to the image supplied to the model. The origin is the top-left corner. Convert to pixels with `x * image_width / 1000` and `y * image_height / 1000`. For cropped inputs, coordinates are relative to that crop, not the original page.
|
| 260 |
+
|
| 261 |
+
Markdown and structured parsing can reference visual regions using `<img src="images/bbox_left_top_right_bottom.jpg" />`. These paths describe crops to create from the input image; the model does not write image files. Preserve the image orientation used for inference when extracting crops.
|
| 262 |
+
|
| 263 |
+
<details>
|
| 264 |
+
<summary>Save Markdown together with its referenced image crops</summary>
|
| 265 |
+
|
| 266 |
+
```python
|
| 267 |
+
import re
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
BBOX_IMAGE_PATTERN = re.compile(r'images/bbox_(\d+)_(\d+)_(\d+)_(\d+)\.jpg')
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def save_markdown_with_crops(markdown: str, page_image: Image.Image, output_dir: str):
|
| 274 |
+
output_dir = Path(output_dir)
|
| 275 |
+
images_dir = output_dir / "images"
|
| 276 |
+
images_dir.mkdir(parents=True, exist_ok=True)
|
| 277 |
+
width, height = page_image.size
|
| 278 |
+
|
| 279 |
+
for left, top, right, bottom in set(BBOX_IMAGE_PATTERN.findall(markdown)):
|
| 280 |
+
x1 = max(0, min(width, round(int(left) * width / 1000)))
|
| 281 |
+
y1 = max(0, min(height, round(int(top) * height / 1000)))
|
| 282 |
+
x2 = max(0, min(width, round(int(right) * width / 1000)))
|
| 283 |
+
y2 = max(0, min(height, round(int(bottom) * height / 1000)))
|
| 284 |
+
if x2 <= x1 or y2 <= y1:
|
| 285 |
+
warnings.warn(f"Skipping invalid crop: {(left, top, right, bottom)}")
|
| 286 |
+
continue
|
| 287 |
+
crop_path = images_dir / f"bbox_{left}_{top}_{right}_{bottom}.jpg"
|
| 288 |
+
page_image.crop((x1, y1, x2, y2)).convert("RGB").save(crop_path)
|
| 289 |
+
|
| 290 |
+
(output_dir / "output.md").write_text(markdown, encoding="utf-8")
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
save_markdown_with_crops(markdown, page, "output")
|
| 294 |
+
```
|
| 295 |
+
|
| 296 |
+
This helper handles single-page Markdown references. For reconstructed tables, resolve each `page_N/` prefix against its corresponding source page and save crops under that page's directory.
|
| 297 |
+
|
| 298 |
+
</details>
|
| 299 |
+
|
| 300 |
+
### General OCR
|
| 301 |
+
|
| 302 |
+
<a id="general-ocr-prompts"></a>
|
| 303 |
+
|
| 304 |
+
| Task | Prompt | Output |
|
| 305 |
+
| --- | --- | --- |
|
| 306 |
+
| Plain OCR | [plain_ocr.txt](./prompts/plain_ocr.txt) | Text without markup or bounding boxes |
|
| 307 |
+
| Word-level text spotting | [word_spotting.txt](./prompts/word_spotting.txt) | JSON records with `bbox` and `text`, one per word or text token |
|
| 308 |
+
| Reference grounding | [reference_grounding.txt](./prompts/reference_grounding.txt) | JSON records with `bbox` and the requested `reference` |
|
| 309 |
+
| Region OCR | [region_ocr.txt](./prompts/region_ocr.txt) | Text within a specified region |
|
| 310 |
+
| Formula recognition | [formula_recognition.txt](./prompts/formula_recognition.txt) | LaTeX expressions enclosed in `$$ ... $$` |
|
| 311 |
+
| Chart parsing | [chart_parsing.txt](./prompts/chart_parsing.txt) | JSON with `title`, `source`, `x_title`, `y_title`, and `values` |
|
| 312 |
+
| Text translation | [text_translation.txt](./prompts/text_translation.txt) | Text from a specified region translated into the target language |
|
| 313 |
+
| Image classification | [image_classification.txt](./prompts/image_classification.txt) | Exactly one label from the supplied candidates |
|
| 314 |
+
| Visual question answering | [visual_question_answering.txt](./prompts/visual_question_answering.txt) | Reasoning followed by a final answer inside `\boxed{}` |
|
| 315 |
+
| Position-aware VQA | [position_aware_vqa.txt](./prompts/position_aware_vqa.txt) | JSON with `answer` and a string-valued `bbox` |
|
| 316 |
+
|
| 317 |
+
All ten tasks read the linked `.txt` prompt files. Image classification takes a target description and candidate labels, and position-aware VQA uses its own answer-and-box instruction. Fill only the requested task inputs in each template.
|
| 318 |
+
|
| 319 |
+
```python
|
| 320 |
+
import re
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def general_ocr_prompt(name: str) -> str:
|
| 324 |
+
prompt = load_prompt(name)
|
| 325 |
+
return prompt if name == "position_aware_vqa" else prompt.strip()
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def general_vqa_prompt(question: str) -> str:
|
| 329 |
+
prompt = general_ocr_prompt("visual_question_answering").replace("{question}", question)
|
| 330 |
+
return re.sub(r"\n{3,}", "\n\n", prompt).strip()
|
| 331 |
+
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
```python
|
| 335 |
+
import json
|
| 336 |
+
|
| 337 |
+
text = infer([page], general_ocr_prompt("plain_ocr"))
|
| 338 |
+
words_json = infer([page], general_ocr_prompt("word_spotting"))
|
| 339 |
+
|
| 340 |
+
references = json.dumps(["Invoice number", "Total"], ensure_ascii=False, separators=(",", ":"))
|
| 341 |
+
grounding_prompt = general_ocr_prompt("reference_grounding").replace("{references}", references)
|
| 342 |
+
grounding_json = infer([page], grounding_prompt)
|
| 343 |
+
|
| 344 |
+
region = json.dumps([100, 100, 900, 300], separators=(",", ":"))
|
| 345 |
+
region_prompt = general_ocr_prompt("region_ocr").replace("{target_region}", region)
|
| 346 |
+
region_text = infer([page], region_prompt)
|
| 347 |
+
|
| 348 |
+
formula_latex = infer([page], general_ocr_prompt("formula_recognition"))
|
| 349 |
+
chart_json = infer([page], general_ocr_prompt("chart_parsing"))
|
| 350 |
+
|
| 351 |
+
translation_prompt = general_ocr_prompt("text_translation").replace("{target_region}", region)
|
| 352 |
+
translation_prompt = translation_prompt.replace("{target_language}", "English")
|
| 353 |
+
translated_text = infer([page], translation_prompt)
|
| 354 |
+
|
| 355 |
+
target_description = "the main subject"
|
| 356 |
+
candidate_labels = json.dumps(["cat", "dog", "bird", "fish"], ensure_ascii=False)
|
| 357 |
+
classification_prompt = general_ocr_prompt("image_classification").replace(
|
| 358 |
+
"{target description}", target_description
|
| 359 |
+
).replace("{candidate_labels}", candidate_labels)
|
| 360 |
+
selected_label = infer([page], classification_prompt)
|
| 361 |
+
|
| 362 |
+
question = "What is the invoice number?"
|
| 363 |
+
vqa_prompt = general_vqa_prompt(question)
|
| 364 |
+
answer_with_reasoning = infer([page], vqa_prompt)
|
| 365 |
+
|
| 366 |
+
position_prompt = general_ocr_prompt("position_aware_vqa").replace("{question}", question)
|
| 367 |
+
answer_json = infer([page], position_prompt)
|
| 368 |
+
```
|
| 369 |
+
|
| 370 |
+
References and regions use compact JSON. Supply references expected to be visible in the image. For classification, `{target description}` specifies what to classify, and `{candidate_labels}` is a JSON array of candidate labels. VQA accepts the complete question, including answer choices when applicable; its visible reasoning ends with a boxed answer. Position-aware VQA accepts the question text and uses its own answer-and-box prompt. Formula recognition and chart parsing need no template substitutions.
|
| 371 |
+
|
| 372 |
+
Spotting and grounding boxes, as well as input regions, use `[x_min,y_min,x_max,y_max]` with integer coordinates in `[0,1000)`, normalized independently by image width and height. The origin is the top-left corner, and coordinates refer to the supplied image, including when it is a crop. Use `[0,0,999,999]` for whole-image region OCR or translation.
|
| 373 |
+
|
| 374 |
+
Chart parsing represents scalar data values as strings and uses the literal string `"None"` for missing title, source, or axis metadata. Position-aware VQA uses `"bbox": "(x1,y1,x2,y2)"`, unlike the numeric arrays used by spotting and grounding.
|
| 375 |
+
|
| 376 |
+
### Key Information Extraction
|
| 377 |
+
|
| 378 |
+
| Mode | Prompt | Additional input |
|
| 379 |
+
| --- | --- | --- |
|
| 380 |
+
| Open extraction | [kie_open.txt](./prompts/kie_open.txt) | None; extract all visible key-value pairs |
|
| 381 |
+
| Specified-field extraction | [kie_fields.txt](./prompts/kie_fields.txt) | `{keys_json}`: a JSON array of field names |
|
| 382 |
+
| Schema-based extraction | [kie_schema.txt](./prompts/kie_schema.txt) | `{schema_json}`: a JSON template specifying the desired output structure |
|
| 383 |
+
|
| 384 |
+
Schema-based extraction uses an example of the desired JSON output, not a formal JSON Schema declaration. Empty strings serve as value placeholders, while nested objects and arrays define the output structure.
|
| 385 |
+
|
| 386 |
+
```python
|
| 387 |
+
import json
|
| 388 |
+
|
| 389 |
+
open_json = infer([page], load_prompt("kie_open"))
|
| 390 |
+
|
| 391 |
+
keys = json.dumps(["invoice_number", "date", "total"], ensure_ascii=False)
|
| 392 |
+
fields_prompt = load_prompt("kie_fields").replace("{keys_json}", keys)
|
| 393 |
+
fields_json = infer([page], fields_prompt)
|
| 394 |
+
|
| 395 |
+
schema = {
|
| 396 |
+
"invoice_number": "",
|
| 397 |
+
"date": "",
|
| 398 |
+
"seller": {
|
| 399 |
+
"name": "",
|
| 400 |
+
"address": "",
|
| 401 |
+
},
|
| 402 |
+
"items": [
|
| 403 |
+
{
|
| 404 |
+
"name": "",
|
| 405 |
+
"quantity": "",
|
| 406 |
+
"unit_price": "",
|
| 407 |
+
"amount": "",
|
| 408 |
+
}
|
| 409 |
+
],
|
| 410 |
+
"total": "",
|
| 411 |
+
}
|
| 412 |
+
schema_prompt = load_prompt("kie_schema").replace(
|
| 413 |
+
"{schema_json}", json.dumps(schema, ensure_ascii=False)
|
| 414 |
+
)
|
| 415 |
+
schema_json = infer([page], schema_prompt)
|
| 416 |
+
```
|
| 417 |
+
|
| 418 |
+
Values should retain the visible source text. Open and specified-field extraction use arrays for repeated values and empty strings for empty values; specified-field extraction also uses empty strings for missing fields. Schema-based extraction follows the supplied template and uses empty strings for missing values. Adherence to the template is prompted, not enforced by constrained decoding in this example; validate generated JSON before downstream use.
|
| 419 |
+
|
| 420 |
+
### Document Figure Captioning
|
| 421 |
+
|
| 422 |
+
Use the **full page image** with [figure_captioning.txt](./prompts/figure_captioning.txt):
|
| 423 |
+
|
| 424 |
+
```python
|
| 425 |
+
caption_json = infer([page], load_prompt("figure_captioning"))
|
| 426 |
+
```
|
| 427 |
+
|
| 428 |
+
The model returns a structured page JSON array with `bbox`, `category`, and `content`. For visual categories such as `image` and `chart`, `content` contains a concise description inside `<caption>...</caption>`, with a maximum of 100 words requested by the prompt. These captions replace the image references used for visual regions in structured document parsing. Flowcharts additionally include a Mermaid representation inside `<mermaid>...</mermaid>`. Other page elements retain their task-specific text, formula, or table content.
|
| 429 |
+
|
| 430 |
+
This is localized figure description within a document page, not just transcription of a printed figure caption.
|
| 431 |
+
|
| 432 |
+
### Cross-Page Table Reconstruction
|
| 433 |
+
|
| 434 |
+
OvisOCR3 reconstructs cross-page tables in two stages using the same checkpoint:
|
| 435 |
+
|
| 436 |
+
1. **Stage 1: page parsing.** Parse each page independently with [document_markdown.txt](./prompts/document_markdown.txt) or [document_structured.txt](./prompts/document_structured.txt). For structured JSON output, concatenate each element’s `content` in reading order to obtain Markdown, preserving HTML tables, formulas, and image references.
|
| 437 |
+
2. **Stage 2: table reconstruction.** Supply two adjacent page images and their parsed Markdown with [crosspage_table.txt](./prompts/crosspage_table.txt). The model identifies whether a table continues across the page boundary and reconstructs continued fragments as a single HTML table; otherwise, it returns a negative prediction.
|
| 438 |
+
|
| 439 |
+
```python
|
| 440 |
+
import re
|
| 441 |
+
|
| 442 |
+
pages = [
|
| 443 |
+
Image.open("page1.jpg").convert("RGB"),
|
| 444 |
+
Image.open("page2.jpg").convert("RGB"),
|
| 445 |
+
]
|
| 446 |
+
page_results = [
|
| 447 |
+
infer([image], load_prompt("document_markdown"), return_metadata=True)
|
| 448 |
+
for image in pages
|
| 449 |
+
]
|
| 450 |
+
page_markdowns = []
|
| 451 |
+
for index, (raw, reason) in enumerate(page_results, start=1):
|
| 452 |
+
if reason not in ("stop", "length"):
|
| 453 |
+
raise ValueError(f"Page {index}: finish_reason={reason}")
|
| 454 |
+
markdown = _clean_truncated_repeats(raw.strip())
|
| 455 |
+
markdown = re.sub(r'^```(?:markdown|html)?\s*\n|\n```\s*$', '', markdown.strip(), flags=re.I)
|
| 456 |
+
if not markdown:
|
| 457 |
+
raise ValueError(f"Page {index}: empty Markdown")
|
| 458 |
+
page_markdowns.append(re.sub(
|
| 459 |
+
r'(?<![\w/])(?:page_\d+/)?(images/bbox_\d+_\d+_\d+_\d+\.jpg)',
|
| 460 |
+
rf'page_{index}/\1', markdown,
|
| 461 |
+
))
|
| 462 |
+
|
| 463 |
+
if all(re.search(r'<table\b[^>]*>.*?</table\s*>', md, re.I | re.S) for md in page_markdowns):
|
| 464 |
+
page_context = "\n\n".join(
|
| 465 |
+
f'<INPUT_PAGE index="{index}">\n{markdown}\n</INPUT_PAGE>'
|
| 466 |
+
for index, markdown in enumerate(page_markdowns, start=1)
|
| 467 |
+
)
|
| 468 |
+
prompt = load_prompt("crosspage_table").removesuffix("\n") + "\n\nInput Markdown:\n\n" + page_context
|
| 469 |
+
tables_json, reason = infer(pages, prompt, return_metadata=True)
|
| 470 |
+
if reason != "stop":
|
| 471 |
+
raise ValueError(f"Cross-page output is incomplete: finish_reason={reason}")
|
| 472 |
+
else:
|
| 473 |
+
tables_json = '[{"source_pages": [], "html": "No cross-page table exists between these pages."}]'
|
| 474 |
+
print(tables_json)
|
| 475 |
+
```
|
| 476 |
+
|
| 477 |
+
The result is a JSON array. Each record has `source_pages`, using **input-local, 1-based page numbers**, and `html`, containing one complete HTML table. Images within tables retain page-qualified references such as `page_1/images/bbox_100_200_300_400.jpg`.
|
| 478 |
+
|
| 479 |
+
When no cross-page table exists, the specified response is:
|
| 480 |
+
|
| 481 |
+
```json
|
| 482 |
+
[{"source_pages": [], "html": "No cross-page table exists between these pages."}]
|
| 483 |
+
```
|
| 484 |
+
|
| 485 |
+
For tables spanning more than two pages, **Stage 2 is applied recursively**, processing adjacent pairs from the end of each consecutive sequence of table-bearing pages toward its beginning. After each merge, the reconstructed table is written into the earlier page and its updated Markdown is re-rendered before the next step, keeping the image and text consistent. For example, the model first merges pages 2 and 3, then pairs the updated page 2 with page 1. Each step uses only two page images while preserving the original source-page associations. A ready-to-run document-level implementation is available in [document_crosspage.py](./document_crosspage.py), supporting either `document_markdown.txt` or `document_structured.txt` for Stage 1 and converting structured JSON to Markdown before reconstruction.
|
| 486 |
+
|
| 487 |
+

|
| 488 |
+
|
| 489 |
+
*Stage 2 first reconstructs the table across pages 2 and 3, then pairs the re-rendered page 2 with page 1. Each step uses two page images and their current Markdown.*
|
| 490 |
+
|
| 491 |
+
The script uses the same vLLM version and inference settings as the example above. Install the additional dependencies and run the command below from the model repository root, supplying page images in reading order.
|
| 492 |
+
|
| 493 |
+
```bash
|
| 494 |
+
python -m pip install beautifulsoup4 markdown-it-py playwright
|
| 495 |
+
python -m playwright install --with-deps chromium
|
| 496 |
+
|
| 497 |
+
python document_crosspage.py \
|
| 498 |
+
--model . \
|
| 499 |
+
--stage1-prompt document_structured.txt \
|
| 500 |
+
--output crosspage_output \
|
| 501 |
+
page1.jpg page2.jpg page3.jpg
|
| 502 |
+
```
|
| 503 |
+
|
| 504 |
+
Use a new output directory for each run; it contains `document.md`, per-page Markdown, raw responses, and `result.json` with table provenance and any processing errors.
|
| 505 |
+
|
| 506 |
+
## Output Handling
|
| 507 |
+
|
| 508 |
+
### Validation and Rendering
|
| 509 |
+
|
| 510 |
+
- Preserve raw outputs before parsing, rendering, or applying cleanup. JSON, HTML, LaTeX, and Mermaid may need validation, and generation can stop before an output is complete.
|
| 511 |
+
- Render Markdown with support for HTML tables and LaTeX. Figure captions and Mermaid content require their own handling inside structured JSON.
|
| 512 |
+
- Treat generated HTML and Mermaid as untrusted content. Sanitize or isolate them before rendering in a browser.
|
| 513 |
+
- For evaluation, freeze prompts, preprocessing, generation parameters, and postprocessing. These usage examples do not define a benchmark protocol.
|
| 514 |
+
|
| 515 |
+
## Limitations
|
| 516 |
+
|
| 517 |
+
OvisOCR3 can produce incorrect, incomplete, repeated, or malformed outputs. Small or blurred text, severe perspective distortion, dense layouts, complex formulas, and long tables may affect recognition and structure recovery. Captions and answers can include unsupported details; verify them against the source image.
|
| 518 |
+
|
| 519 |
+
Cross-page reconstruction also depends on the quality of the first-stage page parsing and correct page order. Missing table fragments, inconsistent page context, or truncated generations can lead to incorrect merges. Review results before using them in critical applications.
|
| 520 |
+
|
| 521 |
+
## Technical Report and Citation
|
| 522 |
+
|
| 523 |
+
If you find OvisOCR3 useful, please consider citing our technical report:
|
| 524 |
+
|
| 525 |
+
```bibtex
|
| 526 |
+
@misc{ovisocr3,
|
| 527 |
+
title = {{OvisOCR3 Technical Report}},
|
| 528 |
+
author = {{ATH-MaaS}},
|
| 529 |
+
year = {2026}
|
| 530 |
+
}
|
| 531 |
+
```
|
| 532 |
+
|
| 533 |
+
The technical report link will be added when available.
|
| 534 |
+
|
| 535 |
+
## License
|
| 536 |
+
|
| 537 |
+
This project is licensed under the [Apache License, Version 2.0](./LICENSE) (SPDX-License-Identifier: Apache-2.0).
|
assets/crosspage_recursive.png
ADDED
|
Git LFS Details
|
assets/mathjax.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
assets/ovisocr3_performance_overview.png
ADDED
|
Git LFS Details
|
chat_template.jinja
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{%- set image_count = namespace(value=0) %}
|
| 2 |
+
{%- set video_count = namespace(value=0) %}
|
| 3 |
+
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
|
| 4 |
+
{%- if content is string %}
|
| 5 |
+
{{- content }}
|
| 6 |
+
{%- elif content is iterable and content is not mapping %}
|
| 7 |
+
{%- for item in content %}
|
| 8 |
+
{%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
|
| 9 |
+
{%- if is_system_content %}
|
| 10 |
+
{{- raise_exception('System message cannot contain images.') }}
|
| 11 |
+
{%- endif %}
|
| 12 |
+
{%- if do_vision_count %}
|
| 13 |
+
{%- set image_count.value = image_count.value + 1 %}
|
| 14 |
+
{%- endif %}
|
| 15 |
+
{%- if add_vision_id %}
|
| 16 |
+
{{- 'Picture ' ~ image_count.value ~ ': ' }}
|
| 17 |
+
{%- endif %}
|
| 18 |
+
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
|
| 19 |
+
{%- elif 'video' in item or item.type == 'video' %}
|
| 20 |
+
{%- if is_system_content %}
|
| 21 |
+
{{- raise_exception('System message cannot contain videos.') }}
|
| 22 |
+
{%- endif %}
|
| 23 |
+
{%- if do_vision_count %}
|
| 24 |
+
{%- set video_count.value = video_count.value + 1 %}
|
| 25 |
+
{%- endif %}
|
| 26 |
+
{%- if add_vision_id %}
|
| 27 |
+
{{- 'Video ' ~ video_count.value ~ ': ' }}
|
| 28 |
+
{%- endif %}
|
| 29 |
+
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
|
| 30 |
+
{%- elif 'text' in item %}
|
| 31 |
+
{{- item.text }}
|
| 32 |
+
{%- else %}
|
| 33 |
+
{{- raise_exception('Unexpected item type in content.') }}
|
| 34 |
+
{%- endif %}
|
| 35 |
+
{%- endfor %}
|
| 36 |
+
{%- elif content is none or content is undefined %}
|
| 37 |
+
{{- '' }}
|
| 38 |
+
{%- else %}
|
| 39 |
+
{{- raise_exception('Unexpected content type.') }}
|
| 40 |
+
{%- endif %}
|
| 41 |
+
{%- endmacro %}
|
| 42 |
+
{%- if not messages %}
|
| 43 |
+
{{- raise_exception('No messages provided.') }}
|
| 44 |
+
{%- endif %}
|
| 45 |
+
{%- if tools and tools is iterable and tools is not mapping %}
|
| 46 |
+
{{- '<|im_start|>system\n' }}
|
| 47 |
+
{{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
|
| 48 |
+
{%- for tool in tools %}
|
| 49 |
+
{{- "\n" }}
|
| 50 |
+
{{- tool | tojson }}
|
| 51 |
+
{%- endfor %}
|
| 52 |
+
{{- "\n</tools>" }}
|
| 53 |
+
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
|
| 54 |
+
{%- if messages[0].role == 'system' %}
|
| 55 |
+
{%- set content = render_content(messages[0].content, false, true)|trim %}
|
| 56 |
+
{%- if content %}
|
| 57 |
+
{{- '\n\n' + content }}
|
| 58 |
+
{%- endif %}
|
| 59 |
+
{%- endif %}
|
| 60 |
+
{{- '<|im_end|>\n' }}
|
| 61 |
+
{%- else %}
|
| 62 |
+
{%- if messages[0].role == 'system' %}
|
| 63 |
+
{%- set content = render_content(messages[0].content, false, true)|trim %}
|
| 64 |
+
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
|
| 65 |
+
{%- endif %}
|
| 66 |
+
{%- endif %}
|
| 67 |
+
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
|
| 68 |
+
{%- for message in messages[::-1] %}
|
| 69 |
+
{%- set index = (messages|length - 1) - loop.index0 %}
|
| 70 |
+
{%- if ns.multi_step_tool and message.role == "user" %}
|
| 71 |
+
{%- set content = render_content(message.content, false)|trim %}
|
| 72 |
+
{%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
|
| 73 |
+
{%- set ns.multi_step_tool = false %}
|
| 74 |
+
{%- set ns.last_query_index = index %}
|
| 75 |
+
{%- endif %}
|
| 76 |
+
{%- endif %}
|
| 77 |
+
{%- endfor %}
|
| 78 |
+
{%- if ns.multi_step_tool %}
|
| 79 |
+
{{- raise_exception('No user query found in messages.') }}
|
| 80 |
+
{%- endif %}
|
| 81 |
+
{%- for message in messages %}
|
| 82 |
+
{%- set content = render_content(message.content, true)|trim %}
|
| 83 |
+
{%- if message.role == "system" %}
|
| 84 |
+
{%- if not loop.first %}
|
| 85 |
+
{{- raise_exception('System message must be at the beginning.') }}
|
| 86 |
+
{%- endif %}
|
| 87 |
+
{%- elif message.role == "user" %}
|
| 88 |
+
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
|
| 89 |
+
{%- elif message.role == "assistant" %}
|
| 90 |
+
{%- set reasoning_content = '' %}
|
| 91 |
+
{%- if message.reasoning_content is string %}
|
| 92 |
+
{%- set reasoning_content = message.reasoning_content %}
|
| 93 |
+
{%- else %}
|
| 94 |
+
{%- if '</think>' in content %}
|
| 95 |
+
{%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
|
| 96 |
+
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
|
| 97 |
+
{%- endif %}
|
| 98 |
+
{%- endif %}
|
| 99 |
+
{%- set reasoning_content = reasoning_content|trim %}
|
| 100 |
+
{%- if loop.index0 > ns.last_query_index %}
|
| 101 |
+
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
|
| 102 |
+
{%- else %}
|
| 103 |
+
{{- '<|im_start|>' + message.role + '\n' + content }}
|
| 104 |
+
{%- endif %}
|
| 105 |
+
{%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
|
| 106 |
+
{%- for tool_call in message.tool_calls %}
|
| 107 |
+
{%- if tool_call.function is defined %}
|
| 108 |
+
{%- set tool_call = tool_call.function %}
|
| 109 |
+
{%- endif %}
|
| 110 |
+
{%- if loop.first %}
|
| 111 |
+
{%- if content|trim %}
|
| 112 |
+
{{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
| 113 |
+
{%- else %}
|
| 114 |
+
{{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
| 115 |
+
{%- endif %}
|
| 116 |
+
{%- else %}
|
| 117 |
+
{{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
| 118 |
+
{%- endif %}
|
| 119 |
+
{%- if tool_call.arguments is defined %}
|
| 120 |
+
{%- for args_name, args_value in tool_call.arguments|items %}
|
| 121 |
+
{{- '<parameter=' + args_name + '>\n' }}
|
| 122 |
+
{%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
|
| 123 |
+
{{- args_value }}
|
| 124 |
+
{{- '\n</parameter>\n' }}
|
| 125 |
+
{%- endfor %}
|
| 126 |
+
{%- endif %}
|
| 127 |
+
{{- '</function>\n</tool_call>' }}
|
| 128 |
+
{%- endfor %}
|
| 129 |
+
{%- endif %}
|
| 130 |
+
{{- '<|im_end|>\n' }}
|
| 131 |
+
{%- elif message.role == "tool" %}
|
| 132 |
+
{%- if loop.previtem and loop.previtem.role != "tool" %}
|
| 133 |
+
{{- '<|im_start|>user' }}
|
| 134 |
+
{%- endif %}
|
| 135 |
+
{{- '\n<tool_response>\n' }}
|
| 136 |
+
{{- content }}
|
| 137 |
+
{{- '\n</tool_response>' }}
|
| 138 |
+
{%- if not loop.last and loop.nextitem.role != "tool" %}
|
| 139 |
+
{{- '<|im_end|>\n' }}
|
| 140 |
+
{%- elif loop.last %}
|
| 141 |
+
{{- '<|im_end|>\n' }}
|
| 142 |
+
{%- endif %}
|
| 143 |
+
{%- else %}
|
| 144 |
+
{{- raise_exception('Unexpected message role.') }}
|
| 145 |
+
{%- endif %}
|
| 146 |
+
{%- endfor %}
|
| 147 |
+
{%- if add_generation_prompt %}
|
| 148 |
+
{{- '<|im_start|>assistant\n' }}
|
| 149 |
+
{%- if enable_thinking is defined and enable_thinking is true %}
|
| 150 |
+
{{- '<think>\n' }}
|
| 151 |
+
{%- else %}
|
| 152 |
+
{{- '<think>\n\n</think>\n\n' }}
|
| 153 |
+
{%- endif %}
|
| 154 |
+
{%- endif %}
|
config.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"Qwen3_5ForConditionalGeneration"
|
| 4 |
+
],
|
| 5 |
+
"image_token_id": 248056,
|
| 6 |
+
"model_type": "qwen3_5",
|
| 7 |
+
"text_config": {
|
| 8 |
+
"attention_bias": false,
|
| 9 |
+
"attention_dropout": 0.0,
|
| 10 |
+
"attn_output_gate": true,
|
| 11 |
+
"dtype": "bfloat16",
|
| 12 |
+
"eos_token_id": 248044,
|
| 13 |
+
"full_attention_interval": 4,
|
| 14 |
+
"head_dim": 256,
|
| 15 |
+
"hidden_act": "silu",
|
| 16 |
+
"hidden_size": 1024,
|
| 17 |
+
"initializer_range": 0.02,
|
| 18 |
+
"intermediate_size": 3584,
|
| 19 |
+
"layer_types": [
|
| 20 |
+
"linear_attention",
|
| 21 |
+
"linear_attention",
|
| 22 |
+
"linear_attention",
|
| 23 |
+
"full_attention",
|
| 24 |
+
"linear_attention",
|
| 25 |
+
"linear_attention",
|
| 26 |
+
"linear_attention",
|
| 27 |
+
"full_attention",
|
| 28 |
+
"linear_attention",
|
| 29 |
+
"linear_attention",
|
| 30 |
+
"linear_attention",
|
| 31 |
+
"full_attention",
|
| 32 |
+
"linear_attention",
|
| 33 |
+
"linear_attention",
|
| 34 |
+
"linear_attention",
|
| 35 |
+
"full_attention",
|
| 36 |
+
"linear_attention",
|
| 37 |
+
"linear_attention",
|
| 38 |
+
"linear_attention",
|
| 39 |
+
"full_attention",
|
| 40 |
+
"linear_attention",
|
| 41 |
+
"linear_attention",
|
| 42 |
+
"linear_attention",
|
| 43 |
+
"full_attention"
|
| 44 |
+
],
|
| 45 |
+
"linear_conv_kernel_dim": 4,
|
| 46 |
+
"linear_key_head_dim": 128,
|
| 47 |
+
"linear_num_key_heads": 16,
|
| 48 |
+
"linear_num_value_heads": 16,
|
| 49 |
+
"linear_value_head_dim": 128,
|
| 50 |
+
"max_position_embeddings": 262144,
|
| 51 |
+
"mlp_only_layers": [],
|
| 52 |
+
"model_type": "qwen3_5_text",
|
| 53 |
+
"mtp_num_hidden_layers": 0,
|
| 54 |
+
"mtp_use_dedicated_embeddings": false,
|
| 55 |
+
"num_attention_heads": 8,
|
| 56 |
+
"num_hidden_layers": 24,
|
| 57 |
+
"num_key_value_heads": 2,
|
| 58 |
+
"rms_norm_eps": 1e-06,
|
| 59 |
+
"tie_word_embeddings": true,
|
| 60 |
+
"use_cache": true,
|
| 61 |
+
"vocab_size": 248320,
|
| 62 |
+
"mamba_ssm_dtype": "float32",
|
| 63 |
+
"rope_parameters": {
|
| 64 |
+
"mrope_interleaved": true,
|
| 65 |
+
"mrope_section": [
|
| 66 |
+
11,
|
| 67 |
+
11,
|
| 68 |
+
10
|
| 69 |
+
],
|
| 70 |
+
"rope_type": "default",
|
| 71 |
+
"rope_theta": 10000000,
|
| 72 |
+
"partial_rotary_factor": 0.25
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
"tie_word_embeddings": true,
|
| 76 |
+
"transformers_version": "4.57.0.dev0",
|
| 77 |
+
"video_token_id": 248057,
|
| 78 |
+
"vision_config": {
|
| 79 |
+
"deepstack_visual_indexes": [],
|
| 80 |
+
"depth": 12,
|
| 81 |
+
"hidden_act": "gelu_pytorch_tanh",
|
| 82 |
+
"hidden_size": 768,
|
| 83 |
+
"in_channels": 3,
|
| 84 |
+
"initializer_range": 0.02,
|
| 85 |
+
"intermediate_size": 3072,
|
| 86 |
+
"model_type": "qwen3_5",
|
| 87 |
+
"num_heads": 12,
|
| 88 |
+
"num_position_embeddings": 2304,
|
| 89 |
+
"out_hidden_size": 1024,
|
| 90 |
+
"patch_size": 16,
|
| 91 |
+
"spatial_merge_size": 2,
|
| 92 |
+
"temporal_patch_size": 2
|
| 93 |
+
},
|
| 94 |
+
"vision_end_token_id": 248054,
|
| 95 |
+
"vision_start_token_id": 248053
|
| 96 |
+
}
|
document_crosspage.py
ADDED
|
@@ -0,0 +1,1747 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single-file OvisOCR3 document parsing and cross-page table reconstruction.
|
| 2 |
+
|
| 3 |
+
Reading guide: public entry point -> document pipeline -> text/table helpers ->
|
| 4 |
+
model backend -> browser renderer. Import the text helpers without installing
|
| 5 |
+
vLLM or browser dependencies; those are loaded only when used.
|
| 6 |
+
|
| 7 |
+
Algorithm: parse pages independently, then visit adjacent table-bearing pages
|
| 8 |
+
from last to first. Write each merged table into the earlier page and render
|
| 9 |
+
that updated page before its next prediction.
|
| 10 |
+
Keep non-table source untouched.
|
| 11 |
+
|
| 12 |
+
Support: the document pipeline accepts one merged table per adjacent pair and
|
| 13 |
+
binds the earlier page's last table to the later page's first table. Multiple
|
| 14 |
+
merges need explicit fragment bindings via apply_pair(); nested or incomplete
|
| 15 |
+
HTML tables are rejected on writeback. This is a conservative binding policy,
|
| 16 |
+
not a general table-fragment matching algorithm.
|
| 17 |
+
|
| 18 |
+
Page numbers are 1-based; table fragment indices are 0-based. Model predictions
|
| 19 |
+
use pair-local pages 1 and 2; saved Markdown and provenance use document pages.
|
| 20 |
+
Raw predictions, intermediate files and partial results are retained.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import base64
|
| 27 |
+
import copy
|
| 28 |
+
import html
|
| 29 |
+
import io
|
| 30 |
+
import json
|
| 31 |
+
import math
|
| 32 |
+
import re
|
| 33 |
+
import time
|
| 34 |
+
import traceback
|
| 35 |
+
import uuid
|
| 36 |
+
from dataclasses import asdict, dataclass, field
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
from typing import Callable, Mapping, NamedTuple
|
| 39 |
+
|
| 40 |
+
NEGATIVE = "No cross-page table exists between these pages."
|
| 41 |
+
TABLE = re.compile(r"<table\b[^>]*>.*?</table\s*>", re.I | re.S)
|
| 42 |
+
IMAGE_REF = re.compile(
|
| 43 |
+
r"(?<![\w/])(?:page_(\d+)/)?images/bbox_(\d+)_(\d+)_(\d+)_(\d+)\.jpg"
|
| 44 |
+
)
|
| 45 |
+
REF = re.compile(r"page_(\d+)/images/")
|
| 46 |
+
CATEGORIES = set(
|
| 47 |
+
"abstract algorithm aside_text chart content display_formula doc_title figure_title footer footer_image footnote formula_number header header_image image inline_formula number paragraph_title reference_content seal table text vertical_text vision_footnote".split()
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
FragmentId = tuple[int, int]
|
| 51 |
+
Backend = Callable[[list[str], str, Path, int], tuple[str, str]]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class GenerationResult(NamedTuple):
|
| 55 |
+
"""Tuple-compatible raw model output; cleaning belongs to the page adapter."""
|
| 56 |
+
|
| 57 |
+
text: str
|
| 58 |
+
finish_reason: str
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class RenderResult(NamedTuple):
|
| 62 |
+
"""Tuple-compatible render result; aliases map rendered crops to originals."""
|
| 63 |
+
|
| 64 |
+
image_path: str
|
| 65 |
+
rewritten_source: str
|
| 66 |
+
alias_to_original: dict[str, str]
|
| 67 |
+
diagnostics: dict
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class PageState:
|
| 72 |
+
"""One document page and the original fragments behind its current tables."""
|
| 73 |
+
|
| 74 |
+
number: int
|
| 75 |
+
image_path: str
|
| 76 |
+
original_markdown: str
|
| 77 |
+
markdown: str
|
| 78 |
+
finish_reason: str
|
| 79 |
+
parse_diagnostics: dict | None = None
|
| 80 |
+
table_sources: list[set[FragmentId]] = field(init=False)
|
| 81 |
+
|
| 82 |
+
def __post_init__(self):
|
| 83 |
+
self.table_sources = [
|
| 84 |
+
{(self.number, index)}
|
| 85 |
+
for index, _ in enumerate(TABLE.finditer(self.markdown))
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@dataclass
|
| 90 |
+
class PairInput:
|
| 91 |
+
"""Two model inputs with explicit document/pair-local coordinate mapping."""
|
| 92 |
+
|
| 93 |
+
page_numbers: tuple[int, int]
|
| 94 |
+
image_paths: list[str]
|
| 95 |
+
markdowns: list[str]
|
| 96 |
+
alias_to_original: dict[str, str]
|
| 97 |
+
rendered_pages: set[int]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass
|
| 101 |
+
class DocumentState:
|
| 102 |
+
pages: list[PageState] = field(default_factory=list)
|
| 103 |
+
chunks: list[list[int]] = field(default_factory=list)
|
| 104 |
+
steps: list[dict] = field(default_factory=list)
|
| 105 |
+
merged_tables: list[dict] = field(default_factory=list)
|
| 106 |
+
parse_warnings: list[dict] = field(default_factory=list)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# Public entry point and document pipeline.
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def reconstruct_document(
|
| 113 |
+
image_paths,
|
| 114 |
+
output,
|
| 115 |
+
*,
|
| 116 |
+
model_path=".",
|
| 117 |
+
stage1_prompt="document_structured",
|
| 118 |
+
mathjax=None,
|
| 119 |
+
backend: Backend | None = None,
|
| 120 |
+
renderer=None,
|
| 121 |
+
):
|
| 122 |
+
"""Reconstruct ordered page images into a document, keeping all evidence.
|
| 123 |
+
|
| 124 |
+
Use a fresh output directory. Stage 1 accepts document_markdown[.txt] or
|
| 125 |
+
document_structured[.txt]; prompts are loaded from model_path/prompts.
|
| 126 |
+
Returns the saved result.json dictionary. Truncated generations, recovered
|
| 127 |
+
JSON or invalid structured fields return PARTIAL with issue-specific warnings;
|
| 128 |
+
other processing failures save partial output and raise RuntimeError.
|
| 129 |
+
|
| 130 |
+
For research, inject backend(paths, prompt, folder, stage) -> (raw, reason)
|
| 131 |
+
and/or renderer.render(source, owner, paths, folder, name) -> RenderResult.
|
| 132 |
+
Injected renderers remain caller-owned. Defaults load vLLM and Chromium lazily.
|
| 133 |
+
Engine settings, prompt bytes and renderer resize policy are unchanged.
|
| 134 |
+
"""
|
| 135 |
+
stage1_prompt = stage1_prompt.removesuffix(".txt")
|
| 136 |
+
if stage1_prompt not in {"document_markdown", "document_structured"}:
|
| 137 |
+
raise ValueError("Choose document_markdown or document_structured")
|
| 138 |
+
images = _document_images(image_paths)
|
| 139 |
+
if Path(output).exists():
|
| 140 |
+
raise FileExistsError("Use a fresh output directory")
|
| 141 |
+
|
| 142 |
+
prompt_dir = Path(model_path) / "prompts"
|
| 143 |
+
page_prompt = (prompt_dir / f"{stage1_prompt}.txt").read_text().removesuffix("\n")
|
| 144 |
+
table_prompt = (prompt_dir / "crosspage_table.txt").read_text().removesuffix("\n")
|
| 145 |
+
if backend is None:
|
| 146 |
+
backend = VllmBackend(model_path)
|
| 147 |
+
owns_renderer = renderer is None
|
| 148 |
+
if owns_renderer:
|
| 149 |
+
renderer = _BrowserRenderer(
|
| 150 |
+
mathjax or Path(__file__).resolve().parent / "assets/mathjax.js"
|
| 151 |
+
)
|
| 152 |
+
try:
|
| 153 |
+
result = _run_document(
|
| 154 |
+
images,
|
| 155 |
+
output,
|
| 156 |
+
backend,
|
| 157 |
+
renderer,
|
| 158 |
+
page_prompt=page_prompt,
|
| 159 |
+
table_prompt=table_prompt,
|
| 160 |
+
stage1_prompt=stage1_prompt,
|
| 161 |
+
)
|
| 162 |
+
finally:
|
| 163 |
+
if owns_renderer:
|
| 164 |
+
renderer.close()
|
| 165 |
+
|
| 166 |
+
if result["error"] and not result["error"].get("recoverable", False):
|
| 167 |
+
raise RuntimeError(f"Document incomplete; inspect {output}/failure.json")
|
| 168 |
+
return result
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _run_document(
|
| 172 |
+
image_paths, output, backend, renderer, *, page_prompt, table_prompt, stage1_prompt
|
| 173 |
+
):
|
| 174 |
+
"""Parse pages, reconstruct adjacent pairs backwards, then persist results.
|
| 175 |
+
|
| 176 |
+
Recovery is confined to Stage 1 output parsing. A failed window stops further
|
| 177 |
+
reconstruction while preserving earlier pages, merges and failure details.
|
| 178 |
+
No benchmark labels are used. The output directory must not already exist.
|
| 179 |
+
"""
|
| 180 |
+
images = _document_images(image_paths)
|
| 181 |
+
output = Path(output)
|
| 182 |
+
output.mkdir(parents=True, exist_ok=False)
|
| 183 |
+
save(
|
| 184 |
+
output / "inputs.json",
|
| 185 |
+
{
|
| 186 |
+
"images": images,
|
| 187 |
+
"stage1_prompt": stage1_prompt,
|
| 188 |
+
"page_prompt": page_prompt,
|
| 189 |
+
"table_prompt": table_prompt,
|
| 190 |
+
},
|
| 191 |
+
)
|
| 192 |
+
state = DocumentState()
|
| 193 |
+
error = None
|
| 194 |
+
context = {"stage": 1, "pages": []}
|
| 195 |
+
try:
|
| 196 |
+
for number, image in enumerate(images, 1):
|
| 197 |
+
context = {"stage": 1, "pages": [number]}
|
| 198 |
+
page = _parse_page(
|
| 199 |
+
number, image, output, backend, page_prompt, stage1_prompt
|
| 200 |
+
)
|
| 201 |
+
state.pages.append(page)
|
| 202 |
+
warning = _page_warning(page)
|
| 203 |
+
if warning:
|
| 204 |
+
state.parse_warnings.append(warning)
|
| 205 |
+
|
| 206 |
+
state.chunks = find_chunks([page.markdown for page in state.pages])
|
| 207 |
+
save(
|
| 208 |
+
output / "chunks.json",
|
| 209 |
+
{
|
| 210 |
+
"chunks": state.chunks,
|
| 211 |
+
"basis": "Stage 1 predicted HTML tables only",
|
| 212 |
+
},
|
| 213 |
+
)
|
| 214 |
+
for left, right in _adjacent_pairs(state.chunks):
|
| 215 |
+
context = {"stage": 2, "pages": [left, right]}
|
| 216 |
+
folder = output / "stage2" / f"pair_{left:04d}_{right:04d}"
|
| 217 |
+
folder.mkdir(parents=True)
|
| 218 |
+
pair = _prepare_pair(
|
| 219 |
+
state.pages[left - 1 : right], images, folder, renderer
|
| 220 |
+
)
|
| 221 |
+
tables = _predict_pair(pair, folder, backend, table_prompt)
|
| 222 |
+
_apply_merge(state, pair, tables)
|
| 223 |
+
state.steps.append(
|
| 224 |
+
{
|
| 225 |
+
"pages": list(pair.page_numbers),
|
| 226 |
+
"merged": bool(tables),
|
| 227 |
+
"rendered_pages": sorted(pair.rendered_pages),
|
| 228 |
+
}
|
| 229 |
+
)
|
| 230 |
+
save(
|
| 231 |
+
folder / "result.json",
|
| 232 |
+
{
|
| 233 |
+
"tables": tables,
|
| 234 |
+
"pages": [
|
| 235 |
+
state.pages[number - 1].markdown for number in pair.page_numbers
|
| 236 |
+
],
|
| 237 |
+
"groups": copy.deepcopy(state.merged_tables),
|
| 238 |
+
},
|
| 239 |
+
)
|
| 240 |
+
except Exception as exc:
|
| 241 |
+
error = {
|
| 242 |
+
"type": type(exc).__name__,
|
| 243 |
+
"message": str(exc),
|
| 244 |
+
"recoverable": False,
|
| 245 |
+
**context,
|
| 246 |
+
}
|
| 247 |
+
save(output / "failure.json", {**error, "traceback": traceback.format_exc()})
|
| 248 |
+
return _finalize_document(state, images, output, error)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _parse_page(number, image, output, backend, page_prompt, stage1_prompt):
|
| 252 |
+
"""Retain raw output, apply the existing cleanup, then adapt the page format."""
|
| 253 |
+
folder = output / "stage1" / f"page_{number:04d}"
|
| 254 |
+
folder.mkdir(parents=True)
|
| 255 |
+
generation = GenerationResult(*backend([image], page_prompt, folder, 1))
|
| 256 |
+
raw, reason = generation
|
| 257 |
+
(folder / "raw.txt").write_text(raw)
|
| 258 |
+
cleaned = _clean_truncated_repeats(raw.strip())
|
| 259 |
+
save(
|
| 260 |
+
folder / "generation.json",
|
| 261 |
+
{
|
| 262 |
+
"finish_reason": reason,
|
| 263 |
+
"complete": reason == "stop",
|
| 264 |
+
"tail_changed": cleaned != raw.strip(),
|
| 265 |
+
"raw_chars": len(raw.strip()),
|
| 266 |
+
"cleaned_chars": len(cleaned),
|
| 267 |
+
},
|
| 268 |
+
)
|
| 269 |
+
(folder / "cleaned_raw.txt").write_text(cleaned)
|
| 270 |
+
if reason not in ("stop", "length"):
|
| 271 |
+
raise ValueError(f"Stage 1 page {number}: finish_reason={reason}")
|
| 272 |
+
|
| 273 |
+
diagnostics = None
|
| 274 |
+
if stage1_prompt == "document_structured":
|
| 275 |
+
projected, blocks, diagnostics = structured_to_markdown(cleaned)
|
| 276 |
+
save(folder / "blocks.json", blocks)
|
| 277 |
+
save(folder / "adapter.json", diagnostics)
|
| 278 |
+
(folder / "projected.md").write_text(projected)
|
| 279 |
+
markdown = page_markdown(projected, number) if projected.strip() else ""
|
| 280 |
+
else:
|
| 281 |
+
markdown = page_markdown(cleaned, number)
|
| 282 |
+
(folder / "page.md").write_text(markdown)
|
| 283 |
+
return PageState(number, image, markdown, markdown, reason, diagnostics)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _page_warning(page):
|
| 287 |
+
"""Separate generation truncation, JSON recovery and structured schema issues."""
|
| 288 |
+
codes = []
|
| 289 |
+
if page.finish_reason == "length":
|
| 290 |
+
codes.append("generation_truncated")
|
| 291 |
+
diagnostics = page.parse_diagnostics
|
| 292 |
+
if diagnostics is not None:
|
| 293 |
+
if not diagnostics["json_complete"]:
|
| 294 |
+
codes.append("json_incomplete")
|
| 295 |
+
if diagnostics["schema_issues"]:
|
| 296 |
+
codes.append("schema_invalid")
|
| 297 |
+
if (
|
| 298 |
+
diagnostics["invalid_content_objects"]
|
| 299 |
+
or not diagnostics["emitted_contents"]
|
| 300 |
+
):
|
| 301 |
+
codes.append("content_invalid")
|
| 302 |
+
if not codes:
|
| 303 |
+
return None
|
| 304 |
+
warning = {"page": page.number, "finish_reason": page.finish_reason, "codes": codes}
|
| 305 |
+
if diagnostics is not None:
|
| 306 |
+
warning["diagnostics"] = diagnostics
|
| 307 |
+
return warning
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _adjacent_pairs(chunks):
|
| 311 |
+
"""Yield (2, 3), then (1, 2) for a three-page table-bearing run."""
|
| 312 |
+
for chunk in chunks:
|
| 313 |
+
for left in range(chunk[-1] - 1, chunk[0] - 1, -1):
|
| 314 |
+
yield left, left + 1
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _prepare_pair(pages, images, folder, renderer):
|
| 318 |
+
page_numbers = tuple(page.number for page in pages)
|
| 319 |
+
document_to_local = {number: local for local, number in enumerate(page_numbers, 1)}
|
| 320 |
+
paths, markdowns, aliases, rendered_pages = [], [], {}, set()
|
| 321 |
+
for page in pages:
|
| 322 |
+
path, markdown = page.image_path, page.markdown
|
| 323 |
+
if markdown != page.original_markdown:
|
| 324 |
+
rendered = RenderResult(
|
| 325 |
+
*renderer.render(
|
| 326 |
+
markdown, page.number, images, folder, f"page_{page.number:04d}"
|
| 327 |
+
)
|
| 328 |
+
)
|
| 329 |
+
path, markdown = rendered.image_path, rendered.rewritten_source
|
| 330 |
+
aliases.update(rendered.alias_to_original)
|
| 331 |
+
rendered_pages.add(page.number)
|
| 332 |
+
paths.append(path)
|
| 333 |
+
markdowns.append(remap(markdown, document_to_local))
|
| 334 |
+
return PairInput(page_numbers, paths, markdowns, aliases, rendered_pages)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _predict_pair(pair, folder, backend, table_prompt):
|
| 338 |
+
page_context = "\n\n".join(
|
| 339 |
+
f'<INPUT_PAGE index="{index}">\n{markdown}\n</INPUT_PAGE>'
|
| 340 |
+
for index, markdown in enumerate(pair.markdowns, 1)
|
| 341 |
+
)
|
| 342 |
+
prompt = table_prompt.removesuffix("\n") + "\n\nInput Markdown:\n\n" + page_context
|
| 343 |
+
if all(TABLE.search(markdown) for markdown in pair.markdowns):
|
| 344 |
+
generation = GenerationResult(*backend(pair.image_paths, prompt, folder, 2))
|
| 345 |
+
else:
|
| 346 |
+
generation = GenerationResult(
|
| 347 |
+
json.dumps([{"source_pages": [], "html": NEGATIVE}]), "stop"
|
| 348 |
+
)
|
| 349 |
+
save(
|
| 350 |
+
folder / "gate_skip.json",
|
| 351 |
+
{"reason": "README table gate", "model_called": False},
|
| 352 |
+
)
|
| 353 |
+
(folder / "raw.txt").write_text(generation.text)
|
| 354 |
+
save(folder / "generation.json", {"finish_reason": generation.finish_reason})
|
| 355 |
+
if generation.finish_reason != "stop":
|
| 356 |
+
raise ValueError(
|
| 357 |
+
f"Stage 2 pair {list(pair.page_numbers)}: finish_reason={generation.finish_reason}"
|
| 358 |
+
)
|
| 359 |
+
tables = parse_tables(generation.text)
|
| 360 |
+
if len(tables) > 1:
|
| 361 |
+
raise ValueError(
|
| 362 |
+
"Multiple merges need explicit fragment bindings; output retained."
|
| 363 |
+
)
|
| 364 |
+
return [
|
| 365 |
+
dict(table, html=_restore_image_refs(table["html"], pair)) for table in tables
|
| 366 |
+
]
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _restore_image_refs(table_html, pair):
|
| 370 |
+
"""Restore document page IDs, then resolve crops from re-rendered pages exactly."""
|
| 371 |
+
local_to_document = {
|
| 372 |
+
local: number for local, number in enumerate(pair.page_numbers, 1)
|
| 373 |
+
}
|
| 374 |
+
table_html = remap(table_html, local_to_document)
|
| 375 |
+
|
| 376 |
+
def restore(match):
|
| 377 |
+
reference = match[0]
|
| 378 |
+
if match[1] is None:
|
| 379 |
+
raise ValueError("Output crop lacks a page number.")
|
| 380 |
+
if int(match[1]) in pair.rendered_pages:
|
| 381 |
+
if reference not in pair.alias_to_original:
|
| 382 |
+
raise ValueError("Unmappable crop from a rendered page: " + reference)
|
| 383 |
+
return pair.alias_to_original[reference]
|
| 384 |
+
return reference
|
| 385 |
+
|
| 386 |
+
return IMAGE_REF.sub(restore, table_html)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def _apply_merge(state, pair, tables):
|
| 390 |
+
"""Validate writeback before updating page contents or fragment provenance."""
|
| 391 |
+
earlier, later = (state.pages[number - 1] for number in pair.page_numbers)
|
| 392 |
+
before = (earlier.markdown, later.markdown)
|
| 393 |
+
after = apply_pair(before, tables)
|
| 394 |
+
if any(TABLE.sub("", old) != TABLE.sub("", new) for old, new in zip(before, after)):
|
| 395 |
+
raise ValueError("Writeback changed non-table content.")
|
| 396 |
+
if tables:
|
| 397 |
+
sources = earlier.table_sources[-1] | later.table_sources[0]
|
| 398 |
+
state.merged_tables = [
|
| 399 |
+
group
|
| 400 |
+
for group in state.merged_tables
|
| 401 |
+
if not sources.intersection(map(tuple, group["fragment_ids"]))
|
| 402 |
+
]
|
| 403 |
+
state.merged_tables.append(
|
| 404 |
+
{
|
| 405 |
+
"source_pages": sorted({number for number, _ in sources}),
|
| 406 |
+
"fragment_ids": sorted(sources),
|
| 407 |
+
"html": tables[0]["html"],
|
| 408 |
+
}
|
| 409 |
+
)
|
| 410 |
+
earlier.table_sources[-1] = sources
|
| 411 |
+
del later.table_sources[0]
|
| 412 |
+
earlier.markdown, later.markdown = after
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def _save_document_crops(markdown, images, output):
|
| 416 |
+
"""Materialize page-qualified references from the original input images."""
|
| 417 |
+
from PIL import Image
|
| 418 |
+
|
| 419 |
+
errors = []
|
| 420 |
+
references = {}
|
| 421 |
+
for match in IMAGE_REF.finditer(markdown):
|
| 422 |
+
references.setdefault(match[1], {})[match[0]] = match.groups()[1:]
|
| 423 |
+
for page, crops in references.items():
|
| 424 |
+
try:
|
| 425 |
+
if page is None or not 1 <= int(page) <= len(images):
|
| 426 |
+
raise ValueError("Image reference needs a valid source page number")
|
| 427 |
+
with Image.open(images[int(page) - 1]) as source:
|
| 428 |
+
for reference, coordinates in crops.items():
|
| 429 |
+
try:
|
| 430 |
+
left, top, right, bottom = map(int, coordinates)
|
| 431 |
+
width, height = source.size
|
| 432 |
+
box = (
|
| 433 |
+
max(0, min(width, round(left * width / 1000))),
|
| 434 |
+
max(0, min(height, round(top * height / 1000))),
|
| 435 |
+
max(0, min(width, round(right * width / 1000))),
|
| 436 |
+
max(0, min(height, round(bottom * height / 1000))),
|
| 437 |
+
)
|
| 438 |
+
if box[2] <= box[0] or box[3] <= box[1]:
|
| 439 |
+
raise ValueError("Image crop has zero or negative pixel area")
|
| 440 |
+
target = output / reference
|
| 441 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 442 |
+
source.crop(box).convert("RGB").save(target)
|
| 443 |
+
except Exception as exc:
|
| 444 |
+
errors.append({"reference": reference, "message": str(exc)})
|
| 445 |
+
except Exception as exc:
|
| 446 |
+
errors.extend(
|
| 447 |
+
{"reference": reference, "message": str(exc)} for reference in crops
|
| 448 |
+
)
|
| 449 |
+
return errors
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _finalize_document(state, images, output, error):
|
| 453 |
+
document = "\n\n".join(page.markdown for page in state.pages) + "\n"
|
| 454 |
+
crop_errors = _save_document_crops(document, images, output)
|
| 455 |
+
for page in state.pages:
|
| 456 |
+
target = output / "pages" / f"page_{page.number:04d}.md"
|
| 457 |
+
target.parent.mkdir(exist_ok=True)
|
| 458 |
+
# Per-page Markdown is one directory below the shared crop assets.
|
| 459 |
+
target.write_text(IMAGE_REF.sub(lambda m: "../" + m[0], page.markdown))
|
| 460 |
+
(output / "document.md").write_text(document)
|
| 461 |
+
if crop_errors and error is None:
|
| 462 |
+
error = {"type": "ImageCropExportFailed", "recoverable": False}
|
| 463 |
+
save(output / "failure.json", {**error, "image_export_errors": crop_errors})
|
| 464 |
+
if error is None and state.parse_warnings:
|
| 465 |
+
error = {
|
| 466 |
+
"type": "Stage1OutputIncomplete",
|
| 467 |
+
"recoverable": True,
|
| 468 |
+
"pages": [warning["page"] for warning in state.parse_warnings],
|
| 469 |
+
}
|
| 470 |
+
result = {
|
| 471 |
+
"status": "COMPLETE" if error is None else "PARTIAL",
|
| 472 |
+
"error": error,
|
| 473 |
+
"input_pages": len(images),
|
| 474 |
+
"parsed_pages": len(state.pages),
|
| 475 |
+
"chunks": state.chunks,
|
| 476 |
+
"steps": state.steps,
|
| 477 |
+
"merged_tables": state.merged_tables,
|
| 478 |
+
"stage1_parse_warnings": state.parse_warnings,
|
| 479 |
+
"image_export_errors": crop_errors,
|
| 480 |
+
}
|
| 481 |
+
save(output / "result.json", result)
|
| 482 |
+
return result
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
# Reusable text adapters and table operations (standard library only).
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def _document_images(image_paths):
|
| 489 |
+
images = [str(Path(path).resolve()) for path in image_paths]
|
| 490 |
+
if not images or not all(Path(path).is_file() for path in images):
|
| 491 |
+
raise ValueError("Provide existing page images in reading order.")
|
| 492 |
+
return images
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def save(path, value):
|
| 496 |
+
"""Write a JSON artifact, creating its parent directory when needed."""
|
| 497 |
+
path = Path(path)
|
| 498 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 499 |
+
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def _is_valid_bbox(value):
|
| 503 |
+
if not isinstance(value, list) or len(value) != 4:
|
| 504 |
+
return False
|
| 505 |
+
if not all(type(coordinate) is int for coordinate in value):
|
| 506 |
+
return False
|
| 507 |
+
left, top, right, bottom = value
|
| 508 |
+
return 0 <= left <= right < 1000 and 0 <= top <= bottom < 1000
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
def _recover_json_prefix(text):
|
| 512 |
+
"""Return only complete prefix objects; never guess or repair JSON syntax."""
|
| 513 |
+
blocks, unparsed_tail_chars = [], 0
|
| 514 |
+
if not text.startswith("["):
|
| 515 |
+
return blocks, unparsed_tail_chars
|
| 516 |
+
position = 1
|
| 517 |
+
decoder = json.JSONDecoder()
|
| 518 |
+
while position < len(text):
|
| 519 |
+
while position < len(text) and text[position].isspace():
|
| 520 |
+
position += 1
|
| 521 |
+
if position == len(text) or text[position] == "]":
|
| 522 |
+
break
|
| 523 |
+
try:
|
| 524 |
+
block, end = decoder.raw_decode(text, position)
|
| 525 |
+
except json.JSONDecodeError:
|
| 526 |
+
unparsed_tail_chars = len(text) - position
|
| 527 |
+
break
|
| 528 |
+
blocks.append(block)
|
| 529 |
+
position = end
|
| 530 |
+
while position < len(text) and text[position].isspace():
|
| 531 |
+
position += 1
|
| 532 |
+
if position < len(text) and text[position] == ",":
|
| 533 |
+
position += 1
|
| 534 |
+
elif position < len(text) and text[position] != "]":
|
| 535 |
+
unparsed_tail_chars = len(text) - position
|
| 536 |
+
break
|
| 537 |
+
return blocks, unparsed_tail_chars
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def structured_to_markdown(text):
|
| 541 |
+
"""Project content in emitted order, retaining diagnostics for incomplete JSON.
|
| 542 |
+
|
| 543 |
+
Preserve every category and keep only complete prefix objects on JSON failure.
|
| 544 |
+
Invalid bbox/category fields are reported without discarding valid content.
|
| 545 |
+
"""
|
| 546 |
+
stripped = text.strip()
|
| 547 |
+
if stripped.startswith("```") and stripped.endswith("```"):
|
| 548 |
+
stripped = re.sub(r"^```(?:json)?\s*\n|\n```$", "", stripped, flags=re.I)
|
| 549 |
+
diagnostics = {
|
| 550 |
+
"json_complete": False,
|
| 551 |
+
"schema_issues": [],
|
| 552 |
+
"invalid_content_objects": [],
|
| 553 |
+
"unparsed_tail_chars": 0,
|
| 554 |
+
}
|
| 555 |
+
try:
|
| 556 |
+
blocks = json.loads(stripped)
|
| 557 |
+
if not isinstance(blocks, list):
|
| 558 |
+
raise ValueError("Expected one JSON array")
|
| 559 |
+
diagnostics["json_complete"] = True
|
| 560 |
+
except ValueError as exc:
|
| 561 |
+
diagnostics["parse_error"] = str(exc)
|
| 562 |
+
blocks, diagnostics["unparsed_tail_chars"] = _recover_json_prefix(stripped)
|
| 563 |
+
|
| 564 |
+
contents = []
|
| 565 |
+
for index, block in enumerate(blocks):
|
| 566 |
+
if not isinstance(block, dict) or not isinstance(block.get("content"), str):
|
| 567 |
+
diagnostics["invalid_content_objects"].append(index)
|
| 568 |
+
continue
|
| 569 |
+
contents.append(block["content"])
|
| 570 |
+
if not _is_valid_bbox(block.get("bbox")):
|
| 571 |
+
diagnostics["schema_issues"].append({"index": index, "field": "bbox"})
|
| 572 |
+
if (
|
| 573 |
+
not isinstance(block.get("category"), str)
|
| 574 |
+
or block["category"] not in CATEGORIES
|
| 575 |
+
):
|
| 576 |
+
diagnostics["schema_issues"].append({"index": index, "field": "category"})
|
| 577 |
+
diagnostics.update(
|
| 578 |
+
parsed_objects=len(blocks),
|
| 579 |
+
emitted_contents=len(contents),
|
| 580 |
+
policy="join content in emitted array order; preserve every category; retain complete prefix objects only",
|
| 581 |
+
)
|
| 582 |
+
diagnostics["valid"] = (
|
| 583 |
+
diagnostics["json_complete"]
|
| 584 |
+
and not diagnostics["schema_issues"]
|
| 585 |
+
and not diagnostics["invalid_content_objects"]
|
| 586 |
+
and bool(contents)
|
| 587 |
+
)
|
| 588 |
+
return "\n\n".join(contents), blocks, diagnostics
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def _clean_truncated_repeats(
|
| 592 |
+
text: str,
|
| 593 |
+
min_text_len: int = 8000,
|
| 594 |
+
max_period: int = 200,
|
| 595 |
+
min_period: int = 1,
|
| 596 |
+
min_repeat_chars: int = 100,
|
| 597 |
+
min_repeat_times: int = 5,
|
| 598 |
+
) -> str:
|
| 599 |
+
n = len(text)
|
| 600 |
+
if n < min_text_len:
|
| 601 |
+
return text
|
| 602 |
+
|
| 603 |
+
max_period = min(max_period, n - 1)
|
| 604 |
+
for unit_len in range(min_period, max_period + 1):
|
| 605 |
+
if text[n - 1] != text[n - 1 - unit_len]:
|
| 606 |
+
continue
|
| 607 |
+
|
| 608 |
+
match_len = 1
|
| 609 |
+
idx = n - 2
|
| 610 |
+
while idx >= unit_len and text[idx] == text[idx - unit_len]:
|
| 611 |
+
match_len += 1
|
| 612 |
+
idx -= 1
|
| 613 |
+
|
| 614 |
+
total_len = match_len + unit_len
|
| 615 |
+
repeat_times = total_len // unit_len
|
| 616 |
+
tail_len = total_len % unit_len
|
| 617 |
+
|
| 618 |
+
if repeat_times >= min_repeat_times and total_len >= min_repeat_chars:
|
| 619 |
+
return text[: n - total_len + unit_len] + text[n - tail_len :]
|
| 620 |
+
|
| 621 |
+
return text
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def parse_tables(raw: str) -> list[dict]:
|
| 625 |
+
"""Return all positive objects or [] for the exact negative sentinel."""
|
| 626 |
+
items = json.loads(raw)
|
| 627 |
+
if not isinstance(items, list) or not items:
|
| 628 |
+
raise ValueError("Expected a nonempty JSON array.")
|
| 629 |
+
for item in items:
|
| 630 |
+
if not isinstance(item, dict) or set(item) != {"source_pages", "html"}:
|
| 631 |
+
raise ValueError("Each object needs exactly source_pages and html.")
|
| 632 |
+
if not isinstance(item["source_pages"], list) or not isinstance(
|
| 633 |
+
item["html"], str
|
| 634 |
+
):
|
| 635 |
+
raise ValueError("Invalid field types.")
|
| 636 |
+
if not item["source_pages"]:
|
| 637 |
+
if len(items) == 1 and item["html"] == NEGATIVE:
|
| 638 |
+
return []
|
| 639 |
+
raise ValueError("Invalid or mixed negative sentinel.")
|
| 640 |
+
if item["source_pages"] != [1, 2] or any(
|
| 641 |
+
type(p) is not int for p in item["source_pages"]
|
| 642 |
+
):
|
| 643 |
+
raise ValueError("A positive two-page result must use [1, 2].")
|
| 644 |
+
if not TABLE.fullmatch(item["html"].strip()):
|
| 645 |
+
raise ValueError("Expected complete HTML table output.")
|
| 646 |
+
return items
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def apply_pair(
|
| 650 |
+
markdowns: tuple[str, str],
|
| 651 |
+
tables: list[dict],
|
| 652 |
+
bindings: list[tuple[int, int]] | None = None,
|
| 653 |
+
) -> tuple[str, str]:
|
| 654 |
+
"""Replace page 1's last table and remove page 2's first table by default.
|
| 655 |
+
|
| 656 |
+
Multiple merged tables require explicit zero-based fragment bindings.
|
| 657 |
+
Nested or incomplete HTML tables are rejected; non-table source is retained.
|
| 658 |
+
"""
|
| 659 |
+
if len(markdowns) != 2:
|
| 660 |
+
raise ValueError("Need exactly two pages.")
|
| 661 |
+
spans = [list(TABLE.finditer(md)) for md in markdowns]
|
| 662 |
+
if bindings is None:
|
| 663 |
+
if not tables:
|
| 664 |
+
return tuple(markdowns)
|
| 665 |
+
if len(tables) != 1:
|
| 666 |
+
raise ValueError("Multiple merged tables require explicit source bindings.")
|
| 667 |
+
bindings = [(len(spans[0]) - 1, 0)]
|
| 668 |
+
if len(tables) != len(bindings):
|
| 669 |
+
raise ValueError("Need one explicit binding per merged table.")
|
| 670 |
+
for md, matches in zip(markdowns, spans):
|
| 671 |
+
if len(re.findall(r"<table\b", md, re.I)) != len(matches) or len(
|
| 672 |
+
re.findall(r"</table\s*>", md, re.I)
|
| 673 |
+
) != len(matches):
|
| 674 |
+
raise ValueError(
|
| 675 |
+
"Nested or incomplete tables require a layout-aware editor."
|
| 676 |
+
)
|
| 677 |
+
edits = [{}, {}]
|
| 678 |
+
for item, binding in zip(tables, bindings):
|
| 679 |
+
parse_tables(json.dumps([item]))
|
| 680 |
+
if len(re.findall(r"<table\b", item["html"], re.I)) != 1:
|
| 681 |
+
raise ValueError(
|
| 682 |
+
"Nested or multiple tables cannot be written into one fragment."
|
| 683 |
+
)
|
| 684 |
+
if item["source_pages"] != [1, 2] or len(binding) != 2:
|
| 685 |
+
raise ValueError("Only positive two-page tables can be written back.")
|
| 686 |
+
for page, index in enumerate(binding):
|
| 687 |
+
if type(index) is not int or not 0 <= index < len(spans[page]):
|
| 688 |
+
raise ValueError("Source table index out of range.")
|
| 689 |
+
if index in edits[page]:
|
| 690 |
+
raise ValueError("Two results claim the same source fragment.")
|
| 691 |
+
edits[page][index] = item["html"] if page == 0 else ""
|
| 692 |
+
output = []
|
| 693 |
+
for page, md in enumerate(markdowns):
|
| 694 |
+
for index in sorted(edits[page], reverse=True):
|
| 695 |
+
span = spans[page][index]
|
| 696 |
+
md = md[: span.start()] + edits[page][index] + md[span.end() :]
|
| 697 |
+
output.append(md)
|
| 698 |
+
return tuple(output)
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def find_chunks(pages):
|
| 702 |
+
"""Maximal runs of >=2 pages with predicted HTML tables; 1-based IDs."""
|
| 703 |
+
chunks, run = [], []
|
| 704 |
+
for page, markdown in enumerate(pages, 1):
|
| 705 |
+
if TABLE.search(markdown):
|
| 706 |
+
run.append(page)
|
| 707 |
+
else:
|
| 708 |
+
if len(run) >= 2:
|
| 709 |
+
chunks.append(run)
|
| 710 |
+
run = []
|
| 711 |
+
if len(run) >= 2:
|
| 712 |
+
chunks.append(run)
|
| 713 |
+
return chunks
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def remap(markdown: str, mapping: Mapping[int, int]) -> str:
|
| 717 |
+
def replace(match):
|
| 718 |
+
page = int(match[1])
|
| 719 |
+
if page not in mapping:
|
| 720 |
+
raise ValueError(f"Image page {page} is outside the current pair.")
|
| 721 |
+
return f"page_{mapping[page]}/images/"
|
| 722 |
+
|
| 723 |
+
return REF.sub(replace, markdown)
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def page_markdown(raw, page):
|
| 727 |
+
"""Accept Markdown, optionally fenced; qualify page-local crop references."""
|
| 728 |
+
markdown = re.sub(
|
| 729 |
+
r"^```(?:markdown|html)?\s*\n|\n```\s*$", "", raw.strip(), flags=re.I
|
| 730 |
+
)
|
| 731 |
+
if not markdown:
|
| 732 |
+
raise ValueError("Empty Stage 1 Markdown.")
|
| 733 |
+
# Each Stage 1 call contains one image, so crop IDs refer to this page.
|
| 734 |
+
return IMAGE_REF.sub(
|
| 735 |
+
lambda m: f"page_{page}/images/bbox_" + "_".join(m.groups()[1:]) + ".jpg",
|
| 736 |
+
markdown,
|
| 737 |
+
)
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
# Model inference and lazy browser lifecycle.
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
class VllmBackend:
|
| 744 |
+
"""Keep engine, sampling and request settings aligned with README infer().
|
| 745 |
+
|
| 746 |
+
Benchmark-specific scheduling overrides do not belong in this backend.
|
| 747 |
+
"""
|
| 748 |
+
|
| 749 |
+
def __init__(self, model_path):
|
| 750 |
+
from vllm import LLM, SamplingParams
|
| 751 |
+
|
| 752 |
+
self.model = LLM(
|
| 753 |
+
model=str(Path(model_path).resolve()),
|
| 754 |
+
tensor_parallel_size=1,
|
| 755 |
+
gpu_memory_utilization=0.8,
|
| 756 |
+
gdn_prefill_backend="triton",
|
| 757 |
+
limit_mm_per_prompt={"image": 2},
|
| 758 |
+
)
|
| 759 |
+
self.tokenizer = self.model.get_tokenizer()
|
| 760 |
+
self.sampling = SamplingParams(max_tokens=16384, temperature=0.0)
|
| 761 |
+
|
| 762 |
+
def __call__(self, paths, task_prompt, folder, stage):
|
| 763 |
+
from PIL import Image
|
| 764 |
+
|
| 765 |
+
images = []
|
| 766 |
+
for path in paths:
|
| 767 |
+
with Image.open(path) as image:
|
| 768 |
+
images.append(image.convert("RGB"))
|
| 769 |
+
content = [{"type": "image"} for _ in images]
|
| 770 |
+
content.append({"type": "text", "text": task_prompt})
|
| 771 |
+
prompt = self.tokenizer.apply_chat_template(
|
| 772 |
+
[{"role": "user", "content": content}],
|
| 773 |
+
tokenize=False,
|
| 774 |
+
add_generation_prompt=True,
|
| 775 |
+
enable_thinking=False,
|
| 776 |
+
)
|
| 777 |
+
request = {
|
| 778 |
+
"prompt": prompt,
|
| 779 |
+
"multi_modal_data": {"image": images},
|
| 780 |
+
"mm_processor_kwargs": {
|
| 781 |
+
"images_kwargs": {
|
| 782 |
+
"min_pixels": 448 * 448,
|
| 783 |
+
"max_pixels": 2880 * 2880,
|
| 784 |
+
}
|
| 785 |
+
},
|
| 786 |
+
}
|
| 787 |
+
save(
|
| 788 |
+
folder / "input.json",
|
| 789 |
+
{
|
| 790 |
+
"stage": stage,
|
| 791 |
+
"images": list(paths),
|
| 792 |
+
"prompt": prompt,
|
| 793 |
+
"mm_processor_kwargs": request["mm_processor_kwargs"],
|
| 794 |
+
},
|
| 795 |
+
)
|
| 796 |
+
output = self.model.generate([request], self.sampling)[0].outputs[0]
|
| 797 |
+
return GenerationResult(output.text, output.finish_reason)
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
class _BrowserRenderer:
|
| 801 |
+
def __init__(self, mathjax):
|
| 802 |
+
self.mathjax = mathjax
|
| 803 |
+
self.playwright = self.browser = self.renderer = None
|
| 804 |
+
|
| 805 |
+
def render(self, *args):
|
| 806 |
+
if self.renderer is None:
|
| 807 |
+
from playwright.sync_api import sync_playwright
|
| 808 |
+
|
| 809 |
+
self.playwright = sync_playwright().start()
|
| 810 |
+
self.browser = self.playwright.chromium.launch(headless=True)
|
| 811 |
+
self.renderer = Renderer(self.browser, self.mathjax)
|
| 812 |
+
return self.renderer.render(*args)
|
| 813 |
+
|
| 814 |
+
def close(self):
|
| 815 |
+
if self.browser:
|
| 816 |
+
self.browser.close()
|
| 817 |
+
if self.playwright:
|
| 818 |
+
self.playwright.stop()
|
| 819 |
+
|
| 820 |
+
|
| 821 |
+
# Content-preserving Markdown/HTML rendering.
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
CROP = re.compile(r"page_(\d+)/images/bbox_(\d+)_(\d+)_(\d+)_(\d+)\.jpg")
|
| 825 |
+
_CROP_ASSET = re.compile(
|
| 826 |
+
r"(?:page_(\d+)/)?images/(?:bbox_)?(\d+)_(\d+)_(\d+)_(\d+)\.(?:jpg|jpeg|png)", re.I
|
| 827 |
+
)
|
| 828 |
+
_TAG = re.compile(
|
| 829 |
+
r"<!--.*?-->|</?[A-Za-z][\w:-]*(?:[^>\'\"]|\"[^\"]*\"|\'[^\']*\')*>", re.S
|
| 830 |
+
)
|
| 831 |
+
_SRC = re.compile(r"""(?<![\w:-])src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I)
|
| 832 |
+
_ALLOWED = set(
|
| 833 |
+
"p div span br hr h1 h2 h3 h4 h5 h6 table thead tbody tfoot tr td th caption colgroup col ul ol li dl dt dd blockquote pre code b strong em i u s del sup sub a img figure figcaption section article main center font".split()
|
| 834 |
+
)
|
| 835 |
+
_DROP = set("script style iframe object embed link meta base template".split())
|
| 836 |
+
_CSS = """
|
| 837 |
+
html,body{margin:0;padding:0;background:#fff;color:#111}
|
| 838 |
+
#capture{padding:28px;box-sizing:border-box;font-family:Arial,'Noto Sans CJK SC',sans-serif;font-size:18px;line-height:1.4}
|
| 839 |
+
table{border-collapse:collapse;width:100%;margin:12px 0}td,th{border:1px solid #666;padding:5px;vertical-align:top;overflow-wrap:anywhere}
|
| 840 |
+
img{max-width:100%;height:auto}h1{font-size:28px}h2{font-size:24px}h3{font-size:21px}p{margin:10px 0}
|
| 841 |
+
pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}code{font-family:monospace;white-space:pre-wrap;overflow-wrap:anywhere}
|
| 842 |
+
.math-display{display:block;text-align:center;margin:10px 0}.math-inline{display:inline-block;vertical-align:middle}.math-fallback{white-space:pre-wrap;overflow-wrap:anywhere}
|
| 843 |
+
mjx-container{max-width:none}svg{overflow:visible}th{font-weight:600}
|
| 844 |
+
"""
|
| 845 |
+
|
| 846 |
+
|
| 847 |
+
@dataclass(frozen=True)
|
| 848 |
+
class RenderLimits:
|
| 849 |
+
"""Configurable engineering limits, not validated OCR accuracy thresholds."""
|
| 850 |
+
|
| 851 |
+
max_side: int = 32768
|
| 852 |
+
max_render_pixels: int = 64_000_000
|
| 853 |
+
max_source_chars: int = 4_000_000
|
| 854 |
+
min_effective_font_px: float = 12.0
|
| 855 |
+
min_effective_cell_height_px: float = 8.0
|
| 856 |
+
timeout_ms: int = 30000
|
| 857 |
+
|
| 858 |
+
def __post_init__(self):
|
| 859 |
+
for name, value in asdict(self).items():
|
| 860 |
+
if not math.isfinite(value) or value <= 0:
|
| 861 |
+
raise ValueError(name + " must be finite and positive")
|
| 862 |
+
|
| 863 |
+
|
| 864 |
+
class RenderError(ValueError):
|
| 865 |
+
"""Concise caller-facing error; details remain in the render.json sidecar."""
|
| 866 |
+
|
| 867 |
+
def __init__(self, code, message):
|
| 868 |
+
self.code = code
|
| 869 |
+
super().__init__(message)
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
def _escaped(text, pos):
|
| 873 |
+
start = pos
|
| 874 |
+
while start and text[start - 1] == "\\":
|
| 875 |
+
start -= 1
|
| 876 |
+
return (pos - start) % 2 == 1
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
def _dollar_is_math(body, mode):
|
| 880 |
+
if mode == "all":
|
| 881 |
+
return True
|
| 882 |
+
if mode == "off" or not body or body != body.strip():
|
| 883 |
+
return False
|
| 884 |
+
if re.search(r"\\[A-Za-z]+", body):
|
| 885 |
+
return True
|
| 886 |
+
# Pure numbers and prose are ambiguous; preserve them, rather than invent math.
|
| 887 |
+
if re.fullmatch(r"[A-Za-z]", body):
|
| 888 |
+
return True
|
| 889 |
+
if re.search(r"[^\x00-\x7f]", body):
|
| 890 |
+
return bool(
|
| 891 |
+
re.fullmatch(r"[\w\s\d.,_^=+*/<>{}()−-]+", body)
|
| 892 |
+
and re.search(r"[_^=+*/<>−-]", body)
|
| 893 |
+
)
|
| 894 |
+
if re.fullmatch(r"\d+(?:\.\d+)?\\[,;!: ](?:[A-Za-z]+)", body):
|
| 895 |
+
return True
|
| 896 |
+
words = re.findall(r"[A-Za-z]+", body)
|
| 897 |
+
if any(
|
| 898 |
+
len(w) > 1
|
| 899 |
+
and w not in {"sin", "cos", "tan", "log", "ln", "exp", "max", "min", "lim"}
|
| 900 |
+
for w in words
|
| 901 |
+
):
|
| 902 |
+
return False
|
| 903 |
+
return bool(re.search(r"[_^=+*/<>{}]", body) or re.search(r"\w\s*-\s*\w", body))
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
def _markdown_image(text, pos, parser, env):
|
| 907 |
+
from markdown_it.common.utils import normalizeReference
|
| 908 |
+
from markdown_it.helpers import parseLinkDestination, parseLinkLabel, parseLinkTitle
|
| 909 |
+
from markdown_it.rules_inline.state_inline import StateInline
|
| 910 |
+
|
| 911 |
+
state = StateInline(text, parser, env, [])
|
| 912 |
+
end_label = parseLinkLabel(state, pos + 1, False)
|
| 913 |
+
if end_label < 0:
|
| 914 |
+
return None
|
| 915 |
+
alt = text[pos + 2 : end_label]
|
| 916 |
+
cursor = end_label + 1
|
| 917 |
+
title = ""
|
| 918 |
+
if cursor < len(text) and text[cursor] == "(":
|
| 919 |
+
cursor += 1
|
| 920 |
+
while cursor < len(text) and text[cursor].isspace():
|
| 921 |
+
cursor += 1
|
| 922 |
+
dest = parseLinkDestination(text, cursor, len(text))
|
| 923 |
+
if not dest.ok:
|
| 924 |
+
return None
|
| 925 |
+
ref, cursor = dest.str, dest.pos
|
| 926 |
+
spaces = cursor
|
| 927 |
+
while cursor < len(text) and text[cursor].isspace():
|
| 928 |
+
cursor += 1
|
| 929 |
+
if cursor > spaces:
|
| 930 |
+
parsed_title = parseLinkTitle(text, cursor, len(text))
|
| 931 |
+
if parsed_title.ok:
|
| 932 |
+
title, cursor = parsed_title.str, parsed_title.pos
|
| 933 |
+
while cursor < len(text) and text[cursor].isspace():
|
| 934 |
+
cursor += 1
|
| 935 |
+
if cursor >= len(text) or text[cursor] != ")":
|
| 936 |
+
return None
|
| 937 |
+
end = cursor + 1
|
| 938 |
+
else:
|
| 939 |
+
label, end = alt, cursor
|
| 940 |
+
if cursor < len(text) and text[cursor] == "[":
|
| 941 |
+
close = text.find("]", cursor + 1)
|
| 942 |
+
if close < 0:
|
| 943 |
+
return None
|
| 944 |
+
label = text[cursor + 1 : close] or alt
|
| 945 |
+
end = close + 1
|
| 946 |
+
definition = env.get("references", {}).get(normalizeReference(label))
|
| 947 |
+
if not definition:
|
| 948 |
+
return None
|
| 949 |
+
ref, title = definition["href"], definition.get("title", "")
|
| 950 |
+
return end, ref, alt, title
|
| 951 |
+
|
| 952 |
+
|
| 953 |
+
def _image_markup(item, ref):
|
| 954 |
+
if item["kind"] == "html":
|
| 955 |
+
return html.escape(ref, quote=True)
|
| 956 |
+
title = (
|
| 957 |
+
(' "' + item["title"].replace("\\", "\\\\").replace('"', '\\"') + '"')
|
| 958 |
+
if item["title"]
|
| 959 |
+
else ""
|
| 960 |
+
)
|
| 961 |
+
return "![" + item["alt"] + "](<" + ref + ">" + title + ")"
|
| 962 |
+
|
| 963 |
+
|
| 964 |
+
def _edit_source(text, edits):
|
| 965 |
+
out, previous = [], 0
|
| 966 |
+
for start, end, value in sorted(edits):
|
| 967 |
+
if start < previous:
|
| 968 |
+
raise RenderError(
|
| 969 |
+
"overlapping_source_spans",
|
| 970 |
+
"Cannot safely map overlapping source elements.",
|
| 971 |
+
)
|
| 972 |
+
out.extend([text[previous:start], value])
|
| 973 |
+
previous = end
|
| 974 |
+
out.append(text[previous:])
|
| 975 |
+
return "".join(out)
|
| 976 |
+
|
| 977 |
+
|
| 978 |
+
def prepare_source(source, parser, dollar_math="auto", html_document=False):
|
| 979 |
+
"""Protect math before CommonMark and identify each actual image occurrence.
|
| 980 |
+
|
| 981 |
+
Code fences, indented code, inline code, HTML attributes and comments remain
|
| 982 |
+
literal. Markdown image references are expanded per occurrence, not per URL.
|
| 983 |
+
"""
|
| 984 |
+
from markdown_it.helpers import parseLinkDestination, parseLinkTitle
|
| 985 |
+
|
| 986 |
+
prefix = "RDR" + uuid.uuid4().hex.upper()
|
| 987 |
+
env = {}
|
| 988 |
+
tokens = [] if html_document else parser.parse(source, env)
|
| 989 |
+
offsets = [0]
|
| 990 |
+
for line in source.splitlines(keepends=True):
|
| 991 |
+
offsets.append(offsets[-1] + len(line))
|
| 992 |
+
literal = {
|
| 993 |
+
offsets[t.map[0]]: offsets[min(t.map[1], len(offsets) - 1)]
|
| 994 |
+
for t in tokens
|
| 995 |
+
if t.type in {"fence", "code_block"} and t.map
|
| 996 |
+
}
|
| 997 |
+
for definition in env.get("references", {}).values():
|
| 998 |
+
if definition.get("map"):
|
| 999 |
+
a, b = definition["map"]
|
| 1000 |
+
literal[offsets[a]] = offsets[min(b, len(offsets) - 1)]
|
| 1001 |
+
formulas, images, edits, notes = [], [], [], []
|
| 1002 |
+
i = 0
|
| 1003 |
+
while i < len(source):
|
| 1004 |
+
if i in literal:
|
| 1005 |
+
i = literal[i]
|
| 1006 |
+
continue
|
| 1007 |
+
if source[i] == "`" and not _escaped(source, i):
|
| 1008 |
+
run = re.match(r"`+", source[i:])[0]
|
| 1009 |
+
close = re.search(
|
| 1010 |
+
r"(?<!`)" + re.escape(run) + r"(?!`)", source[i + len(run) :]
|
| 1011 |
+
)
|
| 1012 |
+
if close:
|
| 1013 |
+
i += len(run) + close.end()
|
| 1014 |
+
continue
|
| 1015 |
+
if source.startswith("](", i):
|
| 1016 |
+
# Markdown link destinations are not mathematical prose.
|
| 1017 |
+
dest = parseLinkDestination(source, i + 2, len(source))
|
| 1018 |
+
if dest.ok:
|
| 1019 |
+
cursor = dest.pos
|
| 1020 |
+
while cursor < len(source) and source[cursor].isspace():
|
| 1021 |
+
cursor += 1
|
| 1022 |
+
title = parseLinkTitle(source, cursor, len(source))
|
| 1023 |
+
if title.ok:
|
| 1024 |
+
cursor = title.pos
|
| 1025 |
+
while cursor < len(source) and source[cursor].isspace():
|
| 1026 |
+
cursor += 1
|
| 1027 |
+
if cursor < len(source) and source[cursor] == ")":
|
| 1028 |
+
i = cursor + 1
|
| 1029 |
+
continue
|
| 1030 |
+
if source[i] == "<":
|
| 1031 |
+
tag = _TAG.match(source, i)
|
| 1032 |
+
if tag:
|
| 1033 |
+
raw = tag[0]
|
| 1034 |
+
kind = re.match(r"<([A-Za-z][\w:-]*)", raw)
|
| 1035 |
+
kind = kind[1].lower() if kind else ""
|
| 1036 |
+
if kind in _DROP | {"code"}:
|
| 1037 |
+
close = re.search(
|
| 1038 |
+
r"</" + re.escape(kind) + r"\s*>", source[tag.end() :], re.I
|
| 1039 |
+
)
|
| 1040 |
+
if close:
|
| 1041 |
+
i = tag.end() + close.end()
|
| 1042 |
+
continue
|
| 1043 |
+
if kind == "img":
|
| 1044 |
+
src = _SRC.search(raw)
|
| 1045 |
+
if src:
|
| 1046 |
+
group = next(j for j in (1, 2, 3) if src[j] is not None)
|
| 1047 |
+
start, end = i + src.start(group), i + src.end(group)
|
| 1048 |
+
item = dict(
|
| 1049 |
+
start=start,
|
| 1050 |
+
end=end,
|
| 1051 |
+
ref=html.unescape(src[group]),
|
| 1052 |
+
kind="html",
|
| 1053 |
+
token=prefix + "IMG" + str(len(images)),
|
| 1054 |
+
)
|
| 1055 |
+
images.append(item)
|
| 1056 |
+
edits.append((start, end, item["token"]))
|
| 1057 |
+
i = tag.end()
|
| 1058 |
+
continue
|
| 1059 |
+
if source.startswith("![", i) and not _escaped(source, i):
|
| 1060 |
+
found = _markdown_image(source, i, parser, env)
|
| 1061 |
+
if found:
|
| 1062 |
+
end, ref, alt, title = found
|
| 1063 |
+
item = dict(
|
| 1064 |
+
start=i,
|
| 1065 |
+
end=end,
|
| 1066 |
+
ref=ref,
|
| 1067 |
+
alt=alt,
|
| 1068 |
+
title=title,
|
| 1069 |
+
kind="markdown",
|
| 1070 |
+
token=prefix + "IMG" + str(len(images)),
|
| 1071 |
+
)
|
| 1072 |
+
images.append(item)
|
| 1073 |
+
edits.append((i, end, _image_markup(item, item["token"])))
|
| 1074 |
+
i = end
|
| 1075 |
+
continue
|
| 1076 |
+
opening = next(
|
| 1077 |
+
(s for s in ("\\(", "\\[", "$$", "$") if source.startswith(s, i)), None
|
| 1078 |
+
)
|
| 1079 |
+
if opening and not _escaped(source, i):
|
| 1080 |
+
closing = {"\\(": "\\)", "\\[": "\\]", "$$": "$$", "$": "$"}[opening]
|
| 1081 |
+
pos = i + len(opening)
|
| 1082 |
+
while True:
|
| 1083 |
+
end = source.find(closing, pos)
|
| 1084 |
+
if end < 0:
|
| 1085 |
+
break
|
| 1086 |
+
if not _escaped(source, end):
|
| 1087 |
+
break
|
| 1088 |
+
pos = end + len(closing)
|
| 1089 |
+
# Delimiters may not consume table cells or block boundaries.
|
| 1090 |
+
# An explicit unclosed formula remains visible in its own cell.
|
| 1091 |
+
boundary = re.search(
|
| 1092 |
+
r"</?(?:table|thead|tbody|tfoot|tr|td|th|div|p|h[1-6]|li|ul|ol|pre|blockquote)\b[^>]*>|<br\b[^>]*>",
|
| 1093 |
+
source[pos:],
|
| 1094 |
+
re.I,
|
| 1095 |
+
)
|
| 1096 |
+
if boundary and end >= 0 and pos + boundary.start() < end:
|
| 1097 |
+
notes.append({"code": "formula_block_boundary", "offset": i})
|
| 1098 |
+
end = -1
|
| 1099 |
+
if end >= 0:
|
| 1100 |
+
body = source[i + len(opening) : end]
|
| 1101 |
+
allowed = True
|
| 1102 |
+
if opening == "$":
|
| 1103 |
+
allowed = (
|
| 1104 |
+
"\n" not in body
|
| 1105 |
+
and not source.startswith("$$", end)
|
| 1106 |
+
and _dollar_is_math(body, dollar_math)
|
| 1107 |
+
)
|
| 1108 |
+
if not allowed:
|
| 1109 |
+
notes.append(
|
| 1110 |
+
{"code": "literal_or_ambiguous_dollar", "offset": i}
|
| 1111 |
+
)
|
| 1112 |
+
elif "\n\n" in body and opening == "\\(":
|
| 1113 |
+
allowed = False
|
| 1114 |
+
if allowed:
|
| 1115 |
+
token = prefix + "MATH" + str(len(formulas)) + "X"
|
| 1116 |
+
formulas.append(
|
| 1117 |
+
dict(
|
| 1118 |
+
token=token,
|
| 1119 |
+
tex=html.unescape(body),
|
| 1120 |
+
raw=source[i : end + len(closing)],
|
| 1121 |
+
display=opening in {"\\[", "$$"},
|
| 1122 |
+
)
|
| 1123 |
+
)
|
| 1124 |
+
edits.append((i, end + len(closing), token))
|
| 1125 |
+
i = end + len(closing)
|
| 1126 |
+
continue
|
| 1127 |
+
elif opening != "$":
|
| 1128 |
+
# Preserve malformed explicit notation visibly, even through Markdown.
|
| 1129 |
+
stop = source.find("\n\n", i)
|
| 1130 |
+
stop = len(source) if stop < 0 else stop
|
| 1131 |
+
tag_boundary = _TAG.search(source, i + len(opening))
|
| 1132 |
+
if tag_boundary:
|
| 1133 |
+
stop = min(stop, tag_boundary.start())
|
| 1134 |
+
token = prefix + "MATH" + str(len(formulas)) + "X"
|
| 1135 |
+
formulas.append(
|
| 1136 |
+
dict(
|
| 1137 |
+
token=token,
|
| 1138 |
+
tex="",
|
| 1139 |
+
raw=source[i:stop],
|
| 1140 |
+
display=False,
|
| 1141 |
+
malformed=True,
|
| 1142 |
+
)
|
| 1143 |
+
)
|
| 1144 |
+
edits.append((i, stop, token))
|
| 1145 |
+
i = stop
|
| 1146 |
+
continue
|
| 1147 |
+
# Consume an unrecognized delimiter as a unit (do not reinterpret $$ as $).
|
| 1148 |
+
i += len(opening)
|
| 1149 |
+
continue
|
| 1150 |
+
i += 1
|
| 1151 |
+
return _edit_source(source, edits), formulas, images, notes
|
| 1152 |
+
|
| 1153 |
+
|
| 1154 |
+
def sanitize(soup):
|
| 1155 |
+
from bs4 import Comment
|
| 1156 |
+
|
| 1157 |
+
removed = 0
|
| 1158 |
+
for node in list(soup.find_all(string=lambda t: isinstance(t, Comment))):
|
| 1159 |
+
node.extract()
|
| 1160 |
+
for tag in list(soup.find_all(True)):
|
| 1161 |
+
if tag.name is None:
|
| 1162 |
+
continue
|
| 1163 |
+
if tag.name in {"svg", "canvas", "math", "video", "audio"}:
|
| 1164 |
+
raise RenderError(
|
| 1165 |
+
"unsupported_embedded_media",
|
| 1166 |
+
"Embedded media needs an explicit image asset; source retained.",
|
| 1167 |
+
)
|
| 1168 |
+
if tag.name in _DROP:
|
| 1169 |
+
tag.decompose()
|
| 1170 |
+
removed += 1
|
| 1171 |
+
continue
|
| 1172 |
+
if tag.name not in _ALLOWED:
|
| 1173 |
+
tag.unwrap()
|
| 1174 |
+
removed += 1
|
| 1175 |
+
continue
|
| 1176 |
+
attrs = {}
|
| 1177 |
+
if tag.name in {"td", "th", "col", "colgroup"}:
|
| 1178 |
+
for key in ("rowspan", "colspan", "span"):
|
| 1179 |
+
if key in tag.attrs:
|
| 1180 |
+
value = str(tag[key])
|
| 1181 |
+
if not value.isdecimal() or not (
|
| 1182 |
+
0 if key == "rowspan" else 1
|
| 1183 |
+
) <= int(value) <= (65534 if key == "rowspan" else 1000):
|
| 1184 |
+
raise RenderError(
|
| 1185 |
+
"invalid_table_span",
|
| 1186 |
+
"Invalid table cell span; source retained.",
|
| 1187 |
+
)
|
| 1188 |
+
attrs[key] = value
|
| 1189 |
+
if tag.name == "img":
|
| 1190 |
+
attrs.update({k: tag[k] for k in ("src", "alt", "title") if k in tag.attrs})
|
| 1191 |
+
if tag.name == "ol" and str(tag.get("start", "")).lstrip("-").isdigit():
|
| 1192 |
+
attrs["start"] = tag["start"]
|
| 1193 |
+
# Retain safe alignment, but no CSS that can hide, move, clip or fetch.
|
| 1194 |
+
declarations = dict(
|
| 1195 |
+
(k.strip().lower(), v.strip().lower())
|
| 1196 |
+
for k, v in re.findall(r"([\w-]+)\s*:\s*([^;]+)", str(tag.get("style", "")))
|
| 1197 |
+
)
|
| 1198 |
+
align = declarations.get("text-align", str(tag.get("align", "")).lower())
|
| 1199 |
+
valign = declarations.get("vertical-align", str(tag.get("valign", "")).lower())
|
| 1200 |
+
style = []
|
| 1201 |
+
if align in {"left", "right", "center", "justify"}:
|
| 1202 |
+
style.append("text-align:" + align)
|
| 1203 |
+
if valign in {"top", "middle", "bottom", "baseline"}:
|
| 1204 |
+
style.append("vertical-align:" + valign)
|
| 1205 |
+
if style:
|
| 1206 |
+
attrs["style"] = ";".join(style)
|
| 1207 |
+
if dict(tag.attrs) != attrs:
|
| 1208 |
+
removed += 1
|
| 1209 |
+
tag.attrs = attrs
|
| 1210 |
+
return removed
|
| 1211 |
+
|
| 1212 |
+
|
| 1213 |
+
def _png_data(image):
|
| 1214 |
+
from PIL import Image
|
| 1215 |
+
|
| 1216 |
+
image = image.convert("RGBA")
|
| 1217 |
+
background = Image.new("RGBA", image.size, "white")
|
| 1218 |
+
background.alpha_composite(image)
|
| 1219 |
+
buf = io.BytesIO()
|
| 1220 |
+
background.convert("RGB").save(buf, format="PNG")
|
| 1221 |
+
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
|
| 1222 |
+
|
| 1223 |
+
|
| 1224 |
+
def resolve_alias(ref, aliases, *, tolerance=2, min_iou=0.98, events=None):
|
| 1225 |
+
"""Exact first; a small coordinate jitter must match one unique occurrence.
|
| 1226 |
+
|
| 1227 |
+
This is a configurable rounding tolerance, not semantic figure localization.
|
| 1228 |
+
An ambiguous or newly predicted region is never assigned to the nearest image.
|
| 1229 |
+
"""
|
| 1230 |
+
if ref in aliases:
|
| 1231 |
+
return aliases[ref]
|
| 1232 |
+
target = CROP.fullmatch(ref)
|
| 1233 |
+
if not target:
|
| 1234 |
+
raise RenderError("unmapped_crop", "Cannot map an image region to its source.")
|
| 1235 |
+
page, *box = map(int, target.groups())
|
| 1236 |
+
candidates = []
|
| 1237 |
+
for key in aliases:
|
| 1238 |
+
m = CROP.fullmatch(key)
|
| 1239 |
+
if not m or int(m[1]) != page:
|
| 1240 |
+
continue
|
| 1241 |
+
other = list(map(int, m.groups()[1:]))
|
| 1242 |
+
if max(abs(a - b) for a, b in zip(box, other)) > tolerance:
|
| 1243 |
+
continue
|
| 1244 |
+
intersection = max(0, min(box[2], other[2]) - max(box[0], other[0])) * max(
|
| 1245 |
+
0, min(box[3], other[3]) - max(box[1], other[1])
|
| 1246 |
+
)
|
| 1247 |
+
area = max(0, box[2] - box[0]) * max(0, box[3] - box[1])
|
| 1248 |
+
area2 = max(0, other[2] - other[0]) * max(0, other[3] - other[1])
|
| 1249 |
+
union = area + area2 - intersection
|
| 1250 |
+
if union and intersection / union >= min_iou:
|
| 1251 |
+
candidates.append(key)
|
| 1252 |
+
if len(candidates) != 1:
|
| 1253 |
+
raise RenderError(
|
| 1254 |
+
"ambiguous_crop" if candidates else "unmapped_crop",
|
| 1255 |
+
"Cannot uniquely map an image region to its source.",
|
| 1256 |
+
)
|
| 1257 |
+
if events is not None:
|
| 1258 |
+
events.append(
|
| 1259 |
+
{
|
| 1260 |
+
"code": "coordinate_rounding_match",
|
| 1261 |
+
"predicted": ref,
|
| 1262 |
+
"matched": candidates[0],
|
| 1263 |
+
}
|
| 1264 |
+
)
|
| 1265 |
+
return aliases[candidates[0]]
|
| 1266 |
+
|
| 1267 |
+
|
| 1268 |
+
class Renderer:
|
| 1269 |
+
def __init__(
|
| 1270 |
+
self,
|
| 1271 |
+
browser,
|
| 1272 |
+
mathjax,
|
| 1273 |
+
*,
|
| 1274 |
+
limits=None,
|
| 1275 |
+
dollar_math="auto",
|
| 1276 |
+
assets=None,
|
| 1277 |
+
asset_root=None,
|
| 1278 |
+
resize=None,
|
| 1279 |
+
resize_name=None,
|
| 1280 |
+
max_input_pixels=None,
|
| 1281 |
+
):
|
| 1282 |
+
from markdown_it import MarkdownIt
|
| 1283 |
+
|
| 1284 |
+
if dollar_math not in {"auto", "all", "off"}:
|
| 1285 |
+
raise ValueError("dollar_math must be auto, all or off")
|
| 1286 |
+
self.mathjax = Path(mathjax).resolve()
|
| 1287 |
+
self.limits = limits or RenderLimits()
|
| 1288 |
+
self.dollar_math = dollar_math
|
| 1289 |
+
self.assets = dict(assets or {})
|
| 1290 |
+
self.asset_root = Path(asset_root).resolve() if asset_root else None
|
| 1291 |
+
self.resize = resize
|
| 1292 |
+
self.resize_name = resize_name or (
|
| 1293 |
+
"custom"
|
| 1294 |
+
if resize
|
| 1295 |
+
else "area_budget_estimate" if max_input_pixels else "unknown"
|
| 1296 |
+
)
|
| 1297 |
+
if max_input_pixels is not None and max_input_pixels <= 0:
|
| 1298 |
+
raise ValueError("max_input_pixels must be positive")
|
| 1299 |
+
self.max_input_pixels = max_input_pixels
|
| 1300 |
+
self.page = browser.new_page(
|
| 1301 |
+
viewport={"width": 1200, "height": 900}, device_scale_factor=1
|
| 1302 |
+
)
|
| 1303 |
+
self.page.set_default_timeout(self.limits.timeout_ms)
|
| 1304 |
+
# No external assets, CSS or network dependencies in model-supplied HTML.
|
| 1305 |
+
self.page.route("**/*", lambda route: route.abort())
|
| 1306 |
+
self.md = MarkdownIt("commonmark", {"html": True}).enable(
|
| 1307 |
+
["table", "strikethrough"]
|
| 1308 |
+
)
|
| 1309 |
+
|
| 1310 |
+
def _asset(self, ref, owner, paths):
|
| 1311 |
+
from PIL import Image
|
| 1312 |
+
|
| 1313 |
+
crop = _CROP_ASSET.fullmatch(ref)
|
| 1314 |
+
if crop:
|
| 1315 |
+
page, left, top, right, bottom = crop.groups()
|
| 1316 |
+
page = int(page) if page else owner
|
| 1317 |
+
left, top, right, bottom = map(int, (left, top, right, bottom))
|
| 1318 |
+
if (
|
| 1319 |
+
not 1 <= page <= len(paths)
|
| 1320 |
+
or not 0 <= left < right <= 1000
|
| 1321 |
+
or not 0 <= top < bottom <= 1000
|
| 1322 |
+
):
|
| 1323 |
+
raise RenderError(
|
| 1324 |
+
"invalid_crop",
|
| 1325 |
+
"An image crop has invalid coordinates; source retained.",
|
| 1326 |
+
)
|
| 1327 |
+
with Image.open(paths[page - 1]) as im:
|
| 1328 |
+
box = (
|
| 1329 |
+
round(left * im.width / 1000),
|
| 1330 |
+
round(top * im.height / 1000),
|
| 1331 |
+
round(right * im.width / 1000),
|
| 1332 |
+
round(bottom * im.height / 1000),
|
| 1333 |
+
)
|
| 1334 |
+
if box[0] >= box[2] or box[1] >= box[3]:
|
| 1335 |
+
raise RenderError(
|
| 1336 |
+
"empty_crop", "An image crop is smaller than one pixel."
|
| 1337 |
+
)
|
| 1338 |
+
if (box[2] - box[0]) * (
|
| 1339 |
+
box[3] - box[1]
|
| 1340 |
+
) > self.limits.max_render_pixels:
|
| 1341 |
+
raise RenderError(
|
| 1342 |
+
"asset_limit",
|
| 1343 |
+
"An image asset exceeds the rendering pixel budget.",
|
| 1344 |
+
)
|
| 1345 |
+
return _png_data(im.crop(box))
|
| 1346 |
+
if ref.startswith("data:"):
|
| 1347 |
+
header, sep, payload = ref.partition(",")
|
| 1348 |
+
if not sep or not re.fullmatch(
|
| 1349 |
+
r"data:image/(?:png|jpeg|webp|gif);base64", header, re.I
|
| 1350 |
+
):
|
| 1351 |
+
raise RenderError(
|
| 1352 |
+
"unsupported_asset",
|
| 1353 |
+
"An image format is unsupported; source retained.",
|
| 1354 |
+
)
|
| 1355 |
+
with Image.open(io.BytesIO(base64.b64decode(payload, validate=True))) as im:
|
| 1356 |
+
if im.width * im.height > self.limits.max_render_pixels:
|
| 1357 |
+
raise RenderError(
|
| 1358 |
+
"asset_limit",
|
| 1359 |
+
"An image asset exceeds the rendering pixel budget.",
|
| 1360 |
+
)
|
| 1361 |
+
return _png_data(im)
|
| 1362 |
+
path = self.assets.get(ref)
|
| 1363 |
+
if path is None and self.asset_root:
|
| 1364 |
+
from urllib.parse import unquote, urlsplit
|
| 1365 |
+
|
| 1366 |
+
if not urlsplit(ref).scheme and not ref.startswith("//"):
|
| 1367 |
+
candidate = (self.asset_root / unquote(ref)).resolve()
|
| 1368 |
+
if candidate.is_relative_to(self.asset_root):
|
| 1369 |
+
path = candidate
|
| 1370 |
+
if path is None:
|
| 1371 |
+
raise RenderError(
|
| 1372 |
+
"unresolved_asset",
|
| 1373 |
+
"An image needs a local asset mapping; source retained.",
|
| 1374 |
+
)
|
| 1375 |
+
with Image.open(path) as im:
|
| 1376 |
+
if im.width * im.height > self.limits.max_render_pixels:
|
| 1377 |
+
raise RenderError(
|
| 1378 |
+
"asset_limit", "An image asset exceeds the rendering pixel budget."
|
| 1379 |
+
)
|
| 1380 |
+
return _png_data(im)
|
| 1381 |
+
|
| 1382 |
+
def render(self, md, owner, paths, directory, name):
|
| 1383 |
+
directory = Path(directory)
|
| 1384 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 1385 |
+
if Path(name).name != name or not name:
|
| 1386 |
+
raise ValueError("name must be a filename stem")
|
| 1387 |
+
started = time.monotonic()
|
| 1388 |
+
diag = {
|
| 1389 |
+
"version": 2,
|
| 1390 |
+
"status": "ERROR",
|
| 1391 |
+
"math": 0,
|
| 1392 |
+
"errors": 0,
|
| 1393 |
+
"broken": 0,
|
| 1394 |
+
"images": [],
|
| 1395 |
+
"notes": [],
|
| 1396 |
+
"limits": asdict(self.limits),
|
| 1397 |
+
"resize_policy": self.resize_name,
|
| 1398 |
+
}
|
| 1399 |
+
(directory / (name + ".source.md")).write_text(md)
|
| 1400 |
+
try:
|
| 1401 |
+
result = self._render(md, owner, paths, directory, name, diag)
|
| 1402 |
+
diag["status"] = "DEGRADED" if diag.get("formula_fallbacks") else "COMPLETE"
|
| 1403 |
+
return result
|
| 1404 |
+
except Exception as exc:
|
| 1405 |
+
diag["error"] = {
|
| 1406 |
+
"code": getattr(exc, "code", "render_failed"),
|
| 1407 |
+
"type": type(exc).__name__,
|
| 1408 |
+
"message": str(exc)[:1000],
|
| 1409 |
+
}
|
| 1410 |
+
if isinstance(exc, RenderError):
|
| 1411 |
+
raise
|
| 1412 |
+
raise RenderError(
|
| 1413 |
+
"render_failed",
|
| 1414 |
+
"Page rendering failed; source and diagnostics were retained.",
|
| 1415 |
+
) from exc
|
| 1416 |
+
finally:
|
| 1417 |
+
diag["seconds"] = time.monotonic() - started
|
| 1418 |
+
save(directory / (name + ".render.json"), diag)
|
| 1419 |
+
|
| 1420 |
+
def _render(self, md, owner, paths, directory, name, diag):
|
| 1421 |
+
from bs4 import BeautifulSoup, Comment, NavigableString
|
| 1422 |
+
from PIL import Image
|
| 1423 |
+
|
| 1424 |
+
if not 1 <= owner <= len(paths):
|
| 1425 |
+
raise RenderError("invalid_owner", "Page index is outside the document.")
|
| 1426 |
+
if len(md) > self.limits.max_source_chars:
|
| 1427 |
+
raise RenderError(
|
| 1428 |
+
"source_limit", "Page source exceeds the configured rendering limit."
|
| 1429 |
+
)
|
| 1430 |
+
probe = (
|
| 1431 |
+
BeautifulSoup(md, "html.parser") if md.lstrip().startswith("<") else None
|
| 1432 |
+
)
|
| 1433 |
+
html_document = bool(
|
| 1434 |
+
probe is not None
|
| 1435 |
+
and probe.find(True)
|
| 1436 |
+
and all(
|
| 1437 |
+
not isinstance(x, NavigableString)
|
| 1438 |
+
or isinstance(x, Comment)
|
| 1439 |
+
or not str(x).strip()
|
| 1440 |
+
for x in probe.contents
|
| 1441 |
+
)
|
| 1442 |
+
)
|
| 1443 |
+
prepared, formulas, occurrences, notes = prepare_source(
|
| 1444 |
+
md, self.md, self.dollar_math, html_document
|
| 1445 |
+
)
|
| 1446 |
+
diag["input_format"] = "html" if html_document else "markdown"
|
| 1447 |
+
diag["notes"].extend(notes)
|
| 1448 |
+
soup = BeautifulSoup(
|
| 1449 |
+
prepared if html_document else self.md.render(prepared), "html.parser"
|
| 1450 |
+
)
|
| 1451 |
+
diag["sanitized_elements"] = sanitize(soup)
|
| 1452 |
+
by_token = {x["token"]: x for x in occurrences}
|
| 1453 |
+
seen = set()
|
| 1454 |
+
for image in soup.find_all("img"):
|
| 1455 |
+
key = image.get("src", "")
|
| 1456 |
+
if key not in by_token or key in seen:
|
| 1457 |
+
raise RenderError(
|
| 1458 |
+
"image_mapping", "Cannot safely map image occurrences."
|
| 1459 |
+
)
|
| 1460 |
+
seen.add(key)
|
| 1461 |
+
item = by_token[key]
|
| 1462 |
+
image["src"] = self._asset(item["ref"], owner, paths)
|
| 1463 |
+
image["data-image-id"] = key
|
| 1464 |
+
if len(seen) != len(occurrences):
|
| 1465 |
+
raise RenderError(
|
| 1466 |
+
"image_mapping", "An image occurrence was lost during parsing."
|
| 1467 |
+
)
|
| 1468 |
+
by_math = {x["token"]: x for x in formulas}
|
| 1469 |
+
if by_math:
|
| 1470 |
+
pattern = re.compile(
|
| 1471 |
+
"|".join(map(re.escape, sorted(by_math, key=len, reverse=True)))
|
| 1472 |
+
)
|
| 1473 |
+
found = []
|
| 1474 |
+
for node in list(soup.find_all(string=pattern)):
|
| 1475 |
+
if isinstance(node, Comment):
|
| 1476 |
+
continue
|
| 1477 |
+
pieces = pattern.split(str(node))
|
| 1478 |
+
matches = list(pattern.finditer(str(node)))
|
| 1479 |
+
for k, part in enumerate(pieces):
|
| 1480 |
+
if part:
|
| 1481 |
+
node.insert_before(NavigableString(part))
|
| 1482 |
+
if k < len(matches):
|
| 1483 |
+
item = by_math[matches[k][0]]
|
| 1484 |
+
found.append(item["token"])
|
| 1485 |
+
tag = soup.new_tag("span")
|
| 1486 |
+
tag["data-math-id"] = item["token"]
|
| 1487 |
+
tag["class"] = (
|
| 1488 |
+
"math-display" if item["display"] else "math-inline"
|
| 1489 |
+
)
|
| 1490 |
+
tag.string = item["raw"]
|
| 1491 |
+
node.insert_before(tag)
|
| 1492 |
+
node.extract()
|
| 1493 |
+
if sorted(found) != sorted(by_math):
|
| 1494 |
+
raise RenderError(
|
| 1495 |
+
"formula_mapping", "A formula was lost during parsing."
|
| 1496 |
+
)
|
| 1497 |
+
tables = soup.find_all("table")
|
| 1498 |
+
expected_cells = len(soup.find_all(["td", "th"]))
|
| 1499 |
+
columns = max(
|
| 1500 |
+
(
|
| 1501 |
+
sum(
|
| 1502 |
+
int(c.get("colspan", 1))
|
| 1503 |
+
for c in row.find_all(["td", "th"], recursive=False)
|
| 1504 |
+
)
|
| 1505 |
+
for row in soup.find_all("tr")
|
| 1506 |
+
),
|
| 1507 |
+
default=1,
|
| 1508 |
+
)
|
| 1509 |
+
with Image.open(paths[owner - 1]) as original:
|
| 1510 |
+
width = max(original.width, columns * 120 + 56)
|
| 1511 |
+
document = (
|
| 1512 |
+
'<!doctype html><html><head><meta charset="utf-8"><style>'
|
| 1513 |
+
+ _CSS
|
| 1514 |
+
+ '</style></head><body><div id="capture" style="width:'
|
| 1515 |
+
+ str(width)
|
| 1516 |
+
+ 'px">'
|
| 1517 |
+
+ str(soup)
|
| 1518 |
+
+ "</div></body></html>"
|
| 1519 |
+
)
|
| 1520 |
+
# Persist complete render input before any resource/readability rejection.
|
| 1521 |
+
(directory / (name + ".html")).write_text(document)
|
| 1522 |
+
if width > self.limits.max_side:
|
| 1523 |
+
raise RenderError(
|
| 1524 |
+
"size_limit",
|
| 1525 |
+
"Rendered page exceeds the configured size limit; complete source retained.",
|
| 1526 |
+
)
|
| 1527 |
+
page = self.page
|
| 1528 |
+
page.set_viewport_size({"width": width, "height": 900})
|
| 1529 |
+
page.set_content(document, wait_until="domcontentloaded")
|
| 1530 |
+
diag["expected_formulas"] = len(formulas)
|
| 1531 |
+
formula_results = []
|
| 1532 |
+
if formulas:
|
| 1533 |
+
if not self.mathjax.is_file():
|
| 1534 |
+
raise RenderError("mathjax_missing", "Local MathJax bundle is missing.")
|
| 1535 |
+
page.evaluate(
|
| 1536 |
+
"window.MathJax={startup:{typeset:false},svg:{fontCache:'none'},tex:{maxBuffer:100000,packages:['base','ams','newcommand']}}"
|
| 1537 |
+
)
|
| 1538 |
+
page.add_script_tag(path=str(self.mathjax))
|
| 1539 |
+
page.wait_for_function(
|
| 1540 |
+
"window.MathJax && MathJax.startup && MathJax.startup.promise"
|
| 1541 |
+
)
|
| 1542 |
+
formula_results = page.evaluate(
|
| 1543 |
+
"""async ({items,timeout})=>{
|
| 1544 |
+
const work=async()=>{
|
| 1545 |
+
await MathJax.startup.promise;
|
| 1546 |
+
if(typeof MathJax.tex2svgPromise!=='function') throw Error('MathJax SVG API unavailable');
|
| 1547 |
+
const results=[];
|
| 1548 |
+
for(const item of items){
|
| 1549 |
+
const target=document.querySelector('[data-math-id="'+item.token+'"]');
|
| 1550 |
+
let error=null;
|
| 1551 |
+
try{
|
| 1552 |
+
if(item.malformed) throw Error('Unclosed formula delimiter');
|
| 1553 |
+
if(!item.tex.trim()) throw Error('Empty formula');
|
| 1554 |
+
const rendered=await MathJax.tex2svgPromise(item.tex,{display:item.display});
|
| 1555 |
+
if(rendered.querySelector('[data-mml-node="merror"],mjx-merror,[data-mjx-error]')) throw Error('Invalid TeX');
|
| 1556 |
+
// This screenshot renderer needs only SVG. Standalone tex2svg
|
| 1557 |
+
// may emit assistive MathML without the CSS that hides it.
|
| 1558 |
+
rendered.querySelectorAll('mjx-assistive-mml').forEach(node=>node.remove());
|
| 1559 |
+
target.replaceChildren(rendered);
|
| 1560 |
+
}catch(e){error=String(e.message||e);target.textContent=item.raw;target.classList.add('math-fallback');}
|
| 1561 |
+
results.push({id:item.token,rendered:!error,error});
|
| 1562 |
+
}
|
| 1563 |
+
return results;
|
| 1564 |
+
};
|
| 1565 |
+
let timer;try{return await Promise.race([work(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Formula rendering timeout')),timeout)})]);}finally{clearTimeout(timer);}
|
| 1566 |
+
}""",
|
| 1567 |
+
{"items": formulas, "timeout": self.limits.timeout_ms},
|
| 1568 |
+
)
|
| 1569 |
+
diag["formulas"] = formula_results
|
| 1570 |
+
diag["formula_fallbacks"] = sum(not x["rendered"] for x in formula_results)
|
| 1571 |
+
page.evaluate(
|
| 1572 |
+
"""async timeout=>{
|
| 1573 |
+
let timer;try{await Promise.race([(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(i=>i.decode()));await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));})(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Image/font readiness timeout')),timeout)})]);}finally{clearTimeout(timer);}
|
| 1574 |
+
}""",
|
| 1575 |
+
self.limits.timeout_ms,
|
| 1576 |
+
)
|
| 1577 |
+
# Allow intrinsically wide math/tables to grow once, within the resource budget.
|
| 1578 |
+
needed = page.evaluate("document.documentElement.scrollWidth")
|
| 1579 |
+
if needed > width:
|
| 1580 |
+
width = needed + 28
|
| 1581 |
+
if width > self.limits.max_side:
|
| 1582 |
+
raise RenderError(
|
| 1583 |
+
"size_limit",
|
| 1584 |
+
"Rendered content is too wide; complete source retained.",
|
| 1585 |
+
)
|
| 1586 |
+
page.set_viewport_size({"width": width, "height": 900})
|
| 1587 |
+
page.locator("#capture").evaluate('(e,w)=>e.style.width=w+"px"', width)
|
| 1588 |
+
layout = page.evaluate("""()=>{
|
| 1589 |
+
const root=document.getElementById('capture');const area=root.getBoundingClientRect();
|
| 1590 |
+
const boxes=[...root.querySelectorAll('table,td,th,img,[data-math-id]')];
|
| 1591 |
+
const bad=boxes.filter(e=>{const b=e.getBoundingClientRect(),s=getComputedStyle(e);return !b.width||!b.height||s.display==='none'||s.visibility==='hidden'||Number(s.opacity)===0||b.left<0||b.right>area.right+.5;}).map(e=>({tag:e.tagName,id:e.dataset.imageId||e.dataset.mathId||null}));
|
| 1592 |
+
const fonts=[];const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let n;
|
| 1593 |
+
while(n=walker.nextNode()){if(n.textContent.trim()&&!n.parentElement.closest('mjx-container')){const s=getComputedStyle(n.parentElement);fonts.push(parseFloat(s.fontSize));}}
|
| 1594 |
+
const cellHeights=[...root.querySelectorAll('td,th')].map(e=>e.getBoundingClientRect().height);
|
| 1595 |
+
return {math:root.querySelectorAll('mjx-container').length,broken:[...root.querySelectorAll('img')].filter(i=>!i.complete||!i.naturalWidth).length,
|
| 1596 |
+
tables:root.querySelectorAll('table').length,cells:cellHeights.length,invisible_or_clipped:bad,
|
| 1597 |
+
min_font_px:fonts.length?fonts.reduce((a,b)=>Math.min(a,b),Infinity):18,min_cell_height_px:cellHeights.length?cellHeights.reduce((a,b)=>Math.min(a,b),Infinity):null,
|
| 1598 |
+
width:document.documentElement.scrollWidth,height:document.documentElement.scrollHeight,
|
| 1599 |
+
images:[...root.querySelectorAll('img')].map(i=>{const b=i.getBoundingClientRect();return {id:i.dataset.imageId,x:b.x,y:b.y,w:b.width,h:b.height}})};
|
| 1600 |
+
}""")
|
| 1601 |
+
diag.update(layout)
|
| 1602 |
+
diag["errors"] = diag["formula_fallbacks"]
|
| 1603 |
+
if layout["tables"] != len(tables) or layout["cells"] != expected_cells:
|
| 1604 |
+
raise RenderError(
|
| 1605 |
+
"table_structure",
|
| 1606 |
+
"Browser parsing changed the table structure; source retained.",
|
| 1607 |
+
)
|
| 1608 |
+
if layout["invisible_or_clipped"] or layout["broken"]:
|
| 1609 |
+
raise RenderError(
|
| 1610 |
+
"invisible_content",
|
| 1611 |
+
"Some page content is not visible; source retained.",
|
| 1612 |
+
)
|
| 1613 |
+
if layout["math"] + diag["formula_fallbacks"] != len(formulas):
|
| 1614 |
+
raise RenderError(
|
| 1615 |
+
"formula_coverage", "Formula rendering coverage is incomplete."
|
| 1616 |
+
)
|
| 1617 |
+
w, h = layout["width"], layout["height"]
|
| 1618 |
+
# Snapshot has no live external scripts or assets; it opens offline as rendered.
|
| 1619 |
+
(directory / (name + ".html")).write_text(
|
| 1620 |
+
page.evaluate(
|
| 1621 |
+
"() => { const root=document.documentElement.cloneNode(true); root.querySelectorAll('script').forEach(e=>e.remove()); return '<!doctype html>'+root.outerHTML; }"
|
| 1622 |
+
)
|
| 1623 |
+
)
|
| 1624 |
+
if max(w, h) > self.limits.max_side or w * h > self.limits.max_render_pixels:
|
| 1625 |
+
raise RenderError(
|
| 1626 |
+
"size_limit",
|
| 1627 |
+
"Rendered page exceeds the configured size limit; complete HTML retained.",
|
| 1628 |
+
)
|
| 1629 |
+
target = (w, h)
|
| 1630 |
+
if not self.resize and not self.max_input_pixels:
|
| 1631 |
+
diag["notes"].append({"code": "model_resize_unspecified"})
|
| 1632 |
+
if self.resize:
|
| 1633 |
+
try:
|
| 1634 |
+
target = tuple(self.resize(w, h))
|
| 1635 |
+
except Exception as exc:
|
| 1636 |
+
raise RenderError(
|
| 1637 |
+
"model_resize_limit",
|
| 1638 |
+
"Page exceeds the model image-size limits; complete HTML retained.",
|
| 1639 |
+
) from exc
|
| 1640 |
+
elif self.max_input_pixels:
|
| 1641 |
+
scale = min(1, math.sqrt(self.max_input_pixels / (w * h)))
|
| 1642 |
+
target = (max(1, math.floor(w * scale)), max(1, math.floor(h * scale)))
|
| 1643 |
+
if len(target) != 2 or min(target) <= 0:
|
| 1644 |
+
raise RenderError(
|
| 1645 |
+
"resize_policy", "Invalid target dimensions from resize policy."
|
| 1646 |
+
)
|
| 1647 |
+
scale = min(target[0] / w, target[1] / h)
|
| 1648 |
+
font = layout["min_font_px"] * scale
|
| 1649 |
+
cell = (
|
| 1650 |
+
layout["min_cell_height_px"] * scale
|
| 1651 |
+
if layout["min_cell_height_px"]
|
| 1652 |
+
else None
|
| 1653 |
+
)
|
| 1654 |
+
diag["readability"] = {
|
| 1655 |
+
"input_size": [w, h],
|
| 1656 |
+
"model_size": list(target),
|
| 1657 |
+
"scale": scale,
|
| 1658 |
+
"effective_font_px": font,
|
| 1659 |
+
"effective_cell_height_px": cell,
|
| 1660 |
+
"model_resize_known": bool(self.resize),
|
| 1661 |
+
"estimated_from_pixel_budget": bool(
|
| 1662 |
+
not self.resize and self.max_input_pixels
|
| 1663 |
+
),
|
| 1664 |
+
"thresholds_are_heuristic": True,
|
| 1665 |
+
}
|
| 1666 |
+
if font < self.limits.min_effective_font_px or (
|
| 1667 |
+
cell is not None and cell < self.limits.min_effective_cell_height_px
|
| 1668 |
+
):
|
| 1669 |
+
raise RenderError(
|
| 1670 |
+
"readability_limit",
|
| 1671 |
+
"Page text would be too small after resizing; complete HTML retained.",
|
| 1672 |
+
)
|
| 1673 |
+
aliases, edits = {}, []
|
| 1674 |
+
for image in layout["images"]:
|
| 1675 |
+
item = by_token[image["id"]]
|
| 1676 |
+
image["ref"] = item["ref"]
|
| 1677 |
+
box = [
|
| 1678 |
+
round(image["x"] / w * 1000),
|
| 1679 |
+
round(image["y"] / h * 1000),
|
| 1680 |
+
round((image["x"] + image["w"]) / w * 1000),
|
| 1681 |
+
round((image["y"] + image["h"]) / h * 1000),
|
| 1682 |
+
]
|
| 1683 |
+
if not 0 <= box[0] < box[2] <= 1000 or not 0 <= box[1] < box[3] <= 1000:
|
| 1684 |
+
raise RenderError(
|
| 1685 |
+
"crop_quantization",
|
| 1686 |
+
"An image is too small for normalized coordinate mapping.",
|
| 1687 |
+
)
|
| 1688 |
+
alias = f"page_{owner}/images/bbox_" + "_".join(map(str, box)) + ".jpg"
|
| 1689 |
+
if alias in aliases:
|
| 1690 |
+
raise RenderError(
|
| 1691 |
+
"crop_alias_collision",
|
| 1692 |
+
"Image positions cannot be uniquely represented.",
|
| 1693 |
+
)
|
| 1694 |
+
aliases[alias] = item["ref"]
|
| 1695 |
+
edits.append((item["start"], item["end"], _image_markup(item, alias)))
|
| 1696 |
+
rewritten = _edit_source(md, edits)
|
| 1697 |
+
path = directory / (name + ".png")
|
| 1698 |
+
page.screenshot(path=str(path), full_page=True, timeout=self.limits.timeout_ms)
|
| 1699 |
+
with Image.open(path) as rendered:
|
| 1700 |
+
if rendered.size != (w, h):
|
| 1701 |
+
raise RenderError(
|
| 1702 |
+
"screenshot_dimensions",
|
| 1703 |
+
"Screenshot dimensions differ from image coordinate mapping.",
|
| 1704 |
+
)
|
| 1705 |
+
(directory / (name + ".rewritten.md")).write_text(rewritten)
|
| 1706 |
+
save(directory / (name + ".aliases.json"), aliases)
|
| 1707 |
+
return RenderResult(str(path), rewritten, aliases, diag)
|
| 1708 |
+
|
| 1709 |
+
|
| 1710 |
+
# Command-line interface.
|
| 1711 |
+
|
| 1712 |
+
|
| 1713 |
+
def main():
|
| 1714 |
+
parser = argparse.ArgumentParser(
|
| 1715 |
+
description="OvisOCR3 document-level cross-page reconstruction"
|
| 1716 |
+
)
|
| 1717 |
+
parser.add_argument("images", nargs="+", help="Page images in reading order")
|
| 1718 |
+
parser.add_argument("--model", default=".")
|
| 1719 |
+
parser.add_argument("--output", required=True)
|
| 1720 |
+
parser.add_argument(
|
| 1721 |
+
"--stage1-prompt",
|
| 1722 |
+
default="document_structured.txt",
|
| 1723 |
+
choices=[
|
| 1724 |
+
"document_markdown",
|
| 1725 |
+
"document_markdown.txt",
|
| 1726 |
+
"document_structured",
|
| 1727 |
+
"document_structured.txt",
|
| 1728 |
+
],
|
| 1729 |
+
)
|
| 1730 |
+
parser.add_argument(
|
| 1731 |
+
"--mathjax",
|
| 1732 |
+
default=None,
|
| 1733 |
+
help="Local MathJax bundle; defaults to assets/mathjax.js beside this script",
|
| 1734 |
+
)
|
| 1735 |
+
args = parser.parse_args()
|
| 1736 |
+
result = reconstruct_document(
|
| 1737 |
+
args.images,
|
| 1738 |
+
args.output,
|
| 1739 |
+
model_path=args.model,
|
| 1740 |
+
stage1_prompt=args.stage1_prompt,
|
| 1741 |
+
mathjax=args.mathjax,
|
| 1742 |
+
)
|
| 1743 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 1744 |
+
|
| 1745 |
+
|
| 1746 |
+
if __name__ == "__main__":
|
| 1747 |
+
main()
|
merges.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aa2311c6b22b07cf3afe9aac6e8bcb02a2b19dd8671b2a91d0b144071577e600
|
| 3 |
+
size 1706030528
|
preprocessor_config.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"size": {
|
| 3 |
+
"longest_edge": 16777216,
|
| 4 |
+
"shortest_edge": 65536
|
| 5 |
+
},
|
| 6 |
+
"patch_size": 16,
|
| 7 |
+
"temporal_patch_size": 2,
|
| 8 |
+
"merge_size": 2,
|
| 9 |
+
"image_mean": [
|
| 10 |
+
0.5,
|
| 11 |
+
0.5,
|
| 12 |
+
0.5
|
| 13 |
+
],
|
| 14 |
+
"image_std": [
|
| 15 |
+
0.5,
|
| 16 |
+
0.5,
|
| 17 |
+
0.5
|
| 18 |
+
],
|
| 19 |
+
"processor_class": "Qwen3VLProcessor",
|
| 20 |
+
"image_processor_type": "Qwen2VLImageProcessorFast"
|
| 21 |
+
}
|
prompts/chart_parsing.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Parse all key information in the chart and output one valid JSON object using exactly this top-level schema:
|
| 2 |
+
{"title":"","source":"","x_title":"","y_title":"","values":{}}
|
| 3 |
+
|
| 4 |
+
Rules:
|
| 5 |
+
1. Preserve visible title, source, axis titles, series or category labels, x-axis labels, and values without explanation or inference.
|
| 6 |
+
2. Use the literal string "None" when the chart has no visible value for title, source, or an axis title. For a dual-axis chart, `y_title` may be an array of two strings in visual order; otherwise it must be one string.
|
| 7 |
+
3. `values` must be a non-empty object. Preserve the chart hierarchy and encode every scalar data value as a JSON string. An empty series name may be represented by the key "" when the chart has no visible series label.
|
| 8 |
+
4. Output JSON only. Do not output Python syntax, Markdown fences, comments, prose, or extra keys.
|
prompts/crosspage_table.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Find and reconstruct every table whose content continues across two or more consecutive input pages.
|
| 2 |
+
|
| 3 |
+
Return only one JSON array. Each cross-page table is one object with exactly these fields: source_pages and html. source_pages contains the input-local 1-based pages of that table. html contains one complete HTML <table>...</table>.
|
| 4 |
+
|
| 5 |
+
If no cross-page table exists, return [{"source_pages":[],"html":"No cross-page table exists between these pages."}]. Preserve figures inside cross-page tables as <img src="page_N/images/bbox_left_top_right_bottom.jpg" />; page_N is 1-based. Return no explanation or code fence.
|
prompts/document_layout.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Analyze the document structure and return a single JSON array in natural reading order.
|
| 2 |
+
|
| 3 |
+
Each element follows this schema:
|
| 4 |
+
`{"bbox": [x_min, y_min, x_max, y_max], "category": "string"}`
|
| 5 |
+
|
| 6 |
+
Rules:
|
| 7 |
+
1. The bbox coordinates are integers scaled to [0, 1000).
|
| 8 |
+
2. The category is exactly one of:
|
| 9 |
+
["abstract", "algorithm", "aside_text", "chart", "content", "display_formula", "doc_title", "figure_title", "footer", "footer_image", "footnote", "formula_number", "header", "header_image", "image", "inline_formula", "number", "paragraph_title", "reference_content", "seal", "table", "text", "vertical_text", "vision_footnote"].
|
prompts/document_markdown.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Extract all readable content from the image in natural human reading order and output the result as a single Markdown document. For charts or images, represent them using an HTML image tag: <img src="images/bbox_{left}_{top}_{right}_{bottom}.jpg" />, where left, top, right, bottom are bounding box coordinates scaled to [0, 1000). Format formulas as LaTeX. Format tables as HTML: <table>...</table>. Transcribe all other text as standard Markdown. Preserve the original text without translation or paraphrasing.
|
prompts/document_structured.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Analyze the document structure and extract text content. Return a single JSON array in natural reading order.
|
| 2 |
+
|
| 3 |
+
Each element follows this schema:
|
| 4 |
+
`{"bbox": [x_min, y_min, x_max, y_max], "category": "string", "content": "string"}`
|
| 5 |
+
|
| 6 |
+
Rules:
|
| 7 |
+
1. The bbox coordinates are integers scaled to [0, 1000).
|
| 8 |
+
2. The category is exactly one of:
|
| 9 |
+
["abstract", "algorithm", "aside_text", "chart", "content", "display_formula", "doc_title", "figure_title", "footer", "footer_image", "footnote", "formula_number", "header", "header_image", "image", "inline_formula", "number", "paragraph_title", "reference_content", "seal", "table", "text", "vertical_text", "vision_footnote"].
|
| 10 |
+
3. The content formatting depends on category:
|
| 11 |
+
- For "footer_image", "header_image", "image", or "chart": write as an HTML image tag <img src="images/bbox_{x_min}_{y_min}_{x_max}_{y_max}.jpg" />.
|
| 12 |
+
- For "display_formula" or "inline_formula": write in LaTeX.
|
| 13 |
+
- For "table": write as a standard HTML `<table>...</table>`.
|
| 14 |
+
- Otherwise: write in Markdown.
|
| 15 |
+
4. Output must be strictly valid JSON. Properly escape quotes and special characters inside content.
|
prompts/figure_captioning.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Analyze the document structure and extract text content. Return a single JSON array in natural reading order.
|
| 2 |
+
|
| 3 |
+
Each element follows this schema:
|
| 4 |
+
`{"bbox": [x_min, y_min, x_max, y_max], "category": "string", "content": "string"}`
|
| 5 |
+
|
| 6 |
+
Rules:
|
| 7 |
+
1. The bbox coordinates are integers scaled to [0, 1000).
|
| 8 |
+
2. The category is exactly one of:
|
| 9 |
+
["abstract", "algorithm", "aside_text", "chart", "content", "display_formula", "doc_title", "figure_title", "footer", "footer_image", "footnote", "formula_number", "header", "header_image", "image", "inline_formula", "number", "paragraph_title", "reference_content", "seal", "table", "text", "vertical_text", "vision_footnote"].
|
| 10 |
+
3. The content formatting depends on category:
|
| 11 |
+
- For "footer_image", "header_image", "image", or "chart":
|
| 12 |
+
- Always provide a concise English caption of no more than 100 words inside `<caption>...</caption>`, preserving visible labels in their original language when needed.
|
| 13 |
+
- For a flowchart, a valid Mermaid representation may follow the caption inside `<mermaid>...</mermaid>`.
|
| 14 |
+
- For "display_formula" or "inline_formula": write in LaTeX.
|
| 15 |
+
- For "table": write as a standard HTML `<table>...</table>`.
|
| 16 |
+
- Otherwise: write in Markdown.
|
| 17 |
+
4. Output must be strictly valid JSON. Properly escape quotes and special characters inside content.
|
prompts/formula_recognition.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Recognize and transcribe every mathematical expression in the image as LaTeX. Directly output the formula content.
|
| 2 |
+
|
| 3 |
+
Rules:
|
| 4 |
+
1. Preserve every visible symbol, operator, delimiter, superscript, subscript, accent, matrix row, alignment, and line break without translation, paraphrasing, correction, completion, or omission.
|
| 5 |
+
2. Wrap each standalone or display expression as `$$ ... $$`.
|
| 6 |
+
3. If multiple standalone expressions are visible, output one `$$ ... $$` block per expression in natural reading order, separated by a single line break.
|
| 7 |
+
4. Use one decoded backslash for a LaTeX command and two decoded backslashes for a LaTeX row break. JSON serialization may escape these backslashes on disk, but the decoded target must retain valid LaTeX semantics.
|
| 8 |
+
5. Output formula content only. Do not output prose, explanations, translations, Markdown code fences, HTML, document-level table markup, or JSON.
|
prompts/image_classification.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Classify {target description} in the image. Select exactly one label from the candidate list.
|
| 2 |
+
|
| 3 |
+
Candidate labels: {candidate_labels}
|
| 4 |
+
|
| 5 |
+
Directly output the selected label exactly as written. Do not output explanations, punctuation, JSON, Markdown, or any other text.
|
prompts/kie_fields.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Extract the values for the following keys from the image: {keys_json}. Return a JSON object using exactly these keys. Keep the values exactly as shown, use empty strings for missing values, and use arrays for repeated values. Output JSON only.
|
prompts/kie_open.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Extract all key-value pairs from the image and return them as a JSON object. Keep the values exactly as shown, use empty strings for empty values, and use arrays for repeated values. Output JSON only.
|
prompts/kie_schema.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Extract the information from the image using exactly this JSON schema:
|
| 2 |
+
{schema_json}
|
| 3 |
+
Keep the values exactly as shown and use empty strings for missing values. Output JSON only.
|
prompts/plain_ocr.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Read all the text in the image. Directly output the content.
|
| 2 |
+
|
| 3 |
+
Rules:
|
| 4 |
+
1. Preserve the original text exactly without translation, paraphrasing, correction, or completion.
|
| 5 |
+
2. Follow the natural human reading order.
|
| 6 |
+
3. Output plain text only. Use spaces and line breaks only to preserve text separation and reading order.
|
| 7 |
+
4. Do not output Markdown, HTML, JSON, bounding boxes, category labels, explanations, or code fences.
|
prompts/position_aware_vqa.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{question} Output the answer with 'answer' and 'bbox'. 'bbox' refers to the bounding box position of the 'answer' content in the image. The output format is "answer:gt, bbox:(x1,y1,x2,y2)", where the bbox is the coordinates of the top-left corner and the bottom-right corners. The 'bbox' should be normalized coordinates ranging from 0 to 1000 by the image width and height.
|
| 2 |
+
Your answer should be in the JSON format:
|
| 3 |
+
{
|
| 4 |
+
"answer": "..", # The answer
|
| 5 |
+
"bbox": "(x1,y1,x2,y2)" # The bounding box position of the 'answer'
|
| 6 |
+
}
|
prompts/reference_grounding.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Locate every visible region corresponding to the requested reference or references in the image.
|
| 2 |
+
|
| 3 |
+
Requested references (JSON array, source order):
|
| 4 |
+
{references}
|
| 5 |
+
|
| 6 |
+
Return a single JSON array in the requested reference order.
|
| 7 |
+
|
| 8 |
+
Each element follows this schema:
|
| 9 |
+
`{"bbox": [x_min, y_min, x_max, y_max], "reference": "string"}`
|
| 10 |
+
|
| 11 |
+
Rules:
|
| 12 |
+
1. Each element must represent exactly one requested reference and one corresponding visible region. If one requested reference corresponds to multiple separate regions, output one element for each bbox and repeat the same reference.
|
| 13 |
+
2. The bbox coordinates are integers scaled to [0, 1000).
|
| 14 |
+
3. Each bbox must tightly enclose its corresponding referenced entity or region and must not include unrelated content.
|
| 15 |
+
4. Copy each reference exactly from the requested-references array without translation, paraphrasing, correction, or completion. Do not output any reference that was not requested.
|
| 16 |
+
5. Do not add leading or trailing whitespace to reference.
|
| 17 |
+
6. Return bounding boxes only. Do not return points, polygons, masks, explanations, or confidence scores.
|
| 18 |
+
7. Every requested reference must appear in at least one output element.
|
| 19 |
+
8. Output strictly valid JSON only. Do not output Markdown, explanations, or code fences. Properly escape quotes, backslashes, and other special characters inside reference.
|
prompts/region_ocr.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Transcribe all readable text in the specified image region. Directly output the content.
|
| 2 |
+
Target region: {target_region}
|
| 3 |
+
|
| 4 |
+
Rules:
|
| 5 |
+
1. Read only the visible text inside the target region.
|
| 6 |
+
2. Preserve the original text exactly without translation, paraphrasing, correction, completion, or omission.
|
| 7 |
+
3. Follow the natural human reading order within the target region.
|
| 8 |
+
4. Output plain text only. Use spaces and line breaks only where needed to preserve text separation and reading order.
|
| 9 |
+
5. Do not output the target region, bounding boxes, coordinates, category labels, Markdown formatting, HTML, JSON, explanations, or code fences.
|
prompts/text_translation.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Translate all readable text in the specified image region into {target_language}. Directly output the translated content.
|
| 2 |
+
Target region: {target_region}
|
| 3 |
+
|
| 4 |
+
Rules:
|
| 5 |
+
1. Translate only the visible text inside the target region.
|
| 6 |
+
2. Preserve the original meaning, names, numbers, symbols, and line order. Do not add explanations, corrections, or omitted context.
|
| 7 |
+
3. Keep content that should not be translated unchanged.
|
| 8 |
+
4. Output plain translated text only. Use spaces and line breaks only where needed to preserve the source separation.
|
| 9 |
+
5. Do not output the source text, language labels, bounding boxes, Markdown, JSON, or code fences.
|
prompts/visual_question_answering.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{question}
|
| 2 |
+
|
| 3 |
+
Please reason step by step, and put your final answer within \boxed{}.
|
prompts/word_spotting.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Locate and transcribe every readable word or text token in the image. Return a single JSON array in natural reading order.
|
| 2 |
+
|
| 3 |
+
Each element follows this schema:
|
| 4 |
+
`{"bbox": [x_min, y_min, x_max, y_max], "text": "string"}`
|
| 5 |
+
|
| 6 |
+
Rules:
|
| 7 |
+
1. Use word-level annotations. Each element must represent exactly one visible word or text token. Do not merge multiple words into a text line, paragraph, label, or table cell.
|
| 8 |
+
2. The bbox coordinates are integers scaled to [0, 1000).
|
| 9 |
+
3. Each bbox must tightly enclose its corresponding visible word or text token and must not include adjacent words or unrelated text regions.
|
| 10 |
+
4. Preserve the original text exactly without translation, paraphrasing, correction, or completion.
|
| 11 |
+
5. Do not add leading or trailing whitespace to text.
|
| 12 |
+
6. Include every readable word or text token and exclude non-text visual content.
|
| 13 |
+
7. Output strictly valid JSON only. Do not output Markdown, explanations, or code fences. Properly escape quotes, backslashes, and other special characters inside text.
|
tokenizer.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42
|
| 3 |
+
size 12807982
|
tokenizer_config.json
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"add_prefix_space": false,
|
| 3 |
+
"added_tokens_decoder": {
|
| 4 |
+
"248044": {
|
| 5 |
+
"content": "<|endoftext|>",
|
| 6 |
+
"lstrip": false,
|
| 7 |
+
"normalized": false,
|
| 8 |
+
"rstrip": false,
|
| 9 |
+
"single_word": false,
|
| 10 |
+
"special": true
|
| 11 |
+
},
|
| 12 |
+
"248045": {
|
| 13 |
+
"content": "<|im_start|>",
|
| 14 |
+
"lstrip": false,
|
| 15 |
+
"normalized": false,
|
| 16 |
+
"rstrip": false,
|
| 17 |
+
"single_word": false,
|
| 18 |
+
"special": true
|
| 19 |
+
},
|
| 20 |
+
"248046": {
|
| 21 |
+
"content": "<|im_end|>",
|
| 22 |
+
"lstrip": false,
|
| 23 |
+
"normalized": false,
|
| 24 |
+
"rstrip": false,
|
| 25 |
+
"single_word": false,
|
| 26 |
+
"special": true
|
| 27 |
+
},
|
| 28 |
+
"248047": {
|
| 29 |
+
"content": "<|object_ref_start|>",
|
| 30 |
+
"lstrip": false,
|
| 31 |
+
"normalized": false,
|
| 32 |
+
"rstrip": false,
|
| 33 |
+
"single_word": false,
|
| 34 |
+
"special": true
|
| 35 |
+
},
|
| 36 |
+
"248048": {
|
| 37 |
+
"content": "<|object_ref_end|>",
|
| 38 |
+
"lstrip": false,
|
| 39 |
+
"normalized": false,
|
| 40 |
+
"rstrip": false,
|
| 41 |
+
"single_word": false,
|
| 42 |
+
"special": true
|
| 43 |
+
},
|
| 44 |
+
"248049": {
|
| 45 |
+
"content": "<|box_start|>",
|
| 46 |
+
"lstrip": false,
|
| 47 |
+
"normalized": false,
|
| 48 |
+
"rstrip": false,
|
| 49 |
+
"single_word": false,
|
| 50 |
+
"special": true
|
| 51 |
+
},
|
| 52 |
+
"248050": {
|
| 53 |
+
"content": "<|box_end|>",
|
| 54 |
+
"lstrip": false,
|
| 55 |
+
"normalized": false,
|
| 56 |
+
"rstrip": false,
|
| 57 |
+
"single_word": false,
|
| 58 |
+
"special": true
|
| 59 |
+
},
|
| 60 |
+
"248051": {
|
| 61 |
+
"content": "<|quad_start|>",
|
| 62 |
+
"lstrip": false,
|
| 63 |
+
"normalized": false,
|
| 64 |
+
"rstrip": false,
|
| 65 |
+
"single_word": false,
|
| 66 |
+
"special": true
|
| 67 |
+
},
|
| 68 |
+
"248052": {
|
| 69 |
+
"content": "<|quad_end|>",
|
| 70 |
+
"lstrip": false,
|
| 71 |
+
"normalized": false,
|
| 72 |
+
"rstrip": false,
|
| 73 |
+
"single_word": false,
|
| 74 |
+
"special": true
|
| 75 |
+
},
|
| 76 |
+
"248053": {
|
| 77 |
+
"content": "<|vision_start|>",
|
| 78 |
+
"lstrip": false,
|
| 79 |
+
"normalized": false,
|
| 80 |
+
"rstrip": false,
|
| 81 |
+
"single_word": false,
|
| 82 |
+
"special": true
|
| 83 |
+
},
|
| 84 |
+
"248054": {
|
| 85 |
+
"content": "<|vision_end|>",
|
| 86 |
+
"lstrip": false,
|
| 87 |
+
"normalized": false,
|
| 88 |
+
"rstrip": false,
|
| 89 |
+
"single_word": false,
|
| 90 |
+
"special": true
|
| 91 |
+
},
|
| 92 |
+
"248055": {
|
| 93 |
+
"content": "<|vision_pad|>",
|
| 94 |
+
"lstrip": false,
|
| 95 |
+
"normalized": false,
|
| 96 |
+
"rstrip": false,
|
| 97 |
+
"single_word": false,
|
| 98 |
+
"special": true
|
| 99 |
+
},
|
| 100 |
+
"248056": {
|
| 101 |
+
"content": "<|image_pad|>",
|
| 102 |
+
"lstrip": false,
|
| 103 |
+
"normalized": false,
|
| 104 |
+
"rstrip": false,
|
| 105 |
+
"single_word": false,
|
| 106 |
+
"special": true
|
| 107 |
+
},
|
| 108 |
+
"248057": {
|
| 109 |
+
"content": "<|video_pad|>",
|
| 110 |
+
"lstrip": false,
|
| 111 |
+
"normalized": false,
|
| 112 |
+
"rstrip": false,
|
| 113 |
+
"single_word": false,
|
| 114 |
+
"special": true
|
| 115 |
+
},
|
| 116 |
+
"248058": {
|
| 117 |
+
"content": "<tool_call>",
|
| 118 |
+
"lstrip": false,
|
| 119 |
+
"normalized": false,
|
| 120 |
+
"rstrip": false,
|
| 121 |
+
"single_word": false,
|
| 122 |
+
"special": false
|
| 123 |
+
},
|
| 124 |
+
"248059": {
|
| 125 |
+
"content": "</tool_call>",
|
| 126 |
+
"lstrip": false,
|
| 127 |
+
"normalized": false,
|
| 128 |
+
"rstrip": false,
|
| 129 |
+
"single_word": false,
|
| 130 |
+
"special": false
|
| 131 |
+
},
|
| 132 |
+
"248060": {
|
| 133 |
+
"content": "<|fim_prefix|>",
|
| 134 |
+
"lstrip": false,
|
| 135 |
+
"normalized": false,
|
| 136 |
+
"rstrip": false,
|
| 137 |
+
"single_word": false,
|
| 138 |
+
"special": false
|
| 139 |
+
},
|
| 140 |
+
"248061": {
|
| 141 |
+
"content": "<|fim_middle|>",
|
| 142 |
+
"lstrip": false,
|
| 143 |
+
"normalized": false,
|
| 144 |
+
"rstrip": false,
|
| 145 |
+
"single_word": false,
|
| 146 |
+
"special": false
|
| 147 |
+
},
|
| 148 |
+
"248062": {
|
| 149 |
+
"content": "<|fim_suffix|>",
|
| 150 |
+
"lstrip": false,
|
| 151 |
+
"normalized": false,
|
| 152 |
+
"rstrip": false,
|
| 153 |
+
"single_word": false,
|
| 154 |
+
"special": false
|
| 155 |
+
},
|
| 156 |
+
"248063": {
|
| 157 |
+
"content": "<|fim_pad|>",
|
| 158 |
+
"lstrip": false,
|
| 159 |
+
"normalized": false,
|
| 160 |
+
"rstrip": false,
|
| 161 |
+
"single_word": false,
|
| 162 |
+
"special": false
|
| 163 |
+
},
|
| 164 |
+
"248064": {
|
| 165 |
+
"content": "<|repo_name|>",
|
| 166 |
+
"lstrip": false,
|
| 167 |
+
"normalized": false,
|
| 168 |
+
"rstrip": false,
|
| 169 |
+
"single_word": false,
|
| 170 |
+
"special": false
|
| 171 |
+
},
|
| 172 |
+
"248065": {
|
| 173 |
+
"content": "<|file_sep|>",
|
| 174 |
+
"lstrip": false,
|
| 175 |
+
"normalized": false,
|
| 176 |
+
"rstrip": false,
|
| 177 |
+
"single_word": false,
|
| 178 |
+
"special": false
|
| 179 |
+
},
|
| 180 |
+
"248066": {
|
| 181 |
+
"content": "<tool_response>",
|
| 182 |
+
"lstrip": false,
|
| 183 |
+
"normalized": false,
|
| 184 |
+
"rstrip": false,
|
| 185 |
+
"single_word": false,
|
| 186 |
+
"special": false
|
| 187 |
+
},
|
| 188 |
+
"248067": {
|
| 189 |
+
"content": "</tool_response>",
|
| 190 |
+
"lstrip": false,
|
| 191 |
+
"normalized": false,
|
| 192 |
+
"rstrip": false,
|
| 193 |
+
"single_word": false,
|
| 194 |
+
"special": false
|
| 195 |
+
},
|
| 196 |
+
"248068": {
|
| 197 |
+
"content": "<think>",
|
| 198 |
+
"lstrip": false,
|
| 199 |
+
"normalized": false,
|
| 200 |
+
"rstrip": false,
|
| 201 |
+
"single_word": false,
|
| 202 |
+
"special": false
|
| 203 |
+
},
|
| 204 |
+
"248069": {
|
| 205 |
+
"content": "</think>",
|
| 206 |
+
"lstrip": false,
|
| 207 |
+
"normalized": false,
|
| 208 |
+
"rstrip": false,
|
| 209 |
+
"single_word": false,
|
| 210 |
+
"special": false
|
| 211 |
+
},
|
| 212 |
+
"248070": {
|
| 213 |
+
"content": "<|audio_start|>",
|
| 214 |
+
"lstrip": false,
|
| 215 |
+
"normalized": false,
|
| 216 |
+
"rstrip": false,
|
| 217 |
+
"single_word": false,
|
| 218 |
+
"special": true
|
| 219 |
+
},
|
| 220 |
+
"248071": {
|
| 221 |
+
"content": "<|audio_end|>",
|
| 222 |
+
"lstrip": false,
|
| 223 |
+
"normalized": false,
|
| 224 |
+
"rstrip": false,
|
| 225 |
+
"single_word": false,
|
| 226 |
+
"special": true
|
| 227 |
+
},
|
| 228 |
+
"248072": {
|
| 229 |
+
"content": "<tts_pad>",
|
| 230 |
+
"lstrip": false,
|
| 231 |
+
"normalized": false,
|
| 232 |
+
"rstrip": false,
|
| 233 |
+
"single_word": false,
|
| 234 |
+
"special": true
|
| 235 |
+
},
|
| 236 |
+
"248073": {
|
| 237 |
+
"content": "<tts_text_bos>",
|
| 238 |
+
"lstrip": false,
|
| 239 |
+
"normalized": false,
|
| 240 |
+
"rstrip": false,
|
| 241 |
+
"single_word": false,
|
| 242 |
+
"special": true
|
| 243 |
+
},
|
| 244 |
+
"248074": {
|
| 245 |
+
"content": "<tts_text_eod>",
|
| 246 |
+
"lstrip": false,
|
| 247 |
+
"normalized": false,
|
| 248 |
+
"rstrip": false,
|
| 249 |
+
"single_word": false,
|
| 250 |
+
"special": true
|
| 251 |
+
},
|
| 252 |
+
"248075": {
|
| 253 |
+
"content": "<tts_text_bos_single>",
|
| 254 |
+
"lstrip": false,
|
| 255 |
+
"normalized": false,
|
| 256 |
+
"rstrip": false,
|
| 257 |
+
"single_word": false,
|
| 258 |
+
"special": true
|
| 259 |
+
},
|
| 260 |
+
"248076": {
|
| 261 |
+
"content": "<|audio_pad|>",
|
| 262 |
+
"lstrip": false,
|
| 263 |
+
"normalized": false,
|
| 264 |
+
"rstrip": false,
|
| 265 |
+
"single_word": false,
|
| 266 |
+
"special": true
|
| 267 |
+
}
|
| 268 |
+
},
|
| 269 |
+
"additional_special_tokens": [
|
| 270 |
+
"<|im_start|>",
|
| 271 |
+
"<|im_end|>",
|
| 272 |
+
"<|object_ref_start|>",
|
| 273 |
+
"<|object_ref_end|>",
|
| 274 |
+
"<|box_start|>",
|
| 275 |
+
"<|box_end|>",
|
| 276 |
+
"<|quad_start|>",
|
| 277 |
+
"<|quad_end|>",
|
| 278 |
+
"<|vision_start|>",
|
| 279 |
+
"<|vision_end|>",
|
| 280 |
+
"<|vision_pad|>",
|
| 281 |
+
"<|image_pad|>",
|
| 282 |
+
"<|video_pad|>"
|
| 283 |
+
],
|
| 284 |
+
"bos_token": null,
|
| 285 |
+
"chat_template": "{%- set image_count = namespace(value=0) %}\n{%- set video_count = namespace(value=0) %}\n{%- macro render_content(content, do_vision_count, is_system_content=false) %}\n {%- if content is string %}\n {{- content }}\n {%- elif content is iterable and content is not mapping %}\n {%- for item in content %}\n {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}\n {%- if is_system_content %}\n {{- raise_exception('System message cannot contain images.') }}\n {%- endif %}\n {%- if do_vision_count %}\n {%- set image_count.value = image_count.value + 1 %}\n {%- endif %}\n {%- if add_vision_id %}\n {{- 'Picture ' ~ image_count.value ~ ': ' }}\n {%- endif %}\n {{- '<|vision_start|><|image_pad|><|vision_end|>' }}\n {%- elif 'video' in item or item.type == 'video' %}\n {%- if is_system_content %}\n {{- raise_exception('System message cannot contain videos.') }}\n {%- endif %}\n {%- if do_vision_count %}\n {%- set video_count.value = video_count.value + 1 %}\n {%- endif %}\n {%- if add_vision_id %}\n {{- 'Video ' ~ video_count.value ~ ': ' }}\n {%- endif %}\n {{- '<|vision_start|><|video_pad|><|vision_end|>' }}\n {%- elif 'text' in item %}\n {{- item.text }}\n {%- else %}\n {{- raise_exception('Unexpected item type in content.') }}\n {%- endif %}\n {%- endfor %}\n {%- elif content is none or content is undefined %}\n {{- '' }}\n {%- else %}\n {{- raise_exception('Unexpected content type.') }}\n {%- endif %}\n{%- endmacro %}\n{%- if not messages %}\n {{- raise_exception('No messages provided.') }}\n{%- endif %}\n{%- if tools and tools is iterable and tools is not mapping %}\n {{- '<|im_start|>system\\n' }}\n {{- \"# Tools\\n\\nYou have access to the following functions:\\n\\n<tools>\" }}\n {%- for tool in tools %}\n {{- \"\\n\" }}\n {{- tool | tojson }}\n {%- endfor %}\n {{- \"\\n</tools>\" }}\n {{- '\\n\\nIf you choose to call a function ONLY reply in the following format with NO suffix:\\n\\n<tool_call>\\n<function=example_function_name>\\n<parameter=example_parameter_1>\\nvalue_1\\n</parameter>\\n<parameter=example_parameter_2>\\nThis is the value for the second parameter\\nthat can span\\nmultiple lines\\n</parameter>\\n</function>\\n</tool_call>\\n\\n<IMPORTANT>\\nReminder:\\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\\n- Required parameters MUST be specified\\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\\n</IMPORTANT>' }}\n {%- if messages[0].role == 'system' %}\n {%- set content = render_content(messages[0].content, false, true)|trim %}\n {%- if content %}\n {{- '\\n\\n' + content }}\n {%- endif %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n{%- else %}\n {%- if messages[0].role == 'system' %}\n {%- set content = render_content(messages[0].content, false, true)|trim %}\n {{- '<|im_start|>system\\n' + content + '<|im_end|>\\n' }}\n {%- endif %}\n{%- endif %}\n{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}\n{%- for message in messages[::-1] %}\n {%- set index = (messages|length - 1) - loop.index0 %}\n {%- if ns.multi_step_tool and message.role == \"user\" %}\n {%- set content = render_content(message.content, false)|trim %}\n {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}\n {%- set ns.multi_step_tool = false %}\n {%- set ns.last_query_index = index %}\n {%- endif %}\n {%- endif %}\n{%- endfor %}\n{%- if ns.multi_step_tool %}\n {{- raise_exception('No user query found in messages.') }}\n{%- endif %}\n{%- for message in messages %}\n {%- set content = render_content(message.content, true)|trim %}\n {%- if message.role == \"system\" %}\n {%- if not loop.first %}\n {{- raise_exception('System message must be at the beginning.') }}\n {%- endif %}\n {%- elif message.role == \"user\" %}\n {{- '<|im_start|>' + message.role + '\\n' + content + '<|im_end|>' + '\\n' }}\n {%- elif message.role == \"assistant\" %}\n {%- set reasoning_content = '' %}\n {%- if message.reasoning_content is string %}\n {%- set reasoning_content = message.reasoning_content %}\n {%- else %}\n {%- if '</think>' in content %}\n {%- set reasoning_content = content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n') %}\n {%- set content = content.split('</think>')[-1].lstrip('\\n') %}\n {%- endif %}\n {%- endif %}\n {%- set reasoning_content = reasoning_content|trim %}\n {%- if loop.index0 > ns.last_query_index %}\n {{- '<|im_start|>' + message.role + '\\n<think>\\n' + reasoning_content + '\\n</think>\\n\\n' + content }}\n {%- else %}\n {{- '<|im_start|>' + message.role + '\\n' + content }}\n {%- endif %}\n {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}\n {%- for tool_call in message.tool_calls %}\n {%- if tool_call.function is defined %}\n {%- set tool_call = tool_call.function %}\n {%- endif %}\n {%- if loop.first %}\n {%- if content|trim %}\n {{- '\\n\\n<tool_call>\\n<function=' + tool_call.name + '>\\n' }}\n {%- else %}\n {{- '<tool_call>\\n<function=' + tool_call.name + '>\\n' }}\n {%- endif %}\n {%- else %}\n {{- '\\n<tool_call>\\n<function=' + tool_call.name + '>\\n' }}\n {%- endif %}\n {%- if tool_call.arguments is defined %}\n {%- for args_name, args_value in tool_call.arguments|items %}\n {{- '<parameter=' + args_name + '>\\n' }}\n {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}\n {{- args_value }}\n {{- '\\n</parameter>\\n' }}\n {%- endfor %}\n {%- endif %}\n {{- '</function>\\n</tool_call>' }}\n {%- endfor %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"tool\" %}\n {%- if loop.previtem and loop.previtem.role != \"tool\" %}\n {{- '<|im_start|>user' }}\n {%- endif %}\n {{- '\\n<tool_response>\\n' }}\n {{- content }}\n {{- '\\n</tool_response>' }}\n {%- if not loop.last and loop.nextitem.role != \"tool\" %}\n {{- '<|im_end|>\\n' }}\n {%- elif loop.last %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n {%- else %}\n {{- raise_exception('Unexpected message role.') }}\n {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n {{- '<|im_start|>assistant\\n' }}\n {%- if enable_thinking is defined and enable_thinking is true %}\n {{- '<think>\\n' }}\n {%- else %}\n {{- '<think>\\n\\n</think>\\n\\n' }}\n {%- endif %}\n{%- endif %}",
|
| 286 |
+
"clean_up_tokenization_spaces": false,
|
| 287 |
+
"eos_token": "<|im_end|>",
|
| 288 |
+
"errors": "replace",
|
| 289 |
+
"model_max_length": 262144,
|
| 290 |
+
"pad_token": "<|endoftext|>",
|
| 291 |
+
"split_special_tokens": false,
|
| 292 |
+
"tokenizer_class": "Qwen2Tokenizer",
|
| 293 |
+
"unk_token": null,
|
| 294 |
+
"add_bos_token": false,
|
| 295 |
+
"pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
|
| 296 |
+
"extra_special_tokens": {
|
| 297 |
+
"audio_bos_token": "<|audio_start|>",
|
| 298 |
+
"audio_eos_token": "<|audio_end|>",
|
| 299 |
+
"audio_token": "<|audio_pad|>",
|
| 300 |
+
"image_token": "<|image_pad|>",
|
| 301 |
+
"video_token": "<|video_pad|>",
|
| 302 |
+
"vision_bos_token": "<|vision_start|>",
|
| 303 |
+
"vision_eos_token": "<|vision_end|>"
|
| 304 |
+
}
|
| 305 |
+
}
|
video_preprocessor_config.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"size": {
|
| 3 |
+
"longest_edge": 25165824,
|
| 4 |
+
"shortest_edge": 4096
|
| 5 |
+
},
|
| 6 |
+
"patch_size": 16,
|
| 7 |
+
"temporal_patch_size": 2,
|
| 8 |
+
"merge_size": 2,
|
| 9 |
+
"image_mean": [
|
| 10 |
+
0.5,
|
| 11 |
+
0.5,
|
| 12 |
+
0.5
|
| 13 |
+
],
|
| 14 |
+
"image_std": [
|
| 15 |
+
0.5,
|
| 16 |
+
0.5,
|
| 17 |
+
0.5
|
| 18 |
+
],
|
| 19 |
+
"processor_class": "Qwen3VLProcessor",
|
| 20 |
+
"video_processor_type": "Qwen3VLVideoProcessor"
|
| 21 |
+
}
|
vocab.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|