Skip to content

Commit 3ce0859

Browse files
[misc] Add minor improvements (#2105)
1 parent 7df5b1b commit 3ce0859

6 files changed

Lines changed: 184 additions & 68 deletions

File tree

doctr/io/elements.py

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,37 @@
3939
]
4040

4141

42+
def _resolve_hocr_language(language: dict[str, Any]) -> str:
43+
"""Resolve the language code to use in the hOCR export, falling back to 'en'.
44+
45+
Args:
46+
language: the page language dictionary `{"value": str | None, "confidence": float | None}`
47+
48+
Returns:
49+
the detected language code when available, 'en' otherwise
50+
"""
51+
lang_value = language.get("value") if isinstance(language, dict) else None
52+
return lang_value if isinstance(lang_value, str) and len(lang_value) > 0 else "en"
53+
54+
55+
def _hocr_bbox(geometry: BoundingBox, width: int, height: int) -> str:
56+
"""Format a relative straight bounding box as an absolute hOCR `bbox` property string.
57+
58+
Args:
59+
geometry: the relative bounding box ((xmin, ymin), (xmax, ymax))
60+
width: the page width in pixels
61+
height: the page height in pixels
62+
63+
Returns:
64+
the hOCR `bbox` property string
65+
"""
66+
(xmin, ymin), (xmax, ymax) = geometry
67+
return (
68+
f"bbox {int(round(xmin * width))} {int(round(ymin * height))} "
69+
f"{int(round(xmax * width))} {int(round(ymax * height))}"
70+
)
71+
72+
4273
class Element(NestedObject):
4374
"""Implements an abstract document element with exporting and text rendering capabilities"""
4475

