GGUF
conversational
Sanjay1905 commited on
Commit
2a4163f
Β·
0 Parent(s):

Final initial commit

Browse files
README.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ ---
__pycache__/gradio.cpython-39.pyc ADDED
Binary file (1.02 kB). View file
 
gradiosite.py ADDED
@@ -0,0 +1,910 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+ import gradio as gr
5
+ from ultralytics import YOLO
6
+ from pdf2image import convert_from_path
7
+ from PIL import Image
8
+ import easyocr
9
+ import uuid
10
+ import re
11
+ import difflib
12
+ import math
13
+
14
+ # Conditional import for LLM
15
+ try:
16
+ from llama_cpp import Llama
17
+ LLAMA_AVAILABLE = True
18
+ except ImportError:
19
+ print("Warning: llama_cpp not available. LLM functionality will be disabled.")
20
+ LLAMA_AVAILABLE = False
21
+
22
+ # --- Configuration ---
23
+ # Folders for temporary files and results
24
+ UPLOAD_FOLDER = 'static/uploads/'
25
+ RESULTS_FOLDER = 'static/results/'
26
+
27
+ # --- Model Paths (Update these paths if necessary) ---
28
+ CUSTOM_MODEL_PATH = 'best.pt'
29
+ PRETRAINED_MODEL_PATH = 'yolov10s.pt'
30
+ SIGNATURE_MODEL_PATH = 'yolov8s.pt'
31
+ LLAMA_MODEL_PATH = "unsloth.F16.gguf"
32
+
33
+ # Detection Parameters
34
+ YOLO_CONFIDENCE_THRESHOLD = 0.5
35
+ OCR_CONFIDENCE_THRESHOLD = 0.5
36
+
37
+ # Create directories if they don't exist
38
+ os.makedirs(UPLOAD_FOLDER, exist_ok=True)
39
+ os.makedirs(RESULTS_FOLDER, exist_ok=True)
40
+
41
+ # --- Global Model Placeholders ---
42
+ custom_model, pretrained_model, signature_model, reader, llama_model = None, None, None, None, None
43
+
44
+ def load_models():
45
+ """
46
+ Loads all AI models into the global scope. This function is called on the first
47
+ analysis request to avoid startup conflicts. It ensures models are only loaded once.
48
+ """
49
+ global custom_model, pretrained_model, signature_model, reader, llama_model
50
+
51
+ # If models are already loaded, do nothing.
52
+ if reader is not None and (llama_model is not None or not LLAMA_AVAILABLE):
53
+ print("Models already loaded.")
54
+ return
55
+ print("=== Loading Models (this may take a moment) ===")
56
+
57
+ # Helper function to check for model files
58
+ def check_model_path(path, name):
59
+ if not os.path.exists(path):
60
+ print(f"βœ— WARNING: {name} model not found at '{path}'. The application may not function correctly.")
61
+ return False
62
+ return True
63
+
64
+ # YOLO Models
65
+ if check_model_path(CUSTOM_MODEL_PATH, "Custom YOLO"):
66
+ try:
67
+ custom_model = YOLO(CUSTOM_MODEL_PATH)
68
+ print("βœ“ Custom YOLO model loaded.")
69
+ except Exception as e:
70
+ print(f"βœ— Error loading custom model: {e}")
71
+ if check_model_path(PRETRAINED_MODEL_PATH, "Pre-trained YOLO"):
72
+ try:
73
+ pretrained_model = YOLO(PRETRAINED_MODEL_PATH)
74
+ print("βœ“ Pre-trained YOLO model loaded.")
75
+ except Exception as e:
76
+ print(f"βœ— Error loading pre-trained model: {e}")
77
+ if check_model_path(SIGNATURE_MODEL_PATH, "Signature YOLO"):
78
+ try:
79
+ signature_model = YOLO(SIGNATURE_MODEL_PATH)
80
+ print("βœ“ Signature YOLO model loaded.")
81
+ except Exception as e:
82
+ print(f"βœ— Error loading signature model: {e}")
83
+
84
+ # OCR Model
85
+ try:
86
+ reader = easyocr.Reader(['en'], gpu=True)
87
+ print("βœ“ EasyOCR model loaded.")
88
+ except Exception as e:
89
+ print(f"βœ— Error loading EasyOCR: {e}. Text detection will be unavailable.")
90
+
91
+ # LLM Model - Only load if available
92
+ if LLAMA_AVAILABLE and check_model_path(LLAMA_MODEL_PATH, "LLM"):
93
+ try:
94
+ llama_model = Llama(
95
+ model_path=LLAMA_MODEL_PATH,
96
+ n_gpu_layers=-1, n_ctx=4096, chat_format="llama-3", verbose=False
97
+ )
98
+ print("βœ“ LLM model loaded.")
99
+ except Exception as e:
100
+ print(f"βœ— Error loading LLM model: {e}. Text analysis will be unavailable.")
101
+ print("=== All Models Initialized ===")
102
+
103
+ # YOLO Class Mappings
104
+ CUSTOM_CLASS_NAMES = {0: 'face', 1: 'qr', 2: 'signature'}
105
+ PRETRAINED_CLASS_MAP = {0: 'face'}
106
+
107
+ # --- Core Detection & Processing Functions ---
108
+ def detect_visual_pii(image_data):
109
+ """Runs the three-stage YOLO detection on a single image."""
110
+ all_boxes = []
111
+ all_classes = []
112
+
113
+ if custom_model is None:
114
+ print("Custom model not available for visual detection")
115
+ return all_boxes, all_classes
116
+
117
+ # Pass 1: Custom Model
118
+ custom_results = custom_model.predict(source=image_data, conf=YOLO_CONFIDENCE_THRESHOLD, verbose=False)[0]
119
+ detected_custom_classes = {CUSTOM_CLASS_NAMES[int(cls)] for cls in custom_results.boxes.cls}
120
+
121
+ for box, cls in zip(custom_results.boxes.xyxy.cpu().numpy().astype(int), custom_results.boxes.cls):
122
+ all_boxes.append(box)
123
+ all_classes.append(CUSTOM_CLASS_NAMES[int(cls)])
124
+
125
+ # Pass 2: Pre-trained Model (Face Fallback)
126
+ if 'face' not in detected_custom_classes and pretrained_model is not None:
127
+ print(" Custom model missed 'face'. Trying pre-trained model as fallback.")
128
+ pretrained_results = pretrained_model.predict(source=image_data, conf=YOLO_CONFIDENCE_THRESHOLD, verbose=False)[0]
129
+ for box in pretrained_results.boxes:
130
+ if int(box.cls[0]) in PRETRAINED_CLASS_MAP:
131
+ all_boxes.append(box.xyxy.cpu().numpy().astype(int)[0])
132
+ all_classes.append("face (fallback)")
133
+
134
+ # Pass 3: Specialized Model (Signature Fallback)
135
+ if 'signature' not in detected_custom_classes and signature_model is not None:
136
+ print(" Custom model missed 'signature'. Trying specialized signature model as fallback.")
137
+ signature_results = signature_model.predict(source=image_data, conf=YOLO_CONFIDENCE_THRESHOLD, verbose=False)[0]
138
+ for box in signature_results.boxes:
139
+ all_boxes.append(box.xyxy.cpu().numpy().astype(int)[0])
140
+ all_classes.append("signature (fallback)")
141
+
142
+ return all_boxes, all_classes
143
+
144
+ # --- OCR + LLM Functions ---
145
+ def calculate_distance(bbox1, bbox2):
146
+ """Calculates the Euclidean distance between the centers of two bounding boxes."""
147
+ c1_x = (bbox1[0] + bbox1[2]) / 2
148
+ c1_y = (bbox1[1] + bbox1[3]) / 2
149
+ c2_x = (bbox2[0] + bbox2[2]) / 2
150
+ c2_y = (bbox2[1] + bbox2[3]) / 2
151
+ return math.sqrt((c2_x - c1_x)**2 + (c2_y - c1_y)**2)
152
+
153
+ def refine_pii_flags(ocr_results, isolation_threshold=150):
154
+ """Post-processing step to unmark short, isolated PII detections."""
155
+ pii_indices = [i for i, result in enumerate(ocr_results) if result["is_pii"]]
156
+
157
+ if len(pii_indices) <= 1:
158
+ return ocr_results
159
+ indices_to_unmark = []
160
+ for i in pii_indices:
161
+ current_result = ocr_results[i]
162
+ normalized_text = re.sub(r'[^a-zA-Z0-9]', '', current_result["text"])
163
+
164
+ if len(normalized_text) <= 3:
165
+ min_dist_to_neighbor = float('inf')
166
+
167
+ for j in pii_indices:
168
+ if i == j:
169
+ continue
170
+
171
+ other_result = ocr_results[j]
172
+ dist = calculate_distance(current_result["bbox"], other_result["bbox"])
173
+ if dist < min_dist_to_neighbor:
174
+ min_dist_to_neighbor = dist
175
+
176
+ if min_dist_to_neighbor > isolation_threshold:
177
+ print(f" - Refining PII: Unmarking short ('{current_result['text']}') and isolated (min_dist: {min_dist_to_neighbor:.2f}px) PII.")
178
+ indices_to_unmark.append(i)
179
+
180
+ for i in indices_to_unmark:
181
+ ocr_results[i]["is_pii"] = False
182
+
183
+ return ocr_results
184
+
185
+ def parse_pii_output(generated_text):
186
+ """Parse the new curly braces format PII output"""
187
+ pii_list = []
188
+ try:
189
+ match = re.search(r'\{([^}]*)\}', generated_text)
190
+ if match:
191
+ content = match.group(1)
192
+ items = re.findall(r'"([^"]*)"', content)
193
+ pii_list = [item.strip() for item in items if item.strip()]
194
+ except Exception as e:
195
+ print(f"Error parsing PII output: {e}")
196
+ pii_list = []
197
+ return pii_list
198
+
199
+ def normalize_text(text):
200
+ """Comprehensive text normalization for better matching"""
201
+ if not text:
202
+ return ""
203
+
204
+ normalized = re.sub(r'[.,;:!?()"\'\-_/\\]', '', text)
205
+
206
+ ocr_corrections = {
207
+ '0': 'o', 'O': '0', '1': 'l', 'l': '1', '5': 's', 'S': '5',
208
+ '8': 'b', 'B': '8', 'rn': 'm', 'RN': 'M', 'vv': 'w', 'VV': 'W',
209
+ 'cl': 'd', 'CL': 'D',
210
+ }
211
+
212
+ for wrong, correct in ocr_corrections.items():
213
+ normalized = normalized.replace(wrong, correct)
214
+
215
+ normalized = ' '.join(normalized.split()).lower()
216
+ return normalized
217
+
218
+ def fuzzy_match_score(text1, text2, threshold=0.8):
219
+ """Calculate fuzzy matching score between two strings"""
220
+ if not text1 or not text2:
221
+ return False
222
+ return difflib.SequenceMatcher(None, text1.lower(), text2.lower()).ratio() >= threshold
223
+
224
+ def levenshtein_distance(s1, s2):
225
+ """Calculate Levenshtein distance between two strings"""
226
+ if len(s1) < len(s2):
227
+ return levenshtein_distance(s2, s1)
228
+
229
+ if len(s2) == 0:
230
+ return len(s1)
231
+
232
+ previous_row = list(range(len(s2) + 1))
233
+ for i, c1 in enumerate(s1):
234
+ current_row = [i + 1]
235
+ for j, c2 in enumerate(s2):
236
+ insertions = previous_row[j + 1] + 1
237
+ deletions = current_row[j] + 1
238
+ substitutions = previous_row[j] + (c1 != c2)
239
+ current_row.append(min(insertions, deletions, substitutions))
240
+ previous_row = current_row
241
+
242
+ return previous_row[-1]
243
+
244
+ def is_similar_by_edit_distance(text1, text2, max_distance=2):
245
+ """Check if two texts are similar within edit distance threshold"""
246
+ if not text1 or not text2:
247
+ return False
248
+
249
+ distance = levenshtein_distance(text1.lower(), text2.lower())
250
+ max_len = max(len(text1), len(text2))
251
+
252
+ if max_len <= 3:
253
+ threshold = 1
254
+ elif max_len <= 6:
255
+ threshold = 2
256
+ else:
257
+ threshold = min(max_distance, max_len // 3)
258
+
259
+ return distance <= threshold
260
+
261
+ def extract_sentence_text(ocr_results):
262
+ """Extract sentence-based text for LLM input"""
263
+ paragraph_text = ""
264
+ for item in ocr_results:
265
+ if len(item) == 3:
266
+ _, text, _ = item
267
+ elif len(item) == 2:
268
+ _, text = item
269
+ else:
270
+ print(f"Unexpected OCR result format: {item}")
271
+ continue
272
+ if text.strip():
273
+ paragraph_text += text + " "
274
+ return paragraph_text.strip()
275
+
276
+ def extract_word_bboxes_improved(ocr_results):
277
+ """Improved word extraction with better handling of punctuation and spacing"""
278
+ word_bbox_map = []
279
+
280
+ for item in ocr_results:
281
+ if len(item) == 3:
282
+ bbox, text, confidence = item
283
+ elif len(item) == 2:
284
+ bbox, text = item
285
+ confidence = 1.0
286
+ else:
287
+ print(f"Unexpected OCR result format: {item}")
288
+ continue
289
+
290
+ original_text = text.strip()
291
+ if not original_text:
292
+ continue
293
+
294
+ if isinstance(bbox[0], (list, tuple)):
295
+ x_coords = [point[0] for point in bbox]
296
+ y_coords = [point[1] for point in bbox]
297
+ line_x1, line_y1 = min(x_coords), min(y_coords)
298
+ line_x2, line_y2 = max(x_coords), max(y_coords)
299
+ else:
300
+ line_x1, line_y1, line_x2, line_y2 = bbox
301
+
302
+ tokens = re.findall(r'\S+', original_text)
303
+ if len(tokens) <= 1:
304
+ padding = 1
305
+ word_bbox_map.append({
306
+ "word": original_text,
307
+ "bbox": [
308
+ max(0, int(line_x1 - padding)),
309
+ max(0, int(line_y1 - padding)),
310
+ int(line_x2 + padding),
311
+ int(line_y2 + padding)
312
+ ],
313
+ "confidence": confidence,
314
+ "original_line": original_text
315
+ })
316
+ continue
317
+
318
+ full_width = line_x2 - line_x1
319
+ full_height = line_y2 - line_y1
320
+ text_without_spaces = original_text.replace(' ', '')
321
+ total_chars = len(text_without_spaces)
322
+
323
+ char_position = 0
324
+ for i, token in enumerate(tokens):
325
+ token_start_ratio = char_position / total_chars if total_chars > 0 else 0
326
+ char_position += len(token)
327
+ token_end_ratio = char_position / total_chars if total_chars > 0 else 1
328
+
329
+ token_x1 = line_x1 + (full_width * token_start_ratio)
330
+ token_x2 = line_x1 + (full_width * token_end_ratio)
331
+
332
+ padding = 1
333
+ word_bbox = [
334
+ max(0, int(token_x1 - padding)),
335
+ max(0, int(line_y1 - padding)),
336
+ int(min(token_x2 + padding, line_x2)),
337
+ int(line_y2 + padding)
338
+ ]
339
+
340
+ word_bbox_map.append({
341
+ "word": token,
342
+ "bbox": word_bbox,
343
+ "confidence": confidence,
344
+ "original_line": original_text
345
+ })
346
+
347
+ return sorted(word_bbox_map, key=lambda x: (x['bbox'][1], x['bbox'][0]))
348
+
349
+ def advanced_match_pii_to_words(pii_list, word_bbox_map):
350
+ """Advanced multi-strategy PII matching with comprehensive fallbacks"""
351
+ ocr_results_for_template = []
352
+ words = [info['word'] for info in word_bbox_map]
353
+ bboxes = [info['bbox'] for info in word_bbox_map]
354
+ is_pii_flags = [False] * len(words)
355
+
356
+ # Pre-process all words with different normalization strategies
357
+ normalized_words = [normalize_text(word) for word in words]
358
+
359
+ print(f"Processing {len(pii_list)} PII items against {len(words)} OCR words")
360
+
361
+ for pii_idx, pii_item in enumerate(pii_list):
362
+ if not pii_item.strip():
363
+ continue
364
+
365
+ print(f"Processing PII item {pii_idx + 1}: '{pii_item}'")
366
+
367
+ # Normalize the PII item
368
+ normalized_pii = normalize_text(pii_item)
369
+ pii_words = normalized_pii.split()
370
+
371
+ if not pii_words:
372
+ continue
373
+
374
+ matched = False
375
+
376
+ # Strategy 1: Exact matching after normalization
377
+ if len(pii_words) == 1:
378
+ pii_word = pii_words[0]
379
+ for idx, norm_word in enumerate(normalized_words):
380
+ if norm_word == pii_word and not is_pii_flags[idx]:
381
+ is_pii_flags[idx] = True
382
+ matched = True
383
+ print(f" βœ“ Exact match: '{words[idx]}' -> '{pii_item}'")
384
+ else:
385
+ # Multi-word exact matching
386
+ pii_len = len(pii_words)
387
+ start_idx = 0
388
+ while start_idx < len(normalized_words) - pii_len + 1:
389
+ exact_match = True
390
+ for j in range(pii_len):
391
+ if normalized_words[start_idx + j] != pii_words[j]:
392
+ exact_match = False
393
+ break
394
+
395
+ if exact_match:
396
+ # Check spatial proximity
397
+ spatial_ok = True
398
+ for j in range(1, pii_len):
399
+ prev_bbox = bboxes[start_idx + j - 1]
400
+ curr_bbox = bboxes[start_idx + j]
401
+
402
+ horizontal_distance = curr_bbox[0] - prev_bbox[2]
403
+ vertical_alignment = (abs(prev_bbox[1] - curr_bbox[1]) < 30 and
404
+ abs(prev_bbox[3] - curr_bbox[3]) < 30)
405
+
406
+ if not (vertical_alignment and horizontal_distance <= 150):
407
+ spatial_ok = False
408
+ break
409
+
410
+ if spatial_ok:
411
+ for j in range(pii_len):
412
+ if not is_pii_flags[start_idx + j]:
413
+ is_pii_flags[start_idx + j] = True
414
+ matched = True
415
+ matched_text = ' '.join(words[start_idx:start_idx + pii_len])
416
+ print(f" βœ“ Multi-word exact: '{matched_text}' -> '{pii_item}'")
417
+ start_idx += pii_len
418
+ continue
419
+ start_idx += 1
420
+
421
+ # Strategy 2: Fuzzy matching if exact matching failed
422
+ if not matched:
423
+ if len(pii_words) == 1:
424
+ pii_word = pii_words[0]
425
+ for idx, norm_word in enumerate(normalized_words):
426
+ if (not is_pii_flags[idx] and
427
+ (fuzzy_match_score(norm_word, pii_word, 0.9) or
428
+ is_similar_by_edit_distance(norm_word, pii_word, 2))):
429
+ is_pii_flags[idx] = True
430
+ matched = True
431
+ print(f" βœ“ Fuzzy match: '{words[idx]}' -> '{pii_item}'")
432
+ else:
433
+ # Multi-word fuzzy matching
434
+ pii_len = len(pii_words)
435
+ start_idx = 0
436
+ while start_idx < len(normalized_words) - pii_len + 1:
437
+ fuzzy_match = True
438
+ for j in range(pii_len):
439
+ if not (fuzzy_match_score(normalized_words[start_idx + j], pii_words[j], 0.85) or
440
+ is_similar_by_edit_distance(normalized_words[start_idx + j], pii_words[j], 2)):
441
+ fuzzy_match = False
442
+ break
443
+
444
+ if fuzzy_match:
445
+ # Check spatial proximity
446
+ spatial_ok = True
447
+ for j in range(1, pii_len):
448
+ prev_bbox = bboxes[start_idx + j - 1]
449
+ curr_bbox = bboxes[start_idx + j]
450
+
451
+ horizontal_distance = curr_bbox[0] - prev_bbox[2]
452
+ vertical_alignment = (abs(prev_bbox[1] - curr_bbox[1]) < 30 and
453
+ abs(prev_bbox[3] - curr_bbox[3]) < 30)
454
+
455
+ if not (vertical_alignment and horizontal_distance <= 150):
456
+ spatial_ok = False
457
+ break
458
+
459
+ if spatial_ok:
460
+ for j in range(pii_len):
461
+ if not is_pii_flags[start_idx + j]:
462
+ is_pii_flags[start_idx + j] = True
463
+ matched = True
464
+ matched_text = ' '.join(words[start_idx:start_idx + pii_len])
465
+ print(f" βœ“ Multi-word fuzzy: '{matched_text}' -> '{pii_item}'")
466
+ start_idx += pii_len
467
+ continue
468
+ start_idx += 1
469
+
470
+ # Strategy 3: Substring and partial matching
471
+ if not matched:
472
+ full_normalized_text = ' '.join(normalized_words)
473
+
474
+ pos = 0
475
+ while True:
476
+ start_pos = full_normalized_text.find(normalized_pii, pos)
477
+ if start_pos == -1:
478
+ break
479
+ end_pos = start_pos + len(normalized_pii)
480
+
481
+ char_count = 0
482
+ start_word_idx = None
483
+ end_word_idx = None
484
+
485
+ for idx, norm_word in enumerate(normalized_words):
486
+ word_start = char_count
487
+ word_end = char_count + len(norm_word)
488
+
489
+ if start_word_idx is None and word_end > start_pos:
490
+ start_word_idx = idx
491
+
492
+ if word_start < end_pos:
493
+ end_word_idx = idx
494
+
495
+ char_count += len(norm_word) + 1
496
+
497
+ if start_word_idx is not None and end_word_idx is not None and end_word_idx - start_word_idx + 1 >= len(pii_words):
498
+ spatial_ok = True
499
+ for j in range(start_word_idx, end_word_idx):
500
+ if j + 1 <= end_word_idx:
501
+ prev_bbox = bboxes[j]
502
+ next_bbox = bboxes[j + 1]
503
+
504
+ horizontal_distance = next_bbox[0] - prev_bbox[2]
505
+ vertical_alignment = (abs(prev_bbox[1] - next_bbox[1]) < 30 and
506
+ abs(prev_bbox[3] - next_bbox[3]) < 30)
507
+
508
+ if not (vertical_alignment and horizontal_distance <= 200):
509
+ spatial_ok = False
510
+ break
511
+
512
+ if spatial_ok:
513
+ for j in range(start_word_idx, end_word_idx + 1):
514
+ if not is_pii_flags[j]:
515
+ is_pii_flags[j] = True
516
+ matched = True
517
+ matched_text = ' '.join(words[start_word_idx:end_word_idx + 1])
518
+ print(f" βœ“ Substring match: '{matched_text}' -> '{pii_item}'")
519
+ pos = end_pos
520
+
521
+ # Strategy 4: Individual word matching with relaxed criteria
522
+ if not matched:
523
+ for pii_word in pii_words:
524
+ if len(pii_word) < 3:
525
+ continue
526
+
527
+ for idx, norm_word in enumerate(normalized_words):
528
+ if not is_pii_flags[idx]:
529
+ if (norm_word == pii_word or
530
+ fuzzy_match_score(norm_word, pii_word, 0.8) or
531
+ is_similar_by_edit_distance(norm_word, pii_word, 2) or
532
+ (len(pii_word) > 5 and (pii_word in norm_word or norm_word in pii_word))):
533
+ is_pii_flags[idx] = True
534
+ print(f" βœ“ Individual word match: '{words[idx]}' -> '{pii_word}' from '{pii_item}'")
535
+
536
+ if not matched:
537
+ print(f" βœ— No match found for: '{pii_item}'")
538
+
539
+ for idx, word_info in enumerate(word_bbox_map):
540
+ ocr_results_for_template.append({
541
+ "text": word_info["word"],
542
+ "bbox": word_info["bbox"],
543
+ "is_pii": is_pii_flags[idx],
544
+ "confidence": word_info.get("confidence", 1.0)
545
+ })
546
+
547
+ ocr_results_for_template = merge_horizontal_pii_boxes_improved(ocr_results_for_template)
548
+
549
+ return ocr_results_for_template
550
+
551
+ def merge_horizontal_pii_boxes_improved(ocr_results, merge_distance=50):
552
+ """Improved merging with better spatial awareness and tighter boxes"""
553
+ if not ocr_results:
554
+ return ocr_results
555
+
556
+ merged_results = []
557
+ i = 0
558
+
559
+ while i < len(ocr_results):
560
+ current_word = ocr_results[i]
561
+
562
+ if not current_word["is_pii"]:
563
+ merged_results.append(current_word)
564
+ i += 1
565
+ continue
566
+
567
+ merge_group = [current_word]
568
+ j = i + 1
569
+
570
+ while j < len(ocr_results):
571
+ next_word = ocr_results[j]
572
+
573
+ if not next_word["is_pii"]:
574
+ break
575
+
576
+ current_bbox = merge_group[-1]["bbox"]
577
+ next_bbox = next_word["bbox"]
578
+
579
+ y_center_current = (current_bbox[1] + current_bbox[3]) / 2
580
+ y_center_next = (next_bbox[1] + next_bbox[3]) / 2
581
+ y_overlap = abs(y_center_current - y_center_next) < 20
582
+
583
+ horizontal_distance = next_bbox[0] - current_bbox[2]
584
+
585
+ if y_overlap and horizontal_distance <= merge_distance:
586
+ merge_group.append(next_word)
587
+ j += 1
588
+ else:
589
+ break
590
+
591
+ if len(merge_group) > 1:
592
+ min_x = min(word["bbox"][0] for word in merge_group)
593
+ min_y = min(word["bbox"][1] for word in merge_group)
594
+ max_x = max(word["bbox"][2] for word in merge_group)
595
+ max_y = max(word["bbox"][3] for word in merge_group)
596
+
597
+ merged_text = " ".join(word["text"] for word in merge_group)
598
+
599
+ merged_word = {
600
+ "text": merged_text,
601
+ "bbox": [min_x, min_y, max_x, max_y],
602
+ "is_pii": True,
603
+ "confidence": max(word.get("confidence", 1.0) for word in merge_group)
604
+ }
605
+ merged_results.append(merged_word)
606
+ print(f" βœ“ Merged PII box: '{merged_text}' at [{min_x},{min_y},{max_x},{max_y}]")
607
+ else:
608
+ merged_results.append(current_word)
609
+
610
+ i = j
611
+
612
+ return merged_results
613
+
614
+ def post_process_pii_detection(ocr_results_for_template, pii_list):
615
+ """Post-process to catch any missed PII using relaxed matching"""
616
+ words = [result["text"] for result in ocr_results_for_template]
617
+
618
+ for pii_item in pii_list:
619
+ normalized_pii = normalize_text(pii_item)
620
+ pii_words = normalized_pii.split()
621
+
622
+ if not pii_words:
623
+ continue
624
+
625
+ pii_detected = False
626
+ for result in ocr_results_for_template:
627
+ if result["is_pii"]:
628
+ result_normalized = normalize_text(result["text"])
629
+ if (normalized_pii in result_normalized or
630
+ result_normalized in normalized_pii or
631
+ fuzzy_match_score(result_normalized, normalized_pii, 0.7)):
632
+ pii_detected = True
633
+ break
634
+
635
+ if not pii_detected:
636
+ print(f" ⚠ PII not detected, trying fallback matching: '{pii_item}'")
637
+
638
+ for idx, result in enumerate(ocr_results_for_template):
639
+ if result["is_pii"]:
640
+ continue
641
+
642
+ word_normalized = normalize_text(result["text"])
643
+
644
+ for pii_word in pii_words:
645
+ if (len(pii_word) > 3 and
646
+ (pii_word in word_normalized or
647
+ word_normalized in pii_word or
648
+ fuzzy_match_score(word_normalized, pii_word, 0.6) or
649
+ is_similar_by_edit_distance(word_normalized, pii_word, 3))):
650
+
651
+ ocr_results_for_template[idx]["is_pii"] = True
652
+ print(f" βœ“ Fallback match: '{result['text']}' -> '{pii_word}' from '{pii_item}'")
653
+ break
654
+
655
+ return ocr_results_for_template
656
+
657
+ def detect_pii_from_combined_text(combined_text):
658
+ """Detect PII from combined multi-page text using LLM"""
659
+ if llama_model is None:
660
+ print("LLM model not available for PII detection")
661
+ return [], "LLM model not available"
662
+
663
+ instruction = (
664
+ "Extract all Personally Identifiable Information (PII) of the main subject from the given text. "
665
+ "Include data like Name, Date of Birth, Gender, Address, Phone Number, Email, Social Security Number (SSN), Member ID, Group Number, or any other PII data available. "
666
+ "Ignore any information about doctors, staff, providers, colleagues, organizations, companies, hospitals, educational institutes, or facilities. "
667
+ "Return the results strictly as a flat set of strings enclosed in { } without labels."
668
+ )
669
+ prompt_content = f"{instruction}\n{combined_text}"
670
+
671
+ pii_list = []
672
+ llama_raw_output = ""
673
+ try:
674
+ messages = [{"role": "user", "content": prompt_content}]
675
+ response = llama_model.create_chat_completion(
676
+ messages=messages,
677
+ max_tokens=512,
678
+ temperature=0.1,
679
+ )
680
+ llama_raw_output = response['choices'][0]['message']['content']
681
+ pii_list = parse_pii_output(llama_raw_output)
682
+ print(f"LLM detected {len(pii_list)} PII items from combined text: {pii_list}")
683
+ except Exception as e:
684
+ print(f"Error during Llama PII detection: {e}")
685
+ llama_raw_output = f"Error: {str(e)}"
686
+ pii_list = []
687
+
688
+ return pii_list, llama_raw_output
689
+
690
+ # --- Combined Processing Function ---
691
+ def process_page_combined(img_cv, global_pii_list):
692
+ """Process a single page with both YOLO and OCR+LLM detection"""
693
+ all_detections = []
694
+
695
+ # Step 1: YOLO Visual Detection
696
+ print(" Running YOLO visual detection...")
697
+ visual_boxes, visual_classes = detect_visual_pii(img_cv)
698
+
699
+ for box, cls in zip(visual_boxes, visual_classes):
700
+ all_detections.append({
701
+ "text": cls,
702
+ "bbox": box.tolist() if hasattr(box, 'tolist') else box,
703
+ "is_pii": True,
704
+ "confidence": 1.0,
705
+ "detection_type": "visual"
706
+ })
707
+
708
+ print(f" YOLO detected {len(visual_boxes)} visual elements")
709
+
710
+ # Step 2: OCR + LLM Text Detection
711
+ if reader is not None:
712
+ print(" Running OCR text extraction...")
713
+ word_ocr_results = reader.readtext(img_cv, paragraph=False, width_ths=0.7, height_ths=0.7)
714
+
715
+ if word_ocr_results:
716
+ word_bbox_map = extract_word_bboxes_improved(word_ocr_results)
717
+
718
+ ocr_results_for_template = advanced_match_pii_to_words(global_pii_list, word_bbox_map)
719
+ ocr_results_for_template = post_process_pii_detection(ocr_results_for_template, global_pii_list)
720
+ ocr_results_for_template = refine_pii_flags(ocr_results_for_template)
721
+ ocr_results_for_template = merge_horizontal_pii_boxes_improved(ocr_results_for_template)
722
+
723
+ for result in ocr_results_for_template:
724
+ if result["is_pii"]:
725
+ result["detection_type"] = "text"
726
+ all_detections.append(result)
727
+
728
+ print(f" OCR detected {sum(1 for r in ocr_results_for_template if r['is_pii'])} text PII elements")
729
+
730
+ return all_detections
731
+
732
+ # --- Main Gradio Processing Function ---
733
+ def analyze_document(file, progress=gr.Progress()):
734
+ """
735
+ This function takes an uploaded file, processes it through the PII detection pipeline,
736
+ and returns the annotated images, a redacted PDF, and a summary report.
737
+ """
738
+ load_models()
739
+ if file is None:
740
+ return None, None, "Please upload a document to begin."
741
+
742
+ unique_id = uuid.uuid4().hex
743
+
744
+ if hasattr(file, 'name'):
745
+ filepath = file.name
746
+ else:
747
+ filepath = str(file)
748
+
749
+ filename = os.path.basename(filepath)
750
+ extension = os.path.splitext(filename)[1]
751
+
752
+ progress(0, desc="Converting document to images...")
753
+ images_to_process = []
754
+ try:
755
+ if extension.lower() == '.pdf':
756
+ try:
757
+ images_to_process = [cv2.cvtColor(np.array(page), cv2.COLOR_RGB2BGR) for page in convert_from_path(filepath, dpi=300)]
758
+ except Exception as e:
759
+ print(f"PDF conversion error: {e}. Trying fallback method...")
760
+ try:
761
+ images_to_process = [cv2.cvtColor(np.array(page), cv2.COLOR_RGB2BGR) for page in convert_from_path(filepath, dpi=150)]
762
+ except Exception as e2:
763
+ return None, None, f"πŸ”΄ **Error:** Could not process PDF. Please ensure Poppler is installed or try converting to images first.\nDetails: {e2}"
764
+ else:
765
+ img = cv2.imread(filepath)
766
+ if img is not None:
767
+ images_to_process.append(img)
768
+ except Exception as e:
769
+ return None, None, f"πŸ”΄ **Error:** Could not process file. Details: {e}"
770
+
771
+ if not images_to_process:
772
+ return None, None, "πŸ”΄ **Error:** No pages could be extracted from the document."
773
+
774
+ total_pages = len(images_to_process)
775
+ print(f"Processing {total_pages} pages for job {unique_id}...")
776
+
777
+ progress(0.1, desc="Extracting text from all pages (OCR)...")
778
+ combined_text, all_pages_data = "", []
779
+ for i, img_cv in enumerate(images_to_process):
780
+ page_text = ""
781
+ if reader:
782
+ page_text = extract_sentence_text(reader.readtext(img_cv, paragraph=True))
783
+ combined_text += f"\n--- Page {i+1} ---\n{page_text}\n"
784
+ all_pages_data.append({"img_cv": img_cv, "page_num": i + 1})
785
+
786
+ progress(0.4, desc="Analyzing text for PII with LLM...")
787
+ global_pii_list, llama_raw_output = detect_pii_from_combined_text(combined_text)
788
+
789
+ annotated_paths, redacted_pils = [], []
790
+ report = f"## πŸ” Analysis Report\n**Global PII Found:** `{', '.join(global_pii_list) if global_pii_list else 'None'}`\n\n---\n"
791
+ for i, page_info in enumerate(all_pages_data):
792
+ progress(0.5 + (i / total_pages * 0.4), desc=f"Processing Page {i+1}/{total_pages} (Visual & Text)...")
793
+ img_cv, page_num = page_info["img_cv"], page_info["page_num"]
794
+ detections = process_page_combined(img_cv, global_pii_list)
795
+
796
+ annotated_img, redacted_img = img_cv.copy(), img_cv.copy()
797
+ visual_count = sum(1 for d in detections if d["detection_type"] == "visual")
798
+ text_count = sum(1 for d in detections if d.get("detection_type") == "text")
799
+ for d in detections:
800
+ bbox = d.get("bbox", [])
801
+ if not bbox: continue
802
+ x1, y1, x2, y2 = map(int, bbox)
803
+ color = (0, 255, 0) if d.get("detection_type") == "visual" else (0, 0, 255)
804
+ cv2.rectangle(annotated_img, (x1, y1), (x2, y2), color, 3)
805
+ cv2.rectangle(redacted_img, (x1, y1), (x2, y2), (0, 0, 0), -1)
806
+
807
+ path = os.path.join(RESULTS_FOLDER, f"annotated_{unique_id}_{page_num}.jpg")
808
+ cv2.imwrite(path, annotated_img)
809
+ annotated_paths.append(path)
810
+ redacted_pils.append(Image.fromarray(cv2.cvtColor(redacted_img, cv2.COLOR_BGR2RGB)))
811
+
812
+ report += f"### πŸ“„ Page {page_num}\n- **Visual Detections (🟩 Green):** {visual_count}\n- **Text Detections (πŸŸ₯ Red):** {text_count}\n"
813
+
814
+ progress(0.9, desc="Generating final redacted PDF...")
815
+ redacted_pdf_path = None
816
+ if redacted_pils:
817
+ pdf_path = os.path.join(RESULTS_FOLDER, f"redacted_{unique_id}.pdf")
818
+ redacted_pils[0].save(pdf_path, "PDF", resolution=100.0, save_all=True, append_images=redacted_pils[1:])
819
+ redacted_pdf_path = pdf_path
820
+
821
+ progress(1, desc="Complete!")
822
+ print("Processing Complete.")
823
+ return annotated_paths, redacted_pdf_path, report
824
+
825
+ # --- Gradio Interface Definition ---
826
+ title = "πŸ”’ Combined PII Detection System"
827
+ description = """
828
+ ### Advanced Multi-Modal PII Detection
829
+ This system uses a combination of visual and textual analysis to detect and redact Personally Identifiable Information from your documents.
830
+ - **πŸ–ΌοΈ Visual Detection (YOLO):** Detects Faces, QR Codes, and Signatures.
831
+ - **πŸ“ Text Detection (OCR + LLM):** Detects Names, Addresses, Phone Numbers, IDs, and other contextual PII.
832
+ **How to Use:**
833
+ 1. Upload a document (PDF or image format).
834
+ 2. The system will process each page and display annotated previews with colored boxes.
835
+ 3. A fully redacted PDF with blacked-out PII is generated for you to download.
836
+ 4. An analysis report summarizes the findings for each page.
837
+ """
838
+
839
+ with gr.Blocks(theme=gr.themes.Soft()) as demo:
840
+ gr.Markdown(f"<h1 style='margin-bottom: 0.25rem;'>{title}</h1>")
841
+ gr.Markdown(
842
+ "<p style='color:#475569; line-height:1.6;'>"
843
+ "Upload a PDF or image to detect and redact PII using visual detectors (🟩) and text analysis (πŸŸ₯). "
844
+ "Fixed for local environment with proper YOLO support."
845
+ "</p>"
846
+ )
847
+ with gr.Accordion("About this tool", open=False):
848
+ gr.Markdown(description)
849
+ with gr.Tabs():
850
+ with gr.Tab("Run"):
851
+ with gr.Row():
852
+ with gr.Column(scale=1):
853
+ file_input = gr.File(
854
+ label="Upload Document",
855
+ file_types=['.pdf', '.jpg', '.jpeg', '.png', '.bmp'],
856
+ file_count="single",
857
+ height=100
858
+ )
859
+ submit_btn = gr.Button("πŸš€ Analyze Document", variant="primary")
860
+ with gr.Accordion("Tips", open=False):
861
+ gr.Markdown(
862
+ "- Prefer high-resolution files for better OCR results (300 DPI for PDFs).\n"
863
+ "- For PDFs, ensure Poppler is installed on your system.\n"
864
+ "- Visual detections are drawn in green; text-based detections are in red.\n"
865
+ "- Use the Previews tab to inspect annotated pages and the Report tab to download the redacted PDF."
866
+ )
867
+ with gr.Column(scale=1):
868
+ gr.Markdown("### What happens during analysis")
869
+ gr.Markdown(
870
+ "- Convert pages to images\n"
871
+ "- Run global OCR to build combined text\n"
872
+ "- Use LLM to extract possible PII strings\n"
873
+ "- Match PII back to words and merge boxes\n"
874
+ "- Render annotated previews and build a redacted PDF"
875
+ )
876
+ clear_btn = gr.Button("🧹 Clear Results", variant="secondary")
877
+ with gr.Tab("Previews"):
878
+ gr.Markdown("### Annotated Previews (🟩 Visual, πŸŸ₯ Text)")
879
+ gallery_output = gr.Gallery(
880
+ label="Annotated Pages",
881
+ show_label=False,
882
+ elem_id="gallery",
883
+ columns=[2],
884
+ rows=[1],
885
+ object_fit="contain",
886
+ height=480
887
+ )
888
+ with gr.Tab("Report & Download"):
889
+ with gr.Row():
890
+ with gr.Column(scale=1):
891
+ gr.Markdown("### Download")
892
+ file_output = gr.File(label="Redacted PDF")
893
+ with gr.Column(scale=2):
894
+ gr.Markdown("### Analysis Report")
895
+ report_output = gr.Markdown(label="Analysis Report")
896
+
897
+ submit_btn.click(
898
+ fn=analyze_document,
899
+ inputs=file_input,
900
+ outputs=[gallery_output, file_output, report_output]
901
+ )
902
+
903
+ clear_btn.click(
904
+ fn=lambda: ([], None, "Ready. Upload a document and click Analyze."),
905
+ inputs=None,
906
+ outputs=[gallery_output, file_output, report_output]
907
+ )
908
+
909
+ if __name__ == "__main__":
910
+ demo.queue().launch(server_port=7860)
requirements.txt ADDED
Binary file (6.72 kB). View file