Skip to content

Commit c0ce28d

Browse files
[Feat] Add layout detection dataset class (#2064)
1 parent 53c58dc commit c0ce28d

6 files changed

Lines changed: 276 additions & 6 deletions

File tree

docs/source/modules/datasets.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ Custom dataset loader
5050

5151
.. autoclass:: DetectionDataset
5252

53+
.. autoclass:: LayoutDataset
54+
5355
.. autoclass:: RecognitionDataset
5456

5557
.. autoclass:: OCRDataset

doctr/datasets/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .coco_text import *
33
from .cord import *
44
from .detection import *
5+
from .layout import *
56
from .doc_artefacts import *
67
from .funsd import *
78
from .ic03 import *

doctr/datasets/layout.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright (C) 2021-2026, Mindee.
2+
3+
# This program is licensed under the Apache License 2.0.
4+
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.
5+
6+
import json
7+
import os
8+
from typing import Any
9+
10+
import numpy as np
11+
12+
from .datasets import AbstractDataset
13+
from .utils import pre_transform_multiclass
14+
15+
__all__ = ["LayoutDataset"]
16+
17+
18+
class LayoutDataset(AbstractDataset):
19+
"""Implements a document layout detection dataset.
20+
21+
>>> from doctr.datasets import LayoutDataset
22+
>>> train_set = LayoutDataset(
23+
>>> img_folder="/path/to/images",
24+
>>> label_path="/path/to/labels.json",
25+
>>> )
26+
>>> img, target = train_set[0]
27+
28+
Args:
29+
img_folder: folder containing the dataset images
30+
label_path: path to the labels.json file
31+
use_polygons: whether to keep polygons instead of converting to straight boxes
32+
**kwargs: keyword arguments from `AbstractDataset`
33+
"""
34+
35+
def __init__(
36+
self,
37+
img_folder: str,
38+
label_path: str,
39+
use_polygons: bool = False,
40+
**kwargs: Any,
41+
) -> None:
42+
super().__init__(
43+
img_folder,
44+
pre_transforms=pre_transform_multiclass,
45+
**kwargs,
46+
)
47+
self._class_names: list[str] = []
48+
if not os.path.exists(label_path):
49+
raise FileNotFoundError(f"unable to locate {label_path}")
50+
with open(label_path, "rb") as f:
51+
labels = json.load(f)
52+
53+
self.data: list[tuple[str, tuple[np.ndarray, list[str]]]] = []
54+
np_dtype = np.float32
55+
56+
for img_name, label in labels.items():
57+
img_path = os.path.join(self.root, img_name)
58+
59+
# File existence check
60+
if not os.path.exists(img_path):
61+
raise FileNotFoundError(f"unable to locate {img_path}")
62+
63+
polygons = label.get("polygons")
64+
class_names = label.get("class_names")
65+
66+
if polygons is None:
67+
raise KeyError(f"missing 'polygons' for image: {img_name}")
68+
if class_names is None:
69+
raise KeyError(f"missing 'class_names' for image: {img_name}")
70+
71+
if len(polygons) != len(class_names):
72+
raise ValueError(
73+
f"number of polygons ({len(polygons)}) does not match "
74+
f"number of class_names ({len(class_names)}) for image: {img_name}"
75+
)
76+
77+
geoms, polygon_classes = self.format_polygons(
78+
polygons=polygons,
79+
class_names=class_names,
80+
use_polygons=use_polygons,
81+
np_dtype=np_dtype,
82+
)
83+
84+
self.data.append((
85+
img_name,
86+
(
87+
np.asarray(geoms, dtype=np_dtype),
88+
polygon_classes,
89+
),
90+
))
91+
92+
def format_polygons(
93+
self,
94+
polygons: list,
95+
class_names: list[str],
96+
use_polygons: bool,
97+
np_dtype: type,
98+
) -> tuple[np.ndarray, list[str]]:
99+
"""Format polygons into an array.
100+
101+
Args:
102+
polygons: list of polygons
103+
class_names: list of class names corresponding to polygons
104+
use_polygons: whether polygons should be preserved
105+
np_dtype: numpy dtype
106+
107+
Returns:
108+
geoms: polygons or straight boxes
109+
polygon_classes: list of classes
110+
"""
111+
self._class_names += class_names
112+
113+
_polygons: np.ndarray = np.asarray(polygons, dtype=np_dtype)
114+
if _polygons.ndim != 3 or _polygons.shape[1:] != (4, 2):
115+
raise ValueError(f"polygons are expected to have shape (N, 4, 2), got {_polygons.shape}")
116+
geoms = _polygons if use_polygons else np.concatenate((_polygons.min(axis=1), _polygons.max(axis=1)), axis=1)
117+
118+
return geoms, class_names
119+
120+
@property
121+
def class_names(self) -> list[str]:
122+
return sorted(set(self._class_names))

tests/common/test_cli.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,16 @@ def test_parse_args_custom_values():
6161
assert args.output == "output.json"
6262

6363

64-
def test_main_with_image(mock_image_path):
65-
output_path = "results.json"
66-
cli.main(["--input_path", mock_image_path, "--output", output_path])
64+
def test_main_with_image(mock_image_path, tmp_path):
65+
output_path = tmp_path / "results.json"
66+
cli.main(["--input_path", mock_image_path, "--output", str(output_path)])
6767

6868
assert Path(output_path).exists()
6969

7070

71-
def test_main_with_pdf(mock_pdf):
72-
output_path = "results.json"
73-
cli.main(["--input_path", mock_pdf, "--output", output_path])
71+
def test_main_with_pdf(mock_pdf, tmp_path):
72+
output_path = tmp_path / "results.json"
73+
cli.main(["--input_path", mock_pdf, "--output", str(output_path)])
7474

7575
assert Path(output_path).exists()
7676

tests/conftest.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,37 @@ def mock_detection_label(tmpdir_factory):
132132
return str(labels_path)
133133

134134

135+
@pytest.fixture(scope="session")
136+
def mock_layout_label(tmpdir_factory):
137+
folder = tmpdir_factory.mktemp("labels")
138+
139+
labels = {}
140+
for idx in range(5):
141+
labels[f"mock_image_file_{idx}.jpeg"] = {
142+
"img_dimensions": [800, 600],
143+
"img_hash": "dummy_hash",
144+
"polygons": [
145+
[[1, 2], [1, 3], [2, 1], [2, 3]],
146+
[[10, 20], [10, 30], [20, 10], [20, 30]],
147+
[[3, 2], [3, 3], [4, 1], [4, 3]],
148+
[[30, 20], [30, 30], [40, 10], [40, 30]],
149+
],
150+
"class_names": [
151+
"Table",
152+
"Header",
153+
"Footer",
154+
"Text",
155+
],
156+
}
157+
158+
labels_path = folder.join("labels.json")
159+
160+
with open(labels_path, "w") as f:
161+
json.dump(labels, f)
162+
163+
return str(labels_path)
164+
165+
135166
@pytest.fixture(scope="session")
136167
def mock_recognition_label(tmpdir_factory):
137168
label_file = tmpdir_factory.mktemp("labels").join("labels.json")

tests/pytorch/test_datasets_pt.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
from shutil import move
34

@@ -177,6 +178,119 @@ def test_detection_dataset(mock_image_folder, mock_detection_label):
177178
move(os.path.join(ds.root, "tmp_file"), os.path.join(ds.root, img_name))
178179

179180

181+
@pytest.mark.parametrize(
182+
"use_polygons",
183+
[False, True],
184+
)
185+
def test_layout_dataset(mock_image_folder, mock_layout_label, use_polygons):
186+
input_size = (1024, 1024)
187+
188+
ds = datasets.LayoutDataset(
189+
img_folder=mock_image_folder,
190+
label_path=mock_layout_label,
191+
img_transforms=Resize(input_size),
192+
use_polygons=use_polygons,
193+
)
194+
195+
assert len(ds) == 5
196+
img, target_dict = ds[0]
197+
assert isinstance(img, torch.Tensor)
198+
assert img.dtype == torch.float32
199+
assert img.shape[-2:] == input_size
200+
assert isinstance(target_dict, dict)
201+
expected_classes = {"Table", "Header", "Footer", "Text"}
202+
assert set(target_dict.keys()) == expected_classes
203+
for class_name, target in target_dict.items():
204+
assert isinstance(target, np.ndarray)
205+
assert target.dtype == np.float32
206+
if use_polygons:
207+
assert target.ndim == 3
208+
assert target.shape[1:] == (4, 2)
209+
else:
210+
assert target.ndim == 2
211+
assert target.shape[1:] == (4,)
212+
assert np.all(np.logical_and(target >= 0, target <= 1))
213+
assert ds.class_names == sorted(expected_classes)
214+
loader = DataLoader(ds, batch_size=2, collate_fn=ds.collate_fn)
215+
images, targets = next(iter(loader))
216+
assert isinstance(images, torch.Tensor)
217+
assert images.shape == (2, 3, *input_size)
218+
assert isinstance(targets, list)
219+
assert all(isinstance(target, dict) for target in targets)
220+
for target in targets:
221+
assert set(target.keys()) == expected_classes
222+
assert all(isinstance(v, np.ndarray) for v in target.values())
223+
224+
# File existence check
225+
img_name, _ = ds.data[0]
226+
move(os.path.join(ds.root, img_name), os.path.join(ds.root, "tmp_file"))
227+
with pytest.raises(FileNotFoundError):
228+
datasets.LayoutDataset(
229+
img_folder=mock_image_folder,
230+
label_path=mock_layout_label,
231+
)
232+
move(os.path.join(ds.root, "tmp_file"), os.path.join(ds.root, img_name))
233+
234+
with pytest.raises(FileNotFoundError):
235+
datasets.LayoutDataset(
236+
img_folder=mock_image_folder,
237+
label_path="/tmp/does_not_exist.json",
238+
)
239+
240+
with open(mock_layout_label) as f:
241+
original_labels = json.load(f)
242+
first_key = next(iter(original_labels))
243+
244+
test_cases = [
245+
(
246+
{"class_names": ["Text"]},
247+
KeyError,
248+
"missing 'polygons'",
249+
),
250+
(
251+
{"polygons": [[[0, 0], [1, 0], [1, 1], [0, 1]]]},
252+
KeyError,
253+
"missing 'class_names'",
254+
),
255+
(
256+
{
257+
"polygons": [
258+
[[0, 0], [1, 0], [1, 1], [0, 1]],
259+
[[0, 0], [1, 0], [1, 1], [0, 1]],
260+
],
261+
"class_names": ["Text"],
262+
},
263+
ValueError,
264+
"number of polygons",
265+
),
266+
(
267+
{
268+
"polygons": [[[0, 0], [1, 0], [1, 1]]], # only 3 points
269+
"class_names": ["Text"],
270+
},
271+
ValueError,
272+
"polygons are expected to have shape",
273+
),
274+
]
275+
276+
for sample, exc_type, match in test_cases:
277+
broken_labels = dict(original_labels)
278+
broken_labels[first_key] = sample
279+
280+
with open(mock_layout_label, "w") as f:
281+
json.dump(broken_labels, f)
282+
283+
with pytest.raises(exc_type, match=match):
284+
datasets.LayoutDataset(
285+
img_folder=mock_image_folder,
286+
label_path=mock_layout_label,
287+
)
288+
289+
# Restore original labels
290+
with open(mock_layout_label, "w") as f:
291+
json.dump(original_labels, f)
292+
293+
180294
def test_recognition_dataset(mock_image_folder, mock_recognition_label):
181295
input_size = (32, 128)
182296
ds = datasets.RecognitionDataset(

0 commit comments

Comments
 (0)