Skip to content

Commit 95f015e

Browse files
authored
Add support for SmolDocling
- Add support for SmolDocling - Update benchmark and refactor code - Add script to plot model comparison
1 parent 65cbd7f commit 95f015e

13 files changed

Lines changed: 1138 additions & 589 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,5 @@ _Note:_ Benchmarks are currently done in the zero-shot setting.
141141
| 19 | google/gemma-3-27b-it | 0.624 (±0.357) | 0.750 (±0.327) | 24.51 | 0.00020 |
142142
| 20 | gpt-4.1 | 0.622 (±0.314) | 0.782 (±0.191) | 34.66 | 0.01461 |
143143
| 21 | meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo | 0.559 (±0.233) | 0.822 (±0.119) | 27.74 | 0.01102 |
144-
| 22 | qwen/qwen-2.5-vl-7b-instruct | 0.469 (±0.364) | 0.617 (±0.441) | 13.23 | 0.00060 |
144+
| 22 | ds4sd/SmolDocling-256M-preview | 0.486 (±0.378) | 0.583 (±0.355) | 108.91 | 0.00000 |
145+
| 23 | qwen/qwen-2.5-vl-7b-instruct | 0.469 (±0.364) | 0.617 (±0.441) | 13.23 | 0.00060 |

docs/benchmark.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,12 @@ Here are the detailed parsing performance results for various models, sorted by
215215
- 27.74
216216
- 0.01102
217217
* - 22
218+
- ds4sd/SmolDocling-256M-preview
219+
- 0.486 (±0.378)
220+
- 0.583 (±0.355)
221+
- 108.91
222+
- 0.00000
223+
* - 23
218224
- qwen/qwen-2.5-vl-7b-instruct
219225
- 0.469 (±0.364)
220226
- 0.617 (±0.441)

