Skip to content

Commit cabdc1f

Browse files
[Layout] Add layout integration (#2075)
1 parent 9c1c6e3 commit cabdc1f

20 files changed

Lines changed: 574 additions & 13 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ doc = DocumentFile.from_pdf("path/to/your/doc.pdf")
5959
result = model(doc)
6060
```
6161

62+
### Detecting the document layout
63+
64+
You can additionally run a layout detection model as part of the pipeline by passing `detect_layout=True`. The detected regions (e.g. `Title`, `Text`, `Table`, `Page-header`, `Page-footer`) are attached to every page and rendered by `.show()`:
65+
66+
```python
67+
from doctr.io import DocumentFile
68+
from doctr.models import ocr_predictor
69+
70+
model = ocr_predictor(pretrained=True, detect_layout=True)
71+
doc = DocumentFile.from_images("path/to/your/doc.jpg")
72+
result = model(doc)
73+
74+
# Access the detected layout regions of the first page
75+
for region in result.pages[0].layout:
76+
print(region.type, region.confidence, region.geometry)
77+
```
78+
6279
### Dealing with rotated documents
6380

6481
Should you use docTR on documents that include rotated pages, or pages with multiple box orientations,

api/app/routes/kie.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
88

9-
from app.schemas import KIEElement, KIEIn, KIEOut
9+
from app.schemas import KIEElement, KIEIn, KIEOut, LayoutElementOut
1010
from app.utils import get_documents, resolve_geometry
1111
from app.vision import init_predictor
1212

@@ -30,6 +30,14 @@ async def perform_kie(request: KIEIn = Depends(), files: list[UploadFile] = [Fil
3030
orientation=page.orientation,
3131
language=page.language,
3232
dimensions=page.dimensions,
33+
layout=[
34+
LayoutElementOut(
35+
type=region.type,
36+
geometry=resolve_geometry(region.geometry),
37+
confidence=round(region.confidence, 2),
38+
)
39+
for region in page.layout
40+
],
3341
predictions=[
3442
KIEElement(
3543
class_name=class_name,

api/app/routes/ocr.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
88

9-
from app.schemas import OCRBlock, OCRIn, OCRLine, OCROut, OCRPage, OCRWord
9+
from app.schemas import LayoutElementOut, OCRBlock, OCRIn, OCRLine, OCROut, OCRPage, OCRWord
1010
from app.utils import get_documents, resolve_geometry
1111
from app.vision import init_predictor
1212

@@ -31,6 +31,14 @@ async def perform_ocr(request: OCRIn = Depends(), files: list[UploadFile] = [Fil
3131
orientation=page.orientation,
3232
language=page.language,
3333
dimensions=page.dimensions,
34+
layout=[
35+
LayoutElementOut(
36+
type=region.type,
37+
geometry=resolve_geometry(region.geometry),
38+
confidence=round(region.confidence, 2),
39+
)
40+
for region in page.layout
41+
],
3442
items=[
3543
OCRPage(
3644
blocks=[

api/app/schemas.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class KIEIn(BaseModel):
1515
preserve_aspect_ratio: bool = Field(default=True, examples=[True])
1616
detect_orientation: bool = Field(default=False, examples=[False])
1717
detect_language: bool = Field(default=False, examples=[False])
18+
detect_layout: bool = Field(default=False, examples=[False])
19+
layout_arch: str = Field(default="lw_detr_s", examples=["lw_detr_s"])
1820
symmetric_pad: bool = Field(default=True, examples=[True])
1921
straighten_pages: bool = Field(default=False, examples=[False])
2022
det_bs: int = Field(default=2, examples=[2])
@@ -131,11 +133,21 @@ class OCRPage(BaseModel):
131133
)
132134

133135

136+
class LayoutElementOut(BaseModel):
137+
type: str = Field(..., examples=["Title"])
138+
geometry: list[float] = Field(..., examples=[[0.0, 0.0, 0.0, 0.0]])
139+
confidence: float = Field(..., examples=[0.99])
140+
141+
134142
class OCROut(BaseModel):
135143
name: str = Field(..., examples=["example.jpg"])
136144
orientation: dict[str, float | None] = Field(..., examples=[{"value": 0.0, "confidence": 0.99}])
137145
language: dict[str, str | float | None] = Field(..., examples=[{"value": "en", "confidence": 0.99}])
138146
dimensions: tuple[int, int] = Field(..., examples=[(100, 100)])
147+
layout: list[LayoutElementOut] = Field(
148+
default=[],
149+
examples=[[{"type": "Title", "geometry": [0.0, 0.0, 0.0, 0.0], "confidence": 0.99}]],
150+
)
139151
items: list[OCRPage] = Field(
140152
...,
141153
examples=[
@@ -183,4 +195,8 @@ class KIEOut(BaseModel):
183195
orientation: dict[str, float | None] = Field(..., examples=[{"value": 0.0, "confidence": 0.99}])
184196
language: dict[str, str | float | None] = Field(..., examples=[{"value": "en", "confidence": 0.99}])
185197
dimensions: tuple[int, int] = Field(..., examples=[(100, 100)])
198+
layout: list[LayoutElementOut] = Field(
199+
default=[],
200+
examples=[[{"type": "Title", "geometry": [0.0, 0.0, 0.0, 0.0], "confidence": 0.99}]],
201+
)
186202
predictions: list[KIEElement]

api/tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def mock_kie_response():
7777
"orientation": {"value": None, "confidence": None},
7878
"language": {"value": None, "confidence": None},
7979
"dimensions": [2339, 1654],
80+
"layout": [],
8081
"predictions": [
8182
{
8283
"class_name": "words",
@@ -104,6 +105,7 @@ def mock_kie_response():
104105
"orientation": {"value": None, "confidence": None},
105106
"language": {"value": None, "confidence": None},
106107
"dimensions": [2339, 1654],
108+
"layout": [],
107109
"predictions": [
108110
{
109111
"class_name": "words",
@@ -155,6 +157,7 @@ def mock_ocr_response():
155157
"orientation": {"value": None, "confidence": None},
156158
"language": {"value": None, "confidence": None},
157159
"dimensions": [2339, 1654],
160+
"layout": [],
158161
"items": [
159162
{
160163
"blocks": [
@@ -203,6 +206,7 @@ def mock_ocr_response():
203206
"orientation": {"value": None, "confidence": None},
204207
"language": {"value": None, "confidence": None},
205208
"dimensions": [2339, 1654],
209+
"layout": [],
206210
"items": [
207211
{
208212
"blocks": [

api/tests/routes/test_kie.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ def common_test(json_response, expected_response):
1010
and len(first_pred["dimensions"]) == 2
1111
and all(isinstance(dim, int) for dim in first_pred["dimensions"])
1212
)
13+
assert isinstance(first_pred["layout"], list)
14+
for region in first_pred["layout"]:
15+
assert isinstance(region["type"], str)
16+
assert isinstance(region["confidence"], (int, float))
17+
assert isinstance(region["geometry"], (tuple, list))
1318
assert isinstance(first_pred["predictions"], list)
1419
assert isinstance(expected_response["predictions"], list)
1520

@@ -67,6 +72,27 @@ async def test_kie_poly(test_app_asyncio, mock_detection_image, mock_kie_respons
6772
common_test(json_response, expected_poly_response)
6873

6974

75+
@pytest.mark.asyncio
76+
async def test_kie_layout(test_app_asyncio, mock_detection_image):
77+
headers = {
78+
"accept": "application/json",
79+
}
80+
params = {"det_arch": "db_resnet50", "reco_arch": "crnn_vgg16_bn", "detect_layout": True}
81+
files = [
82+
("files", ("test.jpg", mock_detection_image, "image/jpeg")),
83+
]
84+
response = await test_app_asyncio.post("/kie", params=params, files=files, headers=headers)
85+
assert response.status_code == 200
86+
json_response = response.json()
87+
88+
assert isinstance(json_response, list) and len(json_response) == 1
89+
assert "layout" in json_response[0] and isinstance(json_response[0]["layout"], list)
90+
for region in json_response[0]["layout"]:
91+
assert isinstance(region["type"], str)
92+
assert isinstance(region["confidence"], (int, float))
93+
assert len(region["geometry"]) in (4, 8)
94+
95+
7096
@pytest.mark.asyncio
7197
async def test_kie_invalid_file(test_app_asyncio, mock_txt_file):
7298
headers = {

api/tests/routes/test_ocr.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ def common_test(json_response, expected_response):
1111
and len(first_pred["dimensions"]) == 2
1212
and all(isinstance(dim, int) for dim in first_pred["dimensions"])
1313
)
14+
assert isinstance(first_pred["layout"], list)
15+
for region in first_pred["layout"]:
16+
assert isinstance(region["type"], str)
17+
assert isinstance(region["confidence"], (int, float))
18+
assert isinstance(region["geometry"], (tuple, list))
1419
for item, expected_item in zip(first_pred["items"], expected_response["items"]):
1520
for block, expected_block in zip(item["blocks"], expected_item["blocks"]):
1621
np.testing.assert_allclose(block["geometry"], expected_block["geometry"], rtol=1e-2)
@@ -67,6 +72,27 @@ async def test_ocr_poly(test_app_asyncio, mock_detection_image, mock_ocr_respons
6772
common_test(json_response, expected_poly_response)
6873

6974

75+
@pytest.mark.asyncio
76+
async def test_ocr_layout(test_app_asyncio, mock_detection_image):
77+
headers = {
78+
"accept": "application/json",
79+
}
80+
params = {"det_arch": "db_resnet50", "reco_arch": "crnn_vgg16_bn", "detect_layout": True}
81+
files = [
82+
("files", ("test.jpg", mock_detection_image, "image/jpeg")),
83+
]
84+
response = await test_app_asyncio.post("/ocr", params=params, files=files, headers=headers)
85+
assert response.status_code == 200
86+
json_response = response.json()
87+
88+
assert isinstance(json_response, list) and len(json_response) == 1
89+
assert "layout" in json_response[0] and isinstance(json_response[0]["layout"], list)
90+
for region in json_response[0]["layout"]:
91+
assert isinstance(region["type"], str)
92+
assert isinstance(region["confidence"], (int, float))
93+
assert len(region["geometry"]) in (4, 8)
94+
95+
7096
@pytest.mark.asyncio
7197
async def test_ocr_invalid_file(test_app_asyncio, mock_txt_file):
7298
headers = {

demo/app.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
import numpy as np
99
import streamlit as st
1010
import torch
11-
from backend.pytorch import DET_ARCHS, RECO_ARCHS, forward_image, load_predictor
11+
from backend.pytorch import DET_ARCHS, LAYOUT_ARCHS, RECO_ARCHS, forward_image, load_predictor
1212

1313
from doctr.io import DocumentFile
1414
from doctr.utils.visualization import visualize_page
1515

1616
forward_device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
1717

1818

19-
def main(det_archs, reco_archs):
19+
def main(det_archs, reco_archs, layout_archs):
2020
"""Build a streamlit layout"""
2121
# Wide mode
2222
st.set_page_config(layout="wide")
@@ -67,6 +67,9 @@ def main(det_archs, reco_archs):
6767
straighten_pages = st.sidebar.checkbox("Straighten pages", value=False)
6868
# Export as straight boxes
6969
export_straight_boxes = st.sidebar.checkbox("Export as straight boxes", value=False)
70+
# Layout detection
71+
detect_layout = st.sidebar.checkbox("Detect layout", value=False)
72+
layout_arch = st.sidebar.selectbox("Layout detection model", layout_archs, disabled=not detect_layout)
7073
st.sidebar.write("\n")
7174
# Binarization threshold
7275
bin_thresh = st.sidebar.slider("Binarization threshold", min_value=0.1, max_value=0.9, value=0.3, step=0.1)
@@ -92,6 +95,8 @@ def main(det_archs, reco_archs):
9295
bin_thresh=bin_thresh,
9396
box_thresh=box_thresh,
9497
device=forward_device,
98+
detect_layout=detect_layout,
99+
layout_arch=layout_arch,
95100
)
96101

97102
with st.spinner("Analyzing..."):
@@ -123,4 +128,4 @@ def main(det_archs, reco_archs):
123128

124129

125130
if __name__ == "__main__":
126-
main(DET_ARCHS, RECO_ARCHS)
131+
main(DET_ARCHS, RECO_ARCHS, LAYOUT_ARCHS)

demo/backend/pytorch.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"parseq",
3232
"viptr_tiny",
3333
]
34+
LAYOUT_ARCHS = [
35+
"lw_detr_s",
36+
"lw_detr_m",
37+
]
3438

3539

3640
def load_predictor(
@@ -44,6 +48,8 @@ def load_predictor(
4448
bin_thresh: float,
4549
box_thresh: float,
4650
device: torch.device,
51+
detect_layout: bool,
52+
layout_arch: str,
4753
) -> OCRPredictor:
4854
"""Load a predictor from doctr.models
4955
@@ -58,6 +64,8 @@ def load_predictor(
5864
bin_thresh: binarization threshold for the segmentation map
5965
box_thresh: minimal objectness score to consider a box
6066
device: torch.device, the device to load the predictor on
67+
detect_layout: whether to run a layout detection model and attach the regions to each page
68+
layout_arch: layout architecture to use when detect_layout is True
6169
6270
Returns:
6371
instance of OCRPredictor
@@ -72,6 +80,8 @@ def load_predictor(
7280
detect_orientation=not assume_straight_pages,
7381
disable_page_orientation=disable_page_orientation,
7482
disable_crop_orientation=disable_crop_orientation,
83+
detect_layout=detect_layout,
84+
layout_arch=layout_arch,
7585
).to(device)
7686
predictor.det_predictor.model.postprocessor.bin_thresh = bin_thresh
7787
predictor.det_predictor.model.postprocessor.box_thresh = box_thresh

docs/source/modules/io.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ An Artefact is a non-textual element (e.g. QR code, picture, chart, signature, l
3333

3434
.. autoclass:: Artefact
3535

36+
LayoutElement
37+
^^^^^^^^^^^^^
38+
39+
A LayoutElement is a region predicted by a layout detection model (e.g. Title, Text, Table, Page-header, Page-footer). Layout regions are attached to a :class:`Page` when the ``ocr_predictor`` / ``kie_predictor`` is run with ``detect_layout=True``.
40+
41+
.. autoclass:: LayoutElement
42+
3643
Block
3744
^^^^^
3845
A Block is a collection of Lines (e.g. an address written on several lines) and Artefacts (e.g. a graph with its title underneath).

0 commit comments

Comments
 (0)