datamatters24 commited on
Commit
12165ca
·
verified ·
1 Parent(s): eb3d640

Upload ml/07_detect_redactions.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. ml/07_detect_redactions.py +300 -0
ml/07_detect_redactions.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Phase 4: Redaction Detection
4
+
5
+ Detects blacked-out/redacted regions in scanned document pages using
6
+ OpenCV contour analysis on rendered PDF pages.
7
+
8
+ Algorithm:
9
+ 1. Render page at 150 DPI via PyMuPDF
10
+ 2. Convert to grayscale → binary threshold (< 30 = black)
11
+ 3. Find contours via OpenCV
12
+ 4. Filter: rectangular (> 0.85), min area (500px), aspect ratio (0.1-10)
13
+ 5. Store redaction count, area percentage, and bounding boxes
14
+
15
+ Stores results in page_features (per page) and document_features (aggregate).
16
+ Processes priority collections first (CIA, JFK, DOJ, Lincoln).
17
+
18
+ Runs on: Hetzner CPU with joblib parallelism
19
+ """
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ import sys
25
+ from multiprocessing import cpu_count
26
+
27
+ import cv2
28
+ import fitz # PyMuPDF
29
+ import numpy as np
30
+ import psycopg2
31
+ import psycopg2.extras
32
+
33
+ from db import get_conn
34
+
35
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s")
36
+ log = logging.getLogger(__name__)
37
+
38
+ DPI = 150
39
+ BLACK_THRESHOLD = 30
40
+ MIN_AREA = 500
41
+ MIN_RECTANGULARITY = 0.85
42
+ MIN_ASPECT = 0.1
43
+ MAX_ASPECT = 10.0
44
+ BATCH_SIZE = 100 # pages per DB flush
45
+ WORKERS = max(1, cpu_count() - 2) # leave 2 cores free
46
+
47
+ # Process these collections first — most likely to have redactions
48
+ PRIORITY_SECTIONS = [
49
+ 'cia_declassified', 'cia_mkultra', 'cia_stargate',
50
+ 'jfk_assassination', 'doj_disclosures', 'lincoln_archives',
51
+ 'house_resolutions',
52
+ ]
53
+
54
+
55
+ def get_pending_documents(conn, section, limit=500):
56
+ """Get documents that haven't been analyzed for redactions yet."""
57
+ with conn.cursor() as cur:
58
+ cur.execute("""
59
+ SELECT d.id, d.file_path
60
+ FROM documents d
61
+ WHERE d.source_section = %s
62
+ AND d.id NOT IN (
63
+ SELECT DISTINCT df.document_id FROM document_features df
64
+ WHERE df.feature_name = 'redaction_summary'
65
+ )
66
+ ORDER BY d.id
67
+ LIMIT %s
68
+ """, (section, limit))
69
+ return cur.fetchall()
70
+
71
+
72
+ def get_pages_for_doc(conn, doc_id):
73
+ """Get page IDs and numbers for a document."""
74
+ with conn.cursor() as cur:
75
+ cur.execute("""
76
+ SELECT id, page_number FROM pages
77
+ WHERE document_id = %s
78
+ AND id NOT IN (
79
+ SELECT page_id FROM page_features WHERE feature_name = 'redaction'
80
+ )
81
+ ORDER BY page_number
82
+ """, (doc_id,))
83
+ return cur.fetchall()
84
+
85
+
86
+ def detect_redactions_page(pdf_path, page_num):
87
+ """Detect redacted regions on a single PDF page."""
88
+ try:
89
+ doc = fitz.open(pdf_path)
90
+ if page_num - 1 >= len(doc):
91
+ doc.close()
92
+ return None
93
+
94
+ page = doc[page_num - 1] # 0-indexed
95
+ # Render at DPI
96
+ mat = fitz.Matrix(DPI / 72, DPI / 72)
97
+ pix = page.get_pixmap(matrix=mat)
98
+ img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
99
+ doc.close()
100
+
101
+ # Convert to grayscale
102
+ if img.shape[2] == 4: # RGBA
103
+ gray = cv2.cvtColor(img, cv2.COLOR_RGBA2GRAY)
104
+ elif img.shape[2] == 3: # RGB
105
+ gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
106
+ else:
107
+ gray = img[:, :, 0]
108
+
109
+ # Threshold: black regions
110
+ _, binary = cv2.threshold(gray, BLACK_THRESHOLD, 255, cv2.THRESH_BINARY_INV)
111
+
112
+ # Morphological close to merge nearby black regions
113
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
114
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
115
+
116
+ # Find contours
117
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
118
+
119
+ page_area = pix.width * pix.height
120
+ redactions = []
121
+
122
+ for cnt in contours:
123
+ area = cv2.contourArea(cnt)
124
+ if area < MIN_AREA:
125
+ continue
126
+
127
+ x, y, w, h = cv2.boundingRect(cnt)
128
+ rect_area = w * h
129
+ if rect_area == 0:
130
+ continue
131
+
132
+ rectangularity = area / rect_area
133
+ aspect = w / h if h > 0 else 0
134
+
135
+ if rectangularity >= MIN_RECTANGULARITY and MIN_ASPECT <= aspect <= MAX_ASPECT:
136
+ # Additional filter: must be at least 0.1% of page
137
+ if area / page_area >= 0.001:
138
+ redactions.append({
139
+ 'x': int(x), 'y': int(y),
140
+ 'w': int(w), 'h': int(h),
141
+ 'area': int(area),
142
+ 'area_pct': round(area / page_area * 100, 2),
143
+ })
144
+
145
+ total_redacted_area = sum(r['area'] for r in redactions)
146
+ return {
147
+ 'count': len(redactions),
148
+ 'total_area_pct': round(total_redacted_area / page_area * 100, 2),
149
+ 'bboxes': redactions,
150
+ }
151
+
152
+ except Exception as e:
153
+ log.debug(f"Error on {pdf_path} p{page_num}: {e}")
154
+ return None
155
+
156
+
157
+ def process_document(conn, doc_id, file_path):
158
+ """Process all pages of a document for redactions."""
159
+ # Resolve PDF path
160
+ pdf_path = None
161
+ for base in ['/data/raw/', '/data/ocr_output/']:
162
+ candidate = os.path.join(base, file_path)
163
+ if os.path.exists(candidate):
164
+ pdf_path = candidate
165
+ break
166
+
167
+ # Also try the file_path directly
168
+ if pdf_path is None and os.path.exists(file_path):
169
+ pdf_path = file_path
170
+
171
+ # Try finding the original PDF from file_path pattern
172
+ if pdf_path is None:
173
+ # file_path might be relative, try common patterns
174
+ for base in ['/data/raw/', '/data/']:
175
+ candidate = os.path.join(base, file_path)
176
+ if os.path.exists(candidate):
177
+ pdf_path = candidate
178
+ break
179
+
180
+ if pdf_path is None:
181
+ return 0, 0, [] # Can't find PDF
182
+
183
+ pages = get_pages_for_doc(conn, doc_id)
184
+ if not pages:
185
+ return 0, 0, []
186
+
187
+ page_results = []
188
+ total_redactions = 0
189
+ max_area_pct = 0
190
+
191
+ for page_id, page_num in pages:
192
+ result = detect_redactions_page(pdf_path, page_num)
193
+ if result is None:
194
+ continue
195
+
196
+ page_results.append((
197
+ page_id,
198
+ 'redaction',
199
+ result['count'],
200
+ json.dumps(result),
201
+ ))
202
+
203
+ total_redactions += result['count']
204
+ if result['total_area_pct'] > max_area_pct:
205
+ max_area_pct = result['total_area_pct']
206
+
207
+ return total_redactions, max_area_pct, page_results
208
+
209
+
210
+ def flush_page_features(conn, rows):
211
+ if not rows:
212
+ return
213
+ with conn.cursor() as cur:
214
+ psycopg2.extras.execute_batch(
215
+ cur,
216
+ """INSERT INTO page_features (page_id, feature_name, feature_value, feature_json)
217
+ VALUES (%s, %s, %s, %s::jsonb)
218
+ ON CONFLICT (page_id, feature_name) DO UPDATE SET
219
+ feature_value = EXCLUDED.feature_value,
220
+ feature_json = EXCLUDED.feature_json,
221
+ created_at = NOW()""",
222
+ rows,
223
+ page_size=500,
224
+ )
225
+ conn.commit()
226
+
227
+
228
+ def flush_doc_features(conn, rows):
229
+ if not rows:
230
+ return
231
+ with conn.cursor() as cur:
232
+ psycopg2.extras.execute_batch(
233
+ cur,
234
+ """INSERT INTO document_features (document_id, feature_name, feature_value, feature_json)
235
+ VALUES (%s, %s, %s, %s::jsonb)
236
+ ON CONFLICT (document_id, feature_name) DO UPDATE SET
237
+ feature_value = EXCLUDED.feature_value,
238
+ feature_json = EXCLUDED.feature_json,
239
+ created_at = NOW()""",
240
+ rows,
241
+ page_size=500,
242
+ )
243
+ conn.commit()
244
+
245
+
246
+ def main():
247
+ conn = get_conn()
248
+ grand_total = 0
249
+ docs_with_redactions = 0
250
+
251
+ for section in PRIORITY_SECTIONS:
252
+ log.info(f"=== Processing section: {section} ===")
253
+ batch_num = 0
254
+
255
+ while True:
256
+ docs = get_pending_documents(conn, section, limit=100)
257
+ if not docs:
258
+ break
259
+
260
+ page_feature_buffer = []
261
+ doc_feature_buffer = []
262
+
263
+ for doc_id, file_path in docs:
264
+ total_redactions, max_area_pct, page_results = process_document(conn, doc_id, file_path)
265
+
266
+ page_feature_buffer.extend(page_results)
267
+
268
+ # Store document-level summary
269
+ summary = {
270
+ 'total_redactions': total_redactions,
271
+ 'max_page_area_pct': max_area_pct,
272
+ 'pages_analyzed': len(page_results),
273
+ }
274
+ doc_feature_buffer.append((
275
+ doc_id,
276
+ 'redaction_summary',
277
+ float(total_redactions),
278
+ json.dumps(summary),
279
+ ))
280
+
281
+ if total_redactions > 0:
282
+ docs_with_redactions += 1
283
+
284
+ grand_total += total_redactions
285
+
286
+ flush_page_features(conn, page_feature_buffer)
287
+ flush_doc_features(conn, doc_feature_buffer)
288
+
289
+ batch_num += 1
290
+ log.info(f" {section} batch {batch_num}: {len(docs)} docs, "
291
+ f"{len(page_feature_buffer)} page results, "
292
+ f"{grand_total} total redactions found, "
293
+ f"{docs_with_redactions} docs with redactions")
294
+
295
+ conn.close()
296
+ log.info(f"Done. {grand_total} redactions across {docs_with_redactions} documents.")
297
+
298
+
299
+ if __name__ == "__main__":
300
+ main()