examples/example_notebook.ipynb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,44 @@
4040
"# os.environ[\"OPENAI_API_KEY\"] = \"<>\""
4141
]
4242
},
43+
{
44+
"cell_type": "code",
45+
"execution_count": 8,
46+
"metadata": {},
47+
"outputs": [
48+
{
49+
"name": "stderr",
50+
"output_type": "stream",
51+
"text": [
52+
"\u001b[32m2025-05-25 20:06:10.839\u001b[0m | \u001b[34m\u001b[1mDEBUG \u001b[0m | \u001b[36mlexoid.api\u001b[0m:\u001b[36mparse_chunk\u001b[0m:\u001b[36m66\u001b[0m - \u001b[34m\u001b[1mUsing LLM parser\u001b[0m\n",
53+
"Truncation was not explicitly activated but `max_length` is provided a specific value, please use `truncation=True` to explicitly truncate examples to max length. Defaulting to 'longest_first' truncation strategy. If you encode pairs of sequences (GLUE-style) with the tokenizer you can select this strategy more precisely by providing a specific strategy to `truncation`.\n"
54+
]
55+
}
56+
],
57+
"source": [
58+
"result = parse(\"inputs/cvs_coupon.jpg\", parser_type=\"LLM_PARSE\", model=\"ds4sd/SmolDocling-256M-preview\")"
59+
]
60+
},
61+
{
62+
"cell_type": "code",
63+
"execution_count": 9,
64+
"metadata": {},
65+
"outputs": [
66+
{
67+
"data": {
68+
"text/plain": [
69+
"'<doctag><section_header_level_1><loc_148><loc_102><loc_423><loc_149>$2.00 off</section_header_level_1>\\n<text><loc_148><loc_166><loc_423><loc_233>$2 off CVS HEALTH Topical Pain products</text>\\n<text><loc_148><loc_231><loc_423><loc_244>Expires 11/02/2024 (up to $2.00 value)</text>\\n<text><loc_182><loc_286><loc_345><loc_298>7168 4009 6700 2002</text>\\n<text><loc_109><loc_298><loc_423><loc_326>ExtraCare card required. See www.cvs.com/couponpolicy or policy at register for details.</text>\\n<text><loc_109><loc_326><loc_278><loc_340>ExtraCare Card</text>\\n<text><loc_278><loc_326><loc_293><loc_340>1</text>\\n<text><loc_293><loc_326><loc_298><loc_340>7</text>\\n<text><loc_298><loc_326><loc_303><loc_340>1</text>\\n<text><loc_259><loc_365><loc_264><loc_379>n</text>\\n</doctag>'"
70+
]
71+
},
72+
"execution_count": 9,
73+
"metadata": {},
74+
"output_type": "execute_result"
75+
}
76+
],
77+
"source": [
78+
"result[\"raw\"]"
79+
]
80+
},
4381
{
4482
"cell_type": "code",
4583
"execution_count": 3,

lexoid/core/parse_type/llm_parser.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from PIL import Image
12
import base64
23
import io
34
import mimetypes
@@ -14,6 +15,8 @@
1415
from openai import OpenAI
1516
from requests.exceptions import HTTPError
1617
from together import Together
18+
import torch
19+
from transformers import AutoProcessor, AutoModelForImageTextToText
1720

1821
from lexoid.core.prompt_templates import (
1922
INSTRUCTIONS_ADD_PG_BREAK,
@@ -65,13 +68,18 @@ def get_api_provider_for_model(model: str) -> str:
6568
return "fireworks"
6669
if model.startswith("claude"):
6770
return "anthropic"
71+
if model.startswith("ds4sd/SmolDocling"):
72+
return "local"
6873
raise ValueError(f"Unsupported model: {model}")
6974

7075

7176
@retry_on_http_error
7277
def parse_llm_doc(path: str, **kwargs) -> List[Dict] | str:
73-
if "api_provider" in kwargs and kwargs["api_provider"]:
74-
return parse_with_api(path, api=kwargs["api_provider"], **kwargs)
78+
if "api_provider" in kwargs:
79+
if kwargs["api_provider"] == "local":
80+
return parse_with_local_model(path, **kwargs)
81+
elif kwargs["api_provider"]:
82+
return parse_with_api(path, api=kwargs["api_provider"], **kwargs)
7583

7684
model = kwargs.get("model", "gemini-2.0-flash")
7785
kwargs["model"] = model
@@ -80,8 +88,70 @@ def parse_llm_doc(path: str, **kwargs) -> List[Dict] | str:
8088

8189
if api_provider == "gemini":
8290
return parse_with_gemini(path, **kwargs)
83-
else:
84-
return parse_with_api(path, api=api_provider, **kwargs)
91+
elif api_provider == "local":
92+
return parse_with_local_model(path, **kwargs)
93+
return parse_with_api(path, api=api_provider, **kwargs)
94+
95+
96+
def parse_with_local_model(path: str, **kwargs) -> List[Dict] | str:
97+
# Reference: https://huggingface.co/spaces/aabdullah27/SmolDocling-OCR-App/blob/main/app.py
98+
model_name = kwargs.get("model", "ds4sd/SmolDocling-256M-preview")
99+
if not model_name.startswith("ds4sd/SmolDocling"):
100+
raise ValueError(
101+
f"Local model parsing is only supported for 'ds4sd/SmolDocling*', got {model_name}"
102+
)
103+
processor = AutoProcessor.from_pretrained(model_name)
104+
model = AutoModelForImageTextToText.from_pretrained(model_name)
105+
images = convert_path_to_images(path)
106+
pil_images = [
107+
Image.open(io.BytesIO(base64.b64decode(image.split(",")[1])))
108+
for _, image in images
109+
]
110+
intruction = kwargs.get("docling_command", "Convert this page to docling.")
111+
messages = [
112+
{
113+
"role": "user",
114+
"content": [
115+
{"type": "image"},
116+
{"type": "text", "text": intruction},
117+
],
118+
},
119+
{
120+
"role": "assistant",
121+
"content": [
122+
{"type": "text", "text": "<output>\n"},
123+
],
124+
},
125+
]
126+
prompt = processor.apply_chat_template(messages, add_generation_prompt=True)
127+
inputs = processor(text=prompt, images=pil_images, return_tensors="pt")
128+
with torch.no_grad():
129+
generated_ids = model.generate(
130+
**inputs, max_new_tokens=1500, do_sample=False, num_beams=1, temperature=1.0
131+
)
132+
133+
prompt_length = inputs.input_ids.shape[1]
134+
trimmed_generated_ids = generated_ids[:, prompt_length:]
135+
output = (
136+
processor.batch_decode(
137+
trimmed_generated_ids,
138+
skip_special_tokens=False,
139+
)[0]
140+
.strip()
141+
.replace("<end_of_utterance>", "")
142+
)
143+
return {
144+
"raw": output,
145+
"segments": [
146+
{
147+
"metadata": {"page": kwargs.get("start", 0) + 1},
148+
"content": output,
149+
}
150+
],
151+
"title": kwargs["title"],
152+
"url": kwargs.get("url", ""),
153+
"parent_title": kwargs.get("parent_title", ""),
154+
}
85155

86156

87157
def parse_with_gemini(path: str, **kwargs) -> List[Dict] | str:
@@ -181,6 +251,27 @@ def parse_image_with_gemini(
181251
}
182252

183253

254+
def convert_path_to_images(path):
255+
mime_type, _ = mimetypes.guess_type(path)
256+
if mime_type and mime_type.startswith("image"):
257+
# Single image processing
258+
with open(path, "rb") as img_file:
259+
image_base64 = base64.b64encode(img_file.read()).decode("utf-8")
260+
return [(0, f"data:{mime_type};base64,{image_base64}")]
261+
elif mime_type and mime_type.startswith("application/pdf"):
262+
# PDF processing
263+
pdf_document = pdfium.PdfDocument(path)
264+
return [
265+
(
266+
page_num,
267+
f"data:image/png;base64,{convert_pdf_page_to_base64(pdf_document, page_num)}",
268+
)
269+
for page_num in range(len(pdf_document))
270+
]
271+
else:
272+
raise ValueError(f"Unsupported file type: {mime_type}")
273+
274+
184275
def convert_pdf_page_to_base64(
185276
pdf_document: pdfium.PdfDocument, page_number: int
186277
) -> str:
@@ -376,6 +467,7 @@ def parse_with_api(path: str, api: str, **kwargs) -> List[Dict] | str:
376467
)
377468
for page_num in range(len(pdf_document))
378469
]
470+
images = convert_path_to_images(path)
379471

