Source code for layoutparser.elements.layout_elements

# Copyright 2021 The Layout Parser team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import List, Union, Dict, Dict, Any, Optional, Tuple
from collections.abc import Iterable
from copy import copy
from inspect import getmembers, isfunction
import warnings
import functools

import numpy as np
import pandas as pd
from PIL import Image
from cv2 import getPerspectiveTransform as _getPerspectiveTransform
from cv2 import warpPerspective as _warpPerspective

from .base import BaseCoordElement, BaseLayoutElement
from .utils import (
    cvt_coordinates_to_points,
    cvt_points_to_coordinates,
    perspective_transformation,
    vertice_in_polygon,
    polygon_area,
)
from .errors import NotSupportedShapeError, InvalidShapeError


def mixin_textblock_meta(func):
    @functools.wraps(func)
    def wrap(self, *args, **kwargs):
        out = func(self, *args, **kwargs)
        if isinstance(out, BaseCoordElement):
            self = copy(self)
            self.block = out
            return self

    return wrap


def inherit_docstrings(cls=None, *, base_class=None):

    # Refer to https://stackoverflow.com/a/17393254
    if cls is None:
        return functools.partial(inherit_docstrings, base_class=base_class)

    for name, func in getmembers(cls, isfunction):
        if func.__doc__:
            continue
        if base_class == None:
            for parent in cls.__mro__[1:]:
                if hasattr(parent, name):
                    func.__doc__ = getattr(parent, name).__doc__
                    break
        else:
            if hasattr(base_class, name):
                func.__doc__ = getattr(base_class, name).__doc__

    return cls


def support_textblock(func):
    @functools.wraps(func)
    def wrap(self, other, *args, **kwargs):
        if isinstance(other, TextBlock):
            other = other.block
        out = func(self, other, *args, **kwargs)
        return out

    return wrap


