cells2table 0.2.0__tar.gz
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.
- cells2table-0.2.0/LICENSE +21 -0
- cells2table-0.2.0/PKG-INFO +85 -0
- cells2table-0.2.0/README.md +58 -0
- cells2table-0.2.0/pyproject.toml +56 -0
- cells2table-0.2.0/src/cells2table/__init__.py +3 -0
- cells2table-0.2.0/src/cells2table/docling.py +270 -0
- cells2table-0.2.0/src/cells2table/models/PaddlePaddle/__init__.py +15 -0
- cells2table-0.2.0/src/cells2table/models/PaddlePaddle/cell_detection.py +91 -0
- cells2table-0.2.0/src/cells2table/models/PaddlePaddle/pipeline.py +59 -0
- cells2table-0.2.0/src/cells2table/models/PaddlePaddle/table_classification.py +43 -0
- cells2table-0.2.0/src/cells2table/models/__init__.py +3 -0
- cells2table-0.2.0/src/cells2table/models/utils/download.py +41 -0
- cells2table-0.2.0/src/cells2table/models/utils/runtimes/__init__.py +3 -0
- cells2table-0.2.0/src/cells2table/models/utils/runtimes/onnx.py +58 -0
- cells2table-0.2.0/src/cells2table/models/utils/tasks/__init__.py +5 -0
- cells2table-0.2.0/src/cells2table/models/utils/tasks/base.py +28 -0
- cells2table-0.2.0/src/cells2table/models/utils/tasks/classification.py +19 -0
- cells2table-0.2.0/src/cells2table/models/utils/tasks/detection.py +21 -0
- cells2table-0.2.0/src/cells2table/py.typed +0 -0
- cells2table-0.2.0/src/cells2table/utils/__init__.py +0 -0
- cells2table-0.2.0/src/cells2table/utils/table.py +140 -0
- cells2table-0.2.0/src/cells2table/utils/visualize.py +42 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 João Speranza Pastorello
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cells2table
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Table image parsing with cell detection models
|
|
5
|
+
Keywords: docling,plugin
|
|
6
|
+
Author: jspast
|
|
7
|
+
Author-email: jspast <140563347+jspast@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Dist: huggingface-hub>=0.36.0
|
|
13
|
+
Requires-Dist: numpy>=2.2.0
|
|
14
|
+
Requires-Dist: opencv-python>=4.11.0.86
|
|
15
|
+
Requires-Dist: onnxruntime>=1.23.2 ; extra == 'cpu'
|
|
16
|
+
Requires-Dist: onnxruntime-gpu>=1.23.2 ; extra == 'cuda'
|
|
17
|
+
Requires-Dist: docling>=2.66.0 ; extra == 'docling'
|
|
18
|
+
Requires-Dist: onnxruntime-openvino>=1.23.0 ; extra == 'openvino'
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Homepage, https://github.com/jspast/cells2table
|
|
21
|
+
Project-URL: Issues, https://github.com/jspast/cells2table/issues
|
|
22
|
+
Provides-Extra: cpu
|
|
23
|
+
Provides-Extra: cuda
|
|
24
|
+
Provides-Extra: docling
|
|
25
|
+
Provides-Extra: openvino
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# cells2table
|
|
29
|
+
|
|
30
|
+
Parsing tables in document images with cell detection models
|
|
31
|
+
|
|
32
|
+
## Implemented pipelines
|
|
33
|
+
|
|
34
|
+
### PaddlePaddle models
|
|
35
|
+
|
|
36
|
+
- Classification model (wired / wireless)
|
|
37
|
+
- Cell detection model with different weights for each class
|
|
38
|
+
|
|
39
|
+
Using [ONNX weights](https://huggingface.co/jspast/paddlepaddle-table-models-onnx) (downloaded automatically on first use with `huggingface_hub`)
|
|
40
|
+
|
|
41
|
+
## Instalation
|
|
42
|
+
|
|
43
|
+
With [uv](https://docs.astral.sh/uv/), add to your project with:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
uv add git+https://github.com/jspast/cells2table
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
ONNX models need a [ONNX Runtime](https://onnxruntime.ai/getting-started) installed to run. You can install one on your own or use one of the optionals already configured.
|
|
50
|
+
|
|
51
|
+
| Optional | Description |
|
|
52
|
+
| ---------- | ----------------------- |
|
|
53
|
+
| `cuda` | For NVIDIA GPUs |
|
|
54
|
+
| `openvino` | For Intel GPUs and CPUs |
|
|
55
|
+
| `cpu` | Default CPU runtime |
|
|
56
|
+
| `docling` | For docling usage |
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
cells2table only extract structural information from the tables. Another library is needed to extract content from the cells.
|
|
61
|
+
|
|
62
|
+
### Docling
|
|
63
|
+
|
|
64
|
+
A [docling plugin](https://docling-project.github.io/docling/concepts/plugins/) is provided to allow integrating cells2table in a complete pipeline.
|
|
65
|
+
|
|
66
|
+
Usage example:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from cells2table.docling import CustomDoclingTableStructureOptions
|
|
70
|
+
|
|
71
|
+
pipeline_options = PdfPipelineOptions(
|
|
72
|
+
allow_external_plugins=True,
|
|
73
|
+
table_structure_options=CustomDoclingTableStructureOptions(),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
converter = DocumentConverter(
|
|
77
|
+
format_options={
|
|
78
|
+
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
|
|
79
|
+
InputFormat.IMAGE: PdfFormatOption(pipeline_options=pipeline_options),
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
result = converter.convert("path/to/document.pdf")
|
|
84
|
+
print(result.document.export_to_markdown())
|
|
85
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# cells2table
|
|
2
|
+
|
|
3
|
+
Parsing tables in document images with cell detection models
|
|
4
|
+
|
|
5
|
+
## Implemented pipelines
|
|
6
|
+
|
|
7
|
+
### PaddlePaddle models
|
|
8
|
+
|
|
9
|
+
- Classification model (wired / wireless)
|
|
10
|
+
- Cell detection model with different weights for each class
|
|
11
|
+
|
|
12
|
+
Using [ONNX weights](https://huggingface.co/jspast/paddlepaddle-table-models-onnx) (downloaded automatically on first use with `huggingface_hub`)
|
|
13
|
+
|
|
14
|
+
## Instalation
|
|
15
|
+
|
|
16
|
+
With [uv](https://docs.astral.sh/uv/), add to your project with:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uv add git+https://github.com/jspast/cells2table
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
ONNX models need a [ONNX Runtime](https://onnxruntime.ai/getting-started) installed to run. You can install one on your own or use one of the optionals already configured.
|
|
23
|
+
|
|
24
|
+
| Optional | Description |
|
|
25
|
+
| ---------- | ----------------------- |
|
|
26
|
+
| `cuda` | For NVIDIA GPUs |
|
|
27
|
+
| `openvino` | For Intel GPUs and CPUs |
|
|
28
|
+
| `cpu` | Default CPU runtime |
|
|
29
|
+
| `docling` | For docling usage |
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
cells2table only extract structural information from the tables. Another library is needed to extract content from the cells.
|
|
34
|
+
|
|
35
|
+
### Docling
|
|
36
|
+
|
|
37
|
+
A [docling plugin](https://docling-project.github.io/docling/concepts/plugins/) is provided to allow integrating cells2table in a complete pipeline.
|
|
38
|
+
|
|
39
|
+
Usage example:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from cells2table.docling import CustomDoclingTableStructureOptions
|
|
43
|
+
|
|
44
|
+
pipeline_options = PdfPipelineOptions(
|
|
45
|
+
allow_external_plugins=True,
|
|
46
|
+
table_structure_options=CustomDoclingTableStructureOptions(),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
converter = DocumentConverter(
|
|
50
|
+
format_options={
|
|
51
|
+
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
|
|
52
|
+
InputFormat.IMAGE: PdfFormatOption(pipeline_options=pipeline_options),
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
result = converter.convert("path/to/document.pdf")
|
|
57
|
+
print(result.document.export_to_markdown())
|
|
58
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cells2table"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Table image parsing with cell detection models"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "jspast", email = "140563347+jspast@users.noreply.github.com" }
|
|
11
|
+
]
|
|
12
|
+
keywords = ["docling", "plugin"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"huggingface-hub>=0.36.0",
|
|
19
|
+
"numpy>=2.2.0",
|
|
20
|
+
"opencv-python>=4.11.0.86",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/jspast/cells2table"
|
|
25
|
+
Issues = "https://github.com/jspast/cells2table/issues"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
cells2table = "cells2table.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.entry-points.docling]
|
|
31
|
+
cells2table = "cells2table.docling"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
cpu = [
|
|
35
|
+
"onnxruntime>=1.23.2",
|
|
36
|
+
]
|
|
37
|
+
cuda = [
|
|
38
|
+
"onnxruntime-gpu>=1.23.2",
|
|
39
|
+
]
|
|
40
|
+
docling = [
|
|
41
|
+
"docling>=2.66.0",
|
|
42
|
+
]
|
|
43
|
+
openvino = [
|
|
44
|
+
"onnxruntime-openvino>=1.23.0",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.9.17,<0.10.0"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
|
|
54
|
+
[tool.uv]
|
|
55
|
+
package = true
|
|
56
|
+
conflicts = [[{ extra = "cpu" }, { extra = "cuda" }, { extra = "openvino" }]]
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import ClassVar, Literal, Optional, Sequence, Type
|
|
6
|
+
|
|
7
|
+
import numpy
|
|
8
|
+
from docling.datamodel.accelerator_options import AcceleratorOptions
|
|
9
|
+
from docling.datamodel.base_models import Cluster, Page, Table, TableStructurePrediction
|
|
10
|
+
from docling.datamodel.document import ConversionResult
|
|
11
|
+
from docling.datamodel.pipeline_options import BaseTableStructureOptions
|
|
12
|
+
from docling.datamodel.settings import settings
|
|
13
|
+
from docling.models.base_table_model import BaseTableStructureModel
|
|
14
|
+
from docling.utils.profiling import TimeRecorder
|
|
15
|
+
from docling_core.types.doc.base import BoundingBox
|
|
16
|
+
from docling_core.types.doc.document import TableCell
|
|
17
|
+
from docling_core.types.doc.labels import DocItemLabel
|
|
18
|
+
from docling_core.types.doc.page import BoundingRectangle, TextCellUnit
|
|
19
|
+
from PIL import ImageDraw
|
|
20
|
+
|
|
21
|
+
# from docling.utils.accelerator_utils import decide_device
|
|
22
|
+
from .models import DefaultPipeline
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_tokens(page: Page, table_cluster: Cluster, scale: float) -> list[str]:
|
|
28
|
+
"""Docling logic for token extraction."""
|
|
29
|
+
|
|
30
|
+
# Check if word-level cells are available from backend:
|
|
31
|
+
sp = page._backend.get_segmented_page() if page._backend else None
|
|
32
|
+
if sp is not None:
|
|
33
|
+
tcells = sp.get_cells_in_bbox(
|
|
34
|
+
cell_unit=TextCellUnit.WORD,
|
|
35
|
+
bbox=table_cluster.bbox,
|
|
36
|
+
)
|
|
37
|
+
if len(tcells) == 0:
|
|
38
|
+
# In case word-level cells yield empty
|
|
39
|
+
tcells = table_cluster.cells
|
|
40
|
+
else:
|
|
41
|
+
# Otherwise - we use normal (line/phrase) cells
|
|
42
|
+
tcells = table_cluster.cells
|
|
43
|
+
tokens = []
|
|
44
|
+
for c in tcells:
|
|
45
|
+
# Only allow non empty strings (spaces) into the cells of a table
|
|
46
|
+
if len(c.text.strip()) > 0:
|
|
47
|
+
new_cell = copy.deepcopy(c)
|
|
48
|
+
new_cell.rect = BoundingRectangle.from_bounding_box(
|
|
49
|
+
new_cell.rect.to_bounding_box().scaled(scale=scale)
|
|
50
|
+
)
|
|
51
|
+
tokens.append(
|
|
52
|
+
{
|
|
53
|
+
"id": new_cell.index,
|
|
54
|
+
"text": new_cell.text,
|
|
55
|
+
"bbox": new_cell.rect.to_bounding_box().model_dump(),
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return tokens
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CustomDoclingTableStructureOptions(BaseTableStructureOptions):
|
|
63
|
+
kind: ClassVar[Literal["cells2table"]] = "cells2table"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CustomDoclingTableStructureModel(BaseTableStructureModel):
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
enabled: bool,
|
|
70
|
+
artifacts_path: Optional[Path],
|
|
71
|
+
options: CustomDoclingTableStructureOptions,
|
|
72
|
+
accelerator_options: AcceleratorOptions,
|
|
73
|
+
):
|
|
74
|
+
self.enabled = enabled
|
|
75
|
+
|
|
76
|
+
if self.enabled:
|
|
77
|
+
self.pipeline = DefaultPipeline(artifacts_path)
|
|
78
|
+
|
|
79
|
+
# TODO: decide how to deal with accelerator options
|
|
80
|
+
# device = decide_device(accelerator_options.device)
|
|
81
|
+
|
|
82
|
+
self.scale = 2.0 # Scale up table input images to 144 dpi
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def get_options_type(cls) -> Type[BaseTableStructureOptions]:
|
|
86
|
+
return CustomDoclingTableStructureOptions
|
|
87
|
+
|
|
88
|
+
def predict_tables(
|
|
89
|
+
self,
|
|
90
|
+
conv_res: ConversionResult,
|
|
91
|
+
pages: Sequence[Page],
|
|
92
|
+
) -> Sequence[TableStructurePrediction]:
|
|
93
|
+
pages = list(pages)
|
|
94
|
+
predictions: list[TableStructurePrediction] = []
|
|
95
|
+
|
|
96
|
+
for page in pages:
|
|
97
|
+
assert page._backend is not None
|
|
98
|
+
if not page._backend.is_valid():
|
|
99
|
+
existing_prediction = page.predictions.tablestructure or TableStructurePrediction()
|
|
100
|
+
page.predictions.tablestructure = existing_prediction
|
|
101
|
+
predictions.append(existing_prediction)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
with TimeRecorder(conv_res, "table_structure"):
|
|
105
|
+
assert page.predictions.layout is not None
|
|
106
|
+
assert page.size is not None
|
|
107
|
+
|
|
108
|
+
table_prediction = TableStructurePrediction()
|
|
109
|
+
page.predictions.tablestructure = table_prediction
|
|
110
|
+
|
|
111
|
+
in_tables = [
|
|
112
|
+
(
|
|
113
|
+
cluster,
|
|
114
|
+
[
|
|
115
|
+
round(cluster.bbox.l) * self.scale,
|
|
116
|
+
round(cluster.bbox.t) * self.scale,
|
|
117
|
+
round(cluster.bbox.r) * self.scale,
|
|
118
|
+
round(cluster.bbox.b) * self.scale,
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
for cluster in page.predictions.layout.clusters
|
|
122
|
+
if cluster.label in [DocItemLabel.TABLE, DocItemLabel.DOCUMENT_INDEX]
|
|
123
|
+
]
|
|
124
|
+
if not in_tables:
|
|
125
|
+
predictions.append(table_prediction)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
page_input: dict = {
|
|
129
|
+
"width": page.size.width * self.scale,
|
|
130
|
+
"height": page.size.height * self.scale,
|
|
131
|
+
"image": numpy.asarray(page.get_image(scale=self.scale)),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for table_cluster, tbl_box in in_tables:
|
|
135
|
+
page_input["tokens"] = get_tokens(page, table_cluster, self.scale)
|
|
136
|
+
|
|
137
|
+
table_image = page_input["image"][
|
|
138
|
+
round(tbl_box[1]) : round(tbl_box[3]),
|
|
139
|
+
round(tbl_box[0]) : round(tbl_box[2]),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
table = self.pipeline([table_image])[0]
|
|
143
|
+
|
|
144
|
+
docling_cells = []
|
|
145
|
+
|
|
146
|
+
for cell_id, cell in enumerate(table.cells):
|
|
147
|
+
docling_cell_bbox: dict = {
|
|
148
|
+
"l": (cell.bbox.l + tbl_box[0]) / self.scale,
|
|
149
|
+
"t": (cell.bbox.t + tbl_box[1]) / self.scale,
|
|
150
|
+
"r": (cell.bbox.r + tbl_box[0]) / self.scale,
|
|
151
|
+
"b": (cell.bbox.b + tbl_box[1]) / self.scale,
|
|
152
|
+
"token": "",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
docling_cell: dict = {
|
|
156
|
+
"cell_id": cell_id,
|
|
157
|
+
"bbox": docling_cell_bbox,
|
|
158
|
+
"row_span": cell.row_span,
|
|
159
|
+
"col_span": cell.col_span,
|
|
160
|
+
"start_row_offset_idx": cell.row,
|
|
161
|
+
"end_row_offset_idx": cell.row + cell.row_span,
|
|
162
|
+
"start_col_offset_idx": cell.col,
|
|
163
|
+
"end_col_offset_idx": cell.col + cell.col_span,
|
|
164
|
+
"indentation_level": 0,
|
|
165
|
+
"text_cell_bboxes": [docling_cell_bbox],
|
|
166
|
+
"column_header": False,
|
|
167
|
+
"row_header": False,
|
|
168
|
+
"row_section": False,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
bbox = BoundingBox.model_validate(docling_cell["bbox"])
|
|
172
|
+
|
|
173
|
+
text_piece = page._backend.get_text_in_rect(bbox) if page._backend else ""
|
|
174
|
+
docling_cell["bbox"]["token"] = text_piece
|
|
175
|
+
|
|
176
|
+
tc = TableCell.model_validate(docling_cell)
|
|
177
|
+
docling_cells.append(tc)
|
|
178
|
+
|
|
179
|
+
docling_table = Table(
|
|
180
|
+
otsl_seq=[],
|
|
181
|
+
table_cells=docling_cells,
|
|
182
|
+
num_rows=table.num_rows,
|
|
183
|
+
num_cols=table.num_cols,
|
|
184
|
+
id=table_cluster.id,
|
|
185
|
+
page_no=page.page_no,
|
|
186
|
+
cluster=table_cluster,
|
|
187
|
+
label=table_cluster.label,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
page.predictions.tablestructure.table_map[table_cluster.id] = docling_table
|
|
191
|
+
|
|
192
|
+
if settings.debug.visualize_tables:
|
|
193
|
+
self.draw_table_and_cells(
|
|
194
|
+
conv_res,
|
|
195
|
+
page,
|
|
196
|
+
page.predictions.tablestructure.table_map.values(),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
predictions.append(table_prediction)
|
|
200
|
+
|
|
201
|
+
return predictions
|
|
202
|
+
|
|
203
|
+
def draw_table_and_cells(
|
|
204
|
+
self,
|
|
205
|
+
conv_res: ConversionResult,
|
|
206
|
+
page: Page,
|
|
207
|
+
tbl_list: Iterable[Table],
|
|
208
|
+
show: bool = False,
|
|
209
|
+
):
|
|
210
|
+
assert page._backend is not None
|
|
211
|
+
assert page.size is not None
|
|
212
|
+
|
|
213
|
+
image = page._backend.get_page_image() # make new image to avoid drawing on the saved ones
|
|
214
|
+
|
|
215
|
+
scale_x = image.width / page.size.width
|
|
216
|
+
scale_y = image.height / page.size.height
|
|
217
|
+
|
|
218
|
+
draw = ImageDraw.Draw(image)
|
|
219
|
+
|
|
220
|
+
for table_element in tbl_list:
|
|
221
|
+
x0, y0, x1, y1 = table_element.cluster.bbox.as_tuple()
|
|
222
|
+
y0 *= scale_y
|
|
223
|
+
y1 *= scale_y
|
|
224
|
+
x0 *= scale_x
|
|
225
|
+
x1 *= scale_x
|
|
226
|
+
|
|
227
|
+
draw.rectangle([(x0, y0), (x1, y1)], outline="red")
|
|
228
|
+
|
|
229
|
+
for cell in table_element.cluster.cells:
|
|
230
|
+
x0, y0, x1, y1 = cell.rect.to_bounding_box().as_tuple()
|
|
231
|
+
x0 *= scale_x
|
|
232
|
+
x1 *= scale_x
|
|
233
|
+
y0 *= scale_y
|
|
234
|
+
y1 *= scale_y
|
|
235
|
+
|
|
236
|
+
draw.rectangle([(x0, y0), (x1, y1)], outline="green")
|
|
237
|
+
|
|
238
|
+
for tc in table_element.table_cells:
|
|
239
|
+
if tc.bbox is not None:
|
|
240
|
+
x0, y0, x1, y1 = tc.bbox.as_tuple()
|
|
241
|
+
x0 *= scale_x
|
|
242
|
+
x1 *= scale_x
|
|
243
|
+
y0 *= scale_y
|
|
244
|
+
y1 *= scale_y
|
|
245
|
+
|
|
246
|
+
if tc.column_header:
|
|
247
|
+
width = 3
|
|
248
|
+
else:
|
|
249
|
+
width = 1
|
|
250
|
+
draw.rectangle([(x0, y0), (x1, y1)], outline="blue", width=width)
|
|
251
|
+
draw.text(
|
|
252
|
+
(x0 + 3, y0 + 3),
|
|
253
|
+
text=f"{tc.start_row_offset_idx}, {tc.start_col_offset_idx}",
|
|
254
|
+
fill="black",
|
|
255
|
+
)
|
|
256
|
+
if show:
|
|
257
|
+
image.show()
|
|
258
|
+
else:
|
|
259
|
+
out_path: Path = (
|
|
260
|
+
Path(settings.debug.debug_output_path) / f"debug_{conv_res.input.file.stem}"
|
|
261
|
+
)
|
|
262
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
|
|
264
|
+
out_file = out_path / f"table_struct_page_{page.page_no:05}.png"
|
|
265
|
+
image.save(str(out_file), format="png")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Plugin factory
|
|
269
|
+
def table_structure_engines():
|
|
270
|
+
return {"table_structure_engines": [CustomDoclingTableStructureModel]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .cell_detection import (
|
|
2
|
+
PaddlePaddleCellDetection,
|
|
3
|
+
PaddlePaddleWiredCellDetection,
|
|
4
|
+
PaddlePaddleWirelessCellDetection,
|
|
5
|
+
)
|
|
6
|
+
from .pipeline import PaddlePaddleTablePipeline
|
|
7
|
+
from .table_classification import PaddlePaddleTableClassification
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
PaddlePaddleCellDetection,
|
|
11
|
+
PaddlePaddleWiredCellDetection,
|
|
12
|
+
PaddlePaddleWirelessCellDetection,
|
|
13
|
+
PaddlePaddleTablePipeline,
|
|
14
|
+
PaddlePaddleTableClassification,
|
|
15
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Iterable, Iterator, Sequence
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from numpy.typing import NDArray
|
|
6
|
+
|
|
7
|
+
from ..utils.download import DownloadOptions, DownloadPlatform
|
|
8
|
+
from ..utils.runtimes import OnnxModel
|
|
9
|
+
from ..utils.tasks import DetectionModel, DetectionResult
|
|
10
|
+
|
|
11
|
+
HF_REPO_ID = "jspast/paddlepaddle-table-models-onnx"
|
|
12
|
+
CONFIDENCE_THRESHOLD = 0.5
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PaddlePaddleCellDetection(DetectionModel, OnnxModel):
|
|
18
|
+
"""Table cell detection model from PaddlePaddle."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def input_shape(self):
|
|
22
|
+
return self.session.get_inputs()[1].shape[2:] # assuming NCHW
|
|
23
|
+
|
|
24
|
+
def __call__(self, input: Iterable[NDArray[np.uint8]]) -> list[Iterator[DetectionResult]]:
|
|
25
|
+
logger.debug("Started preprocessing")
|
|
26
|
+
|
|
27
|
+
original_shapes = []
|
|
28
|
+
scale_factors = []
|
|
29
|
+
for img in input:
|
|
30
|
+
original_shape = img.shape[:2]
|
|
31
|
+
original_shapes.append(original_shape)
|
|
32
|
+
scale_factors.append(
|
|
33
|
+
tuple(original_shape[i] / self.input_shape[i] for i in range(0, 2))
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
imgs = self.preprocess(input)
|
|
37
|
+
|
|
38
|
+
input_dict = dict(zip(self.input_names, [original_shapes, imgs, scale_factors]))
|
|
39
|
+
|
|
40
|
+
logger.debug("Done preprocessing")
|
|
41
|
+
logger.debug("Started running the model")
|
|
42
|
+
|
|
43
|
+
output = self.session.run(self.output_names, input_dict)
|
|
44
|
+
|
|
45
|
+
logger.debug("Done running the model")
|
|
46
|
+
logger.debug("Started postprocessing")
|
|
47
|
+
|
|
48
|
+
result = self.postprocess(output, scale_factors) # type: ignore
|
|
49
|
+
|
|
50
|
+
logger.debug("Done postprocessing")
|
|
51
|
+
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
def postprocess(
|
|
55
|
+
self,
|
|
56
|
+
pred: NDArray,
|
|
57
|
+
scale_factors: Sequence[tuple[int, int]],
|
|
58
|
+
) -> list[Iterator[DetectionResult]]:
|
|
59
|
+
last_cell_idx = 0
|
|
60
|
+
batch_size = len(pred[1])
|
|
61
|
+
generators = []
|
|
62
|
+
cells = pred[0]
|
|
63
|
+
|
|
64
|
+
for i in range(batch_size):
|
|
65
|
+
c = cells[last_cell_idx : last_cell_idx + pred[1][i]]
|
|
66
|
+
c = c[c[:, 1] > CONFIDENCE_THRESHOLD]
|
|
67
|
+
|
|
68
|
+
last_cell_idx += pred[1][i]
|
|
69
|
+
|
|
70
|
+
if c.size:
|
|
71
|
+
sx, sy = scale_factors[i]
|
|
72
|
+
scores = c[:, 0]
|
|
73
|
+
boxes = c[:, 2:]
|
|
74
|
+
boxes[:, [0, 2]] *= sy
|
|
75
|
+
boxes[:, [1, 3]] *= sx
|
|
76
|
+
|
|
77
|
+
generators.append((DetectionResult(box, score) for box, score in zip(boxes, scores)))
|
|
78
|
+
|
|
79
|
+
return generators
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PaddlePaddleWiredCellDetection(PaddlePaddleCellDetection):
|
|
83
|
+
download_options = DownloadOptions(
|
|
84
|
+
DownloadPlatform.HUGGINGFACE, HF_REPO_ID, "wired_table_cell_det.onnx"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PaddlePaddleWirelessCellDetection(PaddlePaddleCellDetection):
|
|
89
|
+
download_options = DownloadOptions(
|
|
90
|
+
DownloadPlatform.HUGGINGFACE, HF_REPO_ID, "wireless_table_cell_det.onnx"
|
|
91
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Iterable, Optional
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import NDArray
|
|
7
|
+
|
|
8
|
+
from ...utils.table import Table
|
|
9
|
+
from .cell_detection import PaddlePaddleWiredCellDetection, PaddlePaddleWirelessCellDetection
|
|
10
|
+
from .table_classification import PaddlePaddleTableClassification
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PaddlePaddleTablePipeline:
|
|
16
|
+
"""A table pipeline combining PaddlePaddle classification and detection models."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, models_path: Optional[Path | str] = None):
|
|
19
|
+
cls_path, wired_path, wireless_path = None, None, None
|
|
20
|
+
|
|
21
|
+
if models_path is not None:
|
|
22
|
+
models_path = Path(models_path)
|
|
23
|
+
cls_path = models_path / PaddlePaddleTableClassification.download_options.model_path
|
|
24
|
+
wired_path = models_path / PaddlePaddleWiredCellDetection.download_options.model_path
|
|
25
|
+
wireless_path = models_path / PaddlePaddleWiredCellDetection.download_options.model_path
|
|
26
|
+
|
|
27
|
+
self.cls_predictor = PaddlePaddleTableClassification(cls_path)
|
|
28
|
+
self.wired_predictor = PaddlePaddleWiredCellDetection(wired_path)
|
|
29
|
+
self.wireless_predictor = PaddlePaddleWirelessCellDetection(wireless_path)
|
|
30
|
+
|
|
31
|
+
def __call__(self, input: Iterable[NDArray[np.uint8]]) -> list[Table]:
|
|
32
|
+
wired_images, wireless_images, output = [], [], []
|
|
33
|
+
|
|
34
|
+
cls_result = self.cls_predictor(input)
|
|
35
|
+
|
|
36
|
+
for i, (img, p) in enumerate(zip(input, cls_result)):
|
|
37
|
+
(wired_images if p.cls == "wired" else wireless_images).append(img)
|
|
38
|
+
logger.info("Image %d classified as %s", i, p.cls)
|
|
39
|
+
|
|
40
|
+
if len(wired_images):
|
|
41
|
+
wired_cells = self.wired_predictor(wired_images)
|
|
42
|
+
|
|
43
|
+
if len(wireless_images):
|
|
44
|
+
wireless_cells = self.wireless_predictor(wireless_images)
|
|
45
|
+
|
|
46
|
+
wired_idx = 0
|
|
47
|
+
wireless_idx = 0
|
|
48
|
+
|
|
49
|
+
for i in range(len(cls_result)):
|
|
50
|
+
if cls_result[i].cls == "wired":
|
|
51
|
+
cells_det = wired_cells[wired_idx]
|
|
52
|
+
wired_idx += 1
|
|
53
|
+
else:
|
|
54
|
+
cells_det = wireless_cells[wireless_idx]
|
|
55
|
+
wireless_idx += 1
|
|
56
|
+
|
|
57
|
+
output.append(Table.from_detections(cells_det))
|
|
58
|
+
|
|
59
|
+
return output
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Iterable, Sequence
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from numpy.typing import NDArray
|
|
6
|
+
|
|
7
|
+
from ..utils.download import DownloadOptions, DownloadPlatform
|
|
8
|
+
from ..utils.runtimes import OnnxModel
|
|
9
|
+
from ..utils.tasks import ClassificationModel, ClassificationResult
|
|
10
|
+
|
|
11
|
+
HF_REPO_ID = "jspast/paddlepaddle-table-models-onnx"
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PaddlePaddleTableClassification(ClassificationModel, OnnxModel):
|
|
17
|
+
download_options = DownloadOptions(DownloadPlatform.HUGGINGFACE, HF_REPO_ID, "table_cls.onnx")
|
|
18
|
+
|
|
19
|
+
def __call__(self, input: Iterable[NDArray[np.uint8]]) -> list[ClassificationResult]:
|
|
20
|
+
logger.debug("Started preprocessing")
|
|
21
|
+
input = self.preprocess(input)
|
|
22
|
+
|
|
23
|
+
input_dict = dict(zip(self.input_names, [input]))
|
|
24
|
+
|
|
25
|
+
logger.debug("Done preprocessing")
|
|
26
|
+
logger.debug("Started running the model")
|
|
27
|
+
|
|
28
|
+
output = self.session.run(self.output_names, input_dict)[0]
|
|
29
|
+
|
|
30
|
+
logger.debug("Done running the model")
|
|
31
|
+
logger.debug("Started postprocessing")
|
|
32
|
+
|
|
33
|
+
result = self.postprocess(output) # type: ignore
|
|
34
|
+
|
|
35
|
+
logger.debug("Done postprocessing")
|
|
36
|
+
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def postprocess(pred: Sequence[Sequence[float]]) -> list[ClassificationResult]:
|
|
41
|
+
return [
|
|
42
|
+
ClassificationResult({0: "wired", 1: "wireless"}[np.argmax(p)], max(p)) for p in pred
|
|
43
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import NamedTuple
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DownloadPlatform(Enum):
|
|
10
|
+
HUGGINGFACE = "huggingface"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DownloadOptions(NamedTuple):
|
|
14
|
+
platform: DownloadPlatform
|
|
15
|
+
repo_id: str
|
|
16
|
+
model_path: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def download(options: DownloadOptions) -> Path:
|
|
20
|
+
match options.platform:
|
|
21
|
+
case DownloadPlatform.HUGGINGFACE:
|
|
22
|
+
path = download_hf_model(options.repo_id)
|
|
23
|
+
|
|
24
|
+
return path / options.model_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def download_hf_model(repo_id: str) -> Path:
|
|
28
|
+
"""Download a repository from Hugging Face and return its path."""
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from huggingface_hub import snapshot_download
|
|
32
|
+
from huggingface_hub.utils import disable_progress_bars
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ImportError("huggingface_hub is not installed. Unable to download the model.")
|
|
35
|
+
|
|
36
|
+
disable_progress_bars()
|
|
37
|
+
|
|
38
|
+
logger.info("Downloading HF repo %s", repo_id)
|
|
39
|
+
download_path = snapshot_download(repo_id=repo_id)
|
|
40
|
+
|
|
41
|
+
return Path(download_path)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Iterable, Optional
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
import onnxruntime as ort
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
from ..tasks.base import BaseModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OnnxModel(BaseModel, ABC):
|
|
14
|
+
"""Base interface for ONNX models."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, model_path: Optional[Path | str] = None) -> None:
|
|
17
|
+
if model_path is None:
|
|
18
|
+
model_path = self.download()
|
|
19
|
+
|
|
20
|
+
providers_priority = [
|
|
21
|
+
"CUDAExecutionProvider",
|
|
22
|
+
"MIGraphXExecutionProvider",
|
|
23
|
+
"OpenVINOExecutionProvider",
|
|
24
|
+
"CPUExecutionProvider",
|
|
25
|
+
]
|
|
26
|
+
available_providers = ort.get_available_providers() # type: ignore
|
|
27
|
+
|
|
28
|
+
self.session = ort.InferenceSession(
|
|
29
|
+
model_path,
|
|
30
|
+
providers=[p for p in providers_priority if p in available_providers],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
self.scale = 1 / 255.0
|
|
34
|
+
self.mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
|
35
|
+
self.std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def input_shape(self):
|
|
39
|
+
return self.session.get_inputs()[0].shape[2:] # assuming NCHW
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def input_names(self):
|
|
43
|
+
return [v.name for v in self.session.get_inputs()]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def output_names(self):
|
|
47
|
+
return [v.name for v in self.session.get_outputs()]
|
|
48
|
+
|
|
49
|
+
def preprocess(self, input: Iterable[NDArray[np.uint8]]) -> list[NDArray[np.uint8]]:
|
|
50
|
+
output = []
|
|
51
|
+
|
|
52
|
+
for img in input:
|
|
53
|
+
img = cv2.resize(img, dsize=self.input_shape, interpolation=cv2.INTER_LANCZOS4)
|
|
54
|
+
img = (img.astype(np.float32) * self.scale - self.mean) / self.std # Normalize
|
|
55
|
+
img = img.transpose(2, 0, 1) # HWC to CHW
|
|
56
|
+
output.append(img)
|
|
57
|
+
|
|
58
|
+
return output
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from ..download import DownloadOptions, download
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseModel(ABC):
|
|
9
|
+
"""Base interface for models of any type."""
|
|
10
|
+
|
|
11
|
+
download_options: Optional[DownloadOptions] = None
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def __init__(self, model_path: Optional[Path | str] = None) -> None:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def __call__(self, input: Any):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def download(cls) -> Path:
|
|
23
|
+
if cls.download_options is not None:
|
|
24
|
+
return download(cls.download_options)
|
|
25
|
+
else:
|
|
26
|
+
raise NotImplementedError(
|
|
27
|
+
"Download is not implemented for this model. Please provide a path."
|
|
28
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, NamedTuple
|
|
3
|
+
|
|
4
|
+
from .base import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ClassificationResult(NamedTuple):
|
|
8
|
+
"""Result type for classification models."""
|
|
9
|
+
|
|
10
|
+
cls: str
|
|
11
|
+
confidence: float
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClassificationModel(BaseModel, ABC):
|
|
15
|
+
"""Base interface for table classification models."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def __call__(self, input: Any) -> list[ClassificationResult]:
|
|
19
|
+
pass
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Iterator, NamedTuple
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .base import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DetectionResult(NamedTuple):
|
|
10
|
+
"""Result type for a detection with no class."""
|
|
11
|
+
|
|
12
|
+
bbox: np.ndarray
|
|
13
|
+
confidence: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DetectionModel(BaseModel, ABC):
|
|
17
|
+
"""Base interface for detection models."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def __call__(self, input: Any) -> list[Iterator[DetectionResult]]:
|
|
21
|
+
pass
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Iterable, Optional
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy.typing import ArrayLike
|
|
8
|
+
|
|
9
|
+
from ..models.utils.tasks import DetectionResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class BoundingBox:
|
|
14
|
+
l: float # noqa: E741
|
|
15
|
+
t: float
|
|
16
|
+
r: float
|
|
17
|
+
b: float
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def from_array(bbox: ArrayLike[float]) -> BoundingBox:
|
|
21
|
+
return BoundingBox(l=bbox[0], t=bbox[1], r=bbox[2], b=bbox[3])
|
|
22
|
+
|
|
23
|
+
def as_array(self) -> ArrayLike[float]:
|
|
24
|
+
return np.array([self.l, self.t, self.r, self.b])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Cell:
|
|
29
|
+
bbox: BoundingBox
|
|
30
|
+
row: int
|
|
31
|
+
col: int
|
|
32
|
+
row_span: int = 1
|
|
33
|
+
col_span: int = 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Table:
|
|
38
|
+
cells: list[Cell] = field(default_factory=list)
|
|
39
|
+
num_rows: int = 0
|
|
40
|
+
num_cols: int = 0
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def from_detections(cells_det: Iterable[DetectionResult], tolerance: float = 10) -> Table:
|
|
44
|
+
table = Table()
|
|
45
|
+
|
|
46
|
+
for cell_det in cells_det:
|
|
47
|
+
bbox = BoundingBox.from_array(cell_det.bbox)
|
|
48
|
+
cell = Cell(bbox=bbox, row=0, col=0)
|
|
49
|
+
table.cells.append(cell)
|
|
50
|
+
|
|
51
|
+
table.compute_rows_and_cols(tolerance)
|
|
52
|
+
return table
|
|
53
|
+
|
|
54
|
+
def compute_rows_and_cols(self, tolerance: float) -> None:
|
|
55
|
+
self.compute_rows(tolerance)
|
|
56
|
+
self.compute_cols(tolerance)
|
|
57
|
+
|
|
58
|
+
def sort_cells_by_rows(self, cells: Optional[Iterable[Cell]] = None) -> list[Cell]:
|
|
59
|
+
if cells is None:
|
|
60
|
+
cells = self.cells
|
|
61
|
+
|
|
62
|
+
return sorted(self.cells, key=lambda cell: cell.bbox.t)
|
|
63
|
+
|
|
64
|
+
def sort_cells_by_cols(self, cells: Optional[Iterable[Cell]] = None) -> list[Cell]:
|
|
65
|
+
if cells is None:
|
|
66
|
+
cells = self.cells
|
|
67
|
+
|
|
68
|
+
return sorted(self.cells, key=lambda cell: cell.bbox.l)
|
|
69
|
+
|
|
70
|
+
def compute_rows(self, tolerance: float) -> None:
|
|
71
|
+
self.cells = self.sort_cells_by_rows()
|
|
72
|
+
|
|
73
|
+
row_y = None
|
|
74
|
+
row_num = 0
|
|
75
|
+
row_start_idx = 0
|
|
76
|
+
row_end_idx = None
|
|
77
|
+
check_span_indices: set[int] = set()
|
|
78
|
+
|
|
79
|
+
for i in range(len(self.cells)):
|
|
80
|
+
if row_y is None:
|
|
81
|
+
row_y = self.cells[i].bbox.t
|
|
82
|
+
|
|
83
|
+
elif abs(self.cells[i].bbox.t - row_y) > tolerance:
|
|
84
|
+
row_end_idx = i
|
|
85
|
+
for j in range(row_start_idx, row_end_idx):
|
|
86
|
+
self.cells[j].row = row_num
|
|
87
|
+
check_span_indices.add(j)
|
|
88
|
+
|
|
89
|
+
row_y = self.cells[i].bbox.t
|
|
90
|
+
row_start_idx = row_end_idx
|
|
91
|
+
row_num += 1
|
|
92
|
+
|
|
93
|
+
for j in list(check_span_indices):
|
|
94
|
+
if self.cells[j].bbox.b > row_y + tolerance:
|
|
95
|
+
self.cells[j].row_span += 1
|
|
96
|
+
else:
|
|
97
|
+
check_span_indices.remove(j)
|
|
98
|
+
|
|
99
|
+
row_end_idx = len(self.cells)
|
|
100
|
+
for j in range(row_start_idx, row_end_idx):
|
|
101
|
+
self.cells[j].row = row_num
|
|
102
|
+
check_span_indices.add(j)
|
|
103
|
+
|
|
104
|
+
self.num_rows = row_num + 1
|
|
105
|
+
|
|
106
|
+
def compute_cols(self, tolerance: float) -> None:
|
|
107
|
+
self.cells = self.sort_cells_by_cols()
|
|
108
|
+
|
|
109
|
+
col_x = None
|
|
110
|
+
col_num = 0
|
|
111
|
+
col_start_idx = 0
|
|
112
|
+
col_end_idx = None
|
|
113
|
+
check_span_indices: set[int] = set()
|
|
114
|
+
|
|
115
|
+
for i in range(len(self.cells)):
|
|
116
|
+
if col_x is None:
|
|
117
|
+
col_x = self.cells[i].bbox.l
|
|
118
|
+
|
|
119
|
+
elif abs(self.cells[i].bbox.l - col_x) > tolerance:
|
|
120
|
+
col_end_idx = i
|
|
121
|
+
for j in range(col_start_idx, col_end_idx):
|
|
122
|
+
self.cells[j].col = col_num
|
|
123
|
+
check_span_indices.add(j)
|
|
124
|
+
|
|
125
|
+
col_x = self.cells[i].bbox.l
|
|
126
|
+
col_start_idx = col_end_idx
|
|
127
|
+
col_num += 1
|
|
128
|
+
|
|
129
|
+
for j in list(check_span_indices):
|
|
130
|
+
if self.cells[j].bbox.r > col_x + tolerance:
|
|
131
|
+
self.cells[j].col_span += 1
|
|
132
|
+
else:
|
|
133
|
+
check_span_indices.remove(j)
|
|
134
|
+
|
|
135
|
+
col_end_idx = len(self.cells)
|
|
136
|
+
for j in range(col_start_idx, col_end_idx):
|
|
137
|
+
self.cells[j].col = col_num
|
|
138
|
+
check_span_indices.add(j)
|
|
139
|
+
|
|
140
|
+
self.num_cols = col_num + 1
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
from numpy.typing import NDArray
|
|
4
|
+
|
|
5
|
+
from .table import Table
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def visualize_table(
|
|
9
|
+
image: NDArray[np.uint8],
|
|
10
|
+
table: Table,
|
|
11
|
+
color=(0, 255, 0),
|
|
12
|
+
thickness=2,
|
|
13
|
+
window_name="Bounding Boxes",
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Simple table visualization on top of the image.
|
|
16
|
+
|
|
17
|
+
image: np.ndarray (BGR image loaded with cv2)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
img = image.copy()
|
|
21
|
+
|
|
22
|
+
for cell in table.cells:
|
|
23
|
+
cv2.rectangle(
|
|
24
|
+
img,
|
|
25
|
+
(round(cell.bbox.l), round(cell.bbox.t)),
|
|
26
|
+
(round(cell.bbox.r), round(cell.bbox.b)),
|
|
27
|
+
color,
|
|
28
|
+
thickness,
|
|
29
|
+
)
|
|
30
|
+
cv2.putText(
|
|
31
|
+
img,
|
|
32
|
+
f"{cell.row},{cell.col} : {cell.row_span},{cell.col_span}",
|
|
33
|
+
(round(cell.bbox.l), round(cell.bbox.t) + 10),
|
|
34
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
35
|
+
0.5,
|
|
36
|
+
(128, 192, 0),
|
|
37
|
+
thickness,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
cv2.imshow(window_name, img)
|
|
41
|
+
cv2.waitKey(0)
|
|
42
|
+
cv2.destroyAllWindows()
|