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.
@@ -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