docling-core 2.27.0__py3-none-any.whl → 2.28.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of docling-core might be problematic. Click here for more details.
- docling_core/experimental/serializer/base.py +25 -19
- docling_core/experimental/serializer/common.py +17 -11
- docling_core/experimental/serializer/doctags.py +14 -11
- docling_core/experimental/serializer/html.py +21 -16
- docling_core/experimental/serializer/markdown.py +24 -16
- docling_core/transforms/chunker/hybrid_chunker.py +49 -31
- docling_core/transforms/chunker/tokenizer/__init__.py +1 -0
- docling_core/transforms/chunker/tokenizer/base.py +25 -0
- docling_core/transforms/chunker/tokenizer/huggingface.py +70 -0
- docling_core/transforms/chunker/tokenizer/openai.py +34 -0
- docling_core/transforms/visualizer/__init__.py +1 -0
- docling_core/transforms/visualizer/base.py +23 -0
- docling_core/transforms/visualizer/layout_visualizer.py +212 -0
- docling_core/transforms/visualizer/reading_order_visualizer.py +149 -0
- docling_core/types/doc/document.py +25 -3
- docling_core/types/doc/page.py +4 -3
- docling_core/types/legacy_doc/document.py +2 -2
- {docling_core-2.27.0.dist-info → docling_core-2.28.1.dist-info}/METADATA +4 -2
- {docling_core-2.27.0.dist-info → docling_core-2.28.1.dist-info}/RECORD +22 -14
- {docling_core-2.27.0.dist-info → docling_core-2.28.1.dist-info}/LICENSE +0 -0
- {docling_core-2.27.0.dist-info → docling_core-2.28.1.dist-info}/WHEEL +0 -0
- {docling_core-2.27.0.dist-info → docling_core-2.28.1.dist-info}/entry_points.txt +0 -0
|
@@ -8,27 +8,21 @@ import warnings
|
|
|
8
8
|
from functools import cached_property
|
|
9
9
|
from typing import Any, Iterable, Iterator, Optional, Union
|
|
10
10
|
|
|
11
|
-
from pydantic import
|
|
12
|
-
BaseModel,
|
|
13
|
-
ConfigDict,
|
|
14
|
-
PositiveInt,
|
|
15
|
-
TypeAdapter,
|
|
16
|
-
computed_field,
|
|
17
|
-
model_validator,
|
|
18
|
-
)
|
|
19
|
-
from typing_extensions import Self
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
|
|
20
12
|
|
|
21
13
|
from docling_core.transforms.chunker.hierarchical_chunker import (
|
|
22
14
|
ChunkingSerializerProvider,
|
|
23
15
|
)
|
|
16
|
+
from docling_core.transforms.chunker.tokenizer.base import BaseTokenizer
|
|
24
17
|
|
|
25
18
|
try:
|
|
26
19
|
import semchunk
|
|
27
|
-
from transformers import AutoTokenizer, PreTrainedTokenizerBase
|
|
28
20
|
except ImportError:
|
|
29
21
|
raise RuntimeError(
|
|
30
|
-
"
|
|
31
|
-
"
|
|
22
|
+
"Extra required by module: 'chunking' by default (or 'chunking-openai' if "
|
|
23
|
+
"specifically using OpenAI tokenization); to install, run: "
|
|
24
|
+
"`pip install 'docling-core[chunking]'` or "
|
|
25
|
+
"`pip install 'docling-core[chunking-openai]'`"
|
|
32
26
|
)
|
|
33
27
|
|
|
34
28
|
from docling_core.experimental.serializer.base import (
|
|
@@ -45,6 +39,16 @@ from docling_core.transforms.chunker import (
|
|
|
45
39
|
from docling_core.types import DoclingDocument
|
|
46
40
|
|
|
47
41
|
|
|
42
|
+
def _get_default_tokenizer():
|
|
43
|
+
from docling_core.transforms.chunker.tokenizer.huggingface import (
|
|
44
|
+
HuggingFaceTokenizer,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return HuggingFaceTokenizer.from_pretrained(
|
|
48
|
+
model_name="sentence-transformers/all-MiniLM-L6-v2"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
48
52
|
class HybridChunker(BaseChunker):
|
|
49
53
|
r"""Chunker doing tokenization-aware refinements on top of document layout chunking.
|
|
50
54
|
|
|
@@ -58,26 +62,40 @@ class HybridChunker(BaseChunker):
|
|
|
58
62
|
|
|
59
63
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
60
64
|
|
|
61
|
-
tokenizer:
|
|
62
|
-
"sentence-transformers/all-MiniLM-L6-v2"
|
|
63
|
-
)
|
|
64
|
-
max_tokens: int = None # type: ignore[assignment]
|
|
65
|
+
tokenizer: BaseTokenizer = Field(default_factory=_get_default_tokenizer)
|
|
65
66
|
merge_peers: bool = True
|
|
66
67
|
|
|
67
68
|
serializer_provider: BaseSerializerProvider = ChunkingSerializerProvider()
|
|
68
69
|
|
|
69
|
-
@model_validator(mode="
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
70
|
+
@model_validator(mode="before")
|
|
71
|
+
@classmethod
|
|
72
|
+
def _patch(cls, data: Any) -> Any:
|
|
73
|
+
if isinstance(data, dict) and (tokenizer := data.get("tokenizer")):
|
|
74
|
+
max_tokens = data.get("max_tokens")
|
|
75
|
+
if isinstance(tokenizer, BaseTokenizer):
|
|
76
|
+
pass
|
|
77
|
+
else:
|
|
78
|
+
from docling_core.transforms.chunker.tokenizer.huggingface import (
|
|
79
|
+
HuggingFaceTokenizer,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if isinstance(tokenizer, str):
|
|
83
|
+
data["tokenizer"] = HuggingFaceTokenizer.from_pretrained(
|
|
84
|
+
model_name=tokenizer,
|
|
85
|
+
max_tokens=max_tokens,
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
# migrate previous HF-based tokenizers
|
|
89
|
+
kwargs = {"tokenizer": tokenizer}
|
|
90
|
+
if max_tokens is not None:
|
|
91
|
+
kwargs["max_tokens"] = max_tokens
|
|
92
|
+
data["tokenizer"] = HuggingFaceTokenizer(**kwargs)
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def max_tokens(self) -> int:
|
|
97
|
+
"""Get maximum number of tokens allowed."""
|
|
98
|
+
return self.tokenizer.get_max_tokens()
|
|
81
99
|
|
|
82
100
|
@computed_field # type: ignore[misc]
|
|
83
101
|
@cached_property
|
|
@@ -92,7 +110,7 @@ class HybridChunker(BaseChunker):
|
|
|
92
110
|
for t in text:
|
|
93
111
|
total += self._count_text_tokens(t)
|
|
94
112
|
return total
|
|
95
|
-
return
|
|
113
|
+
return self.tokenizer.count_tokens(text=text)
|
|
96
114
|
|
|
97
115
|
class _ChunkLengthInfo(BaseModel):
|
|
98
116
|
total_len: int
|
|
@@ -101,7 +119,7 @@ class HybridChunker(BaseChunker):
|
|
|
101
119
|
|
|
102
120
|
def _count_chunk_tokens(self, doc_chunk: DocChunk):
|
|
103
121
|
ser_txt = self.contextualize(chunk=doc_chunk)
|
|
104
|
-
return
|
|
122
|
+
return self.tokenizer.count_tokens(text=ser_txt)
|
|
105
123
|
|
|
106
124
|
def _doc_chunk_length(self, doc_chunk: DocChunk):
|
|
107
125
|
text_length = self._count_text_tokens(doc_chunk.text)
|
|
@@ -198,7 +216,7 @@ class HybridChunker(BaseChunker):
|
|
|
198
216
|
# captions:
|
|
199
217
|
available_length = self.max_tokens - lengths.other_len
|
|
200
218
|
sem_chunker = semchunk.chunkerify(
|
|
201
|
-
self.
|
|
219
|
+
self.tokenizer.get_tokenizer(), chunk_size=available_length
|
|
202
220
|
)
|
|
203
221
|
if available_length <= 0:
|
|
204
222
|
warnings.warn(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Define the tokenizer types."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Define base classes for tokenization."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseTokenizer(BaseModel, ABC):
|
|
10
|
+
"""Base tokenizer class."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def count_tokens(self, text: str) -> int:
|
|
14
|
+
"""Get number of tokens for given text."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def get_max_tokens(self) -> int:
|
|
19
|
+
"""Get maximum number of tokens allowed."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def get_tokenizer(self) -> Any:
|
|
24
|
+
"""Get underlying tokenizer object."""
|
|
25
|
+
...
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""HuggingFace tokenization."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from os import PathLike
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
from pydantic import ConfigDict, PositiveInt, TypeAdapter, model_validator
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from docling_core.transforms.chunker.tokenizer.base import BaseTokenizer
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from transformers import AutoTokenizer, PreTrainedTokenizerBase
|
|
14
|
+
except ImportError:
|
|
15
|
+
raise RuntimeError(
|
|
16
|
+
"Module requires 'chunking' extra; to install, run: "
|
|
17
|
+
"`pip install 'docling-core[chunking]'`"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HuggingFaceTokenizer(BaseTokenizer):
|
|
22
|
+
"""HuggingFace tokenizer."""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
25
|
+
|
|
26
|
+
tokenizer: PreTrainedTokenizerBase
|
|
27
|
+
max_tokens: int = None # type: ignore[assignment]
|
|
28
|
+
|
|
29
|
+
@model_validator(mode="after")
|
|
30
|
+
def _patch(self) -> Self:
|
|
31
|
+
if hasattr(self.tokenizer, "model_max_length"):
|
|
32
|
+
model_max_tokens: PositiveInt = TypeAdapter(PositiveInt).validate_python(
|
|
33
|
+
self.tokenizer.model_max_length
|
|
34
|
+
)
|
|
35
|
+
user_max_tokens = self.max_tokens or sys.maxsize
|
|
36
|
+
self.max_tokens = min(model_max_tokens, user_max_tokens)
|
|
37
|
+
elif self.max_tokens is None:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"max_tokens must be defined as model does not define model_max_length"
|
|
40
|
+
)
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def count_tokens(self, text: str):
|
|
44
|
+
"""Get number of tokens for given text."""
|
|
45
|
+
return len(self.tokenizer.tokenize(text=text))
|
|
46
|
+
|
|
47
|
+
def get_max_tokens(self):
|
|
48
|
+
"""Get maximum number of tokens allowed."""
|
|
49
|
+
return self.max_tokens
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_pretrained(
|
|
53
|
+
cls,
|
|
54
|
+
model_name: Union[str, PathLike],
|
|
55
|
+
max_tokens: Optional[int] = None,
|
|
56
|
+
**kwargs,
|
|
57
|
+
) -> Self:
|
|
58
|
+
"""Create tokenizer from model name."""
|
|
59
|
+
my_kwargs = {
|
|
60
|
+
"tokenizer": AutoTokenizer.from_pretrained(
|
|
61
|
+
pretrained_model_name_or_path=model_name, **kwargs
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
if max_tokens is not None:
|
|
65
|
+
my_kwargs["max_tokens"] = max_tokens
|
|
66
|
+
return cls(**my_kwargs)
|
|
67
|
+
|
|
68
|
+
def get_tokenizer(self):
|
|
69
|
+
"""Get underlying tokenizer object."""
|
|
70
|
+
return self.tokenizer
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""OpenAI tokenization."""
|
|
2
|
+
|
|
3
|
+
from pydantic import ConfigDict
|
|
4
|
+
|
|
5
|
+
from docling_core.transforms.chunker.hybrid_chunker import BaseTokenizer
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import tiktoken
|
|
9
|
+
except ImportError:
|
|
10
|
+
raise RuntimeError(
|
|
11
|
+
"Module requires 'chunking-openai' extra; to install, run: "
|
|
12
|
+
"`pip install 'docling-core[chunking-openai]'`"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenAITokenizer(BaseTokenizer):
|
|
17
|
+
"""OpenAI tokenizer."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
20
|
+
|
|
21
|
+
tokenizer: tiktoken.Encoding
|
|
22
|
+
max_tokens: int
|
|
23
|
+
|
|
24
|
+
def count_tokens(self, text: str) -> int:
|
|
25
|
+
"""Get number of tokens for given text."""
|
|
26
|
+
return len(self.tokenizer.encode(text=text))
|
|
27
|
+
|
|
28
|
+
def get_max_tokens(self) -> int:
|
|
29
|
+
"""Get maximum number of tokens allowed."""
|
|
30
|
+
return self.max_tokens
|
|
31
|
+
|
|
32
|
+
def get_tokenizer(self) -> tiktoken.Encoding:
|
|
33
|
+
"""Get underlying tokenizer object."""
|
|
34
|
+
return self.tokenizer
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Define the visualizer types."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Define base classes for visualization."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from PIL.Image import Image
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from docling_core.types.doc import DoclingDocument
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseVisualizer(BaseModel, ABC):
|
|
13
|
+
"""Visualize base class."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def get_visualization(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
doc: DoclingDocument,
|
|
20
|
+
**kwargs,
|
|
21
|
+
) -> dict[Optional[int], Image]:
|
|
22
|
+
"""Get visualization of the document as images by page."""
|
|
23
|
+
raise NotImplementedError()
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Define classes for layout visualization."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Literal, Optional, Union
|
|
5
|
+
|
|
6
|
+
from PIL import ImageDraw, ImageFont
|
|
7
|
+
from PIL.Image import Image
|
|
8
|
+
from PIL.ImageFont import FreeTypeFont
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
|
|
12
|
+
from docling_core.transforms.visualizer.base import BaseVisualizer
|
|
13
|
+
from docling_core.types.doc import DocItemLabel
|
|
14
|
+
from docling_core.types.doc.base import CoordOrigin
|
|
15
|
+
from docling_core.types.doc.document import ContentLayer, DocItem, DoclingDocument
|
|
16
|
+
from docling_core.types.doc.page import BoundingRectangle, TextCell
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _TLBoundingRectangle(BoundingRectangle):
|
|
20
|
+
coord_origin: Literal[CoordOrigin.TOPLEFT] = CoordOrigin.TOPLEFT
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _TLTextCell(TextCell):
|
|
24
|
+
rect: _TLBoundingRectangle
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _TLCluster(BaseModel):
|
|
28
|
+
id: int
|
|
29
|
+
label: DocItemLabel
|
|
30
|
+
brec: _TLBoundingRectangle
|
|
31
|
+
confidence: float = 1.0
|
|
32
|
+
cells: list[_TLTextCell] = []
|
|
33
|
+
children: list["_TLCluster"] = [] # Add child cluster support
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LayoutVisualizer(BaseVisualizer):
|
|
37
|
+
"""Layout visualizer."""
|
|
38
|
+
|
|
39
|
+
class Params(BaseModel):
|
|
40
|
+
"""Layout visualization parameters."""
|
|
41
|
+
|
|
42
|
+
show_label: bool = True
|
|
43
|
+
|
|
44
|
+
base_visualizer: Optional[BaseVisualizer] = None
|
|
45
|
+
params: Params = Params()
|
|
46
|
+
|
|
47
|
+
def _draw_clusters(
|
|
48
|
+
self, image: Image, clusters: list[_TLCluster], scale_x: float, scale_y: float
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Draw clusters on an image."""
|
|
51
|
+
draw = ImageDraw.Draw(image, "RGBA")
|
|
52
|
+
# Create a smaller font for the labels
|
|
53
|
+
font: Union[ImageFont.ImageFont, FreeTypeFont]
|
|
54
|
+
try:
|
|
55
|
+
font = ImageFont.truetype("arial.ttf", 12)
|
|
56
|
+
except OSError:
|
|
57
|
+
# Fallback to default font if arial is not available
|
|
58
|
+
font = ImageFont.load_default()
|
|
59
|
+
for c_tl in clusters:
|
|
60
|
+
all_clusters = [c_tl, *c_tl.children]
|
|
61
|
+
for c in all_clusters:
|
|
62
|
+
# Draw cells first (underneath)
|
|
63
|
+
cell_color = (0, 0, 0, 40) # Transparent black for cells
|
|
64
|
+
for tc in c.cells:
|
|
65
|
+
cx0, cy0, cx1, cy1 = tc.rect.to_bounding_box().as_tuple()
|
|
66
|
+
cx0 *= scale_x
|
|
67
|
+
cx1 *= scale_x
|
|
68
|
+
cy0 *= scale_y
|
|
69
|
+
cy1 *= scale_y
|
|
70
|
+
|
|
71
|
+
draw.rectangle(
|
|
72
|
+
[(cx0, cy0), (cx1, cy1)],
|
|
73
|
+
outline=None,
|
|
74
|
+
fill=cell_color,
|
|
75
|
+
)
|
|
76
|
+
# Draw cluster rectangle
|
|
77
|
+
x0, y0, x1, y1 = c.brec.to_bounding_box().as_tuple()
|
|
78
|
+
x0 *= scale_x
|
|
79
|
+
x1 *= scale_x
|
|
80
|
+
y0 *= scale_y
|
|
81
|
+
y1 *= scale_y
|
|
82
|
+
|
|
83
|
+
cluster_fill_color = (*list(DocItemLabel.get_color(c.label)), 70)
|
|
84
|
+
cluster_outline_color = (
|
|
85
|
+
*list(DocItemLabel.get_color(c.label)),
|
|
86
|
+
255,
|
|
87
|
+
)
|
|
88
|
+
draw.rectangle(
|
|
89
|
+
[(x0, y0), (x1, y1)],
|
|
90
|
+
outline=cluster_outline_color,
|
|
91
|
+
fill=cluster_fill_color,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if self.params.show_label:
|
|
95
|
+
# Add label name and confidence
|
|
96
|
+
label_text = f"{c.label.name} ({c.confidence:.2f})"
|
|
97
|
+
# Create semi-transparent background for text
|
|
98
|
+
text_bbox = draw.textbbox((x0, y0), label_text, font=font)
|
|
99
|
+
text_bg_padding = 2
|
|
100
|
+
draw.rectangle(
|
|
101
|
+
[
|
|
102
|
+
(
|
|
103
|
+
text_bbox[0] - text_bg_padding,
|
|
104
|
+
text_bbox[1] - text_bg_padding,
|
|
105
|
+
),
|
|
106
|
+
(
|
|
107
|
+
text_bbox[2] + text_bg_padding,
|
|
108
|
+
text_bbox[3] + text_bg_padding,
|
|
109
|
+
),
|
|
110
|
+
],
|
|
111
|
+
fill=(255, 255, 255, 180), # Semi-transparent white
|
|
112
|
+
)
|
|
113
|
+
# Draw text
|
|
114
|
+
draw.text(
|
|
115
|
+
(x0, y0),
|
|
116
|
+
label_text,
|
|
117
|
+
fill=(0, 0, 0, 255), # Solid black
|
|
118
|
+
font=font,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _draw_doc_layout(
|
|
122
|
+
self, doc: DoclingDocument, images: Optional[dict[Optional[int], Image]] = None
|
|
123
|
+
):
|
|
124
|
+
"""Draw the document clusters and optionaly the reading order."""
|
|
125
|
+
clusters = []
|
|
126
|
+
my_images: dict[Optional[int], Image] = {}
|
|
127
|
+
|
|
128
|
+
if images is not None:
|
|
129
|
+
my_images = images
|
|
130
|
+
|
|
131
|
+
# Initialise `my_images` beforehand: sometimes, you have the
|
|
132
|
+
# page-images but no DocItems!
|
|
133
|
+
for page_nr, page in doc.pages.items():
|
|
134
|
+
page_image = doc.pages[page_nr].image
|
|
135
|
+
if page_image is None or (pil_img := page_image.pil_image) is None:
|
|
136
|
+
raise RuntimeError("Cannot visualize document without images")
|
|
137
|
+
elif page_nr not in my_images:
|
|
138
|
+
image = deepcopy(pil_img)
|
|
139
|
+
my_images[page_nr] = image
|
|
140
|
+
|
|
141
|
+
prev_image = None
|
|
142
|
+
prev_page_nr = None
|
|
143
|
+
for idx, (elem, _) in enumerate(
|
|
144
|
+
doc.iterate_items(
|
|
145
|
+
included_content_layers={ContentLayer.BODY, ContentLayer.FURNITURE}
|
|
146
|
+
)
|
|
147
|
+
):
|
|
148
|
+
if not isinstance(elem, DocItem):
|
|
149
|
+
continue
|
|
150
|
+
if len(elem.prov) == 0:
|
|
151
|
+
continue # Skip elements without provenances
|
|
152
|
+
prov = elem.prov[0]
|
|
153
|
+
page_nr = prov.page_no
|
|
154
|
+
|
|
155
|
+
if page_nr in my_images:
|
|
156
|
+
image = my_images[page_nr]
|
|
157
|
+
else:
|
|
158
|
+
raise RuntimeError(f"Cannot visualize page-image for {page_nr}")
|
|
159
|
+
|
|
160
|
+
if prev_page_nr is None or page_nr > prev_page_nr: # new page begins
|
|
161
|
+
# complete previous drawing
|
|
162
|
+
if prev_page_nr is not None and prev_image and clusters:
|
|
163
|
+
self._draw_clusters(
|
|
164
|
+
image=prev_image,
|
|
165
|
+
clusters=clusters,
|
|
166
|
+
scale_x=prev_image.width / doc.pages[prev_page_nr].size.width,
|
|
167
|
+
scale_y=prev_image.height / doc.pages[prev_page_nr].size.height,
|
|
168
|
+
)
|
|
169
|
+
clusters = []
|
|
170
|
+
|
|
171
|
+
tlo_bbox = prov.bbox.to_top_left_origin(
|
|
172
|
+
page_height=doc.pages[prov.page_no].size.height
|
|
173
|
+
)
|
|
174
|
+
cluster = _TLCluster(
|
|
175
|
+
id=idx,
|
|
176
|
+
label=elem.label,
|
|
177
|
+
brec=_TLBoundingRectangle.from_bounding_box(bbox=tlo_bbox),
|
|
178
|
+
cells=[],
|
|
179
|
+
)
|
|
180
|
+
clusters.append(cluster)
|
|
181
|
+
|
|
182
|
+
prev_page_nr = page_nr
|
|
183
|
+
prev_image = image
|
|
184
|
+
|
|
185
|
+
# complete last drawing
|
|
186
|
+
if prev_page_nr is not None and prev_image and clusters:
|
|
187
|
+
self._draw_clusters(
|
|
188
|
+
image=prev_image,
|
|
189
|
+
clusters=clusters,
|
|
190
|
+
scale_x=prev_image.width / doc.pages[prev_page_nr].size.width,
|
|
191
|
+
scale_y=prev_image.height / doc.pages[prev_page_nr].size.height,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return my_images
|
|
195
|
+
|
|
196
|
+
@override
|
|
197
|
+
def get_visualization(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
doc: DoclingDocument,
|
|
201
|
+
**kwargs,
|
|
202
|
+
) -> dict[Optional[int], Image]:
|
|
203
|
+
"""Get visualization of the document as images by page."""
|
|
204
|
+
base_images = (
|
|
205
|
+
self.base_visualizer.get_visualization(doc=doc, **kwargs)
|
|
206
|
+
if self.base_visualizer
|
|
207
|
+
else None
|
|
208
|
+
)
|
|
209
|
+
return self._draw_doc_layout(
|
|
210
|
+
doc=doc,
|
|
211
|
+
images=base_images,
|
|
212
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Define classes for reading order visualization."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from PIL import ImageDraw
|
|
7
|
+
from PIL.Image import Image
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from docling_core.transforms.visualizer.base import BaseVisualizer
|
|
11
|
+
from docling_core.types.doc.document import ContentLayer, DocItem, DoclingDocument
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReadingOrderVisualizer(BaseVisualizer):
|
|
15
|
+
"""Reading order visualizer."""
|
|
16
|
+
|
|
17
|
+
base_visualizer: Optional[BaseVisualizer] = None
|
|
18
|
+
|
|
19
|
+
def _draw_arrow(
|
|
20
|
+
self,
|
|
21
|
+
draw: ImageDraw.ImageDraw,
|
|
22
|
+
arrow_coords: tuple[float, float, float, float],
|
|
23
|
+
line_width: int = 2,
|
|
24
|
+
color: str = "red",
|
|
25
|
+
):
|
|
26
|
+
"""Draw an arrow inside the given draw object."""
|
|
27
|
+
x0, y0, x1, y1 = arrow_coords
|
|
28
|
+
|
|
29
|
+
# Arrow parameters
|
|
30
|
+
start_point = (x0, y0) # Starting point of the arrow
|
|
31
|
+
end_point = (x1, y1) # Ending point of the arrow
|
|
32
|
+
arrowhead_length = 20 # Length of the arrowhead
|
|
33
|
+
arrowhead_width = 10 # Width of the arrowhead
|
|
34
|
+
|
|
35
|
+
# Draw the arrow shaft (line)
|
|
36
|
+
draw.line([start_point, end_point], fill=color, width=line_width)
|
|
37
|
+
|
|
38
|
+
# Calculate the arrowhead points
|
|
39
|
+
dx = end_point[0] - start_point[0]
|
|
40
|
+
dy = end_point[1] - start_point[1]
|
|
41
|
+
angle = (dx**2 + dy**2) ** 0.5 + 0.01 # Length of the arrow shaft
|
|
42
|
+
|
|
43
|
+
# Normalized direction vector for the arrow shaft
|
|
44
|
+
ux, uy = dx / angle, dy / angle
|
|
45
|
+
|
|
46
|
+
# Base of the arrowhead
|
|
47
|
+
base_x = end_point[0] - ux * arrowhead_length
|
|
48
|
+
base_y = end_point[1] - uy * arrowhead_length
|
|
49
|
+
|
|
50
|
+
# Left and right points of the arrowhead
|
|
51
|
+
left_x = base_x - uy * arrowhead_width
|
|
52
|
+
left_y = base_y + ux * arrowhead_width
|
|
53
|
+
right_x = base_x + uy * arrowhead_width
|
|
54
|
+
right_y = base_y - ux * arrowhead_width
|
|
55
|
+
|
|
56
|
+
# Draw the arrowhead (triangle)
|
|
57
|
+
draw.polygon(
|
|
58
|
+
[end_point, (left_x, left_y), (right_x, right_y)],
|
|
59
|
+
fill=color,
|
|
60
|
+
)
|
|
61
|
+
return draw
|
|
62
|
+
|
|
63
|
+
def _draw_doc_reading_order(
|
|
64
|
+
self,
|
|
65
|
+
doc: DoclingDocument,
|
|
66
|
+
images: Optional[dict[Optional[int], Image]] = None,
|
|
67
|
+
):
|
|
68
|
+
"""Draw the reading order."""
|
|
69
|
+
# draw = ImageDraw.Draw(image)
|
|
70
|
+
x0, y0 = None, None
|
|
71
|
+
my_images: dict[Optional[int], Image] = images or {}
|
|
72
|
+
prev_page = None
|
|
73
|
+
for elem, _ in doc.iterate_items(
|
|
74
|
+
included_content_layers={ContentLayer.BODY, ContentLayer.FURNITURE},
|
|
75
|
+
):
|
|
76
|
+
if not isinstance(elem, DocItem):
|
|
77
|
+
continue
|
|
78
|
+
if len(elem.prov) == 0:
|
|
79
|
+
continue # Skip elements without provenances
|
|
80
|
+
prov = elem.prov[0]
|
|
81
|
+
page_no = prov.page_no
|
|
82
|
+
image = my_images.get(page_no)
|
|
83
|
+
|
|
84
|
+
if image is None or prev_page is None or page_no > prev_page:
|
|
85
|
+
# new page begins
|
|
86
|
+
prev_page = page_no
|
|
87
|
+
x0 = y0 = None
|
|
88
|
+
|
|
89
|
+
if image is None:
|
|
90
|
+
page_image = doc.pages[page_no].image
|
|
91
|
+
if page_image is None or (pil_img := page_image.pil_image) is None:
|
|
92
|
+
raise RuntimeError("Cannot visualize document without images")
|
|
93
|
+
else:
|
|
94
|
+
image = deepcopy(pil_img)
|
|
95
|
+
my_images[page_no] = image
|
|
96
|
+
draw = ImageDraw.Draw(image)
|
|
97
|
+
|
|
98
|
+
# if prov.page_no not in true_doc.pages or prov.page_no != 1:
|
|
99
|
+
# logging.error(f"{prov.page_no} not in true_doc.pages -> skipping! ")
|
|
100
|
+
# continue
|
|
101
|
+
|
|
102
|
+
tlo_bbox = prov.bbox.to_top_left_origin(
|
|
103
|
+
page_height=doc.pages[prov.page_no].size.height
|
|
104
|
+
)
|
|
105
|
+
ro_bbox = tlo_bbox.normalized(doc.pages[prov.page_no].size)
|
|
106
|
+
ro_bbox.l = round(ro_bbox.l * image.width) # noqa: E741
|
|
107
|
+
ro_bbox.r = round(ro_bbox.r * image.width)
|
|
108
|
+
ro_bbox.t = round(ro_bbox.t * image.height)
|
|
109
|
+
ro_bbox.b = round(ro_bbox.b * image.height)
|
|
110
|
+
|
|
111
|
+
if ro_bbox.b > ro_bbox.t:
|
|
112
|
+
ro_bbox.b, ro_bbox.t = ro_bbox.t, ro_bbox.b
|
|
113
|
+
|
|
114
|
+
if x0 is None and y0 is None:
|
|
115
|
+
x0 = (ro_bbox.l + ro_bbox.r) / 2.0
|
|
116
|
+
y0 = (ro_bbox.b + ro_bbox.t) / 2.0
|
|
117
|
+
else:
|
|
118
|
+
assert x0 is not None
|
|
119
|
+
assert y0 is not None
|
|
120
|
+
|
|
121
|
+
x1 = (ro_bbox.l + ro_bbox.r) / 2.0
|
|
122
|
+
y1 = (ro_bbox.b + ro_bbox.t) / 2.0
|
|
123
|
+
|
|
124
|
+
draw = self._draw_arrow(
|
|
125
|
+
draw=draw,
|
|
126
|
+
arrow_coords=(x0, y0, x1, y1),
|
|
127
|
+
line_width=2,
|
|
128
|
+
color="red",
|
|
129
|
+
)
|
|
130
|
+
x0, y0 = x1, y1
|
|
131
|
+
return my_images
|
|
132
|
+
|
|
133
|
+
@override
|
|
134
|
+
def get_visualization(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
doc: DoclingDocument,
|
|
138
|
+
**kwargs,
|
|
139
|
+
) -> dict[Optional[int], Image]:
|
|
140
|
+
"""Get visualization of the document as images by page."""
|
|
141
|
+
base_images = (
|
|
142
|
+
self.base_visualizer.get_visualization(doc=doc, **kwargs)
|
|
143
|
+
if self.base_visualizer
|
|
144
|
+
else None
|
|
145
|
+
)
|
|
146
|
+
return self._draw_doc_reading_order(
|
|
147
|
+
doc=doc,
|
|
148
|
+
images=base_images,
|
|
149
|
+
)
|