natural-pdf 0.1.12__py3-none-any.whl → 0.1.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- natural_pdf/analyzers/shape_detection_mixin.py +1373 -0
- natural_pdf/classification/manager.py +2 -3
- natural_pdf/collections/pdf_collection.py +19 -39
- natural_pdf/core/highlighting_service.py +29 -38
- natural_pdf/core/page.py +284 -187
- natural_pdf/core/pdf.py +4 -4
- natural_pdf/elements/base.py +54 -20
- natural_pdf/elements/collections.py +160 -9
- natural_pdf/elements/line.py +5 -0
- natural_pdf/elements/region.py +380 -38
- natural_pdf/exporters/paddleocr.py +51 -11
- natural_pdf/flows/__init__.py +12 -0
- natural_pdf/flows/collections.py +533 -0
- natural_pdf/flows/element.py +382 -0
- natural_pdf/flows/flow.py +216 -0
- natural_pdf/flows/region.py +458 -0
- natural_pdf/selectors/parser.py +163 -8
- {natural_pdf-0.1.12.dist-info → natural_pdf-0.1.14.dist-info}/METADATA +2 -1
- {natural_pdf-0.1.12.dist-info → natural_pdf-0.1.14.dist-info}/RECORD +22 -17
- {natural_pdf-0.1.12.dist-info → natural_pdf-0.1.14.dist-info}/WHEEL +1 -1
- natural_pdf/utils/tqdm_utils.py +0 -51
- {natural_pdf-0.1.12.dist-info → natural_pdf-0.1.14.dist-info}/licenses/LICENSE +0 -0
- {natural_pdf-0.1.12.dist-info → natural_pdf-0.1.14.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1373 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
|
3
|
+
|
4
|
+
import numpy as np
|
5
|
+
from PIL import Image, ImageDraw
|
6
|
+
from scipy.signal import find_peaks
|
7
|
+
from scipy.ndimage import gaussian_filter1d, binary_opening, binary_closing
|
8
|
+
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
from natural_pdf.core.page import Page
|
11
|
+
from natural_pdf.core.pdf import PDF
|
12
|
+
from natural_pdf.elements.collections import ElementCollection, PageCollection
|
13
|
+
from natural_pdf.elements.line import LineElement
|
14
|
+
# from natural_pdf.elements.rect import RectangleElement # Removed
|
15
|
+
from natural_pdf.elements.region import Region
|
16
|
+
|
17
|
+
logger = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
# Constants for default values of less commonly adjusted line detection parameters
|
20
|
+
LINE_DETECTION_PARAM_DEFAULTS = {
|
21
|
+
"binarization_method": "adaptive",
|
22
|
+
"adaptive_thresh_block_size": 21,
|
23
|
+
"adaptive_thresh_C_val": 5,
|
24
|
+
"morph_op_h": "none",
|
25
|
+
"morph_kernel_h": (1, 2), # Kernel as (columns, rows)
|
26
|
+
"morph_op_v": "none",
|
27
|
+
"morph_kernel_v": (2, 1), # Kernel as (columns, rows)
|
28
|
+
"smoothing_sigma_h": 0.6,
|
29
|
+
"smoothing_sigma_v": 0.6,
|
30
|
+
"peak_width_rel_height": 0.5,
|
31
|
+
}
|
32
|
+
|
33
|
+
class ShapeDetectionMixin:
|
34
|
+
"""
|
35
|
+
Mixin class to provide shape detection capabilities (lines)
|
36
|
+
for Page, Region, PDFCollection, and PageCollection objects.
|
37
|
+
"""
|
38
|
+
|
39
|
+
def _get_image_for_detection(self, resolution: int) -> Tuple[Optional[np.ndarray], float, Tuple[float, float], Optional['Page']]:
|
40
|
+
"""
|
41
|
+
Gets the image for detection, scale factor, PDF origin offset, and the relevant page object.
|
42
|
+
|
43
|
+
Returns:
|
44
|
+
Tuple containing:
|
45
|
+
- cv_image (np.ndarray, optional): The OpenCV image array.
|
46
|
+
- scale_factor (float): Factor to convert image pixels to PDF points.
|
47
|
+
- origin_offset_pdf (Tuple[float, float]): (x0, top) offset in PDF points.
|
48
|
+
- page_obj (Page, optional): The page object this detection pertains to.
|
49
|
+
"""
|
50
|
+
pil_image = None
|
51
|
+
page_obj = None
|
52
|
+
origin_offset_pdf = (0.0, 0.0)
|
53
|
+
|
54
|
+
# Determine the type of self and get the appropriate image and page context
|
55
|
+
if hasattr(self, 'to_image') and hasattr(self, 'width') and hasattr(self, 'height'): # Page or Region
|
56
|
+
if hasattr(self, 'x0') and hasattr(self, 'top') and hasattr(self, '_page'): # Region
|
57
|
+
logger.debug(f"Shape detection on Region: {self}")
|
58
|
+
page_obj = self._page
|
59
|
+
pil_image = self.to_image(resolution=resolution, crop_only=True, include_highlights=False)
|
60
|
+
if pil_image: # Ensure pil_image is not None before accessing attributes
|
61
|
+
origin_offset_pdf = (self.x0, self.top)
|
62
|
+
logger.debug(f"Region image rendered successfully: {pil_image.width}x{pil_image.height}, origin_offset: {origin_offset_pdf}")
|
63
|
+
else: # Page
|
64
|
+
logger.debug(f"Shape detection on Page: {self}")
|
65
|
+
page_obj = self
|
66
|
+
pil_image = self.to_image(resolution=resolution, include_highlights=False)
|
67
|
+
logger.debug(f"Page image rendered successfully: {pil_image.width}x{pil_image.height}")
|
68
|
+
else:
|
69
|
+
logger.error(f"Instance of type {type(self)} does not support to_image for detection.")
|
70
|
+
return None, 1.0, (0.0, 0.0), None
|
71
|
+
|
72
|
+
if not pil_image:
|
73
|
+
logger.warning("Failed to render image for shape detection.")
|
74
|
+
return None, 1.0, (0.0, 0.0), page_obj
|
75
|
+
|
76
|
+
if pil_image.mode != "RGB":
|
77
|
+
pil_image = pil_image.convert("RGB")
|
78
|
+
cv_image = np.array(pil_image)
|
79
|
+
|
80
|
+
# Calculate scale_factor: points_per_pixel
|
81
|
+
# For a Page, self.width/height are PDF points. pil_image.width/height are pixels.
|
82
|
+
# For a Region, self.width/height are PDF points of the region. pil_image.width/height are pixels of the cropped image.
|
83
|
+
# The scale factor should always relate the dimensions of the *processed image* to the *PDF dimensions* of that same area.
|
84
|
+
|
85
|
+
if page_obj and pil_image.width > 0 and pil_image.height > 0:
|
86
|
+
# If it's a region, its self.width/height are its dimensions in PDF points.
|
87
|
+
# pil_image.width/height are the pixel dimensions of the cropped image of that region.
|
88
|
+
# So, the scale factor remains consistent.
|
89
|
+
# We need to convert pixel distances on the image back to PDF point distances.
|
90
|
+
# If 100 PDF points span 200 pixels, then 1 pixel = 0.5 PDF points. scale_factor = points/pixels
|
91
|
+
# Example: Page width 500pt, image width 1000px. Scale = 500/1000 = 0.5 pt/px
|
92
|
+
# Region width 50pt, cropped image width 100px. Scale = 50/100 = 0.5 pt/px
|
93
|
+
|
94
|
+
# Use self.width/height for scale factor calculation because these correspond to the PDF dimensions of the area imaged.
|
95
|
+
# This ensures that if self is a Region, its specific dimensions are used for scaling its own cropped image.
|
96
|
+
|
97
|
+
# We need two scale factors if aspect ratio is not preserved by to_image,
|
98
|
+
# but to_image generally aims to preserve it when only resolution is changed.
|
99
|
+
# Assuming uniform scaling for now.
|
100
|
+
# A robust way: scale_x = self.width / pil_image.width; scale_y = self.height / pil_image.height
|
101
|
+
# For simplicity, let's assume uniform scaling or average it.
|
102
|
+
# Average scale factor:
|
103
|
+
scale_factor = ( (self.width / pil_image.width) + (self.height / pil_image.height) ) / 2.0
|
104
|
+
logger.debug(f"Calculated scale_factor: {scale_factor:.4f} (PDF dimensions: {self.width:.1f}x{self.height:.1f}, Image: {pil_image.width}x{pil_image.height})")
|
105
|
+
|
106
|
+
else:
|
107
|
+
logger.warning("Could not determine page object or image dimensions for scaling.")
|
108
|
+
scale_factor = 1.0 # Default to no scaling if info is missing
|
109
|
+
|
110
|
+
return cv_image, scale_factor, origin_offset_pdf, page_obj
|
111
|
+
|
112
|
+
|
113
|
+
|
114
|
+
|
115
|
+
def _convert_line_to_element_data(
|
116
|
+
self, line_data_img: Dict, scale_factor: float, origin_offset_pdf: Tuple[float, float], page_obj: 'Page', source_label: str
|
117
|
+
) -> Dict:
|
118
|
+
"""Converts line data from image coordinates to PDF element data."""
|
119
|
+
# Ensure scale_factor is not zero to prevent division by zero or incorrect scaling
|
120
|
+
if scale_factor == 0:
|
121
|
+
logger.warning("Scale factor is zero, cannot convert line coordinates correctly.")
|
122
|
+
# Return something or raise error, for now, try to proceed with unscaled if possible (won't be right)
|
123
|
+
# This situation ideally shouldn't happen if _get_image_for_detection is robust.
|
124
|
+
effective_scale = 1.0
|
125
|
+
else:
|
126
|
+
effective_scale = scale_factor
|
127
|
+
|
128
|
+
x0 = origin_offset_pdf[0] + line_data_img['x1'] * effective_scale
|
129
|
+
top = origin_offset_pdf[1] + line_data_img['y1'] * effective_scale
|
130
|
+
x1 = origin_offset_pdf[0] + line_data_img['x2'] * effective_scale
|
131
|
+
bottom = origin_offset_pdf[1] + line_data_img['y2'] * effective_scale # y2 is the second y-coord
|
132
|
+
|
133
|
+
# For lines, width attribute in PDF points
|
134
|
+
line_width_pdf = line_data_img['width'] * effective_scale
|
135
|
+
|
136
|
+
# initial_doctop might not be loaded if page object is minimal
|
137
|
+
initial_doctop = getattr(page_obj._page, 'initial_doctop', 0) if hasattr(page_obj, '_page') else 0
|
138
|
+
|
139
|
+
return {
|
140
|
+
"x0": x0, "top": top, "x1": x1, "bottom": bottom, # bottom here is y2_pdf
|
141
|
+
"width": abs(x1 - x0), # This is bounding box width
|
142
|
+
"height": abs(bottom - top), # This is bounding box height
|
143
|
+
"linewidth": line_width_pdf, # Actual stroke width of the line
|
144
|
+
"object_type": "line",
|
145
|
+
"page_number": page_obj.page_number,
|
146
|
+
"doctop": top + initial_doctop,
|
147
|
+
"source": source_label,
|
148
|
+
"stroking_color": (0,0,0), # Default, can be enhanced
|
149
|
+
"non_stroking_color": (0,0,0), # Default
|
150
|
+
# Add other raw data if useful
|
151
|
+
"raw_line_thickness_px": line_data_img.get('line_thickness_px'), # Renamed from raw_nfa_score
|
152
|
+
"raw_line_position_px": line_data_img.get('line_position_px'), # Added for clarity
|
153
|
+
}
|
154
|
+
|
155
|
+
def _find_lines_on_image_data(
|
156
|
+
self,
|
157
|
+
cv_image: np.ndarray,
|
158
|
+
pil_image_rgb: Image.Image, # For original dimensions
|
159
|
+
horizontal: bool = True,
|
160
|
+
vertical: bool = True,
|
161
|
+
peak_threshold_h: float = 0.5,
|
162
|
+
min_gap_h: int = 5,
|
163
|
+
peak_threshold_v: float = 0.5,
|
164
|
+
min_gap_v: int = 5,
|
165
|
+
max_lines_h: Optional[int] = None,
|
166
|
+
max_lines_v: Optional[int] = None,
|
167
|
+
binarization_method: str = LINE_DETECTION_PARAM_DEFAULTS["binarization_method"],
|
168
|
+
adaptive_thresh_block_size: int = LINE_DETECTION_PARAM_DEFAULTS["adaptive_thresh_block_size"],
|
169
|
+
adaptive_thresh_C_val: int = LINE_DETECTION_PARAM_DEFAULTS["adaptive_thresh_C_val"],
|
170
|
+
morph_op_h: str = LINE_DETECTION_PARAM_DEFAULTS["morph_op_h"],
|
171
|
+
morph_kernel_h: Tuple[int, int] = LINE_DETECTION_PARAM_DEFAULTS["morph_kernel_h"],
|
172
|
+
morph_op_v: str = LINE_DETECTION_PARAM_DEFAULTS["morph_op_v"],
|
173
|
+
morph_kernel_v: Tuple[int, int] = LINE_DETECTION_PARAM_DEFAULTS["morph_kernel_v"],
|
174
|
+
smoothing_sigma_h: float = LINE_DETECTION_PARAM_DEFAULTS["smoothing_sigma_h"],
|
175
|
+
smoothing_sigma_v: float = LINE_DETECTION_PARAM_DEFAULTS["smoothing_sigma_v"],
|
176
|
+
peak_width_rel_height: float = LINE_DETECTION_PARAM_DEFAULTS["peak_width_rel_height"],
|
177
|
+
) -> Tuple[List[Dict], Optional[np.ndarray], Optional[np.ndarray]]:
|
178
|
+
"""
|
179
|
+
Core image processing logic to detect lines using projection profiling.
|
180
|
+
Returns raw line data (image coordinates) and smoothed profiles.
|
181
|
+
"""
|
182
|
+
if cv_image is None:
|
183
|
+
return [], None, None
|
184
|
+
|
185
|
+
# Convert RGB to grayscale using numpy (faster than PIL)
|
186
|
+
# Using standard luminance weights: 0.299*R + 0.587*G + 0.114*B
|
187
|
+
if len(cv_image.shape) == 3:
|
188
|
+
gray_image = np.dot(cv_image[...,:3], [0.299, 0.587, 0.114]).astype(np.uint8)
|
189
|
+
else:
|
190
|
+
gray_image = cv_image # Already grayscale
|
191
|
+
|
192
|
+
img_height, img_width = gray_image.shape
|
193
|
+
logger.debug(f"Line detection - Image dimensions: {img_width}x{img_height}")
|
194
|
+
|
195
|
+
def otsu_threshold(image):
|
196
|
+
"""Simple Otsu's thresholding implementation using numpy."""
|
197
|
+
# Calculate histogram
|
198
|
+
hist, _ = np.histogram(image.flatten(), bins=256, range=(0, 256))
|
199
|
+
hist = hist.astype(float)
|
200
|
+
|
201
|
+
# Calculate probabilities
|
202
|
+
total_pixels = image.size
|
203
|
+
current_max = 0
|
204
|
+
threshold = 0
|
205
|
+
sum_total = np.sum(np.arange(256) * hist)
|
206
|
+
sum_background = 0
|
207
|
+
weight_background = 0
|
208
|
+
|
209
|
+
for i in range(256):
|
210
|
+
weight_background += hist[i]
|
211
|
+
if weight_background == 0:
|
212
|
+
continue
|
213
|
+
|
214
|
+
weight_foreground = total_pixels - weight_background
|
215
|
+
if weight_foreground == 0:
|
216
|
+
break
|
217
|
+
|
218
|
+
sum_background += i * hist[i]
|
219
|
+
mean_background = sum_background / weight_background
|
220
|
+
mean_foreground = (sum_total - sum_background) / weight_foreground
|
221
|
+
|
222
|
+
# Calculate between-class variance
|
223
|
+
variance_between = weight_background * weight_foreground * (mean_background - mean_foreground) ** 2
|
224
|
+
|
225
|
+
if variance_between > current_max:
|
226
|
+
current_max = variance_between
|
227
|
+
threshold = i
|
228
|
+
|
229
|
+
return threshold
|
230
|
+
|
231
|
+
def adaptive_threshold(image, block_size, C):
|
232
|
+
"""Simple adaptive thresholding implementation."""
|
233
|
+
# Use scipy for gaussian filtering
|
234
|
+
from scipy.ndimage import gaussian_filter
|
235
|
+
|
236
|
+
# Calculate local means using gaussian filter
|
237
|
+
sigma = block_size / 6.0 # Approximate relationship
|
238
|
+
local_mean = gaussian_filter(image.astype(float), sigma=sigma)
|
239
|
+
|
240
|
+
# Apply threshold
|
241
|
+
binary = (image > (local_mean - C)).astype(np.uint8) * 255
|
242
|
+
return 255 - binary # Invert to match binary inverse thresholding
|
243
|
+
|
244
|
+
if binarization_method == "adaptive":
|
245
|
+
binarized_image = adaptive_threshold(gray_image, adaptive_thresh_block_size, adaptive_thresh_C_val)
|
246
|
+
elif binarization_method == "otsu":
|
247
|
+
otsu_thresh_val = otsu_threshold(gray_image)
|
248
|
+
binarized_image = (gray_image <= otsu_thresh_val).astype(np.uint8) * 255 # Inverted binary
|
249
|
+
logger.debug(f"Otsu's threshold applied. Value: {otsu_thresh_val}")
|
250
|
+
else:
|
251
|
+
logger.error(f"Invalid binarization_method: {binarization_method}. Supported: 'otsu', 'adaptive'. Defaulting to 'otsu'.")
|
252
|
+
otsu_thresh_val = otsu_threshold(gray_image)
|
253
|
+
binarized_image = (gray_image <= otsu_thresh_val).astype(np.uint8) * 255 # Inverted binary
|
254
|
+
|
255
|
+
binarized_norm = binarized_image.astype(float) / 255.0
|
256
|
+
|
257
|
+
detected_lines_data = []
|
258
|
+
profile_h_smoothed_for_viz: Optional[np.ndarray] = None
|
259
|
+
profile_v_smoothed_for_viz: Optional[np.ndarray] = None
|
260
|
+
|
261
|
+
def get_lines_from_profile(
|
262
|
+
profile_data: np.ndarray,
|
263
|
+
max_dimension_for_ratio: int,
|
264
|
+
params_key_suffix: str,
|
265
|
+
is_horizontal_detection: bool
|
266
|
+
) -> Tuple[List[Dict], np.ndarray]: # Ensure it always returns profile_smoothed
|
267
|
+
lines_info = []
|
268
|
+
sigma = smoothing_sigma_h if is_horizontal_detection else smoothing_sigma_v
|
269
|
+
profile_smoothed = gaussian_filter1d(profile_data.astype(float), sigma=sigma)
|
270
|
+
|
271
|
+
peak_threshold = peak_threshold_h if is_horizontal_detection else peak_threshold_v
|
272
|
+
min_gap = min_gap_h if is_horizontal_detection else min_gap_v
|
273
|
+
max_lines = max_lines_h if is_horizontal_detection else max_lines_v
|
274
|
+
|
275
|
+
current_peak_height_threshold = peak_threshold * max_dimension_for_ratio
|
276
|
+
find_peaks_distance = min_gap
|
277
|
+
|
278
|
+
if max_lines is not None:
|
279
|
+
current_peak_height_threshold = 1.0
|
280
|
+
find_peaks_distance = 1
|
281
|
+
|
282
|
+
candidate_peaks_indices, candidate_properties = find_peaks(
|
283
|
+
profile_smoothed, height=current_peak_height_threshold, distance=find_peaks_distance,
|
284
|
+
width=1, prominence=1, rel_height=peak_width_rel_height
|
285
|
+
)
|
286
|
+
|
287
|
+
final_peaks_indices = candidate_peaks_indices
|
288
|
+
final_properties = candidate_properties
|
289
|
+
|
290
|
+
if max_lines is not None:
|
291
|
+
if len(candidate_peaks_indices) > 0 and 'prominences' in candidate_properties:
|
292
|
+
prominences = candidate_properties["prominences"]
|
293
|
+
sorted_candidate_indices_by_prominence = np.argsort(prominences)[::-1]
|
294
|
+
selected_peaks_original_indices = []
|
295
|
+
suppressed_profile_indices = np.zeros(len(profile_smoothed), dtype=bool)
|
296
|
+
num_selected = 0
|
297
|
+
for original_idx_in_candidate_list in sorted_candidate_indices_by_prominence:
|
298
|
+
actual_profile_idx = candidate_peaks_indices[original_idx_in_candidate_list]
|
299
|
+
if not suppressed_profile_indices[actual_profile_idx]:
|
300
|
+
selected_peaks_original_indices.append(original_idx_in_candidate_list)
|
301
|
+
num_selected += 1
|
302
|
+
lower_bound = max(0, actual_profile_idx - min_gap)
|
303
|
+
upper_bound = min(len(profile_smoothed), actual_profile_idx + min_gap + 1)
|
304
|
+
suppressed_profile_indices[lower_bound:upper_bound] = True
|
305
|
+
if num_selected >= max_lines: break
|
306
|
+
final_peaks_indices = candidate_peaks_indices[selected_peaks_original_indices]
|
307
|
+
final_properties = {key: val_array[selected_peaks_original_indices] for key, val_array in candidate_properties.items()}
|
308
|
+
logger.debug(f"Selected {len(final_peaks_indices)} {params_key_suffix.upper()}-lines for max_lines={max_lines}.")
|
309
|
+
else:
|
310
|
+
final_peaks_indices = np.array([])
|
311
|
+
final_properties = {}
|
312
|
+
logger.debug(f"No {params_key_suffix.upper()}-peaks for max_lines selection.")
|
313
|
+
elif not final_peaks_indices.size:
|
314
|
+
final_properties = {}
|
315
|
+
logger.debug(f"No {params_key_suffix.upper()}-lines found using threshold.")
|
316
|
+
else:
|
317
|
+
logger.debug(f"Found {len(final_peaks_indices)} {params_key_suffix.upper()}-lines using threshold.")
|
318
|
+
|
319
|
+
if final_peaks_indices.size > 0:
|
320
|
+
sort_order = np.argsort(final_peaks_indices)
|
321
|
+
final_peaks_indices = final_peaks_indices[sort_order]
|
322
|
+
for key in final_properties: final_properties[key] = final_properties[key][sort_order]
|
323
|
+
|
324
|
+
for i, peak_idx in enumerate(final_peaks_indices):
|
325
|
+
center_coord = int(peak_idx)
|
326
|
+
profile_thickness = final_properties.get("widths", [])[i] if "widths" in final_properties and i < len(final_properties["widths"]) else 1.0
|
327
|
+
profile_thickness = max(1, int(round(profile_thickness)))
|
328
|
+
|
329
|
+
current_img_width = pil_image_rgb.width # Use actual passed image dimensions
|
330
|
+
current_img_height = pil_image_rgb.height
|
331
|
+
|
332
|
+
if is_horizontal_detection:
|
333
|
+
lines_info.append({
|
334
|
+
'x1': 0, 'y1': center_coord,
|
335
|
+
'x2': current_img_width -1, 'y2': center_coord,
|
336
|
+
'width': profile_thickness,
|
337
|
+
'length': current_img_width,
|
338
|
+
'line_thickness_px': profile_thickness,
|
339
|
+
'line_position_px': center_coord
|
340
|
+
})
|
341
|
+
else:
|
342
|
+
lines_info.append({
|
343
|
+
'x1': center_coord, 'y1': 0,
|
344
|
+
'x2': center_coord, 'y2': current_img_height -1,
|
345
|
+
'width': profile_thickness,
|
346
|
+
'length': current_img_height,
|
347
|
+
'line_thickness_px': profile_thickness,
|
348
|
+
'line_position_px': center_coord
|
349
|
+
})
|
350
|
+
return lines_info, profile_smoothed
|
351
|
+
|
352
|
+
def apply_morphology(image, operation, kernel_size):
|
353
|
+
"""Apply morphological operations using scipy.ndimage."""
|
354
|
+
if operation == "none":
|
355
|
+
return image
|
356
|
+
|
357
|
+
# Create rectangular structuring element
|
358
|
+
# kernel_size is (width, height) = (cols, rows)
|
359
|
+
cols, rows = kernel_size
|
360
|
+
structure = np.ones((rows, cols)) # Note: numpy uses (rows, cols) order
|
361
|
+
|
362
|
+
# Convert to binary for morphological operations
|
363
|
+
binary_img = (image > 0.5).astype(bool)
|
364
|
+
|
365
|
+
if operation == "open":
|
366
|
+
result = binary_opening(binary_img, structure=structure)
|
367
|
+
elif operation == "close":
|
368
|
+
result = binary_closing(binary_img, structure=structure)
|
369
|
+
else:
|
370
|
+
logger.warning(f"Unknown morphological operation: {operation}. Supported: 'open', 'close', 'none'.")
|
371
|
+
result = binary_img
|
372
|
+
|
373
|
+
# Convert back to float
|
374
|
+
return result.astype(float)
|
375
|
+
|
376
|
+
if horizontal:
|
377
|
+
processed_image_h = binarized_norm.copy()
|
378
|
+
if morph_op_h != "none":
|
379
|
+
processed_image_h = apply_morphology(processed_image_h, morph_op_h, morph_kernel_h)
|
380
|
+
profile_h_raw = np.sum(processed_image_h, axis=1)
|
381
|
+
horizontal_lines, smoothed_h = get_lines_from_profile(profile_h_raw, pil_image_rgb.width, 'h', True)
|
382
|
+
profile_h_smoothed_for_viz = smoothed_h
|
383
|
+
detected_lines_data.extend(horizontal_lines)
|
384
|
+
logger.info(f"Detected {len(horizontal_lines)} horizontal lines.")
|
385
|
+
|
386
|
+
if vertical:
|
387
|
+
processed_image_v = binarized_norm.copy()
|
388
|
+
if morph_op_v != "none":
|
389
|
+
processed_image_v = apply_morphology(processed_image_v, morph_op_v, morph_kernel_v)
|
390
|
+
profile_v_raw = np.sum(processed_image_v, axis=0)
|
391
|
+
vertical_lines, smoothed_v = get_lines_from_profile(profile_v_raw, pil_image_rgb.height, 'v', False)
|
392
|
+
profile_v_smoothed_for_viz = smoothed_v
|
393
|
+
detected_lines_data.extend(vertical_lines)
|
394
|
+
logger.info(f"Detected {len(vertical_lines)} vertical lines.")
|
395
|
+
|
396
|
+
return detected_lines_data, profile_h_smoothed_for_viz, profile_v_smoothed_for_viz
|
397
|
+
|
398
|
+
def detect_lines(
|
399
|
+
self,
|
400
|
+
resolution: int = 192,
|
401
|
+
source_label: str = "detected",
|
402
|
+
method: str = "projection",
|
403
|
+
horizontal: bool = True,
|
404
|
+
vertical: bool = True,
|
405
|
+
peak_threshold_h: float = 0.5,
|
406
|
+
min_gap_h: int = 5,
|
407
|
+
peak_threshold_v: float = 0.5,
|
408
|
+
min_gap_v: int = 5,
|
409
|
+
max_lines_h: Optional[int] = None,
|
410
|
+
max_lines_v: Optional[int] = None,
|
411
|
+
replace: bool = True,
|
412
|
+
binarization_method: str = LINE_DETECTION_PARAM_DEFAULTS["binarization_method"],
|
413
|
+
adaptive_thresh_block_size: int = LINE_DETECTION_PARAM_DEFAULTS["adaptive_thresh_block_size"],
|
414
|
+
adaptive_thresh_C_val: int = LINE_DETECTION_PARAM_DEFAULTS["adaptive_thresh_C_val"],
|
415
|
+
morph_op_h: str = LINE_DETECTION_PARAM_DEFAULTS["morph_op_h"],
|
416
|
+
morph_kernel_h: Tuple[int, int] = LINE_DETECTION_PARAM_DEFAULTS["morph_kernel_h"],
|
417
|
+
morph_op_v: str = LINE_DETECTION_PARAM_DEFAULTS["morph_op_v"],
|
418
|
+
morph_kernel_v: Tuple[int, int] = LINE_DETECTION_PARAM_DEFAULTS["morph_kernel_v"],
|
419
|
+
smoothing_sigma_h: float = LINE_DETECTION_PARAM_DEFAULTS["smoothing_sigma_h"],
|
420
|
+
smoothing_sigma_v: float = LINE_DETECTION_PARAM_DEFAULTS["smoothing_sigma_v"],
|
421
|
+
peak_width_rel_height: float = LINE_DETECTION_PARAM_DEFAULTS["peak_width_rel_height"],
|
422
|
+
# LSD-specific parameters
|
423
|
+
off_angle: int = 5,
|
424
|
+
min_line_length: int = 30,
|
425
|
+
merge_angle_tolerance: int = 5,
|
426
|
+
merge_distance_tolerance: int = 3,
|
427
|
+
merge_endpoint_tolerance: int = 10,
|
428
|
+
initial_min_line_length: int = 10,
|
429
|
+
min_nfa_score_horizontal: float = -10.0,
|
430
|
+
min_nfa_score_vertical: float = -10.0,
|
431
|
+
) -> "ShapeDetectionMixin": # Return type changed back to self
|
432
|
+
"""
|
433
|
+
Detects lines on the Page or Region, or on all pages within a Collection.
|
434
|
+
Adds detected lines as LineElement objects to the ElementManager.
|
435
|
+
|
436
|
+
Args:
|
437
|
+
resolution: DPI for image rendering before detection.
|
438
|
+
source_label: Label assigned to the 'source' attribute of created LineElements.
|
439
|
+
method: Detection method - "projection" (default, no cv2 required) or "lsd" (requires opencv-python).
|
440
|
+
horizontal: If True, detect horizontal lines.
|
441
|
+
vertical: If True, detect vertical lines.
|
442
|
+
|
443
|
+
# Projection profiling parameters:
|
444
|
+
peak_threshold_h: Threshold for peak detection in horizontal profile (ratio of image width).
|
445
|
+
min_gap_h: Minimum gap between horizontal lines (pixels).
|
446
|
+
peak_threshold_v: Threshold for peak detection in vertical profile (ratio of image height).
|
447
|
+
min_gap_v: Minimum gap between vertical lines (pixels).
|
448
|
+
max_lines_h: If set, limits the number of horizontal lines to the top N by prominence.
|
449
|
+
max_lines_v: If set, limits the number of vertical lines to the top N by prominence.
|
450
|
+
replace: If True, remove existing detected lines with the same source_label.
|
451
|
+
binarization_method: "adaptive" or "otsu".
|
452
|
+
adaptive_thresh_block_size: Block size for adaptive thresholding (if method is "adaptive").
|
453
|
+
adaptive_thresh_C_val: Constant subtracted from the mean for adaptive thresholding (if method is "adaptive").
|
454
|
+
morph_op_h: Morphological operation for horizontal lines ("open", "close", "none").
|
455
|
+
morph_kernel_h: Kernel tuple (cols, rows) for horizontal morphology. Example: (1, 2).
|
456
|
+
morph_op_v: Morphological operation for vertical lines ("open", "close", "none").
|
457
|
+
morph_kernel_v: Kernel tuple (cols, rows) for vertical morphology. Example: (2, 1).
|
458
|
+
smoothing_sigma_h: Gaussian smoothing sigma for horizontal profile.
|
459
|
+
smoothing_sigma_v: Gaussian smoothing sigma for vertical profile.
|
460
|
+
peak_width_rel_height: Relative height for `scipy.find_peaks` 'width' parameter.
|
461
|
+
|
462
|
+
# LSD-specific parameters (only used when method="lsd"):
|
463
|
+
off_angle: Maximum angle deviation from horizontal/vertical for line classification.
|
464
|
+
min_line_length: Minimum length for final detected lines.
|
465
|
+
merge_angle_tolerance: Maximum angle difference for merging parallel lines.
|
466
|
+
merge_distance_tolerance: Maximum perpendicular distance for merging lines.
|
467
|
+
merge_endpoint_tolerance: Maximum gap at endpoints for merging lines.
|
468
|
+
initial_min_line_length: Initial minimum length filter before merging.
|
469
|
+
min_nfa_score_horizontal: Minimum NFA score for horizontal lines.
|
470
|
+
min_nfa_score_vertical: Minimum NFA score for vertical lines.
|
471
|
+
|
472
|
+
Returns:
|
473
|
+
Self for method chaining.
|
474
|
+
|
475
|
+
Raises:
|
476
|
+
ImportError: If method="lsd" but opencv-python is not installed.
|
477
|
+
ValueError: If method is not "projection" or "lsd".
|
478
|
+
"""
|
479
|
+
if not horizontal and not vertical:
|
480
|
+
logger.info("Line detection skipped as both horizontal and vertical are False.")
|
481
|
+
return self
|
482
|
+
|
483
|
+
# Validate method parameter
|
484
|
+
if method not in ["projection", "lsd"]:
|
485
|
+
raise ValueError(f"Invalid method '{method}'. Supported methods: 'projection', 'lsd'")
|
486
|
+
|
487
|
+
collection_params = {
|
488
|
+
"resolution": resolution, "source_label": source_label, "method": method,
|
489
|
+
"horizontal": horizontal, "vertical": vertical,
|
490
|
+
"peak_threshold_h": peak_threshold_h, "min_gap_h": min_gap_h,
|
491
|
+
"peak_threshold_v": peak_threshold_v, "min_gap_v": min_gap_v,
|
492
|
+
"max_lines_h": max_lines_h, "max_lines_v": max_lines_v,
|
493
|
+
"replace": replace,
|
494
|
+
"binarization_method": binarization_method,
|
495
|
+
"adaptive_thresh_block_size": adaptive_thresh_block_size,
|
496
|
+
"adaptive_thresh_C_val": adaptive_thresh_C_val,
|
497
|
+
"morph_op_h": morph_op_h, "morph_kernel_h": morph_kernel_h,
|
498
|
+
"morph_op_v": morph_op_v, "morph_kernel_v": morph_kernel_v,
|
499
|
+
"smoothing_sigma_h": smoothing_sigma_h, "smoothing_sigma_v": smoothing_sigma_v,
|
500
|
+
"peak_width_rel_height": peak_width_rel_height,
|
501
|
+
# LSD parameters
|
502
|
+
"off_angle": off_angle, "min_line_length": min_line_length,
|
503
|
+
"merge_angle_tolerance": merge_angle_tolerance, "merge_distance_tolerance": merge_distance_tolerance,
|
504
|
+
"merge_endpoint_tolerance": merge_endpoint_tolerance, "initial_min_line_length": initial_min_line_length,
|
505
|
+
"min_nfa_score_horizontal": min_nfa_score_horizontal, "min_nfa_score_vertical": min_nfa_score_vertical,
|
506
|
+
}
|
507
|
+
|
508
|
+
if hasattr(self, 'pdfs'):
|
509
|
+
for pdf_doc in self.pdfs:
|
510
|
+
for page_obj in pdf_doc.pages:
|
511
|
+
page_obj.detect_lines(**collection_params)
|
512
|
+
return self
|
513
|
+
elif hasattr(self, 'pages') and not hasattr(self, '_page'):
|
514
|
+
for page_obj in self.pages:
|
515
|
+
page_obj.detect_lines(**collection_params)
|
516
|
+
return self
|
517
|
+
|
518
|
+
# Dispatch to appropriate detection method
|
519
|
+
if method == "projection":
|
520
|
+
return self._detect_lines_projection(
|
521
|
+
resolution=resolution, source_label=source_label, horizontal=horizontal, vertical=vertical,
|
522
|
+
peak_threshold_h=peak_threshold_h, min_gap_h=min_gap_h, peak_threshold_v=peak_threshold_v, min_gap_v=min_gap_v,
|
523
|
+
max_lines_h=max_lines_h, max_lines_v=max_lines_v, replace=replace,
|
524
|
+
binarization_method=binarization_method, adaptive_thresh_block_size=adaptive_thresh_block_size,
|
525
|
+
adaptive_thresh_C_val=adaptive_thresh_C_val, morph_op_h=morph_op_h, morph_kernel_h=morph_kernel_h,
|
526
|
+
morph_op_v=morph_op_v, morph_kernel_v=morph_kernel_v, smoothing_sigma_h=smoothing_sigma_h,
|
527
|
+
smoothing_sigma_v=smoothing_sigma_v, peak_width_rel_height=peak_width_rel_height
|
528
|
+
)
|
529
|
+
elif method == "lsd":
|
530
|
+
return self._detect_lines_lsd(
|
531
|
+
resolution=resolution, source_label=source_label, horizontal=horizontal, vertical=vertical,
|
532
|
+
off_angle=off_angle, min_line_length=min_line_length, merge_angle_tolerance=merge_angle_tolerance,
|
533
|
+
merge_distance_tolerance=merge_distance_tolerance, merge_endpoint_tolerance=merge_endpoint_tolerance,
|
534
|
+
initial_min_line_length=initial_min_line_length, min_nfa_score_horizontal=min_nfa_score_horizontal,
|
535
|
+
min_nfa_score_vertical=min_nfa_score_vertical, replace=replace
|
536
|
+
)
|
537
|
+
else:
|
538
|
+
# This should never happen due to validation above, but just in case
|
539
|
+
raise ValueError(f"Unsupported method: {method}")
|
540
|
+
|
541
|
+
def _detect_lines_projection(
|
542
|
+
self,
|
543
|
+
resolution: int,
|
544
|
+
source_label: str,
|
545
|
+
horizontal: bool,
|
546
|
+
vertical: bool,
|
547
|
+
peak_threshold_h: float,
|
548
|
+
min_gap_h: int,
|
549
|
+
peak_threshold_v: float,
|
550
|
+
min_gap_v: int,
|
551
|
+
max_lines_h: Optional[int],
|
552
|
+
max_lines_v: Optional[int],
|
553
|
+
replace: bool,
|
554
|
+
binarization_method: str,
|
555
|
+
adaptive_thresh_block_size: int,
|
556
|
+
adaptive_thresh_C_val: int,
|
557
|
+
morph_op_h: str,
|
558
|
+
morph_kernel_h: Tuple[int, int],
|
559
|
+
morph_op_v: str,
|
560
|
+
morph_kernel_v: Tuple[int, int],
|
561
|
+
smoothing_sigma_h: float,
|
562
|
+
smoothing_sigma_v: float,
|
563
|
+
peak_width_rel_height: float,
|
564
|
+
) -> "ShapeDetectionMixin":
|
565
|
+
"""Internal method for projection profiling line detection."""
|
566
|
+
cv_image, scale_factor, origin_offset_pdf, page_object_ctx = self._get_image_for_detection(resolution)
|
567
|
+
if cv_image is None or page_object_ctx is None:
|
568
|
+
logger.warning(f"Skipping line detection for {self} due to image error.")
|
569
|
+
return self
|
570
|
+
|
571
|
+
pil_image_for_dims = None
|
572
|
+
if hasattr(self, 'to_image') and hasattr(self, 'width') and hasattr(self, 'height'):
|
573
|
+
if hasattr(self, 'x0') and hasattr(self, 'top') and hasattr(self, '_page'):
|
574
|
+
pil_image_for_dims = self.to_image(resolution=resolution, crop_only=True, include_highlights=False)
|
575
|
+
else:
|
576
|
+
pil_image_for_dims = self.to_image(resolution=resolution, include_highlights=False)
|
577
|
+
if pil_image_for_dims is None:
|
578
|
+
logger.warning(f"Could not re-render PIL image for dimensions for {self}.")
|
579
|
+
pil_image_for_dims = Image.fromarray(cv_image) # Ensure it's not None
|
580
|
+
|
581
|
+
if pil_image_for_dims.mode != "RGB":
|
582
|
+
pil_image_for_dims = pil_image_for_dims.convert("RGB")
|
583
|
+
|
584
|
+
if replace:
|
585
|
+
from natural_pdf.elements.line import LineElement
|
586
|
+
element_manager = page_object_ctx._element_mgr
|
587
|
+
if hasattr(element_manager, '_elements') and 'lines' in element_manager._elements:
|
588
|
+
original_count = len(element_manager._elements['lines'])
|
589
|
+
element_manager._elements['lines'] = [
|
590
|
+
line for line in element_manager._elements['lines']
|
591
|
+
if getattr(line, 'source', None) != source_label
|
592
|
+
]
|
593
|
+
removed_count = original_count - len(element_manager._elements['lines'])
|
594
|
+
if removed_count > 0:
|
595
|
+
logger.info(f"Removed {removed_count} existing lines with source '{source_label}' from {page_object_ctx}")
|
596
|
+
|
597
|
+
lines_data_img, profile_h_smoothed, profile_v_smoothed = self._find_lines_on_image_data(
|
598
|
+
cv_image=cv_image,
|
599
|
+
pil_image_rgb=pil_image_for_dims,
|
600
|
+
horizontal=horizontal,
|
601
|
+
vertical=vertical,
|
602
|
+
peak_threshold_h=peak_threshold_h,
|
603
|
+
min_gap_h=min_gap_h,
|
604
|
+
peak_threshold_v=peak_threshold_v,
|
605
|
+
min_gap_v=min_gap_v,
|
606
|
+
max_lines_h=max_lines_h,
|
607
|
+
max_lines_v=max_lines_v,
|
608
|
+
binarization_method=binarization_method,
|
609
|
+
adaptive_thresh_block_size=adaptive_thresh_block_size,
|
610
|
+
adaptive_thresh_C_val=adaptive_thresh_C_val,
|
611
|
+
morph_op_h=morph_op_h, morph_kernel_h=morph_kernel_h,
|
612
|
+
morph_op_v=morph_op_v, morph_kernel_v=morph_kernel_v,
|
613
|
+
smoothing_sigma_h=smoothing_sigma_h, smoothing_sigma_v=smoothing_sigma_v,
|
614
|
+
peak_width_rel_height=peak_width_rel_height,
|
615
|
+
)
|
616
|
+
|
617
|
+
from natural_pdf.elements.line import LineElement
|
618
|
+
element_manager = page_object_ctx._element_mgr
|
619
|
+
|
620
|
+
for line_data_item_img in lines_data_img:
|
621
|
+
element_constructor_data = self._convert_line_to_element_data(
|
622
|
+
line_data_item_img, scale_factor, origin_offset_pdf, page_object_ctx, source_label
|
623
|
+
)
|
624
|
+
try:
|
625
|
+
line_element = LineElement(element_constructor_data, page_object_ctx)
|
626
|
+
element_manager.add_element(line_element, element_type="lines")
|
627
|
+
except Exception as e:
|
628
|
+
logger.error(f"Failed to create or add LineElement: {e}. Data: {element_constructor_data}", exc_info=True)
|
629
|
+
|
630
|
+
logger.info(f"Detected and added {len(lines_data_img)} lines to {page_object_ctx} with source '{source_label}' using projection profiling.")
|
631
|
+
return self
|
632
|
+
|
633
|
+
def _detect_lines_lsd(
|
634
|
+
self,
|
635
|
+
resolution: int,
|
636
|
+
source_label: str,
|
637
|
+
horizontal: bool,
|
638
|
+
vertical: bool,
|
639
|
+
off_angle: int,
|
640
|
+
min_line_length: int,
|
641
|
+
merge_angle_tolerance: int,
|
642
|
+
merge_distance_tolerance: int,
|
643
|
+
merge_endpoint_tolerance: int,
|
644
|
+
initial_min_line_length: int,
|
645
|
+
min_nfa_score_horizontal: float,
|
646
|
+
min_nfa_score_vertical: float,
|
647
|
+
replace: bool,
|
648
|
+
) -> "ShapeDetectionMixin":
|
649
|
+
"""Internal method for LSD line detection."""
|
650
|
+
try:
|
651
|
+
import cv2
|
652
|
+
except ImportError:
|
653
|
+
raise ImportError(
|
654
|
+
"OpenCV (cv2) is required for LSD line detection. "
|
655
|
+
"Install it with: pip install opencv-python\n"
|
656
|
+
"Alternatively, use method='projection' which requires no additional dependencies."
|
657
|
+
)
|
658
|
+
|
659
|
+
cv_image, scale_factor, origin_offset_pdf, page_object_ctx = self._get_image_for_detection(resolution)
|
660
|
+
if cv_image is None or page_object_ctx is None:
|
661
|
+
logger.warning(f"Skipping LSD line detection for {self} due to image error.")
|
662
|
+
return self
|
663
|
+
|
664
|
+
if replace:
|
665
|
+
from natural_pdf.elements.line import LineElement
|
666
|
+
element_manager = page_object_ctx._element_mgr
|
667
|
+
if hasattr(element_manager, '_elements') and 'lines' in element_manager._elements:
|
668
|
+
original_count = len(element_manager._elements['lines'])
|
669
|
+
element_manager._elements['lines'] = [
|
670
|
+
line for line in element_manager._elements['lines']
|
671
|
+
if getattr(line, 'source', None) != source_label
|
672
|
+
]
|
673
|
+
removed_count = original_count - len(element_manager._elements['lines'])
|
674
|
+
if removed_count > 0:
|
675
|
+
logger.info(f"Removed {removed_count} existing lines with source '{source_label}' from {page_object_ctx}")
|
676
|
+
|
677
|
+
lines_data_img = self._process_image_for_lines_lsd(
|
678
|
+
cv_image, off_angle, min_line_length, merge_angle_tolerance,
|
679
|
+
merge_distance_tolerance, merge_endpoint_tolerance, initial_min_line_length,
|
680
|
+
min_nfa_score_horizontal, min_nfa_score_vertical
|
681
|
+
)
|
682
|
+
|
683
|
+
from natural_pdf.elements.line import LineElement
|
684
|
+
element_manager = page_object_ctx._element_mgr
|
685
|
+
|
686
|
+
for line_data_item_img in lines_data_img:
|
687
|
+
element_constructor_data = self._convert_line_to_element_data(
|
688
|
+
line_data_item_img, scale_factor, origin_offset_pdf, page_object_ctx, source_label
|
689
|
+
)
|
690
|
+
try:
|
691
|
+
line_element = LineElement(element_constructor_data, page_object_ctx)
|
692
|
+
element_manager.add_element(line_element, element_type="lines")
|
693
|
+
except Exception as e:
|
694
|
+
logger.error(f"Failed to create or add LineElement: {e}. Data: {element_constructor_data}", exc_info=True)
|
695
|
+
|
696
|
+
logger.info(f"Detected and added {len(lines_data_img)} lines to {page_object_ctx} with source '{source_label}' using LSD.")
|
697
|
+
return self
|
698
|
+
|
699
|
+
def _process_image_for_lines_lsd(
|
700
|
+
self,
|
701
|
+
cv_image: np.ndarray,
|
702
|
+
off_angle: int,
|
703
|
+
min_line_length: int,
|
704
|
+
merge_angle_tolerance: int,
|
705
|
+
merge_distance_tolerance: int,
|
706
|
+
merge_endpoint_tolerance: int,
|
707
|
+
initial_min_line_length: int,
|
708
|
+
min_nfa_score_horizontal: float,
|
709
|
+
min_nfa_score_vertical: float,
|
710
|
+
) -> List[Dict]:
|
711
|
+
"""Processes an image to detect lines using OpenCV LSD and merging logic."""
|
712
|
+
import cv2 # Import is already validated in calling method
|
713
|
+
|
714
|
+
if cv_image is None:
|
715
|
+
return []
|
716
|
+
|
717
|
+
gray_image = cv2.cvtColor(cv_image, cv2.COLOR_RGB2GRAY)
|
718
|
+
lsd = cv2.createLineSegmentDetector(cv2.LSD_REFINE_ADV)
|
719
|
+
coords_arr, widths_arr, precs_arr, nfa_scores_arr = lsd.detect(gray_image)
|
720
|
+
|
721
|
+
lines_raw = []
|
722
|
+
if coords_arr is not None: # nfa_scores_arr can be None if no lines are found
|
723
|
+
nfa_scores_list = nfa_scores_arr.flatten() if nfa_scores_arr is not None else [0.0] * len(coords_arr)
|
724
|
+
widths_list = widths_arr.flatten() if widths_arr is not None else [1.0] * len(coords_arr)
|
725
|
+
precs_list = precs_arr.flatten() if precs_arr is not None else [0.0] * len(coords_arr)
|
726
|
+
|
727
|
+
for i in range(len(coords_arr)):
|
728
|
+
lines_raw.append((
|
729
|
+
coords_arr[i][0],
|
730
|
+
widths_list[i] if i < len(widths_list) else 1.0,
|
731
|
+
precs_list[i] if i < len(precs_list) else 0.0,
|
732
|
+
nfa_scores_list[i] if i < len(nfa_scores_list) else 0.0
|
733
|
+
))
|
734
|
+
|
735
|
+
def get_line_properties(line_data_item):
|
736
|
+
l_coords, l_width, l_prec, l_nfa_score = line_data_item
|
737
|
+
x1, y1, x2, y2 = l_coords
|
738
|
+
angle_rad = np.arctan2(y2 - y1, x2 - x1)
|
739
|
+
angle_deg = np.degrees(angle_rad)
|
740
|
+
normalized_angle_deg = angle_deg % 180
|
741
|
+
if normalized_angle_deg < 0:
|
742
|
+
normalized_angle_deg += 180
|
743
|
+
|
744
|
+
is_h = abs(normalized_angle_deg) <= off_angle or abs(normalized_angle_deg - 180) <= off_angle
|
745
|
+
is_v = abs(normalized_angle_deg - 90) <= off_angle
|
746
|
+
|
747
|
+
if is_h and x1 > x2: x1, x2, y1, y2 = x2, x1, y2, y1
|
748
|
+
elif is_v and y1 > y2: y1, y2, x1, x2 = y2, y1, x2, x1
|
749
|
+
|
750
|
+
length = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
751
|
+
return {'coords': (x1, y1, x2, y2), 'width': l_width, 'prec': l_prec,
|
752
|
+
'angle_deg': normalized_angle_deg, 'is_horizontal': is_h, 'is_vertical': is_v,
|
753
|
+
'length': length, 'nfa_score': l_nfa_score}
|
754
|
+
|
755
|
+
processed_lines = [get_line_properties(ld) for ld in lines_raw]
|
756
|
+
|
757
|
+
filtered_lines = []
|
758
|
+
for p in processed_lines:
|
759
|
+
if p['length'] <= initial_min_line_length: continue
|
760
|
+
if p['is_horizontal'] and p['nfa_score'] >= min_nfa_score_horizontal:
|
761
|
+
filtered_lines.append(p)
|
762
|
+
elif p['is_vertical'] and p['nfa_score'] >= min_nfa_score_vertical:
|
763
|
+
filtered_lines.append(p)
|
764
|
+
|
765
|
+
horizontal_lines = [p for p in filtered_lines if p['is_horizontal']]
|
766
|
+
vertical_lines = [p for p in filtered_lines if p['is_vertical']]
|
767
|
+
|
768
|
+
def merge_lines_list(lines_list, is_horizontal_merge):
|
769
|
+
if not lines_list: return []
|
770
|
+
key_sort = (lambda p: (p['coords'][1], p['coords'][0])) if is_horizontal_merge else (lambda p: (p['coords'][0], p['coords'][1]))
|
771
|
+
lines_list.sort(key=key_sort)
|
772
|
+
|
773
|
+
merged_results = []
|
774
|
+
merged_flags = [False] * len(lines_list)
|
775
|
+
|
776
|
+
for i, current_line_props in enumerate(lines_list):
|
777
|
+
if merged_flags[i]: continue
|
778
|
+
group = [current_line_props]; merged_flags[i] = True
|
779
|
+
|
780
|
+
# Keep trying to expand the group until no more lines can be added
|
781
|
+
# Use multiple passes to ensure transitive merging works properly
|
782
|
+
for merge_pass in range(10): # Up to 10 passes to catch complex merging scenarios
|
783
|
+
group_changed = False
|
784
|
+
|
785
|
+
# Calculate current group boundaries
|
786
|
+
group_x1, group_y1 = min(p['coords'][0] for p in group), min(p['coords'][1] for p in group)
|
787
|
+
group_x2, group_y2 = max(p['coords'][2] for p in group), max(p['coords'][3] for p in group)
|
788
|
+
total_len_in_group = sum(p['length'] for p in group)
|
789
|
+
if total_len_in_group == 0: continue # Should not happen
|
790
|
+
|
791
|
+
# Calculate weighted averages for the group
|
792
|
+
group_avg_angle = sum(p['angle_deg'] * p['length'] for p in group) / total_len_in_group
|
793
|
+
|
794
|
+
if is_horizontal_merge:
|
795
|
+
group_avg_perp_coord = sum(((p['coords'][1] + p['coords'][3]) / 2) * p['length'] for p in group) / total_len_in_group
|
796
|
+
else:
|
797
|
+
group_avg_perp_coord = sum(((p['coords'][0] + p['coords'][2]) / 2) * p['length'] for p in group) / total_len_in_group
|
798
|
+
|
799
|
+
# Check all unmerged lines for potential merging
|
800
|
+
for j, candidate_props in enumerate(lines_list):
|
801
|
+
if merged_flags[j]: continue
|
802
|
+
|
803
|
+
# 1. Check for parallelism (angle similarity)
|
804
|
+
angle_diff = abs(group_avg_angle - candidate_props['angle_deg'])
|
805
|
+
# Handle wraparound for angles near 0/180
|
806
|
+
if angle_diff > 90:
|
807
|
+
angle_diff = 180 - angle_diff
|
808
|
+
if angle_diff > merge_angle_tolerance: continue
|
809
|
+
|
810
|
+
# 2. Check for closeness (perpendicular distance)
|
811
|
+
if is_horizontal_merge:
|
812
|
+
cand_perp_coord = (candidate_props['coords'][1] + candidate_props['coords'][3]) / 2
|
813
|
+
else:
|
814
|
+
cand_perp_coord = (candidate_props['coords'][0] + candidate_props['coords'][2]) / 2
|
815
|
+
|
816
|
+
perp_distance = abs(group_avg_perp_coord - cand_perp_coord)
|
817
|
+
if perp_distance > merge_distance_tolerance: continue
|
818
|
+
|
819
|
+
# 3. Check for reasonable proximity along the primary axis
|
820
|
+
if is_horizontal_merge:
|
821
|
+
# For horizontal lines, check x-axis relationship
|
822
|
+
cand_x1, cand_x2 = candidate_props['coords'][0], candidate_props['coords'][2]
|
823
|
+
# Check if there's overlap OR if the gap is reasonable
|
824
|
+
overlap = max(0, min(group_x2, cand_x2) - max(group_x1, cand_x1))
|
825
|
+
gap_to_group = min(abs(group_x1 - cand_x2), abs(group_x2 - cand_x1))
|
826
|
+
|
827
|
+
# Accept if there's overlap OR the gap is reasonable OR the candidate is contained within group span
|
828
|
+
if not (overlap > 0 or gap_to_group <= merge_endpoint_tolerance or (cand_x1 >= group_x1 and cand_x2 <= group_x2)):
|
829
|
+
continue
|
830
|
+
else:
|
831
|
+
# For vertical lines, check y-axis relationship
|
832
|
+
cand_y1, cand_y2 = candidate_props['coords'][1], candidate_props['coords'][3]
|
833
|
+
overlap = max(0, min(group_y2, cand_y2) - max(group_y1, cand_y1))
|
834
|
+
gap_to_group = min(abs(group_y1 - cand_y2), abs(group_y2 - cand_y1))
|
835
|
+
|
836
|
+
if not (overlap > 0 or gap_to_group <= merge_endpoint_tolerance or (cand_y1 >= group_y1 and cand_y2 <= group_y2)):
|
837
|
+
continue
|
838
|
+
|
839
|
+
# If we reach here, lines should be merged
|
840
|
+
group.append(candidate_props)
|
841
|
+
merged_flags[j] = True
|
842
|
+
group_changed = True
|
843
|
+
|
844
|
+
if not group_changed:
|
845
|
+
break # No more lines added in this pass, stop trying
|
846
|
+
|
847
|
+
# Create final merged line from the group
|
848
|
+
final_x1, final_y1 = min(p['coords'][0] for p in group), min(p['coords'][1] for p in group)
|
849
|
+
final_x2, final_y2 = max(p['coords'][2] for p in group), max(p['coords'][3] for p in group)
|
850
|
+
final_total_len = sum(p['length'] for p in group)
|
851
|
+
if final_total_len == 0: continue
|
852
|
+
|
853
|
+
final_width = sum(p['width'] * p['length'] for p in group) / final_total_len
|
854
|
+
final_nfa = sum(p['nfa_score'] * p['length'] for p in group) / final_total_len
|
855
|
+
|
856
|
+
if is_horizontal_merge:
|
857
|
+
final_y = sum(((p['coords'][1] + p['coords'][3]) / 2) * p['length'] for p in group) / final_total_len
|
858
|
+
merged_line_data = (final_x1, final_y, final_x2, final_y, final_width, final_nfa)
|
859
|
+
else:
|
860
|
+
final_x = sum(((p['coords'][0] + p['coords'][2]) / 2) * p['length'] for p in group) / final_total_len
|
861
|
+
merged_line_data = (final_x, final_y1, final_x, final_y2, final_width, final_nfa)
|
862
|
+
merged_results.append(merged_line_data)
|
863
|
+
return merged_results
|
864
|
+
|
865
|
+
merged_h_lines = merge_lines_list(horizontal_lines, True)
|
866
|
+
merged_v_lines = merge_lines_list(vertical_lines, False)
|
867
|
+
all_merged = merged_h_lines + merged_v_lines
|
868
|
+
|
869
|
+
final_lines_data = []
|
870
|
+
for line_data_item in all_merged:
|
871
|
+
x1, y1, x2, y2, width, nfa = line_data_item
|
872
|
+
length = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
873
|
+
if length > min_line_length:
|
874
|
+
# Ensure x1 <= x2 for horizontal, y1 <= y2 for vertical
|
875
|
+
if abs(y2 - y1) < abs(x2-x1): # Horizontal-ish
|
876
|
+
if x1 > x2: x1_out, y1_out, x2_out, y2_out = x2, y2, x1, y1
|
877
|
+
else: x1_out, y1_out, x2_out, y2_out = x1, y1, x2, y2
|
878
|
+
else: # Vertical-ish
|
879
|
+
if y1 > y2: x1_out, y1_out, x2_out, y2_out = x2, y2, x1, y1
|
880
|
+
else: x1_out, y1_out, x2_out, y2_out = x1, y1, x2, y2
|
881
|
+
|
882
|
+
final_lines_data.append({
|
883
|
+
'x1': x1_out, 'y1': y1_out, 'x2': x2_out, 'y2': y2_out,
|
884
|
+
'width': width, 'nfa_score': nfa, 'length': length
|
885
|
+
})
|
886
|
+
return final_lines_data
|
887
|
+
|
888
|
+
def detect_lines_preview(
|
889
|
+
self,
|
890
|
+
resolution: int = 72, # Preview typically uses lower resolution
|
891
|
+
method: str = "projection",
|
892
|
+
horizontal: bool = True,
|
893
|
+
vertical: bool = True,
|
894
|
+
peak_threshold_h: float = 0.5,
|
895
|
+
min_gap_h: int = 5,
|
896
|
+
peak_threshold_v: float = 0.5,
|
897
|
+
min_gap_v: int = 5,
|
898
|
+
max_lines_h: Optional[int] = None,
|
899
|
+
max_lines_v: Optional[int] = None,
|
900
|
+
binarization_method: str = LINE_DETECTION_PARAM_DEFAULTS["binarization_method"],
|
901
|
+
adaptive_thresh_block_size: int = LINE_DETECTION_PARAM_DEFAULTS["adaptive_thresh_block_size"],
|
902
|
+
adaptive_thresh_C_val: int = LINE_DETECTION_PARAM_DEFAULTS["adaptive_thresh_C_val"],
|
903
|
+
morph_op_h: str = LINE_DETECTION_PARAM_DEFAULTS["morph_op_h"],
|
904
|
+
morph_kernel_h: Tuple[int, int] = LINE_DETECTION_PARAM_DEFAULTS["morph_kernel_h"],
|
905
|
+
morph_op_v: str = LINE_DETECTION_PARAM_DEFAULTS["morph_op_v"],
|
906
|
+
morph_kernel_v: Tuple[int, int] = LINE_DETECTION_PARAM_DEFAULTS["morph_kernel_v"],
|
907
|
+
smoothing_sigma_h: float = LINE_DETECTION_PARAM_DEFAULTS["smoothing_sigma_h"],
|
908
|
+
smoothing_sigma_v: float = LINE_DETECTION_PARAM_DEFAULTS["smoothing_sigma_v"],
|
909
|
+
peak_width_rel_height: float = LINE_DETECTION_PARAM_DEFAULTS["peak_width_rel_height"],
|
910
|
+
# LSD-specific parameters
|
911
|
+
off_angle: int = 5,
|
912
|
+
min_line_length: int = 30,
|
913
|
+
merge_angle_tolerance: int = 5,
|
914
|
+
merge_distance_tolerance: int = 3,
|
915
|
+
merge_endpoint_tolerance: int = 10,
|
916
|
+
initial_min_line_length: int = 10,
|
917
|
+
min_nfa_score_horizontal: float = -10.0,
|
918
|
+
min_nfa_score_vertical: float = -10.0,
|
919
|
+
) -> Optional[Image.Image]:
|
920
|
+
"""
|
921
|
+
Previews detected lines on a Page or Region without adding them to the PDF elements.
|
922
|
+
Generates and returns a debug visualization image.
|
923
|
+
This method is intended for Page or Region objects.
|
924
|
+
|
925
|
+
Args:
|
926
|
+
method: Detection method - "projection" (default) or "lsd" (requires opencv-python).
|
927
|
+
See `detect_lines` for other parameter descriptions. The main difference is a lower default `resolution`.
|
928
|
+
|
929
|
+
Returns:
|
930
|
+
PIL Image with line detection visualization, or None if preview failed.
|
931
|
+
|
932
|
+
Note:
|
933
|
+
Only projection profiling method supports histogram visualization.
|
934
|
+
LSD method will show detected lines overlaid on the original image.
|
935
|
+
"""
|
936
|
+
if hasattr(self, 'pdfs') or (hasattr(self, 'pages') and not hasattr(self, '_page')):
|
937
|
+
logger.warning("preview_detected_lines is intended for single Page/Region objects. For collections, process pages individually.")
|
938
|
+
return None
|
939
|
+
|
940
|
+
if not horizontal and not vertical: # Check this early
|
941
|
+
logger.info("Line preview skipped as both horizontal and vertical are False.")
|
942
|
+
return None
|
943
|
+
|
944
|
+
# Validate method parameter
|
945
|
+
if method not in ["projection", "lsd"]:
|
946
|
+
raise ValueError(f"Invalid method '{method}'. Supported methods: 'projection', 'lsd'")
|
947
|
+
|
948
|
+
cv_image, _, _, page_object_ctx = self._get_image_for_detection(resolution) # scale_factor and origin_offset not needed for preview
|
949
|
+
if cv_image is None or page_object_ctx is None: # page_object_ctx for logging context mostly
|
950
|
+
logger.warning(f"Skipping line preview for {self} due to image error.")
|
951
|
+
return None
|
952
|
+
|
953
|
+
pil_image_for_dims = None
|
954
|
+
if hasattr(self, 'to_image') and hasattr(self, 'width') and hasattr(self, 'height'):
|
955
|
+
if hasattr(self, 'x0') and hasattr(self, 'top') and hasattr(self, '_page'):
|
956
|
+
pil_image_for_dims = self.to_image(resolution=resolution, crop_only=True, include_highlights=False)
|
957
|
+
else:
|
958
|
+
pil_image_for_dims = self.to_image(resolution=resolution, include_highlights=False)
|
959
|
+
|
960
|
+
if pil_image_for_dims is None:
|
961
|
+
logger.warning(f"Could not render PIL image for preview for {self}. Using cv_image to create one.")
|
962
|
+
pil_image_for_dims = Image.fromarray(cv_image)
|
963
|
+
|
964
|
+
if pil_image_for_dims.mode != "RGB":
|
965
|
+
pil_image_for_dims = pil_image_for_dims.convert("RGB")
|
966
|
+
|
967
|
+
# Get lines data based on method
|
968
|
+
if method == "projection":
|
969
|
+
lines_data_img, profile_h_smoothed, profile_v_smoothed = self._find_lines_on_image_data(
|
970
|
+
cv_image=cv_image,
|
971
|
+
pil_image_rgb=pil_image_for_dims,
|
972
|
+
horizontal=horizontal,
|
973
|
+
vertical=vertical,
|
974
|
+
peak_threshold_h=peak_threshold_h,
|
975
|
+
min_gap_h=min_gap_h,
|
976
|
+
peak_threshold_v=peak_threshold_v,
|
977
|
+
min_gap_v=min_gap_v,
|
978
|
+
max_lines_h=max_lines_h,
|
979
|
+
max_lines_v=max_lines_v,
|
980
|
+
binarization_method=binarization_method,
|
981
|
+
adaptive_thresh_block_size=adaptive_thresh_block_size,
|
982
|
+
adaptive_thresh_C_val=adaptive_thresh_C_val,
|
983
|
+
morph_op_h=morph_op_h, morph_kernel_h=morph_kernel_h,
|
984
|
+
morph_op_v=morph_op_v, morph_kernel_v=morph_kernel_v,
|
985
|
+
smoothing_sigma_h=smoothing_sigma_h, smoothing_sigma_v=smoothing_sigma_v,
|
986
|
+
peak_width_rel_height=peak_width_rel_height,
|
987
|
+
)
|
988
|
+
elif method == "lsd":
|
989
|
+
try:
|
990
|
+
import cv2
|
991
|
+
except ImportError:
|
992
|
+
raise ImportError(
|
993
|
+
"OpenCV (cv2) is required for LSD line detection preview. "
|
994
|
+
"Install it with: pip install opencv-python\n"
|
995
|
+
"Alternatively, use method='projection' for preview."
|
996
|
+
)
|
997
|
+
lines_data_img = self._process_image_for_lines_lsd(
|
998
|
+
cv_image, off_angle, min_line_length, merge_angle_tolerance,
|
999
|
+
merge_distance_tolerance, merge_endpoint_tolerance, initial_min_line_length,
|
1000
|
+
min_nfa_score_horizontal, min_nfa_score_vertical
|
1001
|
+
)
|
1002
|
+
profile_h_smoothed, profile_v_smoothed = None, None # LSD doesn't use profiles
|
1003
|
+
|
1004
|
+
if not lines_data_img: # Check if any lines were detected before visualization
|
1005
|
+
logger.info(f"No lines detected for preview on {page_object_ctx or self}")
|
1006
|
+
# Optionally return the base image if no lines, or None
|
1007
|
+
return pil_image_for_dims.convert("RGBA") # Return base image so something is shown
|
1008
|
+
|
1009
|
+
# --- Visualization Logic ---
|
1010
|
+
final_viz_image: Optional[Image.Image] = None
|
1011
|
+
viz_image_base = pil_image_for_dims.convert("RGBA")
|
1012
|
+
draw = ImageDraw.Draw(viz_image_base)
|
1013
|
+
img_width, img_height = viz_image_base.size
|
1014
|
+
|
1015
|
+
viz_params = {
|
1016
|
+
"draw_line_thickness_viz": 2, # Slightly thicker for better visibility
|
1017
|
+
"debug_histogram_size": 100,
|
1018
|
+
"line_color_h": (255, 0, 0, 200), "line_color_v": (0, 0, 255, 200),
|
1019
|
+
"histogram_bar_color_h": (200, 0, 0, 200), "histogram_bar_color_v": (0, 0, 200, 200),
|
1020
|
+
"histogram_bg_color": (240, 240, 240, 255), "padding_between_viz": 10,
|
1021
|
+
"peak_threshold_h": peak_threshold_h,
|
1022
|
+
"peak_threshold_v": peak_threshold_v,
|
1023
|
+
"max_lines_h": max_lines_h,
|
1024
|
+
"max_lines_v": max_lines_v,
|
1025
|
+
}
|
1026
|
+
|
1027
|
+
# Draw detected lines on the image
|
1028
|
+
for line_info in lines_data_img:
|
1029
|
+
is_h_line = abs(line_info['y1'] - line_info['y2']) < abs(line_info['x1'] - line_info['x2'])
|
1030
|
+
line_color = viz_params["line_color_h"] if is_h_line else viz_params["line_color_v"]
|
1031
|
+
draw.line([
|
1032
|
+
(line_info['x1'], line_info['y1']),
|
1033
|
+
(line_info['x2'], line_info['y2'])
|
1034
|
+
], fill=line_color, width=viz_params["draw_line_thickness_viz"])
|
1035
|
+
|
1036
|
+
# For projection method, add histogram visualization
|
1037
|
+
if method == "projection" and (profile_h_smoothed is not None or profile_v_smoothed is not None):
|
1038
|
+
hist_size = viz_params["debug_histogram_size"]
|
1039
|
+
hist_h_img = Image.new("RGBA", (hist_size, img_height), viz_params["histogram_bg_color"])
|
1040
|
+
hist_h_draw = ImageDraw.Draw(hist_h_img)
|
1041
|
+
|
1042
|
+
if profile_h_smoothed is not None and profile_h_smoothed.size > 0:
|
1043
|
+
actual_max_h_profile = profile_h_smoothed.max()
|
1044
|
+
display_threshold_val_h = peak_threshold_h * img_width
|
1045
|
+
# Use the maximum of either the profile max or threshold for scaling, so both are always visible
|
1046
|
+
max_h_profile_val_for_scaling = max(actual_max_h_profile, display_threshold_val_h) if actual_max_h_profile > 0 else img_width
|
1047
|
+
for y_coord, val in enumerate(profile_h_smoothed):
|
1048
|
+
bar_len = 0; thresh_bar_len = 0
|
1049
|
+
if max_h_profile_val_for_scaling > 0:
|
1050
|
+
bar_len = int((val / max_h_profile_val_for_scaling) * hist_size)
|
1051
|
+
if display_threshold_val_h >= 0:
|
1052
|
+
thresh_bar_len = int((display_threshold_val_h / max_h_profile_val_for_scaling) * hist_size)
|
1053
|
+
bar_len = min(max(0, bar_len), hist_size)
|
1054
|
+
if bar_len > 0: hist_h_draw.line([(0, y_coord), (bar_len -1 , y_coord)], fill=viz_params["histogram_bar_color_h"], width=1)
|
1055
|
+
if viz_params["max_lines_h"] is None and display_threshold_val_h >=0 and \
|
1056
|
+
thresh_bar_len > 0 and thresh_bar_len <= hist_size:
|
1057
|
+
# Ensure threshold line is within bounds
|
1058
|
+
thresh_x = min(thresh_bar_len, hist_size - 1)
|
1059
|
+
hist_h_draw.line([(thresh_x, y_coord), (thresh_x, y_coord+1 if y_coord+1 < img_height else y_coord)], fill=(0,255,0,100), width=1)
|
1060
|
+
|
1061
|
+
hist_v_img = Image.new("RGBA", (img_width, hist_size), viz_params["histogram_bg_color"])
|
1062
|
+
hist_v_draw = ImageDraw.Draw(hist_v_img)
|
1063
|
+
if profile_v_smoothed is not None and profile_v_smoothed.size > 0:
|
1064
|
+
actual_max_v_profile = profile_v_smoothed.max()
|
1065
|
+
display_threshold_val_v = peak_threshold_v * img_height
|
1066
|
+
# Use the maximum of either the profile max or threshold for scaling, so both are always visible
|
1067
|
+
max_v_profile_val_for_scaling = max(actual_max_v_profile, display_threshold_val_v) if actual_max_v_profile > 0 else img_height
|
1068
|
+
for x_coord, val in enumerate(profile_v_smoothed):
|
1069
|
+
bar_height = 0; thresh_bar_h = 0
|
1070
|
+
if max_v_profile_val_for_scaling > 0:
|
1071
|
+
bar_height = int((val / max_v_profile_val_for_scaling) * hist_size)
|
1072
|
+
if display_threshold_val_v >=0:
|
1073
|
+
thresh_bar_h = int((display_threshold_val_v / max_v_profile_val_for_scaling) * hist_size)
|
1074
|
+
bar_height = min(max(0, bar_height), hist_size)
|
1075
|
+
if bar_height > 0: hist_v_draw.line([(x_coord, hist_size -1 ), (x_coord, hist_size - bar_height)], fill=viz_params["histogram_bar_color_v"], width=1)
|
1076
|
+
if viz_params["max_lines_v"] is None and display_threshold_val_v >=0 and \
|
1077
|
+
thresh_bar_h > 0 and thresh_bar_h <= hist_size:
|
1078
|
+
# Ensure threshold line is within bounds
|
1079
|
+
thresh_y = min(thresh_bar_h, hist_size - 1)
|
1080
|
+
hist_v_draw.line([(x_coord, hist_size - thresh_y), (x_coord+1 if x_coord+1 < img_width else x_coord, hist_size - thresh_y)], fill=(0,255,0,100), width=1)
|
1081
|
+
|
1082
|
+
padding = viz_params["padding_between_viz"]
|
1083
|
+
total_width = img_width + padding + hist_size
|
1084
|
+
total_height = img_height + padding + hist_size
|
1085
|
+
final_viz_image = Image.new("RGBA", (total_width, total_height), (255, 255, 255, 255))
|
1086
|
+
final_viz_image.paste(viz_image_base, (0, 0))
|
1087
|
+
final_viz_image.paste(hist_h_img, (img_width + padding, 0))
|
1088
|
+
final_viz_image.paste(hist_v_img, (0, img_height + padding))
|
1089
|
+
else:
|
1090
|
+
# For LSD method, just return the image with lines overlaid
|
1091
|
+
final_viz_image = viz_image_base
|
1092
|
+
|
1093
|
+
logger.info(f"Generated line preview visualization for {page_object_ctx or self}")
|
1094
|
+
return final_viz_image
|
1095
|
+
|
1096
|
+
def detect_table_structure_from_lines(
|
1097
|
+
self,
|
1098
|
+
source_label: str = "detected",
|
1099
|
+
ignore_outer_regions: bool = True,
|
1100
|
+
cell_padding: float = 0.5, # Small padding inside cells, default to 0.5px
|
1101
|
+
) -> "ShapeDetectionMixin":
|
1102
|
+
"""
|
1103
|
+
Create table structure (rows, columns, cells) from previously detected lines.
|
1104
|
+
|
1105
|
+
This method analyzes horizontal and vertical lines to create a grid structure,
|
1106
|
+
then generates Region objects for:
|
1107
|
+
- An overall table region that encompasses the entire table structure
|
1108
|
+
- Individual row regions spanning the width of the table
|
1109
|
+
- Individual column regions spanning the height of the table
|
1110
|
+
- Individual cell regions at each row/column intersection
|
1111
|
+
|
1112
|
+
Args:
|
1113
|
+
source_label: Filter lines by this source label (from detect_lines)
|
1114
|
+
ignore_outer_regions: If True, don't create regions outside the defined by lines grid.
|
1115
|
+
If False, include regions from page/object edges to the first/last lines.
|
1116
|
+
cell_padding: Internal padding for cell regions
|
1117
|
+
|
1118
|
+
Returns:
|
1119
|
+
Self for method chaining
|
1120
|
+
"""
|
1121
|
+
# Handle collections
|
1122
|
+
if hasattr(self, 'pdfs'):
|
1123
|
+
for pdf_doc in self.pdfs:
|
1124
|
+
for page_obj in pdf_doc.pages:
|
1125
|
+
page_obj.detect_table_structure_from_lines(
|
1126
|
+
source_label=source_label,
|
1127
|
+
ignore_outer_regions=ignore_outer_regions,
|
1128
|
+
cell_padding=cell_padding,
|
1129
|
+
)
|
1130
|
+
return self
|
1131
|
+
elif hasattr(self, 'pages') and not hasattr(self, '_page'): # PageCollection
|
1132
|
+
for page_obj in self.pages:
|
1133
|
+
page_obj.detect_table_structure_from_lines(
|
1134
|
+
source_label=source_label,
|
1135
|
+
ignore_outer_regions=ignore_outer_regions,
|
1136
|
+
cell_padding=cell_padding,
|
1137
|
+
)
|
1138
|
+
return self
|
1139
|
+
|
1140
|
+
# Determine context (Page or Region) for coordinates and element management
|
1141
|
+
page_object_for_elements = None
|
1142
|
+
origin_x, origin_y = 0.0, 0.0
|
1143
|
+
context_width, context_height = 0.0, 0.0
|
1144
|
+
|
1145
|
+
if hasattr(self, '_element_mgr') and hasattr(self, 'width') and hasattr(self, 'height'): # Likely a Page
|
1146
|
+
page_object_for_elements = self
|
1147
|
+
context_width = self.width
|
1148
|
+
context_height = self.height
|
1149
|
+
logger.debug(f"Operating on Page context: {self}")
|
1150
|
+
elif hasattr(self, '_page') and hasattr(self, 'x0') and hasattr(self, 'width'): # Likely a Region
|
1151
|
+
page_object_for_elements = self._page
|
1152
|
+
origin_x = self.x0
|
1153
|
+
origin_y = self.top
|
1154
|
+
context_width = self.width # Region's own width/height for its boundary calculations
|
1155
|
+
context_height = self.height
|
1156
|
+
logger.debug(f"Operating on Region context: {self}, origin: ({origin_x}, {origin_y})")
|
1157
|
+
else:
|
1158
|
+
logger.warning(f"Could not determine valid page/region context for {self}. Aborting table structure detection.")
|
1159
|
+
return self
|
1160
|
+
|
1161
|
+
element_manager = page_object_for_elements._element_mgr
|
1162
|
+
|
1163
|
+
# Get lines with the specified source
|
1164
|
+
all_lines = element_manager.lines # Access lines from the correct element manager
|
1165
|
+
filtered_lines = [line for line in all_lines if getattr(line, 'source', None) == source_label]
|
1166
|
+
|
1167
|
+
if not filtered_lines:
|
1168
|
+
logger.info(f"No lines found with source '{source_label}' for table structure detection on {self}.")
|
1169
|
+
return self
|
1170
|
+
|
1171
|
+
# Separate horizontal and vertical lines
|
1172
|
+
# For regions, line coordinates are already absolute to the page.
|
1173
|
+
horizontal_lines = [line for line in filtered_lines if line.is_horizontal]
|
1174
|
+
vertical_lines = [line for line in filtered_lines if line.is_vertical]
|
1175
|
+
|
1176
|
+
logger.info(f"Found {len(horizontal_lines)} horizontal and {len(vertical_lines)} vertical lines for {self} with source '{source_label}'.")
|
1177
|
+
|
1178
|
+
# Define boundaries based on line positions (mid-points for sorting, actual edges for boundaries)
|
1179
|
+
# These coordinates are relative to the page_object_for_elements (which is always a Page)
|
1180
|
+
|
1181
|
+
# Horizontal line Y-coordinates (use average y, effectively the line's y-position)
|
1182
|
+
h_line_ys = sorted(list(set([(line.top + line.bottom) / 2 for line in horizontal_lines])))
|
1183
|
+
|
1184
|
+
# Vertical line X-coordinates (use average x, effectively the line's x-position)
|
1185
|
+
v_line_xs = sorted(list(set([(line.x0 + line.x1) / 2 for line in vertical_lines])))
|
1186
|
+
|
1187
|
+
row_boundaries = []
|
1188
|
+
if horizontal_lines:
|
1189
|
+
if not ignore_outer_regions:
|
1190
|
+
row_boundaries.append(origin_y) # Region's top or Page's 0
|
1191
|
+
row_boundaries.extend(h_line_ys)
|
1192
|
+
if not ignore_outer_regions:
|
1193
|
+
row_boundaries.append(origin_y + context_height) # Region's bottom or Page's height
|
1194
|
+
elif not ignore_outer_regions : # No horizontal lines, but we might want full height cells
|
1195
|
+
row_boundaries.extend([origin_y, origin_y + context_height])
|
1196
|
+
row_boundaries = sorted(list(set(row_boundaries)))
|
1197
|
+
|
1198
|
+
|
1199
|
+
col_boundaries = []
|
1200
|
+
if vertical_lines:
|
1201
|
+
if not ignore_outer_regions:
|
1202
|
+
col_boundaries.append(origin_x) # Region's left or Page's 0
|
1203
|
+
col_boundaries.extend(v_line_xs)
|
1204
|
+
if not ignore_outer_regions:
|
1205
|
+
col_boundaries.append(origin_x + context_width) # Region's right or Page's width
|
1206
|
+
elif not ignore_outer_regions: # No vertical lines, but we might want full width cells
|
1207
|
+
col_boundaries.extend([origin_x, origin_x + context_width])
|
1208
|
+
col_boundaries = sorted(list(set(col_boundaries)))
|
1209
|
+
|
1210
|
+
logger.debug(f"Row boundaries for {self}: {row_boundaries}")
|
1211
|
+
logger.debug(f"Col boundaries for {self}: {col_boundaries}")
|
1212
|
+
|
1213
|
+
# Create overall table region that wraps the entire structure
|
1214
|
+
tables_created = 0
|
1215
|
+
if len(row_boundaries) >= 2 and len(col_boundaries) >= 2:
|
1216
|
+
table_left = col_boundaries[0]
|
1217
|
+
table_top = row_boundaries[0]
|
1218
|
+
table_right = col_boundaries[-1]
|
1219
|
+
table_bottom = row_boundaries[-1]
|
1220
|
+
|
1221
|
+
if table_right > table_left and table_bottom > table_top:
|
1222
|
+
try:
|
1223
|
+
table_region = page_object_for_elements.create_region(
|
1224
|
+
table_left, table_top, table_right, table_bottom
|
1225
|
+
)
|
1226
|
+
table_region.source = source_label
|
1227
|
+
table_region.region_type = "table"
|
1228
|
+
table_region.normalized_type = "table" # Add normalized_type for selector compatibility
|
1229
|
+
table_region.metadata.update({
|
1230
|
+
"source_lines_label": source_label,
|
1231
|
+
"num_rows": len(row_boundaries) - 1,
|
1232
|
+
"num_cols": len(col_boundaries) - 1,
|
1233
|
+
"boundaries": {
|
1234
|
+
"rows": row_boundaries,
|
1235
|
+
"cols": col_boundaries
|
1236
|
+
}
|
1237
|
+
})
|
1238
|
+
element_manager.add_element(table_region, element_type="regions")
|
1239
|
+
tables_created += 1
|
1240
|
+
logger.debug(f"Created table region: L{table_left:.1f} T{table_top:.1f} R{table_right:.1f} B{table_bottom:.1f}")
|
1241
|
+
except Exception as e:
|
1242
|
+
logger.error(f"Failed to create or add table Region: {e}. Table abs coords: L{table_left} T{table_top} R{table_right} B{table_bottom}", exc_info=True)
|
1243
|
+
|
1244
|
+
# Create cell regions
|
1245
|
+
cells_created = 0
|
1246
|
+
rows_created = 0
|
1247
|
+
cols_created = 0
|
1248
|
+
|
1249
|
+
# Create Row Regions
|
1250
|
+
if len(row_boundaries) >= 2:
|
1251
|
+
# Determine horizontal extent for rows
|
1252
|
+
row_extent_x0 = origin_x
|
1253
|
+
row_extent_x1 = origin_x + context_width
|
1254
|
+
if col_boundaries: # If columns are defined, rows should span only across them
|
1255
|
+
if len(col_boundaries) >=2:
|
1256
|
+
row_extent_x0 = col_boundaries[0]
|
1257
|
+
row_extent_x1 = col_boundaries[-1]
|
1258
|
+
# If only one col_boundary (e.g. from ignore_outer_regions=False and one line), use context width
|
1259
|
+
# This case should be rare if lines are properly detected to form a grid.
|
1260
|
+
|
1261
|
+
for i in range(len(row_boundaries) - 1):
|
1262
|
+
top_abs = row_boundaries[i]
|
1263
|
+
bottom_abs = row_boundaries[i+1]
|
1264
|
+
|
1265
|
+
# Use calculated row_extent_x0 and row_extent_x1
|
1266
|
+
if bottom_abs > top_abs and row_extent_x1 > row_extent_x0: # Ensure valid region
|
1267
|
+
try:
|
1268
|
+
row_region = page_object_for_elements.create_region(
|
1269
|
+
row_extent_x0, top_abs, row_extent_x1, bottom_abs
|
1270
|
+
)
|
1271
|
+
row_region.source = source_label
|
1272
|
+
row_region.region_type = "table_row"
|
1273
|
+
row_region.normalized_type = "table_row" # Add normalized_type for selector compatibility
|
1274
|
+
row_region.metadata.update({
|
1275
|
+
"row_index": i,
|
1276
|
+
"source_lines_label": source_label
|
1277
|
+
})
|
1278
|
+
element_manager.add_element(row_region, element_type="regions")
|
1279
|
+
rows_created += 1
|
1280
|
+
except Exception as e:
|
1281
|
+
logger.error(f"Failed to create or add table_row Region: {e}. Row abs coords: L{row_extent_x0} T{top_abs} R{row_extent_x1} B{bottom_abs}", exc_info=True)
|
1282
|
+
|
1283
|
+
# Create Column Regions
|
1284
|
+
if len(col_boundaries) >= 2:
|
1285
|
+
# Determine vertical extent for columns
|
1286
|
+
col_extent_y0 = origin_y
|
1287
|
+
col_extent_y1 = origin_y + context_height
|
1288
|
+
if row_boundaries: # If rows are defined, columns should span only across them
|
1289
|
+
if len(row_boundaries) >=2:
|
1290
|
+
col_extent_y0 = row_boundaries[0]
|
1291
|
+
col_extent_y1 = row_boundaries[-1]
|
1292
|
+
# If only one row_boundary, use context height - similar logic to rows
|
1293
|
+
|
1294
|
+
for j in range(len(col_boundaries) - 1):
|
1295
|
+
left_abs = col_boundaries[j]
|
1296
|
+
right_abs = col_boundaries[j+1]
|
1297
|
+
|
1298
|
+
# Use calculated col_extent_y0 and col_extent_y1
|
1299
|
+
if right_abs > left_abs and col_extent_y1 > col_extent_y0: # Ensure valid region
|
1300
|
+
try:
|
1301
|
+
col_region = page_object_for_elements.create_region(
|
1302
|
+
left_abs, col_extent_y0, right_abs, col_extent_y1
|
1303
|
+
)
|
1304
|
+
col_region.source = source_label
|
1305
|
+
col_region.region_type = "table_column"
|
1306
|
+
col_region.normalized_type = "table_column" # Add normalized_type for selector compatibility
|
1307
|
+
col_region.metadata.update({
|
1308
|
+
"col_index": j,
|
1309
|
+
"source_lines_label": source_label
|
1310
|
+
})
|
1311
|
+
element_manager.add_element(col_region, element_type="regions")
|
1312
|
+
cols_created += 1
|
1313
|
+
except Exception as e:
|
1314
|
+
logger.error(f"Failed to create or add table_column Region: {e}. Col abs coords: L{left_abs} T{col_extent_y0} R{right_abs} B{col_extent_y1}", exc_info=True)
|
1315
|
+
|
1316
|
+
# Create Cell Regions (existing logic)
|
1317
|
+
if len(row_boundaries) < 2 or len(col_boundaries) < 2:
|
1318
|
+
logger.info(f"Not enough boundaries to form cells for {self}. Rows: {len(row_boundaries)}, Cols: {len(col_boundaries)}")
|
1319
|
+
# return self # Return will be at the end
|
1320
|
+
else:
|
1321
|
+
for i in range(len(row_boundaries) - 1):
|
1322
|
+
top_abs = row_boundaries[i]
|
1323
|
+
bottom_abs = row_boundaries[i+1]
|
1324
|
+
|
1325
|
+
for j in range(len(col_boundaries) - 1):
|
1326
|
+
left_abs = col_boundaries[j]
|
1327
|
+
right_abs = col_boundaries[j+1]
|
1328
|
+
|
1329
|
+
cell_left_abs = left_abs + cell_padding
|
1330
|
+
cell_top_abs = top_abs + cell_padding
|
1331
|
+
cell_right_abs = right_abs - cell_padding
|
1332
|
+
cell_bottom_abs = bottom_abs - cell_padding
|
1333
|
+
|
1334
|
+
cell_width = cell_right_abs - cell_left_abs
|
1335
|
+
cell_height = cell_bottom_abs - cell_top_abs
|
1336
|
+
|
1337
|
+
if cell_width <= 0 or cell_height <= 0:
|
1338
|
+
logger.debug(f"Skipping cell (zero or negative dimension after padding): L{left_abs:.1f} T{top_abs:.1f} R{right_abs:.1f} B{bottom_abs:.1f} -> W{cell_width:.1f} H{cell_height:.1f}")
|
1339
|
+
continue
|
1340
|
+
|
1341
|
+
try:
|
1342
|
+
cell_region = page_object_for_elements.create_region(
|
1343
|
+
cell_left_abs, cell_top_abs, cell_right_abs, cell_bottom_abs
|
1344
|
+
)
|
1345
|
+
cell_region.source = source_label
|
1346
|
+
cell_region.region_type = "table_cell"
|
1347
|
+
cell_region.normalized_type = "table_cell" # Add normalized_type for selector compatibility
|
1348
|
+
cell_region.metadata.update({
|
1349
|
+
"row_index": i,
|
1350
|
+
"col_index": j,
|
1351
|
+
"source_lines_label": source_label,
|
1352
|
+
"original_boundaries_abs": {
|
1353
|
+
"left": left_abs, "top": top_abs,
|
1354
|
+
"right": right_abs, "bottom": bottom_abs
|
1355
|
+
}
|
1356
|
+
})
|
1357
|
+
element_manager.add_element(cell_region, element_type="regions")
|
1358
|
+
cells_created += 1
|
1359
|
+
except Exception as e:
|
1360
|
+
logger.error(f"Failed to create or add cell Region: {e}. Cell abs coords: L{cell_left_abs} T{cell_top_abs} R{cell_right_abs} B{cell_bottom_abs}", exc_info=True)
|
1361
|
+
|
1362
|
+
logger.info(f"Created {tables_created} table, {rows_created} rows, {cols_created} columns, and {cells_created} table cells from detected lines (source: '{source_label}') for {self}.")
|
1363
|
+
return self
|
1364
|
+
|
1365
|
+
# Example usage would be:
|
1366
|
+
# page.detect_lines(source_label="my_table_lines")
|
1367
|
+
# page.detect_table_structure_from_lines(source_label="my_table_lines", cell_padding=0.5)
|
1368
|
+
#
|
1369
|
+
# Now both selector styles work equivalently:
|
1370
|
+
# table = page.find('table[source*="table_from"]') # Direct type selector
|
1371
|
+
# table = page.find('region[type="table"][source*="table_from"]') # Region attribute selector
|
1372
|
+
# cells = page.find_all('table-cell[source*="table_cells_from"]') # Direct type selector
|
1373
|
+
# cells = page.find_all('region[type="table-cell"][source*="table_cells_from"]') # Region attribute selector
|