Skip to content

Commit 1dcc78f

Browse files
[Feat] Add Layout model LW-DETR (#2059)
1 parent ec4e076 commit 1dcc78f

15 files changed

Lines changed: 2275 additions & 6 deletions

File tree

docs/source/modules/models.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ doctr.models.detection
7676
.. autofunction:: doctr.models.detection.detection_predictor
7777

7878

79+
doctr.models.layout
80+
-------------------
81+
82+
.. autofunction:: doctr.models.layout.lw_detr_s
83+
84+
.. autofunction:: doctr.models.layout.lw_detr_m
85+
86+
7987
doctr.models.recognition
8088
------------------------
8189

doctr/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .classification import *
22
from .detection import *
33
from .recognition import *
4+
from .layout import *
45
from .zoo import *
56
from .factory import *

doctr/models/factory/hub.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"classification": models.classification.zoo.ARCHS + models.classification.zoo.ORIENTATION_ARCHS,
3131
"detection": models.detection.zoo.ARCHS,
3232
"recognition": models.recognition.zoo.ARCHS,
33+
"layout": models.layout.zoo.ARCHS,
3334
}
3435

3536

@@ -96,14 +97,19 @@ def push_to_hf_hub(model: Any, model_name: str, task: str, **kwargs) -> None: #
9697

9798
if run_config is None and arch is None:
9899
raise ValueError("run_config or arch must be specified")
99-
if task not in ["classification", "detection", "recognition"]:
100-
raise ValueError("task must be one of classification, detection, recognition")
100+
if task not in ["classification", "detection", "recognition", "layout"]:
101+
raise ValueError("task must be one of classification, detection, recognition, layout")
101102

