natural-pdf 25.3.16__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.
- examples/__init__.py +3 -0
- examples/another_exclusion_example.py +20 -0
- examples/basic_usage.py +190 -0
- examples/boundary_exclusion_test.py +137 -0
- examples/boundary_inclusion_fix_test.py +157 -0
- examples/chainable_layout_example.py +70 -0
- examples/color_basic_test.py +49 -0
- examples/color_name_example.py +71 -0
- examples/color_test.py +62 -0
- examples/debug_ocr.py +91 -0
- examples/direct_ocr_test.py +148 -0
- examples/direct_paddle_test.py +99 -0
- examples/direct_qa_example.py +165 -0
- examples/document_layout_analysis.py +123 -0
- examples/document_qa_example.py +185 -0
- examples/exclusion_count_debug.py +128 -0
- examples/exclusion_debug.py +107 -0
- examples/exclusion_example.py +150 -0
- examples/exclusion_optimization_example.py +190 -0
- examples/extract_text_test.py +128 -0
- examples/font_aware_example.py +101 -0
- examples/font_variant_example.py +124 -0
- examples/footer_overlap_test.py +124 -0
- examples/highlight_all_example.py +82 -0
- examples/highlight_attributes_test.py +114 -0
- examples/highlight_confidence_display.py +122 -0
- examples/highlight_demo.py +110 -0
- examples/highlight_float_test.py +71 -0
- examples/highlight_test.py +147 -0
- examples/highlighting_example.py +123 -0
- examples/image_width_example.py +84 -0
- examples/improved_api_example.py +128 -0
- examples/layout_confidence_display_test.py +65 -0
- examples/layout_confidence_test.py +82 -0
- examples/layout_coordinate_debug.py +258 -0
- examples/layout_highlight_test.py +77 -0
- examples/logging_example.py +70 -0
- examples/ocr_comprehensive.py +193 -0
- examples/ocr_debug_example.py +87 -0
- examples/ocr_default_test.py +97 -0
- examples/ocr_engine_comparison.py +235 -0
- examples/ocr_example.py +89 -0
- examples/ocr_simplified_params.py +79 -0
- examples/ocr_visualization.py +102 -0
- examples/ocr_visualization_test.py +121 -0
- examples/paddle_layout_example.py +315 -0
- examples/paddle_layout_simple.py +74 -0
- examples/paddleocr_example.py +224 -0
- examples/page_collection_example.py +103 -0
- examples/polygon_highlight_example.py +83 -0
- examples/position_methods_example.py +134 -0
- examples/region_boundary_test.py +73 -0
- examples/region_exclusion_test.py +149 -0
- examples/region_expand_example.py +109 -0
- examples/region_image_example.py +116 -0
- examples/region_ocr_test.py +119 -0
- examples/region_sections_example.py +115 -0
- examples/school_books.py +49 -0
- examples/school_books_all.py +52 -0
- examples/scouring.py +36 -0
- examples/section_extraction_example.py +232 -0
- examples/simple_document_qa.py +97 -0
- examples/spatial_navigation_example.py +108 -0
- examples/table_extraction_example.py +135 -0
- examples/table_structure_detection.py +155 -0
- examples/tatr_cells_test.py +56 -0
- examples/tatr_ocr_table_test.py +94 -0
- examples/text_search_example.py +122 -0
- examples/text_style_example.py +110 -0
- examples/tiny-text.py +61 -0
- examples/until_boundaries_example.py +156 -0
- examples/until_example.py +112 -0
- examples/very_basics.py +15 -0
- natural_pdf/__init__.py +55 -0
- natural_pdf/analyzers/__init__.py +9 -0
- natural_pdf/analyzers/document_layout.py +736 -0
- natural_pdf/analyzers/text_structure.py +153 -0
- natural_pdf/core/__init__.py +3 -0
- natural_pdf/core/page.py +2376 -0
- natural_pdf/core/pdf.py +572 -0
- natural_pdf/elements/__init__.py +3 -0
- natural_pdf/elements/base.py +553 -0
- natural_pdf/elements/collections.py +770 -0
- natural_pdf/elements/line.py +124 -0
- natural_pdf/elements/rect.py +122 -0
- natural_pdf/elements/region.py +1366 -0
- natural_pdf/elements/text.py +304 -0
- natural_pdf/ocr/__init__.py +62 -0
- natural_pdf/ocr/easyocr_engine.py +254 -0
- natural_pdf/ocr/engine.py +158 -0
- natural_pdf/ocr/paddleocr_engine.py +263 -0
- natural_pdf/qa/__init__.py +3 -0
- natural_pdf/qa/document_qa.py +405 -0
- natural_pdf/selectors/__init__.py +4 -0
- natural_pdf/selectors/parser.py +360 -0
- natural_pdf/templates/__init__.py +1 -0
- natural_pdf/templates/ocr_debug.html +517 -0
- natural_pdf/utils/__init__.py +4 -0
- natural_pdf/utils/highlighting.py +605 -0
- natural_pdf/utils/ocr.py +515 -0
- natural_pdf/utils/reading_order.py +227 -0
- natural_pdf/utils/visualization.py +151 -0
- natural_pdf-25.3.16.dist-info/LICENSE +21 -0
- natural_pdf-25.3.16.dist-info/METADATA +268 -0
- natural_pdf-25.3.16.dist-info/RECORD +109 -0
- natural_pdf-25.3.16.dist-info/WHEEL +5 -0
- natural_pdf-25.3.16.dist-info/top_level.txt +3 -0
- tests/__init__.py +3 -0
- tests/test_pdf.py +39 -0
@@ -0,0 +1,605 @@
|
|
1
|
+
"""
|
2
|
+
Highlighting utilities for natural-pdf.
|
3
|
+
"""
|
4
|
+
from typing import List, Dict, Tuple, Optional, Union, Any, Set
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
6
|
+
import io
|
7
|
+
import math
|
8
|
+
import os
|
9
|
+
from .visualization import get_next_highlight_color, create_legend, merge_images_with_legend, reset_highlight_colors
|
10
|
+
|
11
|
+
class Highlight:
|
12
|
+
"""
|
13
|
+
Represents a single highlight with color and optional label.
|
14
|
+
"""
|
15
|
+
def __init__(self,
|
16
|
+
bbox: Tuple[float, float, float, float],
|
17
|
+
color: Tuple[int, int, int, int],
|
18
|
+
label: Optional[str] = None,
|
19
|
+
polygon: Optional[List[Tuple[float, float]]] = None):
|
20
|
+
"""
|
21
|
+
Initialize a highlight.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
bbox: Bounding box (x0, top, x1, bottom)
|
25
|
+
color: RGBA color tuple (0-255 integers)
|
26
|
+
label: Optional label for this highlight
|
27
|
+
polygon: Optional polygon points for non-rectangular highlights
|
28
|
+
"""
|
29
|
+
self.bbox = bbox
|
30
|
+
self.polygon = polygon
|
31
|
+
|
32
|
+
# Ensure color values are integers in 0-255 range
|
33
|
+
if isinstance(color, tuple):
|
34
|
+
# Convert values to integers in 0-255 range
|
35
|
+
processed_color = []
|
36
|
+
for i, c in enumerate(color):
|
37
|
+
if isinstance(c, float):
|
38
|
+
# 0.0-1.0 float format
|
39
|
+
if c <= 1.0:
|
40
|
+
processed_color.append(int(c * 255))
|
41
|
+
# Already in 0-255 range but as float
|
42
|
+
else:
|
43
|
+
processed_color.append(int(c))
|
44
|
+
else:
|
45
|
+
processed_color.append(c)
|
46
|
+
|
47
|
+
# Default alpha value if needed
|
48
|
+
if len(processed_color) == 3:
|
49
|
+
processed_color.append(100) # Default alpha
|
50
|
+
|
51
|
+
self.color = tuple(processed_color)
|
52
|
+
else:
|
53
|
+
# Default if invalid color is provided
|
54
|
+
self.color = (255, 255, 0, 100) # Yellow with semi-transparency
|
55
|
+
|
56
|
+
self.label = label
|
57
|
+
|
58
|
+
# New attributes for displaying element properties
|
59
|
+
self.element = None # Will be set after initialization
|
60
|
+
self.include_attrs = None # Will be set after initialization
|
61
|
+
|
62
|
+
@property
|
63
|
+
def is_polygon(self) -> bool:
|
64
|
+
"""Check if this highlight uses polygon coordinates."""
|
65
|
+
return self.polygon is not None and len(self.polygon) >= 3
|
66
|
+
|
67
|
+
def __repr__(self) -> str:
|
68
|
+
"""String representation of the highlight."""
|
69
|
+
if self.is_polygon:
|
70
|
+
prefix = "PolygonHighlight"
|
71
|
+
else:
|
72
|
+
prefix = "Highlight"
|
73
|
+
|
74
|
+
if self.label:
|
75
|
+
return f"<{prefix} bbox={self.bbox} label='{self.label}'>"
|
76
|
+
else:
|
77
|
+
return f"<{prefix} bbox={self.bbox}>"
|
78
|
+
|
79
|
+
class HighlightManager:
|
80
|
+
"""
|
81
|
+
Manages highlights for a page.
|
82
|
+
"""
|
83
|
+
def __init__(self, page: 'Page'):
|
84
|
+
"""
|
85
|
+
Initialize a highlight manager.
|
86
|
+
|
87
|
+
Args:
|
88
|
+
page: The page to manage highlights for
|
89
|
+
"""
|
90
|
+
self._page = page
|
91
|
+
self._highlights: List[Highlight] = []
|
92
|
+
self._labels_colors: Dict[str, Tuple[int, int, int, int]] = {}
|
93
|
+
|
94
|
+
def add_polygon_highlight(self,
|
95
|
+
polygon: List[Tuple[float, float]],
|
96
|
+
color: Optional[Tuple[Union[int, float], Union[int, float], Union[int, float], Union[int, float]]] = None,
|
97
|
+
label: Optional[str] = None,
|
98
|
+
cycle_colors: bool = False,
|
99
|
+
element: Optional[Any] = None,
|
100
|
+
include_attrs: Optional[List[str]] = None,
|
101
|
+
existing: str = 'append') -> None:
|
102
|
+
"""
|
103
|
+
Add a polygon highlight to the page.
|
104
|
+
|
105
|
+
Args:
|
106
|
+
polygon: List of (x, y) coordinate tuples defining the polygon
|
107
|
+
color: RGBA color tuple (0-255 or 0.0-1.0), or None to use automatic color
|
108
|
+
label: Optional label for this highlight
|
109
|
+
cycle_colors: Force color cycling even with no label (default: False)
|
110
|
+
element: The original element being highlighted (for attribute access)
|
111
|
+
include_attrs: List of attribute names to display on the highlight (e.g., ['confidence', 'type'])
|
112
|
+
existing: How to handle existing highlights - 'append' (default) or 'replace'
|
113
|
+
"""
|
114
|
+
# Calculate bounding box from polygon
|
115
|
+
if polygon:
|
116
|
+
x_coords = [p[0] for p in polygon]
|
117
|
+
y_coords = [p[1] for p in polygon]
|
118
|
+
bbox = (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
|
119
|
+
else:
|
120
|
+
# If invalid polygon, use dummy bbox
|
121
|
+
bbox = (0, 0, 1, 1)
|
122
|
+
|
123
|
+
# Get appropriate color
|
124
|
+
final_color = self._get_highlight_color(color, label, cycle_colors)
|
125
|
+
|
126
|
+
# Clear existing highlights if replacing
|
127
|
+
if existing == 'replace':
|
128
|
+
self.clear_highlights()
|
129
|
+
|
130
|
+
# Create highlight with polygon
|
131
|
+
highlight = Highlight(bbox, final_color, label, polygon)
|
132
|
+
|
133
|
+
# Add element and attrs to the highlight
|
134
|
+
highlight.element = element
|
135
|
+
|
136
|
+
# No automatic display - only show attributes if explicitly requested
|
137
|
+
highlight.include_attrs = include_attrs
|
138
|
+
|
139
|
+
self._highlights.append(highlight)
|
140
|
+
|
141
|
+
def add_highlight(self,
|
142
|
+
bbox: Tuple[float, float, float, float],
|
143
|
+
color: Optional[Tuple[Union[int, float], Union[int, float], Union[int, float], Union[int, float]]] = None,
|
144
|
+
label: Optional[str] = None,
|
145
|
+
cycle_colors: bool = False,
|
146
|
+
element: Optional[Any] = None,
|
147
|
+
include_attrs: Optional[List[str]] = None,
|
148
|
+
existing: str = 'append') -> None:
|
149
|
+
"""
|
150
|
+
Add a highlight to the page.
|
151
|
+
|
152
|
+
Args:
|
153
|
+
bbox: Bounding box (x0, top, x1, bottom)
|
154
|
+
color: RGBA color tuple (0-255 or 0.0-1.0), or None to use automatic color
|
155
|
+
label: Optional label for this highlight
|
156
|
+
cycle_colors: Force color cycling even with no label (default: False)
|
157
|
+
element: The original element being highlighted (for attribute access)
|
158
|
+
include_attrs: List of attribute names to display on the highlight (e.g., ['confidence', 'type'])
|
159
|
+
existing: How to handle existing highlights - 'append' (default) or 'replace'
|
160
|
+
"""
|
161
|
+
# Get appropriate color
|
162
|
+
final_color = self._get_highlight_color(color, label, cycle_colors)
|
163
|
+
|
164
|
+
# Clear existing highlights if replacing
|
165
|
+
if existing == 'replace':
|
166
|
+
self.clear_highlights()
|
167
|
+
|
168
|
+
# Create highlight
|
169
|
+
highlight = Highlight(bbox, final_color, label)
|
170
|
+
|
171
|
+
# Add element and attrs to the highlight
|
172
|
+
highlight.element = element
|
173
|
+
|
174
|
+
# No automatic display - only show attributes if explicitly requested
|
175
|
+
highlight.include_attrs = include_attrs
|
176
|
+
|
177
|
+
self._highlights.append(highlight)
|
178
|
+
|
179
|
+
def _get_highlight_color(self,
|
180
|
+
color: Optional[Tuple[Union[int, float], Union[int, float], Union[int, float], Union[int, float]]] = None,
|
181
|
+
label: Optional[str] = None,
|
182
|
+
cycle_colors: bool = False) -> Tuple[int, int, int, int]:
|
183
|
+
"""
|
184
|
+
Determine the appropriate color for a highlight based on rules.
|
185
|
+
|
186
|
+
Args:
|
187
|
+
color: RGBA color tuple (0-255 or 0.0-1.0), or None to use automatic color
|
188
|
+
label: Optional label for this highlight
|
189
|
+
cycle_colors: Force color cycling even with no label (default: False)
|
190
|
+
|
191
|
+
Returns:
|
192
|
+
RGBA color tuple with values as integers 0-255
|
193
|
+
"""
|
194
|
+
# Color selection logic:
|
195
|
+
# 1. If explicit color is provided, use it
|
196
|
+
# 2. If label exists in color map, use that color (consistency)
|
197
|
+
# 3. If label is provided but new, get a new color and store it
|
198
|
+
# 4. If no label & cycle_colors=True, get next color
|
199
|
+
# 5. If no label & cycle_colors=False, use default yellow highlight
|
200
|
+
|
201
|
+
if color is not None:
|
202
|
+
# Explicit color takes precedence
|
203
|
+
# Convert from 0.0-1.0 to 0-255 if needed
|
204
|
+
if isinstance(color[0], float) and color[0] <= 1.0:
|
205
|
+
highlight_color = (
|
206
|
+
int(color[0] * 255),
|
207
|
+
int(color[1] * 255),
|
208
|
+
int(color[2] * 255),
|
209
|
+
int(color[3] * 255) if len(color) > 3 else 100
|
210
|
+
)
|
211
|
+
else:
|
212
|
+
highlight_color = color
|
213
|
+
elif label is not None and label in self._labels_colors:
|
214
|
+
# Use existing color for this label
|
215
|
+
highlight_color = self._labels_colors[label]
|
216
|
+
elif label is not None:
|
217
|
+
# New label, get a new color and store it
|
218
|
+
highlight_color = get_next_highlight_color()
|
219
|
+
self._labels_colors[label] = highlight_color
|
220
|
+
elif cycle_colors:
|
221
|
+
# No label but cycling requested
|
222
|
+
highlight_color = get_next_highlight_color()
|
223
|
+
else:
|
224
|
+
# Default case: no label, no cycling - use yellow
|
225
|
+
highlight_color = (255, 255, 0, 100) # Default yellow highlight
|
226
|
+
|
227
|
+
return highlight_color
|
228
|
+
|
229
|
+
def clear_highlights(self) -> None:
|
230
|
+
"""Clear all highlights."""
|
231
|
+
self._highlights = []
|
232
|
+
self._labels_colors = {}
|
233
|
+
reset_highlight_colors()
|
234
|
+
|
235
|
+
def get_highlighted_image(self,
|
236
|
+
scale: float = 2.0,
|
237
|
+
labels: bool = True,
|
238
|
+
legend_position: str = 'right',
|
239
|
+
render_ocr: bool = False) -> Image.Image:
|
240
|
+
"""
|
241
|
+
Get an image of the page with highlights.
|
242
|
+
|
243
|
+
Args:
|
244
|
+
scale: Scale factor for rendering
|
245
|
+
labels: Whether to include a legend for labels
|
246
|
+
legend_position: Position of the legend ('right', 'bottom', 'top', 'left')
|
247
|
+
render_ocr: Whether to render OCR text with white background boxes
|
248
|
+
|
249
|
+
Returns:
|
250
|
+
PIL Image with the highlighted page
|
251
|
+
"""
|
252
|
+
# Get the raw page image with higher resolution for clearer results
|
253
|
+
page_image = self._page._page.to_image(resolution=72 * scale)
|
254
|
+
|
255
|
+
# Convert to PIL Image in RGBA mode for transparency
|
256
|
+
img_bytes = io.BytesIO()
|
257
|
+
page_image.save(img_bytes, format='PNG')
|
258
|
+
img_bytes.seek(0)
|
259
|
+
pil_image = Image.open(img_bytes).convert('RGBA')
|
260
|
+
|
261
|
+
# Create a transparent overlay
|
262
|
+
overlay = Image.new('RGBA', pil_image.size, (0, 0, 0, 0))
|
263
|
+
draw = ImageDraw.Draw(overlay)
|
264
|
+
|
265
|
+
# Draw each highlight
|
266
|
+
for highlight in self._highlights:
|
267
|
+
if highlight.is_polygon:
|
268
|
+
# Scale polygon coordinates
|
269
|
+
scaled_polygon = [(p[0] * scale, p[1] * scale) for p in highlight.polygon]
|
270
|
+
|
271
|
+
# Draw polygon with the highlight color
|
272
|
+
draw.polygon(scaled_polygon, fill=highlight.color)
|
273
|
+
|
274
|
+
# Draw attribute text if requested
|
275
|
+
if highlight.element and highlight.include_attrs:
|
276
|
+
# Calculate bounding box from polygon for text positioning
|
277
|
+
x_coords = [p[0] for p in scaled_polygon]
|
278
|
+
y_coords = [p[1] for p in scaled_polygon]
|
279
|
+
bbox_scaled = [min(x_coords), min(y_coords), max(x_coords), max(y_coords)]
|
280
|
+
|
281
|
+
self._draw_element_attributes(
|
282
|
+
draw,
|
283
|
+
highlight.element,
|
284
|
+
highlight.include_attrs,
|
285
|
+
bbox_scaled,
|
286
|
+
scale
|
287
|
+
)
|
288
|
+
else:
|
289
|
+
# Regular rectangle highlight
|
290
|
+
x0, top, x1, bottom = highlight.bbox
|
291
|
+
|
292
|
+
# Scale the bbox to match the rendered image
|
293
|
+
x0_scaled = x0 * scale
|
294
|
+
top_scaled = top * scale
|
295
|
+
x1_scaled = x1 * scale
|
296
|
+
bottom_scaled = bottom * scale
|
297
|
+
|
298
|
+
# Draw the rectangle
|
299
|
+
draw.rectangle([x0_scaled, top_scaled, x1_scaled, bottom_scaled],
|
300
|
+
fill=highlight.color)
|
301
|
+
|
302
|
+
# Draw attribute text if requested
|
303
|
+
if highlight.element and highlight.include_attrs:
|
304
|
+
self._draw_element_attributes(
|
305
|
+
draw,
|
306
|
+
highlight.element,
|
307
|
+
highlight.include_attrs,
|
308
|
+
[x0_scaled, top_scaled, x1_scaled, bottom_scaled],
|
309
|
+
scale
|
310
|
+
)
|
311
|
+
|
312
|
+
# Combine the original image with the highlight overlay
|
313
|
+
highlighted_image = Image.alpha_composite(pil_image, overlay)
|
314
|
+
|
315
|
+
# Add OCR text rendering if requested
|
316
|
+
if render_ocr:
|
317
|
+
highlighted_image = self._render_ocr_text(highlighted_image, scale)
|
318
|
+
|
319
|
+
# Add legend if requested and there are labeled highlights
|
320
|
+
if labels and self._labels_colors:
|
321
|
+
legend = create_legend(self._labels_colors)
|
322
|
+
highlighted_image = merge_images_with_legend(highlighted_image, legend, legend_position)
|
323
|
+
|
324
|
+
return highlighted_image
|
325
|
+
|
326
|
+
def _render_ocr_text(self, image: Image.Image, scale: float = 2.0) -> Image.Image:
|
327
|
+
"""
|
328
|
+
Render OCR text on the image with white background boxes.
|
329
|
+
|
330
|
+
Args:
|
331
|
+
image: Base image to render text on
|
332
|
+
scale: Scale factor for rendering
|
333
|
+
|
334
|
+
Returns:
|
335
|
+
PIL Image with OCR text rendered
|
336
|
+
"""
|
337
|
+
# First check for OCR text elements from the selector approach
|
338
|
+
ocr_elements = self._page.find_all('text[source=ocr]')
|
339
|
+
|
340
|
+
# If that doesn't work, try checking the _elements dict directly
|
341
|
+
if not ocr_elements or len(ocr_elements) == 0:
|
342
|
+
# Check if page has elements loaded
|
343
|
+
if hasattr(self._page, '_elements') and self._page._elements is not None:
|
344
|
+
# Look for OCR elements in various possible locations
|
345
|
+
if 'ocr_text' in self._page._elements and self._page._elements['ocr_text']:
|
346
|
+
ocr_elements = self._page._elements['ocr_text']
|
347
|
+
elif 'ocr' in self._page._elements and self._page._elements['ocr']:
|
348
|
+
ocr_elements = self._page._elements['ocr']
|
349
|
+
|
350
|
+
# If still no elements, try to run extract_ocr_elements first
|
351
|
+
if not ocr_elements or len(ocr_elements) == 0:
|
352
|
+
try:
|
353
|
+
ocr_elements = self._page.extract_ocr_elements()
|
354
|
+
except Exception as e:
|
355
|
+
print(f"Error extracting OCR elements: {e}")
|
356
|
+
|
357
|
+
# Final check if we have OCR elements
|
358
|
+
if not ocr_elements or len(ocr_elements) == 0:
|
359
|
+
raise ValueError("No OCR elements found. Run OCR on the page before rendering.")
|
360
|
+
|
361
|
+
# Create a new overlay for OCR text
|
362
|
+
overlay = Image.new('RGBA', image.size, (0, 0, 0, 0))
|
363
|
+
draw = ImageDraw.Draw(overlay)
|
364
|
+
|
365
|
+
# Try to use a common sans-serif font
|
366
|
+
try:
|
367
|
+
font_path = None
|
368
|
+
for path in ["Arial.ttf", "DejaVuSans.ttf", "Helvetica.ttf", "FreeSans.ttf"]:
|
369
|
+
if os.path.exists(path):
|
370
|
+
font_path = path
|
371
|
+
break
|
372
|
+
|
373
|
+
if not font_path:
|
374
|
+
# Use the default font
|
375
|
+
font = ImageFont.load_default()
|
376
|
+
else:
|
377
|
+
# Use the found font
|
378
|
+
font = ImageFont.truetype(font_path, 12) # Default size, will be scaled
|
379
|
+
except Exception:
|
380
|
+
# Fallback to default font
|
381
|
+
font = ImageFont.load_default()
|
382
|
+
|
383
|
+
# Process each OCR element
|
384
|
+
for element in ocr_elements:
|
385
|
+
# Get the element's bounding box
|
386
|
+
x0, top, x1, bottom = element.bbox
|
387
|
+
|
388
|
+
# Scale the bbox to match the rendered image
|
389
|
+
x0_scaled = x0 * scale
|
390
|
+
top_scaled = top * scale
|
391
|
+
x1_scaled = x1 * scale
|
392
|
+
bottom_scaled = bottom * scale
|
393
|
+
|
394
|
+
# Calculate text size for optimal rendering
|
395
|
+
box_width = x1_scaled - x0_scaled
|
396
|
+
box_height = bottom_scaled - top_scaled
|
397
|
+
|
398
|
+
# Calculate font size based on box height (approx 90% of box height)
|
399
|
+
# Use a higher percentage to make text larger
|
400
|
+
font_size = int(box_height * 0.9)
|
401
|
+
if font_size < 9: # Higher minimum readable size
|
402
|
+
font_size = 9
|
403
|
+
|
404
|
+
# Create a font of the appropriate size
|
405
|
+
try:
|
406
|
+
if font_path:
|
407
|
+
sized_font = ImageFont.truetype(font_path, font_size)
|
408
|
+
else:
|
409
|
+
# If no truetype font, use default and scale as best as possible
|
410
|
+
sized_font = font
|
411
|
+
except Exception:
|
412
|
+
sized_font = font
|
413
|
+
|
414
|
+
# Measure text to check for overflow
|
415
|
+
try:
|
416
|
+
# Get text width with the sized font
|
417
|
+
text_width = draw.textlength(element.text, font=sized_font)
|
418
|
+
|
419
|
+
# If text is too wide, scale down font size - but only if significantly too wide
|
420
|
+
# Allow more overflow (1.25 instead of 1.1)
|
421
|
+
if text_width > box_width * 1.25:
|
422
|
+
# Less aggressive reduction
|
423
|
+
reduction_factor = box_width / text_width
|
424
|
+
# Don't shrink below 75% of original calculated size
|
425
|
+
font_size = max(10, max(int(font_size * 0.75), int(font_size * reduction_factor)))
|
426
|
+
try:
|
427
|
+
if font_path:
|
428
|
+
sized_font = ImageFont.truetype(font_path, font_size)
|
429
|
+
except Exception:
|
430
|
+
pass # Keep current font if failed
|
431
|
+
except Exception:
|
432
|
+
# If text measurement fails, continue with current font
|
433
|
+
pass
|
434
|
+
|
435
|
+
# Add padding to the white background box
|
436
|
+
padding = max(2, int(font_size * 0.1))
|
437
|
+
|
438
|
+
# Draw white background (slightly larger than text area)
|
439
|
+
draw.rectangle(
|
440
|
+
[x0_scaled - padding,
|
441
|
+
top_scaled - padding,
|
442
|
+
x1_scaled + padding,
|
443
|
+
bottom_scaled + padding],
|
444
|
+
fill=(255, 255, 255, 240) # Slightly transparent white
|
445
|
+
)
|
446
|
+
|
447
|
+
# Draw black text - centered horizontally and vertically
|
448
|
+
try:
|
449
|
+
# Calculate text dimensions for centering
|
450
|
+
if hasattr(sized_font, "getbbox"):
|
451
|
+
# PIL 9.2.0+ method
|
452
|
+
text_bbox = sized_font.getbbox(element.text)
|
453
|
+
text_width = text_bbox[2] - text_bbox[0]
|
454
|
+
text_height = text_bbox[3] - text_bbox[1]
|
455
|
+
else:
|
456
|
+
# Fallback for older PIL versions
|
457
|
+
text_width = draw.textlength(element.text, font=sized_font)
|
458
|
+
text_height = font_size # Approximate
|
459
|
+
|
460
|
+
# Center the text both horizontally and vertically
|
461
|
+
text_x = x0_scaled + (box_width - text_width) / 2
|
462
|
+
text_y = top_scaled + (box_height - text_height) / 2
|
463
|
+
|
464
|
+
# Don't let text go out of bounds
|
465
|
+
text_x = max(x0_scaled, text_x)
|
466
|
+
text_y = max(top_scaled, text_y)
|
467
|
+
|
468
|
+
except Exception:
|
469
|
+
# Fallback if calculation fails
|
470
|
+
text_x = x0_scaled
|
471
|
+
text_y = top_scaled
|
472
|
+
|
473
|
+
draw.text(
|
474
|
+
(text_x, text_y),
|
475
|
+
element.text,
|
476
|
+
fill=(0, 0, 0, 255),
|
477
|
+
font=sized_font,
|
478
|
+
)
|
479
|
+
|
480
|
+
# Combine the original image with the OCR text overlay
|
481
|
+
result_image = Image.alpha_composite(image, overlay)
|
482
|
+
return result_image
|
483
|
+
|
484
|
+
def _draw_element_attributes(self,
|
485
|
+
draw: ImageDraw.Draw,
|
486
|
+
element: Any,
|
487
|
+
attr_names: List[str],
|
488
|
+
bbox_scaled: List[float],
|
489
|
+
scale: float) -> None:
|
490
|
+
"""
|
491
|
+
Draw element attributes as text on the highlight.
|
492
|
+
|
493
|
+
Args:
|
494
|
+
draw: PIL ImageDraw object to draw on
|
495
|
+
element: Element being highlighted
|
496
|
+
attr_names: List of attribute names to display
|
497
|
+
bbox_scaled: Scaled bounding box [x0, top, x1, bottom]
|
498
|
+
scale: Scale factor for rendering
|
499
|
+
"""
|
500
|
+
# Try to load the font
|
501
|
+
try:
|
502
|
+
# Load a monospace font for attribute display with larger size
|
503
|
+
try:
|
504
|
+
# Try to get a default system font
|
505
|
+
# Increase font size for better readability
|
506
|
+
font_size = max(16, int(16 * scale))
|
507
|
+
font = ImageFont.truetype("DejaVuSansMono.ttf", font_size)
|
508
|
+
except Exception:
|
509
|
+
# Fall back to default PIL font
|
510
|
+
font = ImageFont.load_default()
|
511
|
+
font_size = 16
|
512
|
+
except Exception:
|
513
|
+
# If font loading fails entirely
|
514
|
+
return
|
515
|
+
|
516
|
+
# Center attributes near the top of the highlight for better visibility
|
517
|
+
# Calculate dimensions
|
518
|
+
width = bbox_scaled[2] - bbox_scaled[0]
|
519
|
+
height = bbox_scaled[3] - bbox_scaled[1]
|
520
|
+
|
521
|
+
# For confidence only, center the text horizontally
|
522
|
+
if attr_names == ['confidence']:
|
523
|
+
x = bbox_scaled[0] + (width / 2) # Will be adjusted after measuring text
|
524
|
+
else:
|
525
|
+
# Left-align attributes at the top of the highlight
|
526
|
+
x = bbox_scaled[0] + 8 # More padding from left edge
|
527
|
+
|
528
|
+
y = bbox_scaled[1] + 8 # More padding from top
|
529
|
+
|
530
|
+
# White background for better readability
|
531
|
+
background_color = (255, 255, 255, 255) # Solid white
|
532
|
+
text_color = (0, 0, 0, 255) # Black
|
533
|
+
|
534
|
+
# Process each requested attribute
|
535
|
+
attr_text_lines = []
|
536
|
+
for attr_name in attr_names:
|
537
|
+
# Try to get the attribute value
|
538
|
+
try:
|
539
|
+
attr_value = getattr(element, attr_name, None)
|
540
|
+
if attr_value is not None:
|
541
|
+
# Special formatting for confidence
|
542
|
+
if attr_name == 'confidence' and isinstance(attr_value, float):
|
543
|
+
# Just display the value without the name for confidence
|
544
|
+
attr_text = f"{attr_value:.2f}"
|
545
|
+
# Format other numeric values
|
546
|
+
elif isinstance(attr_value, float):
|
547
|
+
attr_value = f"{attr_value:.2f}"
|
548
|
+
attr_text = f"{attr_name}: {attr_value}"
|
549
|
+
# Regular formatting for other attributes
|
550
|
+
else:
|
551
|
+
attr_text = f"{attr_name}: {attr_value}"
|
552
|
+
|
553
|
+
attr_text_lines.append(attr_text)
|
554
|
+
except Exception:
|
555
|
+
continue
|
556
|
+
|
557
|
+
if not attr_text_lines:
|
558
|
+
return
|
559
|
+
|
560
|
+
# Calculate text height for background
|
561
|
+
line_height = font_size + 4 # Add more padding between lines
|
562
|
+
total_height = line_height * len(attr_text_lines)
|
563
|
+
|
564
|
+
# Special case for confidence only - center it horizontally
|
565
|
+
if attr_names == ['confidence'] and len(attr_text_lines) == 1:
|
566
|
+
text_width = draw.textlength(attr_text_lines[0], font=font)
|
567
|
+
center_x = bbox_scaled[0] + (width / 2)
|
568
|
+
x = center_x - (text_width / 2) # Center the text
|
569
|
+
|
570
|
+
# Draw a solid background for the text with more padding
|
571
|
+
bg_padding = 8 # Even more padding for confidence
|
572
|
+
bg_width = text_width + (bg_padding * 2)
|
573
|
+
|
574
|
+
# Make background box
|
575
|
+
draw.rectangle(
|
576
|
+
[x - bg_padding,
|
577
|
+
y - bg_padding,
|
578
|
+
x + text_width + bg_padding,
|
579
|
+
y + font_size + bg_padding],
|
580
|
+
fill=background_color,
|
581
|
+
outline=(0, 0, 0, 255), # Add a black outline
|
582
|
+
width=1 # 1px outline
|
583
|
+
)
|
584
|
+
|
585
|
+
# Draw centered confidence value
|
586
|
+
draw.text((x, y), attr_text_lines[0], fill=text_color, font=font)
|
587
|
+
else:
|
588
|
+
# Draw a solid background for the text with more padding
|
589
|
+
bg_padding = 6 # Increased padding
|
590
|
+
bg_width = max(draw.textlength(line, font=font) for line in attr_text_lines) + (bg_padding * 2)
|
591
|
+
draw.rectangle(
|
592
|
+
[x - bg_padding,
|
593
|
+
y - bg_padding,
|
594
|
+
x + bg_width,
|
595
|
+
y + total_height + bg_padding],
|
596
|
+
fill=background_color,
|
597
|
+
outline=(0, 0, 0, 255), # Add a black outline
|
598
|
+
width=1 # 1px outline
|
599
|
+
)
|
600
|
+
|
601
|
+
# Draw each attribute line
|
602
|
+
current_y = y
|
603
|
+
for line in attr_text_lines:
|
604
|
+
draw.text((x, current_y), line, fill=text_color, font=font)
|
605
|
+
current_y += line_height
|