Skip to content

Commit 7df5b1b

Browse files
[misc] Apply safe guards and minor improvements (#2104)
1 parent 51e1f8a commit 7df5b1b

10 files changed

Lines changed: 66 additions & 9 deletions

File tree

doctr/io/image/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,10 @@ def read_img_as_numpy(
3434
if isinstance(file, (str, Path)):
3535
if not Path(file).is_file():
3636
raise FileNotFoundError(f"unable to access {file}")
37-
img = cv2.imread(str(file), cv2.IMREAD_COLOR)
37+
_file: np.ndarray = np.frombuffer(Path(file).read_bytes(), np.uint8)
38+
img = cv2.imdecode(_file, cv2.IMREAD_COLOR)
3839
elif isinstance(file, bytes):
39-
_file: np.ndarray = np.frombuffer(file, np.uint8)
40+
_file = np.frombuffer(file, np.uint8)
4041
img = cv2.imdecode(_file, cv2.IMREAD_COLOR)
4142
else:
4243
raise TypeError("unsupported object type for argument 'file'")

doctr/io/image/pytorch.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import numpy as np
99
import torch
10-
from PIL import Image
10+
from PIL import Image, ImageOps
1111
from torchvision.transforms.functional import to_tensor
1212

1313
from doctr.utils.common_types import AbstractPath
@@ -47,7 +47,9 @@ def read_img_as_tensor(img_path: AbstractPath, dtype: torch.dtype = torch.float3
4747
raise ValueError("insupported value for dtype")
4848

4949
with Image.open(img_path, mode="r") as pil_img:
50-
return tensor_from_pil(pil_img.convert("RGB"), dtype)
50+
# Apply the EXIF orientation before decoding, to stay consistent with `read_img_as_numpy`
51+
# (OpenCV applies EXIF orientation automatically, PIL does not)
52+
return tensor_from_pil(ImageOps.exif_transpose(pil_img).convert("RGB"), dtype)
5153

5254

5355
def decode_img_as_tensor(img_content: bytes, dtype: torch.dtype = torch.float32) -> torch.Tensor:
@@ -64,7 +66,7 @@ def decode_img_as_tensor(img_content: bytes, dtype: torch.dtype = torch.float32)
6466
raise ValueError("insupported value for dtype")
6567

6668
with Image.open(BytesIO(img_content), mode="r") as pil_img:
67-
return tensor_from_pil(pil_img.convert("RGB"), dtype)
69+
return tensor_from_pil(ImageOps.exif_transpose(pil_img).convert("RGB"), dtype)
6870

6971

7072
def tensor_from_numpy(npy_img: np.ndarray, dtype: torch.dtype = torch.float32) -> torch.Tensor:

doctr/models/builder.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,8 @@ def _build_tables(
386386
tables_out: list[Table] = []
387387
for table_dict in table_dicts:
388388
cells = table_dict["cells"]
389+
if len(cells) == 0:
390+
continue
389391
cell_polys = [self._as_cell_polygon(cell["geometry"]) for cell in cells]
390392

391393
# Assign each (still unassigned) word to at most one cell of this table: the first cell (in cell

doctr/models/detection/core.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ def box_score(pred: np.ndarray, points: np.ndarray, assume_straight_pages: bool
6060
mask: np.ndarray = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=np.uint8)
6161
cv2.fillPoly(mask, [pts - np.array([[xmin, ymin]], dtype=np.int32)], 1)
6262
vals = pred[ymin : ymax + 1, xmin : xmax + 1][mask.astype(bool)]
63-
nonzero = np.count_nonzero(vals)
64-
return float(vals.sum() / nonzero) if nonzero > 0 else 0.0
63+
return float(vals.mean()) if vals.size > 0 else 0.0
6564

6665
def bitmap_to_boxes(
6766
self,

doctr/models/detection/fast/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def bitmap_to_boxes(
123123
else:
124124
_box = self.polygon_to_box(np.squeeze(contour))
125125

126-
if _box is None:
126+
if _box is None: # pragma: no cover
127127
continue
128128

129129
if self.assume_straight_pages:

doctr/models/detection/linknet/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def bitmap_to_boxes(
123123
else:
124124
_box = self.polygon_to_box(np.squeeze(contour))
125125

126-
if _box is None:
126+
if _box is None: # pragma: no cover
127127
continue
128128

129129
if self.assume_straight_pages:

doctr/models/predictor/pytorch.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,8 @@ def _tables_from_regions(
260260
new_cell = dict(cell)
261261
new_cell["geometry"] = poly.tolist()
262262
remapped_cells.append(new_cell)
263+
if not remapped_cells:
264+
continue
263265
tables_per_page[p_idx].append({
264266
"cells": remapped_cells,
265267
"num_rows": grid["num_rows"],

tests/common/test_io.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numpy as np
55
import pytest
66
import requests
7+
from PIL import Image
78

89
from doctr import io
910

@@ -97,3 +98,17 @@ def test_pdf(mock_pdf):
9798
# As images
9899
num_pages = 2
99100
_check_doc_content(pages, num_pages)
101+
102+
103+
def test_read_img_as_numpy_exif_orientation(tmpdir_factory):
104+
# A JPEG with EXIF orientation 6 (90° clockwise display rotation)
105+
folder = tmpdir_factory.mktemp("images")
106+
path = str(folder.join("exif_o6.jpg"))
107+
pil_img = Image.fromarray(np.zeros((40, 60, 3), dtype=np.uint8))
108+
exif = pil_img.getexif()
109+
exif[0x0112] = 6 # orientation tag
110+
pil_img.save(path, format="JPEG", exif=exif)
111+
112+
assert io.read_img_as_numpy(path).shape == (60, 40, 3)
113+
with open(path, "rb") as f:
114+
assert io.read_img_as_numpy(f.read()).shape == (60, 40, 3)

tests/common/test_models_builder.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,3 +562,22 @@ def test_sort_boxes_degenerate_heights():
562562
boxes = np.array([[0.5, 0.2, 0.6, 0.2], [0.1, 0.2, 0.2, 0.2]], dtype=np.float32)
563563
idxs, _ = doc_builder._sort_boxes(boxes)
564564
assert sorted(np.asarray(idxs).tolist()) == [0, 1]
565+
566+
567+
def test_documentbuilder_tables_empty_cells():
568+
# A table prediction with no cells (e.g. a false-positive "Table" region where the table model
569+
# finds nothing) must not crash the document build
570+
doc_builder = builder.DocumentBuilder()
571+
boxes = np.array([[0.1, 0.1, 0.2, 0.2]], dtype=np.float32)
572+
out = doc_builder(
573+
[np.zeros((100, 100, 3))],
574+
[boxes],
575+
[np.array([0.9])],
576+
[[("hello", 0.99)]],
577+
[(100, 100)],
578+
[[{"value": 0, "confidence": None}]],
579+
tables=[[{"cells": [], "num_rows": 0, "num_cols": 0}]],
580+
)
581+
assert out.pages[0].tables == []
582+
# the word stays in the regular blocks since it was never consumed by a table
583+
assert [w.value for b in out.pages[0].blocks for line in b.lines for w in line.words] == ["hello"]

tests/pytorch/test_io_image_pt.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import numpy as np
22
import pytest
33
import torch
4+
from PIL import Image
45

56
from doctr.io import decode_img_as_tensor, read_img_as_tensor, tensor_from_numpy
67

@@ -51,3 +52,19 @@ def test_tensor_from_numpy(mock_image_stream):
5152
assert out.dtype == torch.float16
5253
out = tensor_from_numpy(np.zeros((256, 256, 3), dtype=np.uint8), dtype=torch.uint8)
5354
assert out.dtype == torch.uint8
55+
56+
57+
def test_read_img_as_tensor_exif_orientation(tmpdir_factory):
58+
folder = tmpdir_factory.mktemp("images")
59+
path = str(folder.join("exif_o6.jpg"))
60+
pil_img = Image.fromarray(np.zeros((40, 60, 3), dtype=np.uint8))
61+
exif = pil_img.getexif()
62+
exif[0x0112] = 6 # orientation tag
63+
pil_img.save(path, format="JPEG", exif=exif)
64+
65+
img = read_img_as_tensor(path)
66+
assert img.shape == (3, 60, 40)
67+
68+
with open(path, "rb") as f:
69+
img_stream = decode_img_as_tensor(f.read())
70+
assert img_stream.shape == (3, 60, 40)

0 commit comments

Comments
 (0)