document-analyzer 0.1.0__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,229 @@
1
+ import cv2
2
+ import numpy as np
3
+ from io import BytesIO
4
+
5
+ from ..config import logger as default_logger
6
+
7
+ # =============================================================================
8
+ # FILE HANDLING UTILITY
9
+ # =============================================================================
10
+
11
+
12
+ def ensure_bytesio(file):
13
+ """Convert various file types to BytesIO for consistent handling.
14
+
15
+ Args:
16
+ file: Input file in various formats - can be BytesIO, file path (str),
17
+ or file-like object (Django uploads, etc.).
18
+
19
+ Returns:
20
+ BytesIO: File content as BytesIO object with position reset to 0.
21
+
22
+ Raises:
23
+ IOError: If file path cannot be read.
24
+ AttributeError: If file-like object doesn't have read() method.
25
+
26
+ Examples:
27
+ >>> with open("image.jpg", "rb") as f:
28
+ ... bio = ensure_bytesio(f)
29
+ >>> bio = ensure_bytesio("/path/to/image.jpg")
30
+ >>> bio = ensure_bytesio(existing_bytesio_object)
31
+ """
32
+ if isinstance(file, BytesIO):
33
+ file.seek(0)
34
+ return file
35
+ elif isinstance(file, str):
36
+ # Handle file path
37
+ with open(file, "rb") as f:
38
+ return BytesIO(f.read())
39
+ else:
40
+ # Handle file-like objects (Django uploads, etc.)
41
+ return BytesIO(file.read())
42
+
43
+
44
+ # =============================================================================
45
+ # IMAGE PREPROCESSING UTILITY
46
+ # =============================================================================
47
+
48
+
49
+ def preprocess_image(image, logger=None):
50
+ """Preprocess the cédula image for better OCR results.
51
+
52
+ Applies various image enhancement techniques including brightness adjustment,
53
+ sharpness enhancement, and noise reduction based on image characteristics.
54
+
55
+ Args:
56
+ image (np.ndarray): Input image in BGR format from OpenCV.
57
+ logger (logging.Logger, optional): Logger instance for debug messages.
58
+ Defaults to module's default logger.
59
+
60
+ Returns:
61
+ np.ndarray: Preprocessed image in BGR format ready for OCR processing.
62
+
63
+ Note:
64
+ The function automatically determines which enhancements to apply based
65
+ on image statistics like brightness and sharpness variance.
66
+
67
+ Examples:
68
+ >>> import cv2
69
+ >>> image = cv2.imread("cedula.jpg")
70
+ >>> processed = preprocess_image(image)
71
+ >>> # Image is now enhanced for better OCR results
72
+ """
73
+ if logger is None:
74
+ logger = default_logger
75
+
76
+ logger.debug("Starting image preprocessing")
77
+
78
+ # Convert to grayscale (if needed)
79
+ if len(image.shape) == 3:
80
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
81
+ else:
82
+ gray = image.copy()
83
+
84
+ # Brightness adjustment (if needed)
85
+ mean_brightness = np.mean(gray)
86
+ logger.debug(f"Image mean brightness: {mean_brightness:.2f}")
87
+
88
+ if mean_brightness < 80:
89
+ gray = cv2.convertScaleAbs(gray, alpha=1.2, beta=20)
90
+ logger.debug("Applied brightness enhancement for dark image")
91
+ elif mean_brightness > 200:
92
+ gray = cv2.convertScaleAbs(gray, alpha=0.9, beta=-10)
93
+ logger.debug("Applied brightness reduction for bright image")
94
+
95
+ # Minimal sharpness adjustment (if needed)
96
+ laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
97
+ logger.debug(f"Image sharpness variance: {laplacian_var:.2f}")
98
+
99
+ if laplacian_var < 50:
100
+ kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
101
+ gray = cv2.filter2D(gray, -1, kernel)
102
+ logger.debug("Applied sharpening filter for blurry image")
103
+
104
+ # Very light noise reduction (if needed)
105
+ if mean_brightness < 100 or laplacian_var > 2000:
106
+ processed = cv2.bilateralFilter(gray, 5, 50, 50)
107
+ logger.debug("Applied noise reduction")
108
+ else:
109
+ processed = gray
110
+
111
+ # Convert back to BGR for OCR
112
+ processed_image = cv2.cvtColor(processed, cv2.COLOR_GRAY2BGR)
113
+ logger.debug("Image preprocessing completed")
114
+
115
+ return processed_image
116
+
117
+
118
+ # =============================================================================
119
+ # OCR DATA EXTRACTION UTILITIES
120
+ # =============================================================================
121
+
122
+
123
+ def create_text_data(bbox, text, confidence):
124
+ """Create standardized text data structure from OCR results.
125
+
126
+ Processes bounding box coordinates to calculate center point, dimensions,
127
+ and aspect ratio for text analysis and signature detection.
128
+
129
+ Args:
130
+ bbox (list): List of 4 coordinate pairs [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
131
+ representing the bounding box corners.
132
+ text (str): Extracted text content from OCR.
133
+ confidence (float): OCR confidence score between 0.0 and 1.0.
134
+
135
+ Returns:
136
+ dict or None: Standardized text data structure containing:
137
+ - bbox: Original bounding box coordinates
138
+ - text: Cleaned text content (stripped)
139
+ - confidence: OCR confidence score
140
+ - center_x, center_y: Calculated center point coordinates
141
+ - width, height: Bounding box dimensions
142
+ - aspect_ratio: Width/height ratio
143
+ Returns None if bbox is invalid.
144
+
145
+ Examples:
146
+ >>> bbox = [[10, 20], [100, 20], [100, 40], [10, 40]]
147
+ >>> data = create_text_data(bbox, "Sample Text", 0.95)
148
+ >>> print(data['center_x']) # 55.0
149
+ >>> print(data['aspect_ratio']) # 4.5
150
+ """
151
+ if not bbox or len(bbox) < 4:
152
+ return None
153
+
154
+ # Calculate center point and dimensions
155
+ x_coords = [point[0] for point in bbox[:4]]
156
+ y_coords = [point[1] for point in bbox[:4]]
157
+ center_x = sum(x_coords) / 4
158
+ center_y = sum(y_coords) / 4
159
+
160
+ width = max(x_coords) - min(x_coords)
161
+ height = max(y_coords) - min(y_coords)
162
+
163
+ return {
164
+ "bbox": bbox,
165
+ "text": text.strip(),
166
+ "confidence": confidence,
167
+ "center_x": center_x,
168
+ "center_y": center_y,
169
+ "width": width,
170
+ "height": height,
171
+ "aspect_ratio": width / height if height > 0 else 0,
172
+ }
173
+
174
+
175
+ def extract_data_with_boxes(image, ocr, logger=None):
176
+ """Extract text data with bounding boxes using PaddleOCR.
177
+
178
+ Performs OCR on the preprocessed image and returns structured text data
179
+ with bounding box information for each detected text element.
180
+
181
+ Args:
182
+ image (np.ndarray): Preprocessed image in BGR format ready for OCR.
183
+ ocr: PaddleOCR instance configured for text extraction.
184
+ logger (logging.Logger, optional): Logger instance for debug messages.
185
+ Defaults to module's default logger.
186
+
187
+ Returns:
188
+ list: List of dictionaries containing text data structures. Each dict
189
+ contains bbox, text, confidence, center coordinates, dimensions,
190
+ and aspect ratio. Empty list if OCR fails.
191
+
192
+ Raises:
193
+ Exception: Logs OCR extraction errors but returns empty list instead
194
+ of raising to maintain graceful error handling.
195
+
196
+ Examples:
197
+ >>> from paddleocr import PaddleOCR
198
+ >>> ocr = PaddleOCR(lang="es")
199
+ >>> extracted = extract_data_with_boxes(processed_image, ocr)
200
+ >>> for item in extracted:
201
+ ... print(f"Text: {item['text']}, Confidence: {item['confidence']:.2f}")
202
+ """
203
+ if logger is None:
204
+ logger = default_logger
205
+
206
+ try:
207
+ logger.debug("Starting OCR data extraction")
208
+
209
+ results = ocr.predict(image)
210
+ result = results[0]
211
+ extracted_data = []
212
+
213
+ texts = result["rec_texts"]
214
+ scores = result["rec_scores"]
215
+ polys = result["rec_polys"]
216
+
217
+ for _, (text, score, poly) in enumerate(zip(texts, scores, polys)):
218
+ if text and text.strip():
219
+ # Convert poly to the expected bbox format
220
+ bbox = poly.tolist() if hasattr(poly, "tolist") else poly
221
+ extracted_data.append(create_text_data(bbox, text, score))
222
+
223
+ logger.debug(f"OCR extracted {len(extracted_data)} text elements")
224
+
225
+ return extracted_data
226
+
227
+ except Exception as e:
228
+ logger.error(f"OCR extraction failed: {str(e)}")
229
+ return []
@@ -0,0 +1,431 @@
1
+ import re
2
+ import cv2
3
+
4
+ from ..config import logger as default_logger
5
+
6
+ # =============================================================================
7
+ # SIGNATURE DETECTION UTILITIES
8
+ # =============================================================================
9
+
10
+
11
+ def find_expira_block(extracted_data, logger=None):
12
+ """Find the EXPIRA block to use as reference for signature detection.
13
+
14
+ Searches through OCR extracted data to locate text containing "EXPIRA"
15
+ which serves as a reference point for signature location on cédula documents.
16
+
17
+ Args:
18
+ extracted_data (list): List of text data dictionaries from OCR extraction.
19
+ logger (logging.Logger, optional): Logger instance for debug messages.
20
+ Defaults to module's default logger.
21
+
22
+ Returns:
23
+ dict or None: Text data dictionary containing "EXPIRA" text if found,
24
+ None if no EXPIRA block is detected.
25
+
26
+ Note:
27
+ The EXPIRA block typically contains the document expiry date and serves
28
+ as a reliable landmark for signature positioning on cédula documents.
29
+
30
+ Examples:
31
+ >>> expira_block = find_expira_block(extracted_data)
32
+ >>> if expira_block:
33
+ ... print(f"Found EXPIRA at y={expira_block['center_y']}")
34
+ """
35
+ if logger is None:
36
+ logger = default_logger
37
+
38
+ for _, item in enumerate(extracted_data):
39
+ text = item["text"].upper()
40
+ if "EXPIRA" in text:
41
+ logger.debug(f"Found EXPIRA block: '{text}'")
42
+ return item
43
+
44
+ logger.debug("No EXPIRA block found")
45
+
46
+ return None
47
+
48
+
49
+ def fallback_signature_detection(extracted_data, image_shape, logger=None):
50
+ """Fallback signature detection when EXPIRA block is not found.
51
+
52
+ Implements alternative signature detection by analyzing text boxes in the
53
+ bottom portion of the document where signatures are typically located.
54
+
55
+ Args:
56
+ extracted_data (list): List of text data dictionaries from OCR extraction.
57
+ image_shape (tuple): Image dimensions as (height, width, channels).
58
+ logger (logging.Logger, optional): Logger instance for debug messages.
59
+ Defaults to module's default logger.
60
+
61
+ Returns:
62
+ dict or None: Text data dictionary with lowest confidence in bottom area,
63
+ which likely represents handwritten signature text.
64
+ Returns None if no suitable candidates found.
65
+
66
+ Note:
67
+ This method assumes signatures appear in the bottom 40% of the document
68
+ and have lower OCR confidence due to handwriting characteristics.
69
+
70
+ Examples:
71
+ >>> fallback = fallback_signature_detection(extracted_data, image.shape)
72
+ >>> if fallback:
73
+ ... print(f"Fallback signature: {fallback['text']}")
74
+ """
75
+ if logger is None:
76
+ logger = default_logger
77
+
78
+ logger.debug("Using fallback signature detection")
79
+
80
+ height, _ = image_shape[:2]
81
+
82
+ # Filter boxes in the bottom area where signature is expected
83
+ bottom_boxes = []
84
+ for box_data in extracted_data:
85
+ # Check if box is in the bottom 40% of the image
86
+ if box_data["center_y"] > height * 0.6:
87
+ bottom_boxes.append(box_data)
88
+
89
+ if not bottom_boxes:
90
+ logger.debug("No boxes found in the bottom area for fallback detection")
91
+ return None
92
+
93
+ # Return the box with lowest confidence in bottom area
94
+ fallback = min(bottom_boxes, key=lambda x: x["confidence"])
95
+ logger.debug(f"Fallback signature confidence: {fallback['confidence']:.3f})")
96
+
97
+ return fallback
98
+
99
+
100
+ def identify_signature_box(extracted_data, image_shape, logger=None):
101
+ """Identify signature based on EXPIRA block position and text characteristics.
102
+
103
+ Main signature detection function that uses the EXPIRA block as a reference
104
+ point and applies scoring algorithms to identify the most likely signature
105
+ area among text boxes below the expiry date.
106
+
107
+ Args:
108
+ extracted_data (list): List of text data dictionaries from OCR extraction.
109
+ image_shape (tuple): Image dimensions as (height, width, channels).
110
+ logger (logging.Logger, optional): Logger instance for debug messages.
111
+ Defaults to module's default logger.
112
+
113
+ Returns:
114
+ dict or None: Text data dictionary identified as signature with highest
115
+ signature score. Returns None if no suitable signature
116
+ candidate is found.
117
+
118
+ Note:
119
+ Scoring algorithm considers multiple factors:
120
+ - Low OCR confidence (handwriting is harder to read)
121
+ - Non-alphanumeric characters (signature flourishes)
122
+ - Wide aspect ratio (signatures span horizontally)
123
+ - Mixed case patterns
124
+ - Low alphabetic ratio
125
+ - Penalties for obvious document text patterns
126
+
127
+ Examples:
128
+ >>> signature = identify_signature_box(extracted_data, image.shape)
129
+ >>> if signature:
130
+ ... print(f"Signature detected: '{signature['text']}'")
131
+ ... print(f"Confidence: {signature['confidence']:.3f}")
132
+ """
133
+ if logger is None:
134
+ logger = default_logger
135
+
136
+ logger.debug("Starting signature box identification")
137
+
138
+ if not extracted_data:
139
+ logger.warning("No extracted data available for signature identification")
140
+ return None
141
+
142
+ # Find the EXPIRA block
143
+ expira_block = find_expira_block(extracted_data, logger=logger)
144
+
145
+ if not expira_block:
146
+ return fallback_signature_detection(extracted_data, image_shape, logger=logger)
147
+
148
+ expira_y = expira_block["center_y"]
149
+ expira_bottom = expira_y + (expira_block["height"] / 2)
150
+
151
+ # Find all boxes below the EXPIRA block
152
+ below_expira_boxes = []
153
+ for _, box_data in enumerate(extracted_data):
154
+ # Check if box center is below the bottom of EXPIRA block
155
+ if box_data["center_y"] > expira_bottom:
156
+ below_expira_boxes.append(box_data)
157
+
158
+ logger.debug(f"Found {len(below_expira_boxes)} box(es) below the EXPIRA block")
159
+
160
+ if not below_expira_boxes:
161
+ logger.warning("No box(es) found below the EXPIRA block")
162
+ return None
163
+
164
+ # Score each candidate based on the signature characteristics
165
+ signature_candidates = []
166
+
167
+ for _, candidate in enumerate(below_expira_boxes):
168
+ text = candidate["text"]
169
+ confidence = candidate["confidence"]
170
+
171
+ signature_score = 0
172
+ reasons = []
173
+
174
+ # Score 1: Lower confidence is better for signatures (handwriting is harder to OCR)
175
+ if confidence < 0.2:
176
+ signature_score += 6
177
+ reasons.append("very low confidence")
178
+ elif confidence < 0.4:
179
+ signature_score += 4
180
+ reasons.append("low confidence")
181
+ elif confidence < 0.6:
182
+ signature_score += 2
183
+ reasons.append("medium-low confidence")
184
+
185
+ # Score 2: Non-alphanumeric characters (signatures often have curves, flourishes)
186
+ special_chars = len([c for c in text if not c.isalnum() and c not in " .,-"])
187
+ if special_chars > 2:
188
+ signature_score += 4
189
+ reasons.append("many special characters")
190
+ elif special_chars > 0:
191
+ signature_score += 2
192
+ reasons.append("some special characters")
193
+
194
+ # Score 3: Aspect ratio (signatures are typically wider)
195
+ if candidate["aspect_ratio"] > 4:
196
+ signature_score += 4
197
+ reasons.append("very wide aspect ratio")
198
+ elif candidate["aspect_ratio"] > 2.5:
199
+ signature_score += 2
200
+ reasons.append("wide aspect ratio")
201
+ elif candidate["aspect_ratio"] > 1.5:
202
+ signature_score += 1
203
+ reasons.append("moderately wide")
204
+
205
+ # Score 4: Mixed case or unusual patterns
206
+ if any(c.islower() for c in text) and any(c.isupper() for c in text):
207
+ signature_score += 3
208
+ reasons.append("mixed case")
209
+
210
+ # Score 5: Low alphabetic ratio (signatures may have unclear characters)
211
+ alpha_ratio = len([c for c in text if c.isalpha()]) / len(text) if text else 0
212
+ if alpha_ratio < 0.5 and len(text) > 2:
213
+ signature_score += 3
214
+ reasons.append("low alpha ratio")
215
+ elif alpha_ratio < 0.7 and len(text) > 3:
216
+ signature_score += 1
217
+ reasons.append("medium alpha ratio")
218
+
219
+ # Score 6: Short text (signatures can be brief or poorly recognized)
220
+ if len(text) <= 3:
221
+ signature_score += 2
222
+ reasons.append("short text")
223
+
224
+ # Score 7: Contains handwriting-like patterns
225
+ handwriting_patterns = ["j", "g", "y", "f", "p", "q"] # Letters with descenders
226
+ if any(letter in text.lower() for letter in handwriting_patterns):
227
+ signature_score += 1
228
+ reasons.append("handwriting patterns")
229
+
230
+ # PENALTIES: Heavily penalize obvious non-signature text
231
+ penalty = 0
232
+
233
+ # Penalty 1: ID number pattern (flexible format: [A-Z or digits]-[digits]-[digits])
234
+ if re.search(r"([A-Z]+|\d+)-(\d+)-(\d+)", text):
235
+ penalty += 20
236
+ reasons.append("PENALTY: ID number pattern")
237
+
238
+ # Penalty 2: Date patterns
239
+ if re.search(r"\d{2}-\w{3}-\d{4}", text.upper()):
240
+ penalty += 15
241
+ reasons.append("PENALTY: date pattern")
242
+
243
+ # Penalty 3: Clear document text
244
+ from ..config import DOCUMENT_KEYWORDS_ES
245
+
246
+ if any(keyword in text.upper() for keyword in DOCUMENT_KEYWORDS_ES):
247
+ penalty += 15
248
+ reasons.append("PENALTY: document text")
249
+
250
+ # Penalty 4: Very high confidence (printed text is usually high confidence)
251
+ if confidence > 0.85:
252
+ penalty += 4
253
+ reasons.append("PENALTY: very high confidence")
254
+ elif confidence > 0.7:
255
+ penalty += 2
256
+ reasons.append("PENALTY: high confidence")
257
+
258
+ # Penalty 5: Pure numeric text
259
+ if text.replace("-", "").replace(" ", "").isdigit() and len(text) > 2:
260
+ penalty += 12
261
+ reasons.append("PENALTY: numeric text")
262
+
263
+ # Penalty 6: Very long text (signatures are usually short)
264
+ if len(text) > 20:
265
+ penalty += 5
266
+ reasons.append("PENALTY: very long text")
267
+
268
+ final_score = signature_score - penalty
269
+ signature_candidates.append((candidate, final_score))
270
+
271
+ # Step 4: Select best candidate - Sort by score (highest first)
272
+ signature_candidates.sort(key=lambda x: x[1], reverse=True)
273
+
274
+ # Allow slightly negative scores
275
+ if signature_candidates and signature_candidates[0][1] > -5:
276
+ best_candidate = signature_candidates[0][0]
277
+ best_score = signature_candidates[0][1]
278
+ logger.info(
279
+ f"Selected signature candidate: score={best_score} (base={signature_score}, penalty={penalty}) - {', '.join(reasons)}"
280
+ )
281
+ return best_candidate
282
+
283
+ # If no good candidate found, return the one with lowest confidence
284
+ if below_expira_boxes:
285
+ fallback = min(below_expira_boxes, key=lambda x: x["confidence"])
286
+ logger.warning(f"No good signature candidate found, using fallback")
287
+
288
+ return fallback
289
+
290
+ logger.warning("No signature candidate identified")
291
+
292
+ return None
293
+
294
+
295
+ # =============================================================================
296
+ # SIGNATURE EXTRACTION UTILITIES
297
+ # =============================================================================
298
+
299
+
300
+ def process_signature_to_bw(signature_image, logger=None):
301
+ """Enhanced signature processing using adaptive thresholding.
302
+
303
+ Processes extracted signature region to enhance visibility of handwritten
304
+ signatures by converting to black and white using adaptive thresholding.
305
+
306
+ Args:
307
+ signature_image (np.ndarray): Raw signature region extracted from document.
308
+ logger (logging.Logger, optional): Logger instance for debug messages.
309
+ Defaults to module's default logger.
310
+
311
+ Returns:
312
+ np.ndarray: Binary (black and white) signature image with enhanced
313
+ contrast suitable for further processing or display.
314
+
315
+ Note:
316
+ The function applies:
317
+ - Grayscale conversion if needed
318
+ - CLAHE (Contrast Limited Adaptive Histogram Equalization)
319
+ - Normalization for consistent contrast
320
+ - Adaptive thresholding for binary conversion
321
+
322
+ Examples:
323
+ >>> processed_sig = process_signature_to_bw(signature_region)
324
+ >>> cv2.imwrite("signature_bw.png", processed_sig)
325
+ """
326
+ if logger is None:
327
+ logger = default_logger
328
+
329
+ logger.debug("Processing signature with adaptive threshold method")
330
+
331
+ # Convert to grayscale
332
+ if len(signature_image.shape) == 3:
333
+ gray = cv2.cvtColor(signature_image, cv2.COLOR_BGR2GRAY)
334
+ else:
335
+ gray = signature_image.copy()
336
+
337
+ # Enhance contrast more aggressively for faint signatures
338
+ clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
339
+ enhanced = clahe.apply(gray)
340
+
341
+ # Additional contrast enhancement
342
+ enhanced = cv2.normalize(enhanced, None, 0, 255, cv2.NORM_MINMAX)
343
+
344
+ # Apply adaptive threshold
345
+ final_signature = cv2.adaptiveThreshold(
346
+ enhanced, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 15, 8
347
+ )
348
+
349
+ logger.debug("Signature processing completed using adaptive threshold")
350
+
351
+ return final_signature
352
+
353
+
354
+ def extract_signature_image(image, signature_box, logger=None):
355
+ """Extract and process the signature image from the bounding box.
356
+
357
+ Extracts the signature region from the full document image based on the
358
+ identified signature bounding box, applies padding for complete capture,
359
+ and processes the result for optimal signature visibility.
360
+
361
+ Args:
362
+ image (np.ndarray): Original cédula document image in BGR format.
363
+ signature_box (dict): Text data dictionary containing signature bbox
364
+ and other properties from signature identification.
365
+ logger (logging.Logger, optional): Logger instance for debug messages.
366
+ Defaults to module's default logger.
367
+
368
+ Returns:
369
+ np.ndarray or None: Processed binary signature image ready for use,
370
+ or None if extraction fails or signature_box is invalid.
371
+
372
+ Note:
373
+ - Applies generous padding (50% width, 40% height) to capture signature
374
+ elements that may extend beyond OCR detection boundaries
375
+ - Automatically handles image boundary constraints
376
+ - Processes extracted region to black and white for clarity
377
+
378
+ Raises:
379
+ None: Function handles errors gracefully and returns None on failure.
380
+
381
+ Examples:
382
+ >>> signature_img = extract_signature_image(image, signature_box)
383
+ >>> if signature_img is not None:
384
+ ... cv2.imwrite("extracted_signature.png", signature_img)
385
+ ... print("Signature extracted successfully")
386
+ """
387
+ if logger is None:
388
+ logger = default_logger
389
+
390
+ logger.debug("Starting signature image extraction")
391
+
392
+ if not signature_box:
393
+ logger.warning("No signature box provided for extraction")
394
+ return None
395
+
396
+ bbox = signature_box["bbox"]
397
+
398
+ # Get bounding box coordinates
399
+ x_coords = [point[0] for point in bbox]
400
+ y_coords = [point[1] for point in bbox]
401
+
402
+ x1, y1 = int(min(x_coords)), int(min(y_coords))
403
+ x2, y2 = int(max(x_coords)), int(max(y_coords))
404
+
405
+ # Signatures often extend beyond OCR detection boxes
406
+ padding_x = max(30, int((x2 - x1) * 0.5)) # 50% of width or 30px minimum
407
+ padding_y = max(20, int((y2 - y1) * 0.4)) # 40% of height or 20px minimum
408
+
409
+ # Expand the region
410
+ x1_expanded = max(0, x1 - padding_x)
411
+ y1_expanded = max(0, y1 - padding_y)
412
+ x2_expanded = min(image.shape[1], x2 + padding_x)
413
+ y2_expanded = min(image.shape[0], y2 + padding_y)
414
+
415
+ logger.debug(
416
+ f"Extracting signature region: ({x1_expanded},{y1_expanded}) to ({x2_expanded},{y2_expanded})"
417
+ )
418
+
419
+ # Extract signature region
420
+ signature_region = image[y1_expanded:y2_expanded, x1_expanded:x2_expanded]
421
+
422
+ if signature_region.size == 0:
423
+ logger.error("Empty signature region extracted")
424
+ return None
425
+
426
+ # Process signature to black and white
427
+ processed_signature = process_signature_to_bw(signature_region, logger=logger)
428
+
429
+ logger.debug("Signature extraction and processing completed")
430
+
431
+ return processed_signature