@@ -518,7 +549,7 @@ def export_as_xml(self, file_title: str = "docTR - XML export (hOCR)") -> tuple[
518549
line_count: int = 1
519550
word_count: int = 1
520551
height, width = self.dimensions
521-
language = self.language if "language" in self.language.keys() else "en"
552+
language = _resolve_hocr_language(self.language)
522553
# Create the XML root element
523554
page_hocr = ETElement("html", attrib={"xmlns": "http://www.w3.org/1999/xhtml", "xml:lang": str(language)})
524555
# Create the header / SubElements of the root element
@@ -550,15 +581,14 @@ def export_as_xml(self, file_title: str = "docTR - XML export (hOCR)") -> tuple[
550581
for block in self.blocks:
551582
if len(block.geometry) != 2:
552583
raise TypeError("XML export is only available for straight bounding boxes for now.")
553-
(xmin, ymin), (xmax, ymax) = block.geometry
584+
block_bbox = _hocr_bbox(block.geometry, width, height) # type: ignore[arg-type]
554585
block_div = SubElement(
555586
page_div,
556587
"div",
557588
attrib={
558589
"class": "ocr_carea",
559590
"id": f"block_{block_count}",
560-
"title": f"bbox {int(round(xmin * width))} {int(round(ymin * height))} \
561-
{int(round(xmax * width))} {int(round(ymax * height))}",
591+
"title": block_bbox,
562592
},
563593
)
564594
paragraph = SubElement(
@@ -567,38 +597,37 @@ def export_as_xml(self, file_title: str = "docTR - XML export (hOCR)") -> tuple[
567597
attrib={
568598
"class": "ocr_par",
569599
"id": f"par_{block_count}",
570-
"title": f"bbox {int(round(xmin * width))} {int(round(ymin * height))} \
571-
{int(round(xmax * width))} {int(round(ymax * height))}",
600+
"title": block_bbox,
572601
},
573602
)
574603
block_count += 1
575604
for line in block.lines:
576-
(xmin, ymin), (xmax, ymax) = line.geometry
577605
# NOTE: baseline, x_size, x_descenders, x_ascenders is currently initalized to 0
578606
line_span = SubElement(
579607
paragraph,
580608
"span",
581609
attrib={
582610
"class": "ocr_line",
583611
"id": f"line_{line_count}",
584-
"title": f"bbox {int(round(xmin * width))} {int(round(ymin * height))} \
585-
{int(round(xmax * width))} {int(round(ymax * height))}; \
586-
baseline 0 0; x_size 0; x_descenders 0; x_ascenders 0",
612+
"title": (
613+
f"{_hocr_bbox(line.geometry, width, height)}; " # type: ignore[arg-type]
614+
"baseline 0 0; x_size 0; x_descenders 0; x_ascenders 0"
615+
),
587616
},
588617
)
589618
line_count += 1
590619
for word in line.words:
591-
(xmin, ymin), (xmax, ymax) = word.geometry
592620
conf = word.confidence
593621
word_div = SubElement(
594622
line_span,
595623
"span",
596624
attrib={
597625
"class": "ocrx_word",
598626
"id": f"word_{word_count}",
599-
"title": f"bbox {int(round(xmin * width))} {int(round(ymin * height))} \
600-
{int(round(xmax * width))} {int(round(ymax * height))}; \
601-
x_wconf {int(round(conf * 100))}",
627+
"title": (
628+
f"{_hocr_bbox(word.geometry, width, height)}; " # type: ignore[arg-type]
629+
f"x_wconf {int(round(conf * 100))}"
630+
),
602631
},
603632
)
604633
# set the text
@@ -708,7 +737,7 @@ def export_as_xml(self, file_title: str = "docTR - XML export (hOCR)") -> tuple[
708737
p_idx = self.page_idx
709738
prediction_count: int = 1
710739
height, width = self.dimensions
711-
language = self.language if "language" in self.language.keys() else "en"
740+
language = _resolve_hocr_language(self.language)
712741
# Create the XML root element
713742
page_hocr = ETElement("html", attrib={"xmlns": "http://www.w3.org/1999/xhtml", "xml:lang": str(language)})
714743
# Create the header / SubElements of the root element
@@ -741,15 +770,14 @@ def export_as_xml(self, file_title: str = "docTR - XML export (hOCR)") -> tuple[
741770
for prediction in predictions:
742771
if len(prediction.geometry) != 2:
743772
raise TypeError("XML export is only available for straight bounding boxes for now.")
744-
(xmin, ymin), (xmax, ymax) = prediction.geometry
773+
prediction_bbox = _hocr_bbox(prediction.geometry, width, height) # type: ignore[arg-type]
745774
prediction_div = SubElement(
746775
body,
747776
"div",
748777
attrib={
749778
"class": "ocr_carea",
750779
"id": f"{class_name}_prediction_{prediction_count}",
751-
"title": f"bbox {int(round(xmin * width))} {int(round(ymin * height))} \
752-
{int(round(xmax * width))} {int(round(ymax * height))}",
780+
"title": prediction_bbox,
753781
},
754782
)
755783
# NOTE: ocr_par, ocr_line and ocrx_word are the same because the KIE predictions contain only words
@@ -760,8 +788,7 @@ def export_as_xml(self, file_title: str = "docTR - XML export (hOCR)") -> tuple[
760788
attrib={
761789
"class": "ocr_par",
762790
"id": f"{class_name}_par_{prediction_count}",
763-
"title": f"bbox {int(round(xmin * width))} {int(round(ymin * height))} \
764-
{int(round(xmax * width))} {int(round(ymax * height))}",
791+
"title": prediction_bbox,
765792
},
766793
)
767794
line_span = SubElement(
@@ -770,9 +797,7 @@ def export_as_xml(self, file_title: str = "docTR - XML export (hOCR)") -> tuple[
770797
attrib={
771798
"class": "ocr_line",
772799
"id": f"{class_name}_line_{prediction_count}",
773-
"title": f"bbox {int(round(xmin * width))} {int(round(ymin * height))} \
774-
{int(round(xmax * width))} {int(round(ymax * height))}; \
775-
baseline 0 0; x_size 0; x_descenders 0; x_ascenders 0",
800+
"title": f"{prediction_bbox}; baseline 0 0; x_size 0; x_descenders 0; x_ascenders 0",
776801
},
777802
)
778803
word_div = SubElement(
@@ -781,9 +806,7 @@ def export_as_xml(self, file_title: str = "docTR - XML export (hOCR)") -> tuple[
781806
attrib={
782807
"class": "ocrx_word",
783808
"id": f"{class_name}_word_{prediction_count}",
784-
"title": f"bbox {int(round(xmin * width))} {int(round(ymin * height))} \
785-
{int(round(xmax * width))} {int(round(ymax * height))}; \
786-
x_wconf {int(round(prediction.confidence * 100))}",
809+
"title": f"{prediction_bbox}; x_wconf {int(round(prediction.confidence * 100))}",
787810
},
788811
)
789812
word_div.text = prediction.value

doctr/models/_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def estimate_orientation(
5959

6060
# Convert image to grayscale if necessary
6161
if img.shape[-1] == 3:
62-
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
62+
gray_img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
6363
gray_img = cv2.medianBlur(gray_img, 5)
6464
thresh = cv2.threshold(gray_img, thresh=0, maxval=255, type=cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
6565
else:

doctr/models/detection/core.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import cv2
88
import numpy as np
99

10+
from doctr.utils.multithreading import multithread_exec
1011
from doctr.utils.repr import NestedObject
1112

1213
__all__ = ["DetectionPostProcessor"]
@@ -69,6 +70,28 @@ def bitmap_to_boxes(
6970
) -> np.ndarray:
7071
raise NotImplementedError
7172

73+
def _process_sample(self, proba_map: np.ndarray) -> list[np.ndarray]:
74+
"""Performs postprocessing for a single sample
75+
76+
Args:
77+
proba_map: probability map of shape (H, W, C)
78+
79+
Returns:
80+
list of C class predictions, each of shape (*, 5) or (*, 6)
81+
"""
82+
return [
83+
self.bitmap_to_boxes(
84+
proba_map[..., idx],
85+
# Erosion + dilation on the binary map
86+
cv2.morphologyEx(
87+
(proba_map[..., idx] >= self.bin_thresh).astype(np.uint8),
88+
cv2.MORPH_OPEN,
89+
self._opening_kernel,
90+
),
91+
)
92+
for idx in range(proba_map.shape[-1])
93+
]
94+
7295
def __call__(
7396
self,
7497
proba_map,
@@ -84,17 +107,4 @@ def __call__(
84107
"""
85108
if proba_map.ndim != 4:
86109
raise AssertionError(f"arg `proba_map` is expected to be 4-dimensional, got {proba_map.ndim}.")
87-
88-
# Erosion + dilation on the binary map
89-
bin_map = [
90-
[
91-
cv2.morphologyEx(bmap[..., idx], cv2.MORPH_OPEN, self._opening_kernel)
92-
for idx in range(proba_map.shape[-1])
93-
]
94-
for bmap in (proba_map >= self.bin_thresh).astype(np.uint8)
95-
]
96-
97-
return [
98-
[self.bitmap_to_boxes(pmaps[..., idx], bmaps[idx]) for idx in range(proba_map.shape[-1])]
99-
for pmaps, bmaps in zip(proba_map, bin_map)
100-
]
110+
return list(multithread_exec(self._process_sample, proba_map))

doctr/utils/metrics.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import numpy as np
88
from anyascii import anyascii
99
from scipy.optimize import linear_sum_assignment
10-
from shapely import area, intersection, polygons
10+
from shapely import STRtree, area, intersection, polygons
1111

1212
__all__ = [
1313
"TextMatch",
@@ -146,7 +146,7 @@ def box_iou(boxes_1: np.ndarray, boxes_2: np.ndarray) -> np.ndarray:
146146

147147
intersection = np.clip(right - left, 0, np.inf) * np.clip(bot - top, 0, np.inf)
148148
union = (r1 - l1) * (b1 - t1) + ((r2 - l2) * (b2 - t2)).T - intersection
149-
iou_mat = intersection / union
149+
iou_mat = np.divide(intersection, union, out=np.zeros_like(intersection), where=union > 0)
150150

151151
return iou_mat
152152

@@ -170,18 +170,15 @@ def polygon_iou(polys_1: np.ndarray, polys_2: np.ndarray) -> np.ndarray:
170170

171171
geoms_1 = polygons(polys_1)
172172
geoms_2 = polygons(polys_2)
173-
grid_1 = np.repeat(geoms_1, m)
174-
grid_2 = np.tile(geoms_2, n)
175173

176-
# Compute intersections and areas
177-
intersections = area(intersection(grid_1, grid_2))
178-
areas_1 = area(grid_1)
179-
areas_2 = area(grid_2)
180-
181-
# Compute IoU
182-
unions = areas_1 + areas_2 - intersections
183-
iou_flat = np.divide(intersections, unions, out=np.zeros_like(intersections), where=unions > 0)
184-
return iou_flat.reshape(n, m).astype(np.float32)
174+
iou_mat = np.zeros((n, m), dtype=np.float32)
175+
idcs_1, idcs_2 = STRtree(geoms_2).query(geoms_1)
176+
if idcs_1.size > 0:
177+
# Compute intersections and areas for the candidate pairs only
178+
intersections = area(intersection(geoms_1[idcs_1], geoms_2[idcs_2]))
179+
unions = area(geoms_1)[idcs_1] + area(geoms_2)[idcs_2] - intersections
180+
iou_mat[idcs_1, idcs_2] = np.divide(intersections, unions, out=np.zeros_like(intersections), where=unions > 0)
181+
return iou_mat
185182

186183

187184
def nms(boxes: np.ndarray, thresh: float = 0.5) -> list[int]:
@@ -275,7 +272,7 @@ def update(self, gts: np.ndarray, preds: np.ndarray) -> None:
275272
gts: a set of relative bounding boxes either of shape (N, 4) or (N, 5) if they are rotated ones
276273
preds: a set of relative bounding boxes either of shape (M, 4) or (M, 5) if they are rotated ones
277274
"""
278-
if preds.shape[0] > 0:
275+
if gts.shape[0] > 0 and preds.shape[0] > 0:
279276
# Compute IoU
280277
if self.use_polygons:
281278
iou_mat = polygon_iou(gts, preds)
@@ -468,8 +465,7 @@ def update(
468465
"there should be the same number of boxes and string both for the ground truth and the predictions"
469466
)
470467

471-
# Compute IoU
472-
if pred_boxes.shape[0] > 0:
468+
if gt_boxes.shape[0] > 0 and pred_boxes.shape[0] > 0:
473469
if self.use_polygons:
474470
iou_mat = polygon_iou(gt_boxes, pred_boxes)
475471
else:
@@ -598,8 +594,7 @@ def update(
598594
"there should be the same number of boxes and string both for the ground truth and the predictions"
599595
)
600596

601-
# Compute IoU
602-
if pred_boxes.shape[0] > 0:
597+
if gt_boxes.shape[0] > 0 and pred_boxes.shape[0] > 0:
603598
if self.use_polygons:
604599
iou_mat = polygon_iou(gt_boxes, pred_boxes)
605600
else:

tests/common/test_io_elements.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -467,11 +467,17 @@ def test_page():
467467
}
468468

469469
# Export XML
470-
assert (
471-
isinstance(page.export_as_xml(), tuple)
472-
and isinstance(page.export_as_xml()[0], (bytes, bytearray))
473-
and isinstance(page.export_as_xml()[1], ElementTree)
474-
)
470+
xml_bytes, xml_tree = page.export_as_xml()
471+
assert isinstance(xml_bytes, (bytes, bytearray)) and isinstance(xml_tree, ElementTree)
472+
# The detected language must be exported instead of being hardcoded to "en"
473+
assert xml_tree.getroot().get("xml:lang") == "EN"
474+
# hOCR title properties must be single-spaced
475+
titles = [el.get("title") for el in xml_tree.iter() if el.get("title") is not None]
476+
assert any(title.startswith("bbox ") for title in titles)
477+
assert all(" " not in title for title in titles)
478+
# Without a detected language, the export must fall back to "en"
479+
fallback_page = elements.Page(np.zeros((300, 200, 3), dtype=np.uint8), blocks, page_idx, page_size, orientation)
480+
assert fallback_page.export_as_xml()[1].getroot().get("xml:lang") == "en"
475481

476482
# Repr
477483
assert "\n".join(repr(page).split("\n")[:2]) == f"Page(\n dimensions={page_size!r}"
@@ -529,11 +535,19 @@ def test_kiepage():
529535
}
530536

531537
# Export XML
532-
assert (
533-
isinstance(kie_page.export_as_xml(), tuple)
534-
and isinstance(kie_page.export_as_xml()[0], (bytes, bytearray))
535-
and isinstance(kie_page.export_as_xml()[1], ElementTree)
538+
xml_bytes, xml_tree = kie_page.export_as_xml()
539+
assert isinstance(xml_bytes, (bytes, bytearray)) and isinstance(xml_tree, ElementTree)
540+
# The detected language must be exported instead of being hardcoded to "en"
541+
assert xml_tree.getroot().get("xml:lang") == "EN"
542+
# hOCR title properties must be single-spaced
543+
titles = [el.get("title") for el in xml_tree.iter() if el.get("title") is not None]
544+
assert any(title.startswith("bbox ") for title in titles)
545+
assert all(" " not in title for title in titles)
546+
# Without a detected language, the export must fall back to "en"
547+
fallback_page = elements.KIEPage(
548+
np.zeros((300, 200, 3), dtype=np.uint8), predictions, page_idx, page_size, orientation
536549
)
550+
assert fallback_page.export_as_xml()[1].getroot().get("xml:lang") == "en"
537551

538552
# Repr
539553
assert "\n".join(repr(kie_page).split("\n")[:2]) == f"KIEPage(\n dimensions={page_size!r}"

0 commit comments

Comments
 (0)