102103
# default readme
103104
readme = textwrap.dedent(
104-
f"""
105-
105+
f"""---
106106
language: en
107+
tags:
108+
- ocr
109+
- pytorch
110+
- doctr
111+
- {task}
112+
---
107113
108114
109115
<p align="center">
@@ -161,7 +167,8 @@ def push_to_hf_hub(model: Any, model_name: str, task: str, **kwargs) -> None: #
161167

162168
# Create repository
163169
api = HfApi()
164-
api.create_repo(model_name, token=get_token(), exist_ok=False)
170+
repo_url = api.create_repo(model_name, token=get_token(), repo_type="model", exist_ok=False)
171+
full_repo_id = repo_url.repo_id
165172

166173
# Save model files to a temporary directory
167174
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -172,7 +179,8 @@ def push_to_hf_hub(model: Any, model_name: str, task: str, **kwargs) -> None: #
172179
# Upload all files to the hub
173180
api.upload_folder(
174181
folder_path=tmp_dir,
175-
repo_id=model_name,
182+
repo_id=full_repo_id,
183+
repo_type="model",
176184
commit_message=commit_message,
177185
token=get_token(),
178186
)
@@ -208,6 +216,8 @@ def from_hub(repo_id: str, **kwargs: Any):
208216
model = models.detection.__dict__[arch](pretrained=False)
209217
elif task == "recognition":
210218
model = models.recognition.__dict__[arch](pretrained=False, input_shape=cfg["input_shape"], vocab=cfg["vocab"])
219+
elif task == "layout":
220+
model = models.layout.__dict__[arch](pretrained=False, class_names=cfg["class_names"])
211221

212222
# update model cfg
213223
model.cfg = cfg

doctr/models/layout/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .zoo import *
2+
from .lw_detr import *
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .pytorch import *
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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+
from typing import Any
7+
8+
import cv2
9+
import numpy as np
10+
11+
from doctr.models.core import BaseModel
12+
13+
__all__ = ["_LWDETR", "LWDETRPostProcessor"]
14+
15+
16+
class LWDETRPostProcessor:
17+
"""Implements a post processor for LW-DETR model
18+
19+
Args:
20+
num_classes: number of classes
21+
score_thresh: confidence threshold for filtering predictions
22+
iou_thresh: IoU threshold for NMS
23+
topk: number of top predictions to keep before NMS
24+
assume_straight_pages: whether the pages are assumed to be straight (i.e., no rotation)
25+
"""
26+
27+
def __init__(
28+
self,
29+
num_classes: int,
30+
score_thresh: float = 0.3,
31+
iou_thresh: float = 0.5,
32+
topk: int = 300,
33+
assume_straight_pages: bool = True,
34+
):
35+
self.num_classes = num_classes
36+
self.score_thresh = score_thresh
37+
self.iou_thresh = iou_thresh
38+
self.topk = topk
39+
self.assume_straight_pages = assume_straight_pages
40+
41+
def _decode_boxes(self, boxes: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
42+
"""Decode the predicted boxes from OBB format to polygon format
43+
44+
Args:
45+
boxes: array of predicted boxes in OBB format (N, 6) (cx, cy, w, h, sin(theta), cos(theta))
46+
47+
Returns:
48+
tuple of (polys, angles) where polys is an array of decoded polygons (N, 4, 2)
49+
and angles is an array of angles in radians (N,)
50+
"""
51+
cx, cy, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
52+
sin, cos = boxes[:, 4], boxes[:, 5]
53+
54+
angles = np.arctan2(sin, cos)
55+
56+
polys = []
57+
for i in range(len(boxes)):
58+
rect = ((float(cx[i]), float(cy[i])), (float(w[i]), float(h[i])), float(np.degrees(angles[i])))
59+
60+
poly = cv2.boxPoints(rect)
61+
polys.append(poly)
62+
63+
return np.asarray(polys, dtype=np.float32), angles
64+
65+
def _iou(self, poly1: np.ndarray, poly2: np.ndarray) -> float:
66+
"""Compute the IoU between two polygons
67+
68+
Args:
69+
poly1: first polygon (4, 2)
70+
poly2: second polygon (4, 2)
71+
72+
Returns:
73+
IoU between the two polygons
74+
"""
75+
inter = cv2.intersectConvexConvex(
76+
poly1.astype(np.float32),
77+
poly2.astype(np.float32),
78+
)[0]
79+
80+
if inter <= 0:
81+
return 0.0
82+
83+
area1 = cv2.contourArea(poly1)
84+
area2 = cv2.contourArea(poly2)
85+
86+
return inter / (area1 + area2 - inter + 1e-6)
87+
88+
def _nms(self, polys: np.ndarray, scores: np.ndarray) -> list[int]:
89+
"""Perform NMS on the predicted polygons
90+
91+
Args:
92+
polys: array of predicted polygons (N, 4, 2)
93+
scores: array of predicted scores (N,)
94+
95+
Returns:
96+
list of indices of the polygons to keep after NMS
97+
"""
98+
idxs = np.argsort(scores)[::-1]
99+
keep = []
100+
101+
while idxs.size > 0:
102+
i = idxs[0]
103+
keep.append(i)
104+
105+
if idxs.size == 1:
106+
break
107+
108+
rest = idxs[1:]
109+
110+
ious = np.array([self._iou(polys[i], polys[j]) for j in rest])
111+
112+
idxs = rest[ious < self.iou_thresh]
113+
114+
return keep
115+
116+
def __call__(self, logits: np.ndarray, boxes: np.ndarray) -> list[tuple[list[int], np.ndarray, list[float]]]:
117+
logits = np.asarray(logits)
118+
boxes = np.asarray(boxes)
119+
120+
results: list[tuple[list[int], np.ndarray, list[float]]] = []
121+
122+
for b in range(boxes.shape[0]):
123+
# Convert logits to probabilities and get scores and labels
124+
exp = np.exp(logits[b] - logits[b].max(axis=-1, keepdims=True))
125+
prob = exp / exp.sum(axis=-1, keepdims=True)
126+
127+
scores = prob[:, 1:].max(axis=-1)
128+
labels = prob[:, 1:].argmax(axis=-1) + 1
129+
130+
# Keep only topk predictions before NMS
131+
if self.topk is not None and len(scores) > self.topk:
132+
idxs = np.argsort(scores)[::-1][: self.topk]
133+
else:
134+
idxs = np.arange(len(scores))
135+
136+
scores_b = scores[idxs]
137+
labels_b = labels[idxs]
138+
bboxes = boxes[b][idxs]
139+
140+
mask = scores_b > self.score_thresh
141+
142+
bboxes = bboxes[mask]
143+
scores_b = scores_b[mask]
144+
labels_b = labels_b[mask]
145+
146+
polys, _ = (
147+
self._decode_boxes(bboxes)
148+
if len(bboxes) > 0
149+
else (
150+
np.zeros((0, 4, 2), dtype=np.float32),
151+
np.zeros((0,), dtype=np.float32),
152+
)
153+
)
154+
155+
keep = self._nms(polys, scores_b) if len(polys) > 0 else []
156+
157+
final_labels = []
158+
final_boxes = []
159+
final_scores = []
160+
161+
for idx in keep:
162+
poly = polys[idx].reshape(-1).tolist()
163+
if self.assume_straight_pages:
164+
x_coords = poly[0::2]
165+
y_coords = poly[1::2]
166+
xmin, xmax = min(x_coords), max(x_coords)
167+
ymin, ymax = min(y_coords), max(y_coords)
168+
final_boxes.append([xmin, ymin, xmax, ymax])
169+
else:
170+
final_boxes.append(poly)
171+
172+
final_labels.append(int(labels_b[idx]))
173+
final_scores.append(float(scores_b[idx]))
174+
175+
final_boxes_arr = (
176+
np.asarray(final_boxes, dtype=np.float32).reshape(-1, 4, 2)
177+
if not self.assume_straight_pages
178+
else np.asarray(final_boxes, dtype=np.float32).reshape(-1, 4)
179+
)
180+
181+
results.append((
182+
final_labels,
183+
final_boxes_arr,
184+
final_scores,
185+
))
186+
187+
return results
188+
189+
190+
class _LWDETR(BaseModel):
191+
"""LW-DETR as described in `"LW-DETR: A Transformer Replacement to YOLO for Real-Time Detection"
192+
<https://arxiv.org/pdf/2406.03459v1>`_.
193+
"""
194+
195+
def build_target(
196+
self,
197+
target: list[tuple[list[int], np.ndarray]],
198+
) -> list[dict[str, Any]]:
199+
"""Build the target for LW-DETR training
200+
201+
Args:
202+
target: list of tuples (class_ids, boxes) where class_ids is a list of class ids for the boxes
203+
and boxes is an array of shape (num_boxes, 8) containing the coordinates of the 4 corners of the box
204+
in the format (x1, y1, x2, y2, x3, y3, x4, y4)
205+
206+
Returns:
207+
list of dictionaries with keys "boxes" and "labels" where "boxes" is an array of shape (num_boxes, 6)
208+
containing the box parameters in OBB format (cx, cy, w, h, sin(theta), cos(theta))
209+
and "labels" is an array of shape (num_boxes,) containing the class labels
210+
"""
211+
targets = []
212+
213+
def _quad_to_obb(poly: np.ndarray):
214+
p1, p2, p3, p4 = poly
215+
216+
cx, cy = np.mean(poly, axis=0)
217+
218+
w = (np.linalg.norm(p2 - p1) + np.linalg.norm(p3 - p4)) / 2
219+
h = (np.linalg.norm(p3 - p2) + np.linalg.norm(p4 - p1)) / 2
220+
221+
theta = np.arctan2(*(p2 - p1)[::-1])
222+
223+
return np.array(
224+
[cx, cy, w, h, np.sin(theta), np.cos(theta)],
225+
dtype=np.float32,
226+
)
227+
228+
for class_ids, boxes in target:
229+
boxes_all = []
230+
labels_all = []
231+
232+
if len(boxes) == 0:
233+
targets.append({
234+
"boxes": np.zeros((0, 6), dtype=np.float32),
235+
"labels": np.zeros((0,), dtype=np.int64),
236+
})
237+
continue
238+
239+
for cls_id, box in zip(np.asarray(class_ids), np.asarray(boxes)):
240+
poly = box.reshape(4, 2)
241+
obb = _quad_to_obb(poly)
242+
243+
if obb[2] <= 1e-3 or obb[3] <= 1e-3:
244+
continue
245+
246+
boxes_all.append(obb)
247+
labels_all.append(cls_id + 1) # background = 0
248+
249+
targets.append({
250+
"boxes": np.asarray(boxes_all, dtype=np.float32),
251+
"labels": np.asarray(labels_all, dtype=np.int64),
252+
})
253+
254+
return targets
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .pytorch import *

0 commit comments

Comments
 (0)