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.
- document_analyzer/__init__.py +31 -0
- document_analyzer/__main__.py +5 -0
- document_analyzer/analyzers/__init__.py +14 -0
- document_analyzer/analyzers/cedula_analyzer.py +412 -0
- document_analyzer/analyzers/document_analyzer.py +187 -0
- document_analyzer/analyzers/passport_analyzer.py +294 -0
- document_analyzer/cli.py +401 -0
- document_analyzer/config/__init__.py +30 -0
- document_analyzer/config/constants.py +230 -0
- document_analyzer/config/logger.py +36 -0
- document_analyzer/services/__init__.py +3 -0
- document_analyzer/services/paddleocr_service.py +107 -0
- document_analyzer/startup.py +24 -0
- document_analyzer/utils/__init__.py +57 -0
- document_analyzer/utils/cedula_utils.py +155 -0
- document_analyzer/utils/common_utils.py +229 -0
- document_analyzer/utils/extract_cedula_signature.py +431 -0
- document_analyzer/utils/passport_language_detector.py +277 -0
- document_analyzer/utils/passport_utils.py +260 -0
- document_analyzer-0.1.0.dist-info/METADATA +520 -0
- document_analyzer-0.1.0.dist-info/RECORD +25 -0
- document_analyzer-0.1.0.dist-info/WHEEL +5 -0
- document_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
- document_analyzer-0.1.0.dist-info/licenses/LICENSE +201 -0
- document_analyzer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|