diff --git a/CHANGELOG.md b/CHANGELOG.md index e24178e..e5b8146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ Changelog ========= +v0.3.12 (2026-09-22) +-------------------- +* Fix bugs of `BBox` comparison and rotation. + v0.3.11 (2026-09-08) -------------------- * Improve `SkewCorrector` work on images with short text lines diff --git a/VERSION b/VERSION index 99a89b9..a487dd1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.11 \ No newline at end of file +0.3.12 \ No newline at end of file diff --git a/dedocutils/data_structures/bbox.py b/dedocutils/data_structures/bbox.py index bf14ac7..11d67e6 100644 --- a/dedocutils/data_structures/bbox.py +++ b/dedocutils/data_structures/bbox.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import math from collections import OrderedDict from dataclasses import dataclass -from typing import Dict, Tuple +from typing import Dict, Tuple, Union import numpy as np @@ -25,20 +27,21 @@ class BBox: | | V y + + Args: + x_top_left (int): X coordinate of the bbox top left corner. + y_top_left (int): Y coordinate of the bbox top left corner. + width (int): Bounding box width. + height (int): Bounding box height. """ - def __init__(self, x_top_left: int, y_top_left: int, width: int, height: int) -> None: - """ - The following parameters should have values of pixels number. + x_top_left: int + y_top_left: int + width: int + height: int - :param x_top_left: x coordinate of the bbox top left corner - :param y_top_left: y coordinate of the bbox top left corner - :param width: bounding box width - :param height: bounding box height - """ - self.x_top_left = x_top_left - self.y_top_left = y_top_left - self.width = width - self.height = height + def __post_init__(self) -> None: + if self.x_top_left < 0 or self.y_top_left < 0 or self.width < 0 or self.height < 0: + raise ValueError(f"All bbox attributes should be non-negative, got: {self}") @property def x_bottom_right(self) -> int: @@ -49,14 +52,15 @@ def y_bottom_right(self) -> int: return self.y_top_left + self.height @staticmethod - def crop_image_by_box(image: np.ndarray, bbox: "BBox") -> np.ndarray: + def crop_image_by_box(image: np.ndarray, bbox: BBox) -> np.ndarray: return image[bbox.y_top_left:bbox.y_bottom_right, bbox.x_top_left:bbox.x_bottom_right] def shift(self, shift_x: int, shift_y: int) -> None: self.x_top_left += shift_x self.y_top_left += shift_y + self.__post_init__() - def rotate_coordinates(self, angle_rotate: float, image_shape: Tuple[int]) -> None: + def rotate_coordinates(self, angle_rotate: float, image_shape: Union[Tuple[int, int], Tuple[int, int, int]]) -> None: xb, yb = self.x_top_left, self.y_top_left xe, ye = self.x_bottom_right, self.y_bottom_right rad = angle_rotate * math.pi / 180 @@ -64,17 +68,18 @@ def rotate_coordinates(self, angle_rotate: float, image_shape: Tuple[int]) -> No xc = image_shape[1] / 2 yc = image_shape[0] / 2 - bbox_xb = min((int(float(xb - xc) * math.cos(rad) - float(yb - yc) * math.sin(rad) + xc)), image_shape[1]) - bbox_yb = min((int(float(yb - yc) * math.cos(rad) + float(xb - xc) * math.sin(rad) + yc)), image_shape[0]) - bbox_xe = min((int(float(xe - xc) * math.cos(rad) - float(ye - yc) * math.sin(rad) + xc)), image_shape[1]) - bbox_ye = min((int(float(ye - yc) * math.cos(rad) + float(xe - xc) * math.sin(rad) + yc)), image_shape[0]) - self.__init__(bbox_xb, bbox_yb, bbox_xe - bbox_xb, bbox_ye - bbox_yb) - - def __str__(self) -> str: - return f"BBox(x = {self.x_top_left} y = {self.y_top_left}, w = {self.width}, h = {self.height})" + bbox_xb = int(float(xb - xc) * math.cos(rad) - float(yb - yc) * math.sin(rad) + xc) + bbox_yb = int(float(yb - yc) * math.cos(rad) + float(xb - xc) * math.sin(rad) + yc) + bbox_xe = int(float(xe - xc) * math.cos(rad) - float(ye - yc) * math.sin(rad) + xc) + bbox_ye = int(float(ye - yc) * math.cos(rad) + float(xe - xc) * math.sin(rad) + yc) - def __repr__(self) -> str: - return self.__str__() + self.x_top_left = max(0, min(bbox_xb, bbox_xe)) + self.y_top_left = max(0, min(bbox_yb, bbox_ye)) + x_bottom_right = min(image_shape[1], max(bbox_xb, bbox_xe)) + y_bottom_right = min(image_shape[0], max(bbox_yb, bbox_ye)) + self.width = x_bottom_right - self.x_top_left + self.height = y_bottom_right - self.y_top_left + self.__post_init__() @property def square(self) -> int: @@ -84,7 +89,7 @@ def square(self) -> int: return self.height * self.width @staticmethod - def from_two_points(top_left: Tuple[int, int], bottom_right: Tuple[int, int]) -> "BBox": + def from_two_points(top_left: Tuple[int, int], bottom_right: Tuple[int, int]) -> BBox: """ Make the bounding box from two points. @@ -95,7 +100,7 @@ def from_two_points(top_left: Tuple[int, int], bottom_right: Tuple[int, int]) -> x_bottom_right, y_bottom_right = bottom_right return BBox(x_top_left=x_top_left, y_top_left=y_top_left, width=x_bottom_right - x_top_left, height=y_bottom_right - y_top_left) - def have_intersection_with_box(self, box: "BBox", threshold: float = 0.3) -> bool: + def have_intersection_with_box(self, box: BBox, threshold: float = 0.3) -> bool: """ Check if the current bounding box has the intersection with another one. @@ -138,8 +143,11 @@ def to_relative_dict(self, page_width: int, page_height: int) -> dict: return res @staticmethod - def from_dict(some_dict: Dict[str, int]) -> "BBox": + def from_dict(some_dict: Dict[str, int]) -> BBox: return BBox(**some_dict) def __hash__(self) -> int: return hash((self.x_top_left, self.y_top_left, self.width, self.height)) + + def __lt__(self, other: BBox) -> bool: + return (self.y_top_left, self.x_top_left, self.height, self.width) < (other.y_top_left, other.x_top_left, other.height, other.width) diff --git a/tests/unit_tests/test_bbox.py b/tests/unit_tests/test_bbox.py new file mode 100644 index 0000000..2cc041a --- /dev/null +++ b/tests/unit_tests/test_bbox.py @@ -0,0 +1,257 @@ +import unittest + +import numpy as np + +from dedocutils.data_structures.bbox import BBox + + +class TestBBoxBase(unittest.TestCase): + + def test_incorrect_bbox(self) -> None: + for i in range(4): + coords = [0, 0, 0, 0] + coords[i] = -1 + with self.assertRaises(ValueError): + BBox(*coords) + + def test_bottom_right_properties(self) -> None: + bbox = BBox(x_top_left=10, y_top_left=20, width=100, height=50) + self.assertEqual(bbox.x_bottom_right, 110) + self.assertEqual(bbox.y_bottom_right, 70) + + def test_square_property(self) -> None: + bbox = BBox(x_top_left=0, y_top_left=0, width=10, height=5) + self.assertEqual(bbox.square, 50) + + bbox = BBox(x_top_left=0, y_top_left=0, width=0, height=0) + self.assertEqual(bbox.square, 0) + + def test_from_two_points(self) -> None: + bbox = BBox.from_two_points(top_left=(10, 20), bottom_right=(110, 70)) + self.assertEqual(bbox.x_top_left, 10) + self.assertEqual(bbox.y_top_left, 20) + self.assertEqual(bbox.width, 100) + self.assertEqual(bbox.height, 50) + + bbox = BBox.from_two_points(top_left=(5, 5), bottom_right=(5, 5)) + self.assertEqual(bbox.width, 0) + self.assertEqual(bbox.height, 0) + + def test_shift(self) -> None: + bbox = BBox(x_top_left=10, y_top_left=20, width=100, height=50) + bbox.shift(shift_x=5, shift_y=15) + self.assertEqual(bbox.x_top_left, 15) + self.assertEqual(bbox.y_top_left, 35) + self.assertEqual(bbox.width, 100) + self.assertEqual(bbox.height, 50) + + bbox = BBox(x_top_left=10, y_top_left=20, width=100, height=50) + bbox.shift(shift_x=-5, shift_y=-10) + self.assertEqual(bbox.x_top_left, 5) + self.assertEqual(bbox.y_top_left, 10) + + bbox = BBox(x_top_left=10, y_top_left=20, width=100, height=50) + bbox.shift(shift_x=0, shift_y=0) + self.assertEqual(bbox.x_top_left, 10) + self.assertEqual(bbox.y_top_left, 20) + + def test_incorrect_shift(self) -> None: + bbox = BBox(x_top_left=0, y_top_left=0, width=100, height=50) + with self.assertRaises(ValueError): + bbox.shift(shift_x=-1, shift_y=-1) + + +class TestBBoxCropImage(unittest.TestCase): + + def setUp(self) -> None: + self.image = np.arange(100 * 100).reshape(100, 100) + + def test_crop_simple(self) -> None: + bbox = BBox(x_top_left=10, y_top_left=20, width=30, height=40) + cropped = BBox.crop_image_by_box(self.image, bbox) + self.assertEqual(cropped.shape, (40, 30)) + expected = self.image[20:60, 10:40] + np.testing.assert_array_equal(cropped, expected) + + def test_crop_full_image(self) -> None: + bbox = BBox(x_top_left=0, y_top_left=0, width=100, height=100) + cropped = BBox.crop_image_by_box(self.image, bbox) + self.assertEqual(cropped.shape, (100, 100)) + np.testing.assert_array_equal(cropped, self.image) + + def test_crop_zero_size(self) -> None: + bbox = BBox(x_top_left=10, y_top_left=20, width=0, height=0) + cropped = BBox.crop_image_by_box(self.image, bbox) + self.assertEqual(cropped.shape, (0, 0)) + + +class TestBBoxIntersection(unittest.TestCase): + + def test_identical_boxes(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=100, height=100) + bbox2 = BBox(x_top_left=0, y_top_left=0, width=100, height=100) + self.assertTrue(bbox1.have_intersection_with_box(bbox2, threshold=0.99)) + + def test_no_intersection(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + bbox2 = BBox(x_top_left=100, y_top_left=100, width=10, height=10) + self.assertFalse(bbox1.have_intersection_with_box(bbox2)) + + def test_partial_intersection_above_threshold(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + bbox2 = BBox(x_top_left=5, y_top_left=5, width=10, height=10) + self.assertTrue(bbox1.have_intersection_with_box(bbox2, threshold=0.2)) + self.assertFalse(bbox1.have_intersection_with_box(bbox2, threshold=0.3)) + + def test_partial_intersection_exact_threshold(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + bbox2 = BBox(x_top_left=5, y_top_left=5, width=10, height=10) + self.assertFalse(bbox1.have_intersection_with_box(bbox2, threshold=0.25)) + + def test_box_b_fully_inside_a(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=100, height=100) + bbox2 = BBox(x_top_left=10, y_top_left=10, width=20, height=20) + self.assertTrue(bbox1.have_intersection_with_box(bbox2, threshold=0.99)) + + def test_zero_area_box(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + bbox2 = BBox(x_top_left=5, y_top_left=5, width=0, height=0) + self.assertFalse(bbox1.have_intersection_with_box(bbox2)) + + def test_touching_edges(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + bbox2 = BBox(x_top_left=10, y_top_left=0, width=10, height=10) + self.assertFalse(bbox1.have_intersection_with_box(bbox2)) + + +class TestBBoxSerialization(unittest.TestCase): + + def test_to_dict(self) -> None: + bbox = BBox(x_top_left=10, y_top_left=20, width=30, height=40) + result = bbox.to_dict() + self.assertEqual(result["x_top_left"], 10) + self.assertEqual(result["y_top_left"], 20) + self.assertEqual(result["width"], 30) + self.assertEqual(result["height"], 40) + + def test_to_dict_is_ordered(self) -> None: + bbox = BBox(x_top_left=10, y_top_left=20, width=30, height=40) + result = bbox.to_dict() + self.assertEqual(list(result.keys()), ["x_top_left", "y_top_left", "width", "height"]) + + def test_to_relative_dict(self) -> None: + bbox = BBox(x_top_left=10, y_top_left=20, width=30, height=40) + result = bbox.to_relative_dict(page_width=100, page_height=200) + self.assertAlmostEqual(result["x_top_left"], 0.1) + self.assertAlmostEqual(result["y_top_left"], 0.1) + self.assertAlmostEqual(result["width"], 0.3) + self.assertAlmostEqual(result["height"], 0.2) + self.assertEqual(result["page_width"], 100) + self.assertEqual(result["page_height"], 200) + + def test_from_dict(self) -> None: + data = {"x_top_left": 10, "y_top_left": 20, "width": 30, "height": 40} + bbox = BBox.from_dict(data) + self.assertEqual(bbox.x_top_left, 10) + self.assertEqual(bbox.y_top_left, 20) + self.assertEqual(bbox.width, 30) + self.assertEqual(bbox.height, 40) + + def test_dict_roundtrip(self) -> None: + original = BBox(x_top_left=1, y_top_left=2, width=3, height=4) + restored = BBox.from_dict(original.to_dict()) + self.assertEqual(original.x_top_left, restored.x_top_left) + self.assertEqual(original.y_top_left, restored.y_top_left) + self.assertEqual(original.width, restored.width) + self.assertEqual(original.height, restored.height) + self.assertEqual(original, restored) + + +class TestBBoxComparison(unittest.TestCase): + + def test_equal(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + bbox2 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + self.assertEqual(bbox1, bbox2) + self.assertEqual(bbox1.__hash__(), bbox2.__hash__()) + + def test_less_by_y(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + bbox2 = BBox(x_top_left=0, y_top_left=5, width=10, height=10) + self.assertTrue(bbox1 < bbox2) + self.assertFalse(bbox2 < bbox1) + + def test_less_by_x_when_y_equal(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + bbox2 = BBox(x_top_left=5, y_top_left=0, width=10, height=10) + self.assertTrue(bbox1 < bbox2) + self.assertFalse(bbox2 < bbox1) + + def test_less_by_height_when_xy_equal(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=10, height=5) + bbox2 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + self.assertTrue(bbox1 < bbox2) + + def test_less_by_width_when_others_equal(self) -> None: + bbox1 = BBox(x_top_left=0, y_top_left=0, width=5, height=10) + bbox2 = BBox(x_top_left=0, y_top_left=0, width=10, height=10) + self.assertTrue(bbox1 < bbox2) + + def test_equal_boxes_not_less(self) -> None: + bbox1 = BBox(x_top_left=1, y_top_left=2, width=3, height=4) + bbox2 = BBox(x_top_left=1, y_top_left=2, width=3, height=4) + self.assertFalse(bbox1 < bbox2) + self.assertFalse(bbox2 < bbox1) + + def test_sorting(self) -> None: + bboxes = [ + BBox(x_top_left=5, y_top_left=0, width=1, height=1), + BBox(x_top_left=0, y_top_left=10, width=1, height=1), + BBox(x_top_left=0, y_top_left=0, width=1, height=1), + ] + sorted_bboxes = sorted(bboxes) + self.assertEqual(sorted_bboxes[0].y_top_left, 0) + self.assertEqual(sorted_bboxes[0].x_top_left, 0) + self.assertEqual(sorted_bboxes[1].y_top_left, 0) + self.assertEqual(sorted_bboxes[1].x_top_left, 5) + self.assertEqual(sorted_bboxes[2].y_top_left, 10) + + +class TestBBoxRotateCoordinates(unittest.TestCase): + + def test_rotate_zero_degrees(self) -> None: + bbox = BBox(x_top_left=10, y_top_left=20, width=30, height=40) + image_shape = (100, 100) # (height, width) + bbox.rotate_coordinates(angle_rotate=0, image_shape=image_shape) + self.assertEqual(bbox.x_top_left, 10) + self.assertEqual(bbox.y_top_left, 20) + self.assertEqual(bbox.width, 30) + self.assertEqual(bbox.height, 40) + + def test_rotate_360_degrees(self) -> None: + bbox = BBox(x_top_left=0, y_top_left=0, width=100, height=100) + image_shape = (100, 100) + bbox.rotate_coordinates(angle_rotate=360, image_shape=image_shape) + self.assertEqual(bbox.x_top_left, 0) + self.assertEqual(bbox.y_top_left, 0) + self.assertAlmostEqual(bbox.width, 100, delta=1) + self.assertAlmostEqual(bbox.height, 100, delta=1) + + def test_rotate_180_degrees(self) -> None: + bbox = BBox(x_top_left=40, y_top_left=40, width=20, height=20) + image_shape = (100, 100) + bbox.rotate_coordinates(angle_rotate=180, image_shape=image_shape) + + self.assertGreaterEqual(bbox.x_top_left, 0) + self.assertGreaterEqual(bbox.y_top_left, 0) + self.assertLessEqual(bbox.x_top_left + bbox.width, image_shape[1]) + self.assertLessEqual(bbox.y_top_left + bbox.height, image_shape[0]) + + def test_rotate_out_of_image_bounds(self) -> None: + bbox = BBox(x_top_left=0, y_top_left=0, width=100, height=100) + image_shape = (100, 100) + bbox.rotate_coordinates(angle_rotate=45, image_shape=image_shape) + self.assertEqual(bbox.y_top_left, 0) + self.assertEqual(bbox.y_bottom_right, 100) + self.assertAlmostEqual(bbox.x_top_left, 50, delta=1) + self.assertAlmostEqual(bbox.x_bottom_right, 50, delta=1)