-
Notifications
You must be signed in to change notification settings - Fork 654
[feature] Add preserve_original_coords option for straighten_pages=True
#2108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8c6df56
ed8194d
aae751c
f91926b
3941f0c
0778a2f
7fd11bc
2136377
16f6276
47558c5
cde1f2e
b844302
12c9918
0fdceb0
8980535
3fb1b75
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,8 +8,15 @@ | |
|
|
||
| import numpy as np | ||
|
|
||
| from doctr.io.elements import Document, Word | ||
| from doctr.models.builder import DocumentBuilder | ||
| from doctr.utils.geometry import extract_crops, extract_rcrops, remove_image_padding, rotate_image | ||
| from doctr.utils.geometry import ( | ||
| extract_crops, | ||
| extract_rcrops, | ||
| remove_image_padding, | ||
| rotate_image, | ||
| straighten_page, | ||
| ) | ||
|
|
||
| from .._utils import estimate_orientation, mask_boxes, rectify_crops, rectify_loc_preds | ||
| from ..classification import crop_orientation_predictor, page_orientation_predictor | ||
|
|
@@ -34,6 +41,8 @@ | |
| ignore_regions: optional list of layout class names to ignore during detection/recognition. If provided, the | ||
| layout model will be used to locate the regions of the specified classes, and these regions will | ||
| be masked out (filled with black) before passing the pages to the detection/recognition modules. | ||
| preserve_original_coords: if True and straighten_pages is True, bounding boxes are mapped back to the | ||
| original page coordinates. Useful for redaction and annotation. | ||
| **kwargs: keyword args of `DocumentBuilder` | ||
| """ | ||
|
|
||
|
|
@@ -48,10 +57,12 @@ | |
| symmetric_pad: bool = True, | ||
| detect_orientation: bool = False, | ||
| ignore_regions: Collection[str] | None = None, | ||
| preserve_original_coords: bool = False, | ||
| **kwargs: Any, | ||
| ) -> None: | ||
| self.assume_straight_pages = assume_straight_pages | ||
| self.straighten_pages = straighten_pages | ||
| self.preserve_original_coords = preserve_original_coords | ||
| self._page_orientation_disabled = kwargs.pop("disable_page_orientation", False) | ||
| self._crop_orientation_disabled = kwargs.pop("disable_crop_orientation", False) | ||
| self.crop_orientation_predictor = ( | ||
|
|
@@ -134,11 +145,19 @@ | |
| for seq_map, general_orientation in zip(seg_maps, general_pages_orientations) | ||
| ] | ||
| ) | ||
| return [ | ||
| # expand if height and width are not equal, then remove the padding | ||
| remove_image_padding(rotate_image(page, angle, expand=page.shape[0] != page.shape[1])) | ||
| for page, angle in zip(pages, origin_pages_orientations) | ||
| ] | ||
| if not self.preserve_original_coords: | ||
| return [ | ||
| remove_image_padding(rotate_image(page, angle, expand=page.shape[0] != page.shape[1])) | ||
| for page, angle in zip(pages, origin_pages_orientations) | ||
| ] | ||
|
|
||
| self._straighten_m_inv = [] | ||
|
felixdittrich92 marked this conversation as resolved.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This makes the predictor stateful we should avoid saving it in self |
||
| out = [] | ||
| for page, angle in zip(pages, origin_pages_orientations): | ||
| straightened, m_inv = straighten_page(page, angle) | ||
| self._straighten_m_inv.append(m_inv) | ||
| out.append(straightened) | ||
| return out | ||
|
|
||
| @staticmethod | ||
| def _generate_crops( | ||
|
|
@@ -212,6 +231,78 @@ | |
|
|
||
| return loc_preds, text_preds, crop_orientation_preds | ||
|
|
||
| def _remap_to_original_coords( | ||
| self, | ||
| out: Document, | ||
| orig_shapes: list[tuple[int, int]], | ||
| straight_shapes: list[tuple[int, int]], | ||
| orig_pages: list[np.ndarray] | None = None, | ||
| ) -> Document: | ||
| """Remap word geometries from straightened-page coordinates back to original page coordinates. | ||
|
|
||
| This method is called after the document builder when straighten_pages and | ||
| preserve_original_coords are both True. It applies the inverse straightening | ||
| transform to each word's geometry so that bounding boxes align with the input image. | ||
|
|
||
| Supports both Page (blocks -> lines -> words) and KIEPage (predictions dict) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace all `` with single ` for all the added docstrings / comments |
||
| structures, identified by the presence of a ``predictions`` attribute. | ||
|
|
||
| When ``orig_pages`` is provided, each page's image is also restored to the original | ||
| (pre-straightening) input so that ``.show()`` renders boxes on the correct image. | ||
|
|
||
| Args: | ||
| out: the document returned by the builder | ||
| orig_shapes: original (pre-straightening) page shapes (H, W) | ||
| straight_shapes: straightened page shapes (H, W) | ||
| orig_pages: optional list of original page images to restore on ``page.page`` | ||
|
|
||
| Returns | ||
| ------- | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| the document with remapped word geometries | ||
|
|
||
| """ | ||
| for pidx, (page, m_inv) in enumerate(zip(out.pages, self._straighten_m_inv)): | ||
| sh, sw = straight_shapes[pidx] | ||
| oh, ow = orig_shapes[pidx] | ||
|
|
||
| # Collect all geometry-bearing objects (supports Page & KIEPage) | ||
| if hasattr(page, "predictions"): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| objs: list[Word] = [] | ||
| for preds in page.predictions.values(): | ||
| objs.extend(preds) | ||
| else: | ||
| objs = [word for block in page.blocks for line in block.lines for word in line.words] | ||
|
|
||
| for word in objs: | ||
| pts = np.asarray(word.geometry, dtype=np.float64).reshape(-1, 2) | ||
| was_straight = pts.shape[0] == 2 | ||
| if was_straight: | ||
| (x0, y0), (x1, y1) = pts | ||
| pts = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], dtype=np.float64) | ||
|
|
||
| pts[:, 0] *= sw | ||
| pts[:, 1] *= sh | ||
| homo = np.column_stack([pts, np.ones(len(pts))]) | ||
| orig = (homo @ m_inv.T)[:, :2] | ||
| orig[:, 0] = orig[:, 0].clip(0, ow - 1) / ow | ||
| orig[:, 1] = orig[:, 1].clip(0, oh - 1) / oh | ||
|
|
||
| if was_straight: | ||
| word.geometry = ( | ||
| (float(orig[:, 0].min()), float(orig[:, 1].min())), | ||
| (float(orig[:, 0].max()), float(orig[:, 1].max())), | ||
| ) | ||
| else: | ||
| word.geometry = tuple(tuple(r) for r in orig.tolist()) # type: ignore[assignment] | ||
|
|
||
| # Restore original page image and dimensions so .show() and export() | ||
| # render boxes in the correct frame (visualize_page scales geometry by dimensions) | ||
| if orig_pages is not None: | ||
| out.pages[pidx].page = orig_pages[pidx] | ||
| out.pages[pidx].dimensions = orig_shapes[pidx] | ||
|
|
||
| return out | ||
|
|
||
| def add_hook(self, hook: Callable) -> None: | ||
| """Add a hook to the predictor | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ | |
| "compute_expanded_shape", | ||
| "rotate_image", | ||
| "remove_image_padding", | ||
| "straighten_page", | ||
| "estimate_page_angle", | ||
| "convert_to_relative_coords", | ||
| "rotate_abs_geoms", | ||
|
|
@@ -398,6 +399,71 @@ def remove_image_padding(image: np.ndarray) -> np.ndarray: | |
| return image[rmin : rmax + 1, cmin : cmax + 1] | ||
|
|
||
|
|
||
| def straighten_page( | ||
| page: np.ndarray, | ||
| angle: float, | ||
| ) -> tuple[np.ndarray, np.ndarray]: | ||
| """Straighten a page by rotating it and cropping to content, returning the straightened image | ||
| and the inverse affine matrix that maps straightened-page coordinates back to the original page. | ||
|
|
||
| Args: | ||
| page: the original page image (H, W, C) | ||
| angle: rotation angle in degrees (between -90 and +90) | ||
|
|
||
| Returns: | ||
| a tuple (straightened, M_inv) where straightened is the page rotated and cropped to the | ||
| non-padded region, and M_inv is a (3, 3) array mapping points in the straightened image's | ||
| pixel space (x, y, 1) back to the original page's pixel space. | ||
| """ | ||
| h, w = page.shape[:2] | ||
| expand = h != w | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Square pages (h == w) will break the analytic crop. |
||
|
|
||
| # Padding to preserve content after rotation | ||
| if expand: | ||
| exp = compute_expanded_shape((h, w), angle) | ||
| h_pad = int(max(0, np.ceil(exp[0] - h))) | ||
| w_pad = int(max(0, np.ceil(exp[1] - w))) | ||
| else: | ||
| h_pad = w_pad = 0 | ||
|
|
||
| pt, pb = h_pad // 2, h_pad - h_pad // 2 | ||
| pl, pr = w_pad // 2, w_pad - w_pad // 2 | ||
|
|
||
| exp_img = np.pad(page, ((pt, pb), (pl, pr), (0, 0))) | ||
| ph_pad, pw_pad = exp_img.shape[:2] | ||
|
|
||
| # Rotate around the padded image center | ||
| rot_mat = cv2.getRotationMatrix2D((pw_pad / 2, ph_pad / 2), angle, 1.0) | ||
| rotated = cv2.warpAffine(exp_img, rot_mat, (pw_pad, ph_pad)) | ||
|
|
||
| # Aspect-ratio padding (applied only on right/bottom so analytic crop stays simple) | ||
| if expand: | ||
| if (rotated.shape[0] / rotated.shape[1]) > (h / w): | ||
| w_pad2 = int(rotated.shape[0] * w / h - rotated.shape[1]) | ||
| rotated = np.pad(rotated, ((0, 0), (0, w_pad2), (0, 0))) | ||
| else: | ||
| h_pad2 = int(rotated.shape[1] * h / w - rotated.shape[0]) | ||
| rotated = np.pad(rotated, ((0, h_pad2), (0, 0), (0, 0))) | ||
|
|
||
| # Analytic crop: project the four content corners through the rotation matrix | ||
| corners = np.array( | ||
| [[pl, pt, 1], [pl + w, pt, 1], [pl + w, pt + h, 1], [pl, pt + h, 1]], | ||
| dtype=np.float64, | ||
| ).T | ||
| rc = rot_mat @ corners | ||
| cx, cy = int(np.floor(rc[0].min())), int(np.floor(rc[1].min())) | ||
| cropped = rotated[cy:, cx:] | ||
|
|
||
| # Composite forward matrix: crop ∘ rotate ∘ pad | ||
| # cv2.warpAffine treats the rotation matrix as src→dst | ||
| c3 = np.array([[1, 0, -cx], [0, 1, -cy], [0, 0, 1]], dtype=np.float64) | ||
| r3 = np.vstack([rot_mat, [0, 0, 1]]) | ||
| p3 = np.array([[1, 0, pl], [0, 1, pt], [0, 0, 1]], dtype=np.float64) | ||
| m_inv = np.linalg.inv(c3 @ r3 @ p3) | ||
|
|
||
| return cropped, m_inv | ||
|
|
||
|
|
||
| def estimate_page_angle(polys: np.ndarray) -> float: | ||
| """Takes a batch of rotated previously ORIENTED polys (N, 4, 2) (rectified by the classifier) and return the | ||
| estimated angle ccw in degrees | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -326,3 +326,38 @@ def test_extract_rcrops(mock_pdf, assume_horizontal): | |
|
|
||
| # No box | ||
| assert geometry.extract_rcrops(doc_img, np.zeros((0, 4, 2)), assume_horizontal=assume_horizontal) == [] | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("angle", [5, 12, -5, -12, 90 + 13, 180 + 13, 270 + 13]) | ||
| @pytest.mark.parametrize("shape", [(800, 600), (600, 800)]) | ||
| def test_straighten_page_inverse(angle, shape): | ||
| """M_inv returned by straighten_page is the exact inverse of the | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove docstrings after the test function names - it's enough to add small comments at places where it's not clear / required |
||
| pad -> rotate -> crop pipeline, accurate to within interpolation noise. | ||
|
|
||
| Sub-degree angles are excluded: at very small rotations every fiducial | ||
| pixel is blended by interpolation, so exact-colour matching finds nothing. | ||
| """ | ||
| h, w = shape | ||
| page = np.ones((h, w, 3), dtype=np.uint8) * 255 | ||
| page[99:102, 99:102] = (255, 0, 0) | ||
| page[99:102, 499:502] = (0, 255, 0) | ||
| page[699:702, 99:102] = (0, 0, 255) | ||
| page[699:702, 499:502] = (255, 255, 0) | ||
| page[399:402, 299:302] = (255, 0, 255) | ||
| dots = [(100, 100), (500, 100), (100, 700), (500, 700), (300, 400)] | ||
| colours = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255)] | ||
|
|
||
| straightened, m_inv = geometry.straighten_page(page, angle) | ||
|
|
||
| errors = [] | ||
| for (dx, dy), colour in zip(dots, colours): | ||
| mask = np.all(straightened == colour, axis=-1) | ||
| found = np.argwhere(mask) | ||
| if len(found) == 0: | ||
| continue | ||
| fy, fx = found.mean(axis=0) | ||
| recovered = (np.array([float(fx), float(fy), 1.0]) @ m_inv.T)[:2] | ||
| errors.append(np.linalg.norm(recovered - np.array([float(dx), float(dy)]))) | ||
|
|
||
| assert len(errors) > 0, "No fiducial dots found -- interpolation blended all pixels" | ||
| assert max(errors) < 0.6, f"Max remap error {max(errors):.4f}px exceeds 0.6px threshold" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A comment would be good
Additional self._remap_to_original_coords must also handle the geometry from the Layout Elements and table elements not only the word geometry