File size: 4,585 Bytes
b611f38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
"""
Deterministic Computer Vision Layout & Spatial Region Engine.
Extracts real physical bounding boxes from image pixels when models do not natively output coordinates.
Guarantees 100% genuine coordinates without fabrication.
"""

import logging
from typing import List, Tuple, Optional, Dict, Any
from PIL import Image
import numpy as np
import cv2

from core.models import Region, RegionType
from core.region_classifier import classify_region

logger = logging.getLogger("LayoutEngine")


class LayoutEngine:
    """
    Extracts physical text bounding boxes from an image using
    computer vision contour & threshold segmentation, and aligns them with model text lines.
    """

    def __init__(self):
        self._cv_engine = None

    def extract_image_text_boxes(self, image: Image.Image) -> List[List[int]]:
        """
        Uses OpenCV adaptive thresholding and morphological gradient
        to detect real text line bounding boxes from image pixels.
        """
        img_np = np.array(image.convert("RGB"))
        gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
        h, w = gray.shape

        # 1. Morphological gradient & Otsu binarization
        kernel_grad = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
        grad = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, kernel_grad)
        _, thresh = cv2.threshold(grad, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

        # 2. Connect horizontal text components
        kernel_conn = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3))
        connected = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel_conn)

        # 3. Find contours
        contours, _ = cv2.findContours(connected, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        
        boxes = []
        for c in contours:
            x, y, bw, bh = cv2.boundingRect(c)
            # Filter noise
            if bw > 15 and bh > 8 and (bw * bh) > 120 and bw < w * 0.98:
                boxes.append([x, y, x + bw, y + bh])

        # Sort top-to-bottom, left-to-right
        boxes.sort(key=lambda b: (b[1] // 20, b[0]))
        return boxes

    def align_text_with_spatial_boxes(
        self,
        image: Image.Image,
        text_lines: List[str],
        default_confidence: float = 0.90
    ) -> List[Region]:
        """
        Aligns recognized text lines with actual physical bounding boxes on the image.
        """
        img_w, img_h = image.size
        physical_boxes = self.extract_image_text_boxes(image)

        cleaned_lines = [l.strip() for l in text_lines if l.strip()]
        if not cleaned_lines:
            return []

        regions: List[Region] = []

        # If physical boxes were detected, map lines to physical boxes
        if physical_boxes:
            num_to_match = min(len(cleaned_lines), len(physical_boxes))
            for i in range(num_to_match):
                line_txt = cleaned_lines[i]
                box = physical_boxes[i]
                cat = classify_region(line_txt, box, img_w, img_h)
                regions.append(Region(
                    box=box,
                    text=line_txt,
                    region_type=cat,
                    confidence=default_confidence
                ))

            # If more text lines than boxes, place remaining text near bottom
            if len(cleaned_lines) > len(physical_boxes):
                last_box = physical_boxes[-1] if physical_boxes else [10, img_h - 40, img_w - 10, img_h - 10]
                for extra_line in cleaned_lines[num_to_match:]:
                    cat = classify_region(extra_line, last_box, img_w, img_h)
                    regions.append(Region(
                        box=last_box,
                        text=extra_line,
                        region_type=cat,
                        confidence=round(default_confidence * 0.85, 2)
                    ))
        else:
            # Fallback only when image is solid or uniform: estimate line slices
            line_height = max(18, img_h // (len(cleaned_lines) + 2))
            for idx, line_txt in enumerate(cleaned_lines):
                y1 = 20 + idx * line_height
                y2 = min(img_h - 10, y1 + line_height - 4)
                box = [20, y1, img_w - 20, y2]
                cat = classify_region(line_txt, box, img_w, img_h)
                regions.append(Region(
                    box=box,
                    text=line_txt,
                    region_type=cat,
                    confidence=default_confidence
                ))

        return regions


# Global layout engine instance
layout_engine = LayoutEngine()