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.
@@ -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,3 @@
1
+ from .models import DefaultPipeline
2
+
3
+ __all__ = [DefaultPipeline]
@@ -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,3 @@
1
+ from .PaddlePaddle import PaddlePaddleTablePipeline as DefaultPipeline
2
+
3
+ __all__ = [DefaultPipeline]
@@ -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,3 @@
1
+ from .onnx import OnnxModel
2
+
3
+ __all__ = [OnnxModel]
@@ -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,5 @@
1
+ from .base import BaseModel
2
+ from .classification import ClassificationModel, ClassificationResult
3
+ from .detection import DetectionModel, DetectionResult
4
+
5
+ __all__ = [BaseModel, ClassificationModel, ClassificationResult, DetectionModel, DetectionResult]
@@ -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()