hoang.nguyen6
deploy
f66643d unverified
Raw
History Blame Contribute Delete
9.4 kB
"""Bounding box conversion and manipulation utilities.
This module handles coordinate conversion between Surya's model output
coordinates and fitz's PDF-point coordinates.
Coordinate spaces
-----------------
Surya produces three distinct coordinate spaces:
1. **PIL image space** — the raw PIL Image rendered from the PDF (via fitz or
embedded image extraction). This is what you pass into the predictor as
``List[Image.Image]``.
2. **image_processor numpy space** — after ``processor.image_processor()``
resizes the PIL image to the model's ``max_size`` and converts it to a
float32 numpy array. ``LayoutResult.image_bbox`` and
``LayoutBox.polygon`` (and therefore ``LayoutBox.bbox``) live in *this*
space.
3. **OCR image space** — Surya's recognition predictor keeps text-line polygons
in the *PIL image* pixel space. ``OCRResult.image_bbox`` and
``TextLine.bbox`` are in this space.
4. **PDF point space** — the target coordinate system for ``ElementData.bbox_pdf``.
Both axes have top-left origin with Y increasing downward; only scaling is
needed (no Y-axis flip).
Key assumptions:
- All four spaces share top-left origin with Y increasing downward
- No Y-axis flip is required between any two spaces, only scaling
- The correct image dimensions to use for layout→PDF scaling come from
``layout_result.image_bbox``, NOT from the PIL image size
"""
from __future__ import annotations
def convert_bbox(
surya_bbox: list[float] | tuple[float, ...],
image_width: float,
image_height: float,
pdf_width: float,
pdf_height: float,
pad_right: float = 0.0, # Thêm padding bên phải (đơn vị: points)
pad_bottom: float = 0.0, # Thêm padding bên dưới (đơn vị: points)
) -> list[float]:
if image_width <= 0 or image_height <= 0:
raise ValueError(f"Invalid image dimensions: {image_width}x{image_height}")
sx0, sy0, sx1, sy1 = surya_bbox
# Scale factors
scale_x = pdf_width / image_width
scale_y = pdf_height / image_height
# Convert coordinates và cộng padding trực tiếp vào x1, y1
x0 = sx0 * scale_x
y0 = sy0 * scale_y
x1 = (sx1 * scale_x) + pad_right
y1 = (sy1 * scale_y) + pad_bottom
# Giới hạn tọa độ không vượt quá kích thước trang PDF
x1 = min(x1, pdf_width)
y1 = min(y1, pdf_height)
return [x0, y0, x1, y1]
def polygon_to_bbox(polygon: list[list[float]]) -> list[float]:
"""Convert a Surya polygon to an axis-aligned bounding box.
Surya returns ``PolygonBox`` objects whose ``polygon`` field contains four
corners that may be slightly skewed (non-axis-aligned). For coordinate
conversion we need the axis-aligned envelope, which is what
``PolygonBox.bbox`` also computes.
Args:
polygon: 4-corner polygon as [[x0,y0],[x1,y1],[x2,y2],[x3,y3]]
Returns:
[x_min, y_min, x_max, y_max] axis-aligned bbox
"""
xs = [p[0] for p in polygon]
ys = [p[1] for p in polygon]
return [min(xs), min(ys), max(xs), max(ys)]
def image_bbox_to_pdf(
surya_bbox: list[float] | tuple[float, ...],
image_bbox: list[float],
pdf_width: float,
pdf_height: float,
pad_right: float = 0.0, # Thêm padding bên phải (đơn vị: points)
pad_bottom: float = 0.0, # Thêm padding bên dưới (đơn vị: points)
) -> list[float]:
"""Scale a bbox from Surya's ``result.image_bbox`` space to PDF points.
Surya's layout model (and other foundation-model-based predictors) internally
resizes the input PIL image to a fixed ``max_size`` via ``image_processor``
before running inference. The output polygon/bbox coordinates are therefore
in that *resized numpy array* space, not in the original PIL image space.
``LayoutResult.image_bbox`` (and ``OCRResult.image_bbox``) records the
actual dimensions used: ``[0, 0, W, H]``.
This function uses those recorded dimensions to compute the correct scale
factor, avoiding the off-by-scale bug that occurs when you use the PIL
image's ``.size`` instead.
Args:
surya_bbox: [x0, y0, x1, y1] in ``result.image_bbox`` coordinate space
image_bbox: Surya result ``image_bbox`` field, e.g. ``[0, 0, 768, 768]``
pdf_width: Target PDF page width in points
pdf_height: Target PDF page height in points
Returns:
[x0, y0, x1, y1] in PDF points
"""
# image_bbox = [0, 0, image_w, image_h]
_, _, iw, ih = image_bbox
return convert_bbox(
surya_bbox, iw, ih, pdf_width, pdf_height, pad_right, pad_bottom
)
def clamp_bbox(
bbox: list[float],
page_width: float,
page_height: float,
) -> list[float]:
"""Clamp bbox coordinates to page bounds.
Ensures the bbox fits within [0, 0, page_width, page_height].
Args:
bbox: [x0, y0, x1, y1] in PDF points
page_width: Maximum x coordinate
page_height: Maximum y coordinate
Returns:
Clamped [x0, y0, x1, y1]
"""
x0, y0, x1, y1 = bbox
x0 = max(0.0, min(x0, page_width))
y0 = max(0.0, min(y0, page_height))
x1 = max(0.0, min(x1, page_width))
y1 = max(0.0, min(y1, page_height))
return [x0, y0, x1, y1]
def offset_bbox(
bbox: list[float],
offset_x: float,
offset_y: float,
) -> list[float]:
"""Apply offset to bbox coordinates.
Used for converting cell coordinates from table-relative to page-absolute.
Args:
bbox: [x0, y0, x1, y1] in any coordinate space
offset_x: X offset to add
offset_y: Y offset to add
Returns:
Offset [x0, y0, x1, y1]
"""
x0, y0, x1, y1 = bbox
return [
x0 + offset_x,
y0 + offset_y,
x1 + offset_x,
y1 + offset_y,
]
def is_degenerate(bbox: list[float], min_size: float = 0.1) -> bool:
"""Check if bbox is degenerate (zero or negative area).
A bbox is degenerate if:
- x0 >= x1 (no width)
- y0 >= y1 (no height)
- Width or height is less than min_size
Args:
bbox: [x0, y0, x1, y1]
min_size: Minimum acceptable dimension
Returns:
True if bbox is degenerate
"""
x0, y0, x1, y1 = bbox
width = x1 - x0
height = y1 - y0
return width < min_size or height < min_size
def normalize_bbox(bbox: list[float]) -> list[float]:
"""Ensure bbox has x0 < x1 and y0 < y1 by swapping if needed.
Args:
bbox: [x0, y0, x1, y1] possibly with inverted coordinates
Returns:
Normalized [x0, y0, x1, y1] with x0 <= x1 and y0 <= y1
"""
x0, y0, x1, y1 = bbox
if x0 > x1:
x0, x1 = x1, x0
if y0 > y1:
y0, y1 = y1, y0
return [x0, y0, x1, y1]
def bbox_area(bbox: list[float]) -> float:
"""Calculate bbox area.
Args:
bbox: [x0, y0, x1, y1]
Returns:
Area (width * height), or 0 if degenerate
"""
x0, y0, x1, y1 = bbox
width = max(0.0, x1 - x0)
height = max(0.0, y1 - y0)
return width * height
def bbox_intersection(
bbox1: list[float],
bbox2: list[float],
) -> list[float] | None:
"""Calculate intersection of two bboxes.
Args:
bbox1: First [x0, y0, x1, y1]
bbox2: Second [x0, y0, x1, y1]
Returns:
Intersection bbox, or None if no intersection
"""
x0 = max(bbox1[0], bbox2[0])
y0 = max(bbox1[1], bbox2[1])
x1 = min(bbox1[2], bbox2[2])
y1 = min(bbox1[3], bbox2[3])
if x0 >= x1 or y0 >= y1:
return None
return [x0, y0, x1, y1]
def bbox_iou(bbox1: list[float], bbox2: list[float]) -> float:
"""Calculate Intersection over Union (IoU) of two bboxes.
Args:
bbox1: First [x0, y0, x1, y1]
bbox2: Second [x0, y0, x1, y1]
Returns:
IoU value between 0 and 1
"""
intersection = bbox_intersection(bbox1, bbox2)
if intersection is None:
return 0.0
inter_area = bbox_area(intersection)
area1 = bbox_area(bbox1)
area2 = bbox_area(bbox2)
union_area = area1 + area2 - inter_area
if union_area <= 0:
return 0.0
return inter_area / union_area
def bbox_union_area(bboxes: list[list[float]]) -> float:
"""Calculate the union area of multiple axis-aligned bboxes."""
valid_bboxes = [bbox for bbox in bboxes if bbox_area(bbox) > 0]
if not valid_bboxes:
return 0.0
x_points = sorted(
{bbox[0] for bbox in valid_bboxes} | {bbox[2] for bbox in valid_bboxes}
)
if len(x_points) < 2:
return 0.0
total_area = 0.0
for x0, x1 in zip(x_points, x_points[1:]):
if x1 <= x0:
continue
y_intervals: list[tuple[float, float]] = []
for bbox in valid_bboxes:
bx0, by0, bx1, by1 = bbox
if bx0 < x1 and bx1 > x0:
y_intervals.append((by0, by1))
if not y_intervals:
continue
y_intervals.sort()
covered_height = 0.0
current_y0, current_y1 = y_intervals[0]
for next_y0, next_y1 in y_intervals[1:]:
if next_y0 <= current_y1:
current_y1 = max(current_y1, next_y1)
continue
covered_height += current_y1 - current_y0
current_y0, current_y1 = next_y0, next_y1
covered_height += current_y1 - current_y0
total_area += (x1 - x0) * covered_height
return total_area