380472
# Process each page/image
381473
all_results = []

lexoid/core/utils.py

Lines changed: 0 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import os
55
import re
66
import sys
7-
from difflib import SequenceMatcher
87
from hashlib import md5
98
from typing import Dict, List, Optional
109
from urllib.parse import urlparse
@@ -16,20 +15,14 @@
1615
from bs4 import BeautifulSoup
1716
from docx2pdf import convert
1817
from loguru import logger
19-
from markdown import markdown
2018
from markdownify import markdownify as md
21-
from sklearn.feature_extraction.text import TfidfVectorizer
22-
from sklearn.metrics.pairwise import cosine_similarity
2319
from PIL import Image
2420
from PyQt5.QtCore import QMarginsF, QUrl
2521
from PyQt5.QtGui import QPageLayout, QPageSize
2622
from PyQt5.QtPrintSupport import QPrinter
2723
from PyQt5.QtWebEngineWidgets import QWebEngineView
2824
from PyQt5.QtWidgets import QApplication
2925

30-
# Source: https://stackoverflow.com/a/12982689
31-
HTML_TAG_PATTERN = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
32-
3326

3427
def split_pdf(input_path: str, output_dir: str, pages_per_split: int):
3528
paths = []
@@ -69,110 +62,6 @@ def convert_image_to_pdf(image_path: str) -> bytes:
6962
return pdf_buffer.getvalue()
7063

7164