[docs]@inherit_docstrings class Interval(BaseCoordElement): """ This class describes the coordinate system of an interval, a block defined by a pair of start and end point on the designated axis and same length as the base canvas on the other axis. Args: start (:obj:`numeric`): The coordinate of the start point on the designated axis. end (:obj:`numeric`): The end coordinate on the same axis as start. axis (:obj:`str`): The designated axis that the end points belong to. canvas_height (:obj:`numeric`, `optional`, defaults to 0): The height of the canvas that the interval is on. canvas_width (:obj:`numeric`, `optional`, defaults to 0): The width of the canvas that the interval is on. """ _name = "interval" _features = ["start", "end", "axis", "canvas_height", "canvas_width"] def __init__(self, start, end, axis, canvas_height=None, canvas_width=None): assert start <= end, f"Invalid input for start and end. Start must <= end." self.start = start self.end = end assert axis in ["x", "y"], f"Invalid axis {axis}. Axis must be in 'x' or 'y'" self.axis = axis self.canvas_height = canvas_height or 0 self.canvas_width = canvas_width or 0 @property def height(self): """ Calculate the height of the interval. If the interval is along the x-axis, the height will be the height of the canvas, otherwise, it will be the difference between the start and end point. Returns: :obj:`numeric`: Output the numeric value of the height. """ if self.axis == "x": return self.canvas_height else: return self.end - self.start @property def width(self): """ Calculate the width of the interval. If the interval is along the y-axis, the width will be the width of the canvas, otherwise, it will be the difference between the start and end point. Returns: :obj:`numeric`: Output the numeric value of the width. """ if self.axis == "y": return self.canvas_width else: return self.end - self.start @property def coordinates(self): """ This method considers an interval as a rectangle and calculates the coordinates of the upper left and lower right corners to define the interval. Returns: :obj:`Tuple(numeric)`: Output the numeric values of the coordinates in a Tuple of size four. """ if self.axis == "x": coords = (self.start, 0, self.end, self.canvas_height) else: coords = (0, self.start, self.canvas_width, self.end) return coords @property def points(self): """ Return the coordinates of all four corners of the interval in a clockwise fashion starting from the upper left. Returns: :obj:`Numpy array`: A Numpy array of shape 4x2 containing the coordinates. """ return cvt_coordinates_to_points(self.coordinates) @property def center(self): """ Calculate the mid-point between the start and end point. Returns: :obj:`Tuple(numeric)`: Returns of coordinate of the center. """ return (self.start + self.end) / 2.0 @property def area(self): """Return the area of the covered region of the interval. The area is bounded to the canvas. If the interval is put on a canvas, the area equals to interval width * canvas height (axis='x') or interval height * canvas width (axis='y'). Otherwise, the area is zero. """ return self.height * self.width
[docs] def put_on_canvas(self, canvas): """ Set the height and the width of the canvas that the interval is on. Args: canvas (:obj:`Numpy array` or :obj:`BaseCoordElement` or :obj:`PIL.Image.Image`): The base element that the interval is on. The numpy array should be the format of `[height, width]`. Returns: :obj:`Interval`: A copy of the current Interval with its canvas height and width set to those of the input canvas. """ if isinstance(canvas, np.ndarray): h, w = canvas.shape[:2] elif isinstance(canvas, BaseCoordElement): h, w = canvas.height, canvas.width elif isinstance(canvas, Image.Image): w, h = canvas.size else: raise NotImplementedError return self.set(canvas_height=h, canvas_width=w)
[docs] @support_textblock def condition_on(self, other): if isinstance(other, Interval): if other.axis == self.axis: d = other.start # Reset the canvas size in the absolute coordinates return self.__class__(self.start + d, self.end + d, self.axis) else: return copy(self) elif isinstance(other, Rectangle): return self.put_on_canvas(other).to_rectangle().condition_on(other) elif isinstance(other, Quadrilateral): return self.put_on_canvas(other).to_quadrilateral().condition_on(other) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def relative_to(self, other): if isinstance(other, Interval): if other.axis == self.axis: d = other.start # Reset the canvas size in the absolute coordinates return self.__class__(self.start - d, self.end - d, self.axis) else: return copy(self) elif isinstance(other, Rectangle): return self.put_on_canvas(other).to_rectangle().relative_to(other) elif isinstance(other, Quadrilateral): return self.put_on_canvas(other).to_quadrilateral().relative_to(other) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def is_in(self, other, soft_margin={}, center=False): other = other.pad(**soft_margin) if isinstance(other, Interval): if self.axis != other.axis: return False else: if not center: return other.start <= self.start <= self.end <= other.end else: return other.start <= self.center <= other.end elif isinstance(other, Rectangle) or isinstance(other, Quadrilateral): x_1, y_1, x_2, y_2 = other.coordinates if center: if self.axis == "x": return x_1 <= self.center <= x_2 else: return y_1 <= self.center <= y_2 else: if self.axis == "x": return x_1 <= self.start <= self.end <= x_2 else: return y_1 <= self.start <= self.end <= y_2 else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def intersect(self, other: BaseCoordElement, strict: bool = True): """""" if isinstance(other, Interval): if self.axis != other.axis: if self.axis == "x" and other.axis == "y": return Rectangle(self.start, other.start, self.end, other.end) else: return Rectangle(other.start, self.start, other.end, self.end) else: return self.__class__( max(self.start, other.start), min(self.end, other.end), self.axis, self.canvas_height, self.canvas_width, ) elif isinstance(other, Rectangle): x_1, y_1, x_2, y_2 = other.coordinates if self.axis == "x": return Rectangle(max(x_1, self.start), y_1, min(x_2, self.end), y_2) elif self.axis == "y": return Rectangle(x_1, max(y_1, self.start), x_2, min(y_2, self.end)) elif isinstance(other, Quadrilateral): if strict: raise NotSupportedShapeError( "The intersection between an Interval and a Quadrilateral might generate Polygon shapes that are not supported in the current version of layoutparser. You can pass `strict=False` in the input that converts the Quadrilateral to Rectangle to avoid this Exception." ) else: warnings.warn( f"With `strict=False`, the other of shape {other.__class__} will be converted to {Rectangle} for obtaining the intersection" ) return self.intersect(other.to_rectangle()) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def union(self, other: BaseCoordElement, strict: bool = True): """""" if isinstance(other, Interval): if self.axis != other.axis: raise InvalidShapeError( f"Unioning two intervals of different axes is not allowed." ) else: return self.__class__( min(self.start, other.start), max(self.end, other.end), self.axis, self.canvas_height, self.canvas_width, ) elif isinstance(other, Rectangle): x_1, y_1, x_2, y_2 = other.coordinates if self.axis == "x": return Rectangle(min(x_1, self.start), y_1, max(x_2, self.end), y_2) elif self.axis == "y": return Rectangle(x_1, min(y_1, self.start), x_2, max(y_2, self.end)) elif isinstance(other, Quadrilateral): if strict: raise NotSupportedShapeError( "The intersection between an Interval and a Quadrilateral might generate Polygon shapes that are not supported in the current version of layoutparser. You can pass `strict=False` in the input that converts the Quadrilateral to Rectangle to avoid this Exception." ) else: warnings.warn( f"With `strict=False`, the other of shape {other.__class__} will be converted to {Rectangle} for obtaining the intersection" ) return self.union(other.to_rectangle()) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] def pad(self, left=0, right=0, top=0, bottom=0, safe_mode=True): if self.axis == "x": start = self.start - left end = self.end + right if top or bottom: warnings.warn( f"Invalid padding top/bottom for an x axis {self.__class__.__name__}" ) else: start = self.start - top end = self.end + bottom if left or right: warnings.warn( f"Invalid padding right/left for a y axis {self.__class__.__name__}" ) if safe_mode: start = max(0, start) return self.set(start=start, end=end)
[docs] def shift(self, shift_distance): """ Shift the interval by a user specified amount along the same axis that the interval is defined on. Args: shift_distance (:obj:`numeric`): The number of pixels used to shift the interval. Returns: :obj:`BaseCoordElement`: The shifted Interval object. """ if isinstance(shift_distance, Iterable): shift_distance = ( shift_distance[0] if self.axis == "x" else shift_distance[1] ) warnings.warn( f"Input shift for multiple axes. Only use the distance for the {self.axis} axis" ) start = self.start + shift_distance end = self.end + shift_distance return self.set(start=start, end=end)
[docs] def scale(self, scale_factor): """ Scale the layout element by a user specified amount the same axis that the interval is defined on. Args: scale_factor (:obj:`numeric`): The amount for downscaling or upscaling the element. Returns: :obj:`BaseCoordElement`: The scaled Interval object. """ if isinstance(scale_factor, Iterable): scale_factor = scale_factor[0] if self.axis == "x" else scale_factor[1] warnings.warn( f"Input scale for multiple axes. Only use the factor for the {self.axis} axis" ) start = self.start * scale_factor end = self.end * scale_factor return self.set(start=start, end=end)
[docs] def crop_image(self, image): x_1, y_1, x_2, y_2 = self.put_on_canvas(image).coordinates return image[int(y_1) : int(y_2), int(x_1) : int(x_2)]
[docs] def to_rectangle(self): """ Convert the Interval to a Rectangle element. Returns: :obj:`Rectangle`: The converted Rectangle object. """ return Rectangle(*self.coordinates)
[docs] def to_quadrilateral(self): """ Convert the Interval to a Quadrilateral element. Returns: :obj:`Quadrilateral`: The converted Quadrilateral object. """ return Quadrilateral(self.points)
[docs]@inherit_docstrings class Rectangle(BaseCoordElement): """ This class describes the coordinate system of an axial rectangle box using two points as indicated below:: (x_1, y_1) ---- | | | | | | ---- (x_2, y_2) Args: x_1 (:obj:`numeric`): x coordinate on the horizontal axis of the upper left corner of the rectangle. y_1 (:obj:`numeric`): y coordinate on the vertical axis of the upper left corner of the rectangle. x_2 (:obj:`numeric`): x coordinate on the horizontal axis of the lower right corner of the rectangle. y_2 (:obj:`numeric`): y coordinate on the vertical axis of the lower right corner of the rectangle. """ _name = "rectangle" _features = ["x_1", "y_1", "x_2", "y_2"] def __init__(self, x_1, y_1, x_2, y_2): self.x_1 = x_1 self.y_1 = y_1 self.x_2 = x_2 self.y_2 = y_2 @property def height(self): """ Calculate the height of the rectangle. Returns: :obj:`numeric`: Output the numeric value of the height. """ return self.y_2 - self.y_1 @property def width(self): """ Calculate the width of the rectangle. Returns: :obj:`numeric`: Output the numeric value of the width. """ return self.x_2 - self.x_1 @property def coordinates(self): """ Return the coordinates of the two points that define the rectangle. Returns: :obj:`Tuple(numeric)`: Output the numeric values of the coordinates in a Tuple of size four. """ return (self.x_1, self.y_1, self.x_2, self.y_2) @property def points(self): """ Return the coordinates of all four corners of the rectangle in a clockwise fashion starting from the upper left. Returns: :obj:`Numpy array`: A Numpy array of shape 4x2 containing the coordinates. """ return cvt_coordinates_to_points(self.coordinates) @property def center(self): """ Calculate the center of the rectangle. Returns: :obj:`Tuple(numeric)`: Returns of coordinate of the center. """ return (self.x_1 + self.x_2) / 2.0, (self.y_1 + self.y_2) / 2.0 @property def area(self): """ Return the area of the rectangle. """ return self.width * self.height
[docs] @support_textblock def condition_on(self, other): if isinstance(other, Interval): if other.axis == "x": dx, dy = other.start, 0 else: dx, dy = 0, other.start return self.__class__( self.x_1 + dx, self.y_1 + dy, self.x_2 + dx, self.y_2 + dy ) elif isinstance(other, Rectangle): dx, dy, _, _ = other.coordinates return self.__class__( self.x_1 + dx, self.y_1 + dy, self.x_2 + dx, self.y_2 + dy ) elif isinstance(other, Quadrilateral): transformed_points = perspective_transformation( other.perspective_matrix, self.points, is_inv=True ) return other.__class__(transformed_points, self.height, self.width) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def relative_to(self, other): if isinstance(other, Interval): if other.axis == "x": dx, dy = other.start, 0 else: dx, dy = 0, other.start return self.__class__( self.x_1 - dx, self.y_1 - dy, self.x_2 - dx, self.y_2 - dy ) elif isinstance(other, Rectangle): dx, dy, _, _ = other.coordinates return self.__class__( self.x_1 - dx, self.y_1 - dy, self.x_2 - dx, self.y_2 - dy ) elif isinstance(other, Quadrilateral): transformed_points = perspective_transformation( other.perspective_matrix, self.points, is_inv=False ) return other.__class__(transformed_points, self.height, self.width) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def is_in(self, other, soft_margin={}, center=False): other = other.pad(**soft_margin) if isinstance(other, Interval): if not center: if other.axis == "x": start, end = self.x_1, self.x_2 else: start, end = self.y_1, self.y_2 return other.start <= start <= end <= other.end else: c = self.center[0] if other.axis == "x" else self.center[1] return other.start <= c <= other.end elif isinstance(other, Rectangle): x_interval = other.to_interval(axis="x") y_interval = other.to_interval(axis="y") return self.is_in(x_interval, center=center) and self.is_in( y_interval, center=center ) elif isinstance(other, Quadrilateral): if not center: # This is equivalent to determine all the points of the # rectangle is in the quadrilateral. is_vertice_in = [ vertice_in_polygon(vertice, other.points) for vertice in self.points ] return all(is_vertice_in) else: center = np.array(self.center) return vertice_in_polygon(center, other.points) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def intersect(self, other: BaseCoordElement, strict: bool = True): """""" if isinstance(other, Interval): return other.intersect(self) elif isinstance(other, Rectangle): return self.__class__( max(self.x_1, other.x_1), max(self.y_1, other.y_1), min(self.x_2, other.x_2), min(self.y_2, other.y_2), ) elif isinstance(other, Quadrilateral): if strict: raise NotSupportedShapeError( "The intersection between a Rectangle and a Quadrilateral might generate Polygon shapes that are not supported in the current version of layoutparser. You can pass `strict=False` in the input that converts the Quadrilateral to Rectangle to avoid this Exception." ) else: warnings.warn( f"With `strict=False`, the other of shape {other.__class__} will be converted to {Rectangle} for obtaining the intersection" ) return self.intersect(other.to_rectangle()) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def union(self, other: BaseCoordElement, strict: bool = True): """""" if isinstance(other, Interval): return other.intersect(self) elif isinstance(other, Rectangle): return self.__class__( min(self.x_1, other.x_1), min(self.y_1, other.y_1), max(self.x_2, other.x_2), max(self.y_2, other.y_2), ) elif isinstance(other, Quadrilateral): if strict: raise NotSupportedShapeError( "The intersection between an Interval and a Quadrilateral might generate Polygon shapes that are not supported in the current version of layoutparser. You can pass `strict=False` in the input that converts the Quadrilateral to Rectangle to avoid this Exception." ) else: warnings.warn( f"With `strict=False`, the other of shape {other.__class__} will be converted to {Rectangle} for obtaining the intersection" ) return self.union(other.to_rectangle()) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] def pad(self, left=0, right=0, top=0, bottom=0, safe_mode=True): x_1 = self.x_1 - left y_1 = self.y_1 - top x_2 = self.x_2 + right y_2 = self.y_2 + bottom if safe_mode: x_1 = max(0, x_1) y_1 = max(0, y_1) return self.__class__(x_1, y_1, x_2, y_2)
[docs] def shift(self, shift_distance=0): if not isinstance(shift_distance, Iterable): shift_x = shift_distance shift_y = shift_distance else: assert ( len(shift_distance) == 2 ), "shift_distance should have 2 elements, one for x dimension and one for y dimension" shift_x, shift_y = shift_distance x_1 = self.x_1 + shift_x y_1 = self.y_1 + shift_y x_2 = self.x_2 + shift_x y_2 = self.y_2 + shift_y return self.__class__(x_1, y_1, x_2, y_2)
[docs] def scale(self, scale_factor=1): if not isinstance(scale_factor, Iterable): scale_x = scale_factor scale_y = scale_factor else: assert ( len(scale_factor) == 2 ), "scale_factor should have 2 elements, one for x dimension and one for y dimension" scale_x, scale_y = scale_factor x_1 = self.x_1 * scale_x y_1 = self.y_1 * scale_y x_2 = self.x_2 * scale_x y_2 = self.y_2 * scale_y return self.__class__(x_1, y_1, x_2, y_2)
[docs] def crop_image(self, image): x_1, y_1, x_2, y_2 = self.coordinates return image[int(y_1) : int(y_2), int(x_1) : int(x_2)]
[docs] def to_interval(self, axis, **kwargs): if axis == "x": start, end = self.x_1, self.x_2 else: start, end = self.y_1, self.y_2 return Interval(start, end, axis=axis, **kwargs)
[docs] def to_quadrilateral(self): return Quadrilateral(self.points)
[docs]@inherit_docstrings class Quadrilateral(BaseCoordElement): """ This class describes the coodinate system of a four-sided polygon. A quadrilateral is defined by the coordinates of its 4 corners in a clockwise order starting with the upper left corner (as shown below):: points[0] -...- points[1] | | . . . . . . | | points[3] -...- points[2] Args: points (:obj:`Numpy array` or `list`): A `np.ndarray` of shape 4x2 for four corner coordinates or a list of length 8 for in the format of `[p0_x, p0_y, p1_x, p1_y, p2_x, p2_y, p3_x, p3_y]` or a list of length 4 in the format of `[[p0_x, p0_y], [p1_x, p1_y], [p2_x, p2_y], [p3_x, p3_y]]`. height (:obj:`numeric`, `optional`, defaults to `None`): The height of the quadrilateral. This is to better support the perspective transformation from the OpenCV library. width (:obj:`numeric`, `optional`, defaults to `None`): The width of the quadrilateral. Similarly as height, this is to better support the perspective transformation from the OpenCV library. """ _name = "quadrilateral" _features = ["points", "height", "width"] def __init__( self, points: Union[np.ndarray, List, List[List]], height=None, width=None ): if isinstance(points, np.ndarray): if points.shape != (4, 2): raise ValueError(f"Invalid points shape: {points.shape}.") elif isinstance(points, list): if len(points) == 8: points = np.array(points).reshape(4, 2) elif len(points) == 4 and isinstance(points[0], list): points = np.array(points) else: raise ValueError( f"Invalid number of points element {len(points)}. Should be 8." ) else: raise ValueError( f"Invalid input type for points {type(points)}." "Please make sure it is a list of np.ndarray." ) self._points = points self._width = width self._height = height @property def height(self): """ Return the user defined height, otherwise the height of its circumscribed rectangle. Returns: :obj:`numeric`: Output the numeric value of the height. """ if self._height is not None: return self._height return self.points[:, 1].max() - self.points[:, 1].min() @property def width(self): """ Return the user defined width, otherwise the width of its circumscribed rectangle. Returns: :obj:`numeric`: Output the numeric value of the width. """ if self._width is not None: return self._width return self.points[:, 0].max() - self.points[:, 0].min() @property def coordinates(self): """ Return the coordinates of the upper left and lower right corners points that define the circumscribed rectangle. Returns :obj:`Tuple(numeric)`: Output the numeric values of the coordinates in a Tuple of size four. """ return cvt_points_to_coordinates(self.points) @property def points(self): """ Return the coordinates of all four corners of the quadrilateral in a clockwise fashion starting from the upper left. Returns: :obj:`Numpy array`: A Numpy array of shape 4x2 containing the coordinates. """ return self._points @property def center(self): """ Calculate the center of the quadrilateral. Returns: :obj:`Tuple(numeric)`: Returns of coordinate of the center. """ return tuple(self.points.mean(axis=0).tolist()) @property def area(self): """ Return the area of the quadrilateral. """ return polygon_area(self.points[:, 0], self.points[:, 1]) @property def mapped_rectangle_points(self): x_map = {0: 0, 1: 0, 2: self.width, 3: self.width} y_map = {0: 0, 1: 0, 2: self.height, 3: self.height} return self.map_to_points_ordering(x_map, y_map) @property def perspective_matrix(self): return _getPerspectiveTransform( self.points.astype("float32"), self.mapped_rectangle_points.astype("float32"), )
[docs] def map_to_points_ordering(self, x_map, y_map): points_ordering = self.points.argsort(axis=0).argsort(axis=0) # Ref: https://github.com/numpy/numpy/issues/8757#issuecomment-355126992 return np.vstack( [ np.vectorize(x_map.get)(points_ordering[:, 0]), np.vectorize(y_map.get)(points_ordering[:, 1]), ] ).T
[docs] @support_textblock def condition_on(self, other): if isinstance(other, Interval): if other.axis == "x": return self.shift([other.start, 0]) else: return self.shift([0, other.start]) elif isinstance(other, Rectangle): return self.shift([other.x_1, other.y_1]) elif isinstance(other, Quadrilateral): transformed_points = perspective_transformation( other.perspective_matrix, self.points, is_inv=True ) return self.__class__(transformed_points, self.height, self.width) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def relative_to(self, other): if isinstance(other, Interval): if other.axis == "x": return self.shift([-other.start, 0]) else: return self.shift([0, -other.start]) elif isinstance(other, Rectangle): return self.shift([-other.x_1, -other.y_1]) elif isinstance(other, Quadrilateral): transformed_points = perspective_transformation( other.perspective_matrix, self.points, is_inv=False ) return self.__class__(transformed_points, self.height, self.width) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def is_in(self, other, soft_margin={}, center=False): other = other.pad(**soft_margin) if isinstance(other, Interval): if not center: if other.axis == "x": start, end = self.coordinates[0], self.coordinates[2] else: start, end = self.coordinates[1], self.coordinates[3] return other.start <= start <= end <= other.end else: c = self.center[0] if other.axis == "x" else self.center[1] return other.start <= c <= other.end elif isinstance(other, Rectangle): x_interval = other.to_interval(axis="x") y_interval = other.to_interval(axis="y") return self.is_in(x_interval, center=center) and self.is_in( y_interval, center=center ) elif isinstance(other, Quadrilateral): if not center: # This is equivalent to determine all the points of the # rectangle is in the quadrilateral. is_vertice_in = [ vertice_in_polygon(vertice, other.points) for vertice in self.points ] return all(is_vertice_in) else: center = np.array(self.center) return vertice_in_polygon(center, other.points) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def intersect(self, other: BaseCoordElement, strict: bool = True): """""" if strict: raise NotSupportedShapeError( "The intersection between a Quadrilateral and other objects might generate Polygon shapes that are not supported in the current version of layoutparser. You can pass `strict=False` in the input that converts the Quadrilateral to Rectangle to avoid this Exception." ) else: if isinstance(other, Interval) or isinstance(other, Rectangle): warnings.warn( f"With `strict=False`, the current Quadrilateral object will be converted to {Rectangle} for obtaining the intersection" ) return other.intersect(self.to_rectangle()) elif isinstance(other, Quadrilateral): warnings.warn( f"With `strict=False`, both input Quadrilateral objects will be converted to {Rectangle} for obtaining the intersection" ) return self.to_rectangle().intersect(other.to_rectangle()) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] @support_textblock def union(self, other: BaseCoordElement, strict: bool = True): """""" if strict: raise NotSupportedShapeError( "The intersection between a Quadrilateral and other objects might generate Polygon shapes that are not supported in the current version of layoutparser. You can pass `strict=False` in the input that converts the Quadrilateral to Rectangle to avoid this Exception." ) else: if isinstance(other, Interval) or isinstance(other, Rectangle): warnings.warn( f"With `strict=False`, the current Quadrilateral object will be converted to {Rectangle} for obtaining the intersection" ) return other.union(self.to_rectangle()) elif isinstance(other, Quadrilateral): warnings.warn( f"With `strict=False`, both input Quadrilateral objects will be converted to {Rectangle} for obtaining the intersection" ) return self.to_rectangle().union(other.to_rectangle()) else: raise Exception(f"Invalid input type {other.__class__} for other")
[docs] def pad(self, left=0, right=0, top=0, bottom=0, safe_mode=True): x_map = {0: -left, 1: -left, 2: right, 3: right} y_map = {0: -top, 1: -top, 2: bottom, 3: bottom} padding_mat = self.map_to_points_ordering(x_map, y_map) points = self.points + padding_mat if safe_mode: points = np.maximum(points, 0) return self.set(points=points)
[docs] def shift(self, shift_distance=0): if not isinstance(shift_distance, Iterable): shift_mat = [shift_distance, shift_distance] else: assert ( len(shift_distance) == 2 ), "shift_distance should have 2 elements, one for x dimension and one for y dimension" shift_mat = shift_distance points = self.points + np.array(shift_mat) return self.set(points=points)
[docs] def scale(self, scale_factor=1): if not isinstance(scale_factor, Iterable): scale_mat = [scale_factor, scale_factor] else: assert ( len(scale_factor) == 2 ), "scale_factor should have 2 elements, one for x dimension and one for y dimension" scale_mat = scale_factor points = self.points * np.array(scale_mat) return self.set(points=points)
[docs] def crop_image(self, image): """ Crop the input image using the points of the quadrilateral instance. Args: image (:obj:`Numpy array`): The array of the input image. Returns: :obj:`Numpy array`: The array of the cropped image. """ return _warpPerspective( image, self.perspective_matrix, (int(self.width), int(self.height)) )
[docs] def to_interval(self, axis, **kwargs): x_1, y_1, x_2, y_2 = self.coordinates if axis == "x": start, end = x_1, x_2 else: start, end = y_1, y_2 return Interval(start, end, axis=axis, **kwargs)
[docs] def to_rectangle(self): return Rectangle(*self.coordinates)
def __eq__(self, other): if other.__class__ is not self.__class__: return False return np.isclose(self.points, other.points).all() def __repr__(self): keys = ["points", "width", "height"] info_str = ", ".join([f"{key}={getattr(self, key)}" for key in keys]) return f"{self.__class__.__name__}({info_str})"
[docs] def to_dict(self) -> Dict[str, Any]: """ Generate a dictionary representation of the current object:: { "block_type": "quadrilateral", "points": [ p[0,0], p[0,1], p[1,0], p[1,1], p[2,0], p[2,1], p[3,0], p[3,1] ], "height": value, "width": value } """ data = super().to_dict() data["points"] = data["points"].reshape(-1).tolist() return data
ALL_BASECOORD_ELEMENTS = [Interval, Rectangle, Quadrilateral] BASECOORD_ELEMENT_NAMEMAP = {ele._name: ele for ele in ALL_BASECOORD_ELEMENTS} BASECOORD_ELEMENT_INDEXMAP = { ele._name: idx for idx, ele in enumerate(ALL_BASECOORD_ELEMENTS) }
[docs]@inherit_docstrings(base_class=BaseCoordElement) class TextBlock(BaseLayoutElement): """ This class constructs content-related information of a layout element in addition to its coordinate definitions (i.e. Interval, Rectangle or Quadrilateral). Args: block (:obj:`BaseCoordElement`): The shape-specific coordinate systems that the text block belongs to. text (:obj:`str`, `optional`, defaults to None): The ocr'ed text results within the boundaries of the text block. id (:obj:`int`, `optional`, defaults to `None`): The id of the text block. type (:obj:`int`, `optional`, defaults to `None`): The type of the text block. parent (:obj:`int`, `optional`, defaults to `None`): The id of the parent object. next (:obj:`int`, `optional`, defaults to `None`): The id of the next block. score (:obj:`numeric`, defaults to `None`): The prediction confidence of the block """ _name = "textblock" _features = ["text", "id", "type", "parent", "next", "score"] def __init__( self, block, text=None, id=None, type=None, parent=None, next=None, score=None ): assert isinstance(block, BaseCoordElement) self.block = block self.text = text self.id = id self.type = type self.parent = parent self.next = next self.score = score @property def height(self): """ Return the height of the shape-specific block. Returns: :obj:`numeric`: Output the numeric value of the height. """ return self.block.height @property def width(self): """ Return the width of the shape-specific block. Returns: :obj:`numeric`: Output the numeric value of the width. """ return self.block.width @property def coordinates(self): """ Return the coordinates of the two corner points that define the shape-specific block. Returns: :obj:`Tuple(numeric)`: Output the numeric values of the coordinates in a Tuple of size four. """ return self.block.coordinates @property def points(self): """ Return the coordinates of all four corners of the shape-specific block in a clockwise fashion starting from the upper left. Returns: :obj:`Numpy array`: A Numpy array of shape 4x2 containing the coordinates. """ return self.block.points @property def area(self): """ Return the area of associated block. """ return self.block.area
[docs] @mixin_textblock_meta def condition_on(self, other): return self.block.condition_on(other)
[docs] @mixin_textblock_meta def relative_to(self, other): return self.block.relative_to(other)
[docs] def is_in(self, other, soft_margin={}, center=False): return self.block.is_in(other, soft_margin, center)
[docs] @mixin_textblock_meta def union(self, other: BaseCoordElement, strict: bool = True): return self.block.union(other, strict=strict)
[docs] @mixin_textblock_meta def intersect(self, other: BaseCoordElement, strict: bool = True): return self.block.intersect(other, strict=strict)
[docs] @mixin_textblock_meta def shift(self, shift_distance): return self.block.shift(shift_distance)
[docs] @mixin_textblock_meta def pad(self, left=0, right=0, top=0, bottom=0, safe_mode=True): return self.block.pad(left, right, top, bottom, safe_mode)
[docs] @mixin_textblock_meta def scale(self, scale_factor): return self.block.scale(scale_factor)
[docs] def crop_image(self, image): return self.block.crop_image(image)
[docs] def to_interval(self, axis: Optional[str] = None, **kwargs): if isinstance(self.block, Interval): return self else: if not axis: raise ValueError( f"Please provide valid `axis` values {'x' or 'y'} as the input" ) return self.set(block=self.block.to_interval(axis=axis, **kwargs))
[docs] def to_rectangle(self): if isinstance(self.block, Rectangle): return self else: return self.set(block=self.block.to_rectangle())
[docs] def to_quadrilateral(self): if isinstance(self.block, Quadrilateral): return self else: return self.set(block=self.block.to_quadrilateral())
[docs] def to_dict(self) -> Dict[str, Any]: """ Generate a dictionary representation of the current textblock of the format:: { "block_type": <name of self.block>, <attributes of self.block combined with non-empty self._features> } """ base_dict = self.block.to_dict() for f in self._features: val = getattr(self, f) if val is not None: base_dict[f] = getattr(self, f) return base_dict
[docs] @classmethod def from_dict(cls, data: Dict[str, Any]) -> "TextBlock": """Initialize the textblock based on the dictionary representation. It generate the block based on the `block_type` and `block_attr`, and loads the textblock specific features from the dict. Args: data (:obj:`dict`): The dictionary representation of the object """ assert ( data["block_type"] in BASECOORD_ELEMENT_NAMEMAP ), f"Invalid block_type {data['block_type']}" block = BASECOORD_ELEMENT_NAMEMAP[data["block_type"]].from_dict(data) return cls(block, **{f: data.get(f, None) for f in cls._features})