72-
def remove_html_tags(text: str):
73-
html = markdown(text, extensions=["tables"])
74-
return re.sub(HTML_TAG_PATTERN, " ", html)
75-
76-
77-
def clean_text(txt):
78-
# Remove LaTeX commands (e.g. \command, \command[args]{args})
79-
txt = re.sub(r"\\[a-zA-Z]+(\[[^\]]*\])?(\{[^}]*\})?", " ", txt)
80-
81-
# Replace all blocks of whitespace (including tabs and newlines) with a single space
82-
txt = re.sub(r"\s+", " ", txt)
83-
84-
# Remove all non-alphanumeric characters except spaces
85-
txt = re.sub(r"[^a-zA-Z0-9 ]", " ", txt)
86-
87-
return txt.strip()
88-
89-
90-
def cosine_text_similarity(text1, text2):
91-
texts = [clean_text(text1), clean_text(text2)]
92-
vectorizer = TfidfVectorizer().fit_transform(texts)
93-
return cosine_similarity(vectorizer[0], vectorizer[1])[0][0]
94-
95-
96-
def jaccard_text_similarity(text1: str, text2: str) -> float:
97-
set1 = set(clean_text(text1).split())
98-
set2 = set(clean_text(text2).split())
99-
intersection = len(set1.intersection(set2))
100-
union = len(set1.union(set2))
101-
return intersection / union if union > 0 else 0.0
102-
103-
104-
def precision_recall_f1_score(text1: str, text2: str) -> Dict[str, float]:
105-
"""
106-
Calculate precision, recall, and F1 score between two texts.
107-
Precision = TP / (TP + FP)
108-
Recall = TP / (TP + FN)
109-
F1 Score = 2 * (Precision * Recall) / (Precision + Recall)
110-
"""
111-
set1 = set(clean_text(text1).split())
112-
set2 = set(clean_text(text2).split())
113-
114-
true_positive = len(set1.intersection(set2))
115-
false_positive = len(set2 - set1)
116-
false_negative = len(set1 - set2)
117-
118-
precision = (
119-
true_positive / (true_positive + false_positive)
120-
if (true_positive + false_positive) > 0
121-
else 0.0
122-
)
123-
recall = (
124-
true_positive / (true_positive + false_negative)
125-
if (true_positive + false_negative) > 0
126-
else 0.0
127-
)
128-
f1_score = (
129-
2 * (precision * recall) / (precision + recall)
130-
if (precision + recall) > 0
131-
else 0.0
132-
)
133-
134-
return {
135-
"precision": precision,
136-
"recall": recall,
137-
"f1_score": f1_score,
138-
}
139-
140-
141-
def calculate_similarities(
142-
text1: str,
143-
text2: str,
144-
ignore_html: bool = True,
145-
diff_save_path: str = "",
146-
) -> float:
147-
"""Calculate similarity ratio between two texts using SequenceMatcher."""
148-
if ignore_html:
149-
text1 = remove_html_tags(text1)
150-
text2 = remove_html_tags(text2)
151-
152-
text1 = clean_text(clean_text(text1))
153-
text2 = clean_text(clean_text(text2))
154-
155-
similarities = {}
156-
157-
sm = SequenceMatcher(None, text1, text2)
158-
similarities["sequence_matcher"] = sm.ratio()
159-
# Save the diff and the texts for debugging
160-
if diff_save_path:
161-
with open(diff_save_path, "w") as f:
162-
f.write(f"Text 1:\n{text1}\n\n")
163-
f.write(f"Text 2:\n{text2}\n\n")
164-
f.write("Differences:\n")
165-
for tag, i1, i2, j1, j2 in sm.get_opcodes():
166-
if tag == "equal":
167-
continue
168-
f.write(f"{tag} {text1[i1:i2]} -> {text2[j1:j2]}\n")
169-
similarities["cosine"] = cosine_text_similarity(text1, text2)
170-
similarities["jaccard"] = jaccard_text_similarity(text1, text2)
171-
similarities.update(precision_recall_f1_score(text1, text2))
172-
173-
return similarities
174-
175-
17665
def convert_pdf_page_to_image(
17766
pdf_document: pypdfium2.PdfDocument, page_number: int
17867
) -> bytes:

0 commit comments

Comments
 (0)