OpenScrub 1.0.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.
openscrub.py ADDED
@@ -0,0 +1,2445 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ openscrub.py v3 — Automatic PHI redaction for screen-recording videos.
4
+ Windows + Linux. No patient list required. Scroll-aware.
5
+
6
+ Detects and blurs PHI (patient names, dates of birth, MRNs, phone numbers,
7
+ SSNs, email addresses) in screen recordings before they go into DaVinci
8
+ Resolve or anywhere else.
9
+
10
+ What's new in v2:
11
+ * Name detection WITHOUT a patient list, via three stacked signals:
12
+ 1. spaCy NER (PERSON entities) — install spacy + en_core_web_sm
13
+ 2. Label heuristic — text following "Patient:", "Name:", "Pt:", etc.
14
+ 3. Capitalized-name-pair heuristic (fallback / belt-and-suspenders)
15
+ Plus an --allow-names file so provider/staff names stay visible.
16
+ * Scroll tracking: per-frame global motion estimation (phase correlation).
17
+ Blur boxes are anchored in CONTENT coordinates and translated with the
18
+ scroll on every frame — the blur rides along with the text.
19
+ * Safety bands: any screen region that scrolled into view since the last
20
+ OCR scan is blurred until it has been scanned. Nothing unverified is
21
+ ever shown, even mid-scroll.
22
+ * Motion-triggered OCR: extra scans fire automatically every N pixels of
23
+ scroll, independent of the time-based sample interval.
24
+ * Windows support: auto-detects Tesseract install path, graceful ffmpeg
25
+ fallback.
26
+
27
+ Usage (see README.md):
28
+ python openscrub.py recording.mp4
29
+ python openscrub.py recording.mp4 --allow-names providers.txt --preview
30
+ """
31
+
32
+ import argparse
33
+ import datetime
34
+ import hashlib
35
+ import json
36
+ import os
37
+ import re
38
+ import shutil
39
+ import subprocess
40
+ import sys
41
+ from dataclasses import dataclass, asdict
42
+
43
+ import cv2
44
+ import numpy as np
45
+
46
+ VERSION = "1.0.0"
47
+
48
+ # ----------------------------------------------------------------------------
49
+ # OCR backends
50
+ # ----------------------------------------------------------------------------
51
+
52
+ WINDOWS_TESSERACT_PATHS = [
53
+ r"C:\Program Files\Tesseract-OCR\tesseract.exe",
54
+ r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
55
+ os.path.expandvars(r"%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"),
56
+ ]
57
+
58
+
59
+ class OcrBackend:
60
+ """Returns list of (text, (x1, y1, x2, y2), confidence) for a BGR frame."""
61
+
62
+ def read(self, frame):
63
+ raise NotImplementedError
64
+
65
+
66
+ class TesseractBackend(OcrBackend):
67
+ def __init__(self):
68
+ import pytesseract
69
+ self.pt = pytesseract
70
+ if os.name == "nt" and not shutil.which("tesseract"):
71
+ for p in WINDOWS_TESSERACT_PATHS:
72
+ if os.path.exists(p):
73
+ pytesseract.pytesseract.tesseract_cmd = p
74
+ break
75
+ else:
76
+ sys.exit("Tesseract not found. Install from "
77
+ "https://github.com/UB-Mannheim/tesseract/wiki "
78
+ "or add it to PATH.")
79
+
80
+ def read(self, frame):
81
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
82
+ data = self.pt.image_to_data(gray, output_type=self.pt.Output.DICT)
83
+ out = []
84
+ def phi_shaped(t):
85
+ s = t.strip(".,:;()[]")
86
+ return ("@" in t or RE_SSN.search(t) or RE_PHONE.search(t)
87
+ or RE_DATE.search(t)
88
+ or sum(ch.isdigit() for ch in s) >= 6)
89
+ for i in range(len(data["text"])):
90
+ txt = data["text"][i].strip()
91
+ conf = float(data["conf"][i]) if data["conf"][i] not in ("-1", -1) else 0.0
92
+ if not txt:
93
+ continue
94
+ # low-confidence words are normally dropped, but words that are
95
+ # structurally PHI-shaped (emails, phones, SSNs, dates, long
96
+ # digit runs) are rescued: a misread MRN is still an MRN
97
+ if conf < 40 and not (conf >= 5 and phi_shaped(txt)):
98
+ continue
99
+ x, y, w, h = data["left"][i], data["top"][i], data["width"][i], data["height"][i]
100
+ out.append((txt, (x, y, x + w, y + h), conf / 100.0))
101
+ return out
102
+
103
+
104
+ class PaddleBackend(OcrBackend):
105
+ def __init__(self, device="auto"):
106
+ import logging
107
+ for name in ("paddlex", "paddleocr", "ppocr", "paddle"):
108
+ try:
109
+ logging.getLogger(name).setLevel(logging.ERROR)
110
+ except Exception:
111
+ pass
112
+ import paddleocr
113
+ from paddleocr import PaddleOCR
114
+ ver = getattr(paddleocr, "__version__", "3.0.0")
115
+ try:
116
+ self.v3 = int(str(ver).split(".")[0]) >= 3
117
+ except ValueError:
118
+ self.v3 = True
119
+
120
+ # resolve device
121
+ if device == "auto":
122
+ try:
123
+ import paddle
124
+ device = ("gpu" if paddle.device.is_compiled_with_cuda()
125
+ and paddle.device.cuda.device_count() > 0 else "cpu")
126
+ except Exception:
127
+ device = "cpu"
128
+ self.device = device
129
+ print(f" paddle device: {self.device}")
130
+
131
+ if self.v3:
132
+ # PaddleOCR >= 3.0: new pipeline API. Disable the document
133
+ # preprocessing stages — screen recordings are already flat,
134
+ # upright, and undistorted, so they just cost time.
135
+ # enable_mkldnn=False works around a paddlepaddle 3.x bug on
136
+ # Windows CPU ("ConvertPirAttribute2RuntimeAttribute not
137
+ # support" in onednn_instruction.cc); irrelevant on GPU.
138
+ kwargs = dict(
139
+ lang="en",
140
+ device=self.device,
141
+ use_textline_orientation=False,
142
+ use_doc_orientation_classify=False,
143
+ use_doc_unwarping=False,
144
+ )
145
+ if self.device == "cpu":
146
+ kwargs["enable_mkldnn"] = False
147
+ try:
148
+ self.ocr = PaddleOCR(**kwargs)
149
+ except (TypeError, ValueError):
150
+ # builds without enable_mkldnn / device args
151
+ kwargs.pop("enable_mkldnn", None)
152
+ kwargs.pop("device", None)
153
+ self.ocr = PaddleOCR(**kwargs)
154
+ else:
155
+ # PaddleOCR 2.x: legacy API
156
+ self.ocr = PaddleOCR(use_angle_cls=False, lang="en",
157
+ show_log=False, use_gpu=(self.device == "gpu"))
158
+
159
+ def _lines(self, frame):
160
+ """Yield (text, x1, y1, x2, y2, conf) line-level results, either API."""
161
+ if self.v3:
162
+ results = self.ocr.predict(frame)
163
+ for res in results or []:
164
+ texts = res.get("rec_texts") or []
165
+ scores = res.get("rec_scores") or []
166
+ polys = res.get("rec_polys")
167
+ boxes = res.get("rec_boxes")
168
+ for i, txt in enumerate(texts):
169
+ conf = float(scores[i]) if i < len(scores) else 1.0
170
+ if polys is not None and i < len(polys):
171
+ xs = [p[0] for p in polys[i]]
172
+ ys = [p[1] for p in polys[i]]
173
+ yield txt, min(xs), min(ys), max(xs), max(ys), conf
174
+ elif boxes is not None and i < len(boxes):
175
+ b = boxes[i]
176
+ yield txt, b[0], b[1], b[2], b[3], conf
177
+ else:
178
+ result = self.ocr.ocr(frame, cls=False)
179
+ if not result or result[0] is None:
180
+ return
181
+ for line in result[0]:
182
+ box, (txt, conf) = line
183
+ xs = [p[0] for p in box]
184
+ ys = [p[1] for p in box]
185
+ yield txt, min(xs), min(ys), max(xs), max(ys), float(conf)
186
+
187
+ def read(self, frame):
188
+ out = []
189
+ for txt, x1, y1, x2, y2, conf in self._lines(frame):
190
+ words = txt.split()
191
+ if not words:
192
+ continue
193
+ # Paddle returns line-level boxes; split into word boxes by
194
+ # proportional width so per-word redaction stays tight.
195
+ total = sum(len(w) for w in words) + (len(words) - 1)
196
+ cursor = float(x1)
197
+ for w in words:
198
+ frac = (len(w) + 1) / max(total, 1)
199
+ wx2 = cursor + (float(x2) - float(x1)) * frac
200
+ out.append((w, (int(cursor), int(y1), int(wx2), int(y2)), conf))
201
+ cursor = wx2
202
+ return out
203
+
204
+
205
+ def read_adaptive(ocr, frame, mode="auto"):
206
+ """OCR the frame; if the text is small (median word height < 15 px) or
207
+ mode is 'on', re-OCR at 2x and keep whichever pass found more words.
208
+ Upscaling helps small UI fonts but can hurt large text, so it's applied
209
+ adaptively rather than blindly."""
210
+ words = ocr.read(frame)
211
+ if mode == "off":
212
+ return words
213
+ heights = sorted(b[3] - b[1] for _, b, _ in words) or [99]
214
+ small = heights[len(heights) // 2] < 15
215
+ if mode == "on" or small:
216
+ # scale up for small text, but cap the result at ~4000 px on the
217
+ # longest side — PaddleOCR resizes anything larger straight back
218
+ # down, so exceeding it is pure waste
219
+ scale = min(2.0, 4000.0 / max(frame.shape[:2]))
220
+ if scale < 1.2:
221
+ return words # already near the cap: upscaling can't help
222
+ big = cv2.resize(frame, None, fx=scale, fy=scale,
223
+ interpolation=cv2.INTER_CUBIC)
224
+ w2 = [(t, (b[0] / scale, b[1] / scale, b[2] / scale, b[3] / scale), c)
225
+ for t, b, c in ocr.read(big)]
226
+ if len(w2) > len(words):
227
+ return w2
228
+ return words
229
+
230
+
231
+ def make_ocr(engine, device="auto"):
232
+ if engine == "paddle":
233
+ return PaddleBackend(device=device)
234
+ if engine == "tesseract":
235
+ return TesseractBackend()
236
+ try:
237
+ return PaddleBackend(device=device)
238
+ except Exception:
239
+ return TesseractBackend()
240
+
241
+
242
+ # ----------------------------------------------------------------------------
243
+ # Regex PHI detectors
244
+ # ----------------------------------------------------------------------------
245
+
246
+ RE_DATE = re.compile(
247
+ r"""(?ix)\b(
248
+ \d{1,2}[/\-.]\d{1,2}[/\-.](?:\d{4}|\d{2})
249
+ | (?:19|20)\d{2}[/\-.]\d{1,2}[/\-.]\d{1,2}
250
+ | (?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+(?:19|20)\d{2}
251
+ )\b"""
252
+ )
253
+ RE_PHONE = re.compile(r"\(?\b\d{3}\)?[-. ]\d{3}[-. ]\d{4}\b")
254
+ RE_SSN = re.compile(r"\b\d{3}-\d{2}-\d{4}\b")
255
+ RE_EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b")
256
+ # API keys / tokens / secrets: common pref'd shapes + long high-entropy blobs.
257
+ RE_APIKEY = re.compile(r"""(?x)
258
+ \b(?:
259
+ sk-[A-Za-z0-9]{20,} # OpenAI-style
260
+ | gh[pousr]_[A-Za-z0-9]{20,} # GitHub tokens
261
+ | xox[baprs]-[A-Za-z0-9-]{10,} # Slack
262
+ | AKIA[0-9A-Z]{16} # AWS access key id
263
+ | AIza[0-9A-Za-z_\-]{35} # Google API key
264
+ | ya29\.[0-9A-Za-z_\-]+ # Google OAuth
265
+ | eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,} # JWT
266
+ | (?:api[_-]?key|secret|token|bearer)[=:\s"']{1,3}[A-Za-z0-9_\-]{16,}
267
+ )\b""")
268
+ # generic high-entropy blob (>=32 chars) — but only if it mixes letters AND
269
+ # digits, so ordinary long words don't trip it.
270
+ RE_APIKEY_GENERIC = re.compile(r"\b(?=[A-Za-z0-9_\-]*\d)(?=[A-Za-z0-9_\-]*[A-Za-z])[A-Za-z0-9_\-]{32,}\b")
271
+ # Credit/debit card: 13-19 digits, optionally split by spaces or hyphens in
272
+ # groups. Major-brand prefixes keep it specific; the Luhn checksum below
273
+ # rejects random number strings (dates, IDs, phone runs) that happen to match.
274
+ RE_CARD = re.compile(r"""(?x)
275
+ \b(?:
276
+ 4\d{3} # Visa
277
+ | 5[1-5]\d{2} | 2(?:2[2-9]\d|[3-6]\d\d|7[01]\d|720) # Mastercard
278
+ | 3[47]\d{2} # Amex (15 digits)
279
+ | 6(?:011|5\d\d|4[4-9]\d) # Discover
280
+ | 3(?:0[0-5]|[68]\d)\d # Diners
281
+ )[ -]?\d{4}[ -]?\d{4}[ -]?\d{1,4}\b""")
282
+
283
+
284
+ def _luhn_ok(digits):
285
+ """Luhn checksum: real card numbers pass; random digit runs almost never
286
+ do (1-in-10 by chance, and the brand-prefix gate removes most of those)."""
287
+ if not (13 <= len(digits) <= 19):
288
+ return False
289
+ total, alt = 0, False
290
+ for ch in reversed(digits):
291
+ d = ord(ch) - 48
292
+ if alt:
293
+ d *= 2
294
+ if d > 9:
295
+ d -= 9
296
+ total += d
297
+ alt = not alt
298
+ return total % 10 == 0
299
+ # IPv4 (validated octets) and obvious IPv6.
300
+ RE_IP = re.compile(
301
+ r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b"
302
+ r"|\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}\b")
303
+ # Street address: a leading number, then words, ending in a street-type
304
+ # suffix (with or without a trailing abbreviation dot). Also matches secondary
305
+ # unit designators. Case-insensitive.
306
+ RE_STREET = re.compile(r"""(?ix)
307
+ \b\d{1,6}\s+
308
+ (?:[NSEW]\.?\s+|(?:north|south|east|west)\s+)?
309
+ [A-Za-z0-9.'\-]+(?:\s+[A-Za-z0-9.'\-]+){0,4}?\s+
310
+ (?:st|street|ave|avenue|blvd|boulevard|rd|road|dr|drive|ln|lane|ct|court|
311
+ cir|circle|way|pl|place|ter|terrace|pkwy|parkway|hwy|highway|trl|trail|
312
+ loop|pike|row|run|path|crossing|xing|square|sq)\.?
313
+ (?:\s+(?:apt|apartment|suite|ste|unit|bldg|building|fl|floor|rm|room)\.?\s*
314
+ \#?\s*\w+)?
315
+ \b""")
316
+ # City, ST 12345 (the classic last line of a US address)
317
+ RE_CITYSTATEZIP = re.compile(
318
+ r"(?i)\b[A-Za-z.\-]+(?:\s+[A-Za-z.\-]+)*,\s*"
319
+ r"(?:AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|"
320
+ r"MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|"
321
+ r"VA|WA|WV|WI|WY)\s+\d{5}(?:-\d{4})?\b")
322
+ # Secondary unit line on its own (address continuation): "Apt 4B", "Suite 200"
323
+ RE_UNIT_LINE = re.compile(
324
+ r"(?i)^\s*(?:apt|apartment|suite|ste|unit|bldg|building|fl|floor|rm|room|"
325
+ r"#)\.?\s*\#?\s*\w+\s*$")
326
+ # A bare 5-digit (or ZIP+4) on its own line — a wrapped ZIP continuation.
327
+ RE_ZIP_LINE = re.compile(r"^\s*\d{5}(?:-\d{4})?\s*$")
328
+ # "City, ST" with the ZIP wrapped to the next line (continuation only).
329
+ RE_CITYSTATE_NOZIP = re.compile(
330
+ r"(?i)^[A-Za-z.\-]+(?:\s+[A-Za-z.\-]+)*,\s*"
331
+ r"(?:AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|"
332
+ r"MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|"
333
+ r"VA|WA|WV|WI|WY)\s*$")
334
+ # Standalone ZIP+4 or 5-digit ZIP with a ZIP label nearby is weak on its own;
335
+ # we rely on the specific patterns above to avoid noise.
336
+
337
+ RE_MRN_DEFAULT = r"(^(?:1\d{6}|MM\d{10})$)"
338
+ RE_MRN_LABEL = re.compile(r"(?i)\b(mrn|med(?:ical)?\s*rec(?:ord)?|acct|account|chart)\b")
339
+ RE_NAME_LABEL = re.compile(
340
+ r"(?i)\b(patient|name|pt|member|insured|guarantor|subscriber|responsible\s*party)\s*[:#\-]"
341
+ )
342
+
343
+ # Words that should never be treated as names (UI chrome, medical/scheduling
344
+ # vocab). Lowercase. Extend freely — over-including here only reduces
345
+ # false-positive blur, never PHI leakage, because real names still hit the
346
+ # label heuristic and NER.
347
+ STOPWORDS = {
348
+ "patient", "patients", "name", "date", "birth", "phone", "chart", "home",
349
+ "search", "provider", "appointment", "appointments", "visit", "visits",
350
+ "office", "note", "notes", "history", "medications", "medication",
351
+ "allergies", "allergy", "insurance", "today", "new", "open", "save",
352
+ "cancel", "print", "close", "edit", "view", "help", "file", "clinic",
353
+ "schedule", "mohs", "consult", "biopsy", "pathology", "derm",
354
+ "dermatology", "results", "follow", "followup", "exam", "skin", "lesion",
355
+ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
356
+ "sunday", "january", "february", "march", "april", "may", "june", "july",
357
+ "august", "september", "october", "november", "december", "morning",
358
+ "afternoon", "refill", "request", "prior", "auth", "authorization",
359
+ "pending", "review", "approved", "denied", "sent", "received", "inbox",
360
+ "status", "active", "established", "est", "check", "checkout", "checkin",
361
+ "room", "waiting", "billing", "claims", "settings", "admin", "user",
362
+ "logout", "dashboard", "reports", "tasks", "messages", "fax", "faxes",
363
+ "little", "rock", "clinton", "russellville", "west", "pinnacle",
364
+ "suite", "street", "drive", "avenue", "road", "blvd",
365
+ "am", "pm", "min", "mins", "hr", "hrs", "yes", "no",
366
+ }
367
+
368
+ HONORIFICS = {"mr", "mrs", "ms", "miss", "mx"}
369
+
370
+
371
+ # ----------------------------------------------------------------------------
372
+ # Line reconstruction (word boxes -> text lines with char->box mapping)
373
+ # ----------------------------------------------------------------------------
374
+
375
+ def group_lines(words):
376
+ """Group OCR word boxes into lines. Returns list of dicts:
377
+ {text, words: [(word, box, conf, char_start, char_end)]}"""
378
+ if not words:
379
+ return []
380
+ items = sorted(words, key=lambda w: ((w[1][1] + w[1][3]) / 2, w[1][0]))
381
+ lines = []
382
+ for w in items:
383
+ yc = (w[1][1] + w[1][3]) / 2
384
+ h = max(w[1][3] - w[1][1], 1)
385
+ placed = False
386
+ for ln in lines:
387
+ if abs(ln["yc"] - yc) < 0.7 * max(h, ln["h"]):
388
+ ln["raw"].append(w)
389
+ ln["yc"] = (ln["yc"] * (len(ln["raw"]) - 1) + yc) / len(ln["raw"])
390
+ ln["h"] = max(ln["h"], h)
391
+ placed = True
392
+ break
393
+ if not placed:
394
+ lines.append({"raw": [w], "yc": yc, "h": h})
395
+ out = []
396
+ for ln in lines:
397
+ ln["raw"].sort(key=lambda w: w[1][0])
398
+ text = ""
399
+ entries = []
400
+ for w, box, conf in ln["raw"]:
401
+ if text:
402
+ text += " "
403
+ start = len(text)
404
+ text += w
405
+ entries.append((w, box, conf, start, len(text)))
406
+ out.append({"text": text, "words": entries})
407
+ return out
408
+
409
+
410
+ # ----------------------------------------------------------------------------
411
+ # Name detection (no patient list required)
412
+ # ----------------------------------------------------------------------------
413
+
414
+ class NameDetector:
415
+ def __init__(self, allow_names=None, extra_names=None, use_ner=True,
416
+ heuristic="auto"):
417
+ self.allow = set()
418
+ if allow_names:
419
+ with open(allow_names, encoding="utf-8") as f:
420
+ for line in f:
421
+ for tok in line.strip().replace(",", " ").split():
422
+ t = tok.strip(".").lower()
423
+ if t:
424
+ self.allow.add(t)
425
+ self.extra = set()
426
+ if extra_names:
427
+ with open(extra_names, encoding="utf-8") as f:
428
+ for line in f:
429
+ for tok in line.strip().replace(",", " ").split():
430
+ t = tok.lower()
431
+ if len(t) >= 2:
432
+ self.extra.add(t)
433
+ self.nlp = None
434
+ if use_ner:
435
+ try:
436
+ import spacy
437
+ try:
438
+ self.nlp = spacy.load("en_core_web_sm")
439
+ except OSError:
440
+ print(" WARNING: spaCy installed but model missing.\n"
441
+ " Run: python -m spacy download en_core_web_sm\n"
442
+ " Falling back to heuristic name detection.")
443
+ except ImportError:
444
+ print(" WARNING: spaCy not installed — heuristic name "
445
+ "detection only.\n For better accuracy: pip install "
446
+ "spacy && python -m spacy download en_core_web_sm")
447
+ # heuristic: "auto" = on when NER unavailable; "on"/"off" force it
448
+ self.heuristic = (heuristic == "on") or (heuristic == "auto" and self.nlp is None)
449
+
450
+ def _allowed(self, word):
451
+ return word.strip(".,:;()[]").lower() in self.allow
452
+
453
+ @staticmethod
454
+ def _namey(word):
455
+ """Looks like a name token: alpha (plus - ' .), capitalized."""
456
+ w = word.strip(".,:;()[]")
457
+ if len(w) < 2 or not w[0].isupper():
458
+ return False
459
+ core = w.replace("-", "").replace("'", "")
460
+ if not core.isalpha():
461
+ return False
462
+ return w.lower() not in STOPWORDS
463
+
464
+ def find(self, lines):
465
+ """Yield (box, matched_text) for name hits across reconstructed lines."""
466
+ hits = []
467
+
468
+ for ln in lines:
469
+ words = ln["words"]
470
+
471
+ # --- 1. spaCy NER ---
472
+ if self.nlp is not None:
473
+ doc = self.nlp(ln["text"])
474
+ for ent in doc.ents:
475
+ if ent.label_ != "PERSON":
476
+ continue
477
+ for w, box, conf, s, e in words:
478
+ if s < ent.end_char and e > ent.start_char:
479
+ if self._namey(w) and not self._allowed(w):
480
+ hits.append((box, w))
481
+
482
+ # --- 2. label heuristic: "Patient: John Smith DOB: ..." ---
483
+ m = RE_NAME_LABEL.search(ln["text"])
484
+ if m:
485
+ started = False
486
+ count = 0
487
+ for w, box, conf, s, e in words:
488
+ if s < m.end():
489
+ continue
490
+ bare = w.strip(".,:;()[]")
491
+ # stop at the next label-ish token ("DOB:", "MRN:")
492
+ if (w.endswith(":") and started) or bare.lower() in (
493
+ "dob", "mrn", "phone", "sex", "gender", "age"):
494
+ break
495
+ if self._namey(w) or (bare and bare[0].isupper()):
496
+ if not self._allowed(w):
497
+ hits.append((box, w))
498
+ started = True
499
+ count += 1
500
+ if count >= 4:
501
+ break
502
+ elif started:
503
+ break
504
+
505
+ # --- 3. extra names list (exact/substring) ---
506
+ if self.extra:
507
+ for w, box, conf, s, e in words:
508
+ if w.strip(".,:;()[]").lower() in self.extra:
509
+ hits.append((box, w))
510
+
511
+ # --- 4. capitalized-pair heuristic (fallback) ---
512
+ if self.heuristic:
513
+ for i in range(len(words) - 1):
514
+ w1, b1 = words[i][0], words[i][1]
515
+ w2, b2 = words[i + 1][0], words[i + 1][1]
516
+ pair = False
517
+ if self._namey(w1) and self._namey(w2):
518
+ pair = True
519
+ # "Last, First"
520
+ elif w1.endswith(",") and self._namey(w1[:-1]) and self._namey(w2):
521
+ pair = True
522
+ # honorific + name: "Mrs. Henderson"
523
+ elif w1.strip(".").lower() in HONORIFICS and self._namey(w2):
524
+ if not self._allowed(w2):
525
+ hits.append((b2, w2))
526
+ continue
527
+ if pair:
528
+ if not self._allowed(w1):
529
+ hits.append((b1, w1))
530
+ if not self._allowed(w2):
531
+ hits.append((b2, w2))
532
+
533
+ # dedupe by box
534
+ seen = set()
535
+ out = []
536
+ for box, txt in hits:
537
+ if box not in seen:
538
+ seen.add(box)
539
+ out.append((box, txt))
540
+ return out
541
+
542
+
543
+ # ----------------------------------------------------------------------------
544
+ # PHI detection on one OCR'd frame
545
+ # ----------------------------------------------------------------------------
546
+
547
+ @dataclass
548
+ class Detection:
549
+ t_start: float
550
+ t_end: float
551
+ cbox: tuple # box in CONTENT coordinates (x1,y1,x2,y2)
552
+ category: str
553
+ text: str
554
+ confidence: float
555
+ aoff: tuple = (0.0, 0.0) # cumulative offset when detected (drift anchor)
556
+ last_seen: float = 0.0 # time of last positive sighting (t_end incl. hold)
557
+ dense: bool = False # per-frame dense-face detection: never merged
558
+ # across positions (it tracks a moving face)
559
+
560
+
561
+ class PhiMemory:
562
+ """Remembers every string ever confirmed as PHI in this video. At each
563
+ scan, all OCR'd words are checked against memory, so a name identified
564
+ once gets blurred on every later appearance — anywhere on screen — even
565
+ when NER/heuristics fail on that occurrence. Alpha strings match fuzzily
566
+ (handles OCR misreads); numeric strings require same length with at most
567
+ one differing digit (so benign numbers don't collide with MRNs)."""
568
+
569
+ IMMEDIATE = {"dob", "phone", "ssn", "email", "mrn", "address",
570
+ "apikey", "ipaddr", "card", "plate"}
571
+
572
+ def __init__(self, threshold=82, name_sightings=2):
573
+ from rapidfuzz import fuzz
574
+ self.fuzz = fuzz
575
+ self.threshold = threshold
576
+ self.name_sightings = name_sightings
577
+ self.items = {} # normalized text -> category
578
+ self.counts = {} # normalized text -> primary-detector sightings
579
+
580
+ @staticmethod
581
+ def norm(s):
582
+ return s.strip(".,:;()[]").lower()
583
+
584
+ def add(self, text, category, primary=True):
585
+ if category in ("face", "manual"):
586
+ return
587
+ n = self.norm(text)
588
+ if len(n) >= 3 and n not in STOPWORDS:
589
+ self.items.setdefault(n, category)
590
+ if primary:
591
+ self.counts[n] = self.counts.get(n, 0) + 1
592
+
593
+ def _gated(self, key, cat):
594
+ """Names must be seen by a primary detector on name_sightings
595
+ separate scans before memory starts recalling them — one bad
596
+ NER hit shouldn't multiply across the whole video. Regex
597
+ categories are high-precision and recall immediately."""
598
+ if cat in self.IMMEDIATE:
599
+ return cat
600
+ return cat if self.counts.get(key, 0) >= self.name_sightings else None
601
+
602
+ def recall(self, word):
603
+ n = self.norm(word)
604
+ if len(n) < 3 or n in STOPWORDS:
605
+ return None
606
+ if n in self.items:
607
+ return self._gated(n, self.items[n])
608
+ if n.isdigit():
609
+ for k, cat in self.items.items():
610
+ if (k.isdigit() and len(k) == len(n)
611
+ and sum(a != b for a, b in zip(k, n)) <= 1):
612
+ return self._gated(k, cat)
613
+ return None
614
+ if len(n) >= 4:
615
+ for k, cat in self.items.items():
616
+ if (not k.isdigit() and abs(len(k) - len(n)) <= 2
617
+ and self.fuzz.ratio(k, n) >= self.threshold):
618
+ return self._gated(k, cat)
619
+ return None
620
+
621
+
622
+ def detect_phi(words, lines, t, offset, namer, mrn_re):
623
+ """offset = cumulative scroll (dx, dy) at this frame; boxes are converted
624
+ to content coordinates by subtracting it."""
625
+ dets = []
626
+ ox, oy = offset
627
+
628
+ def add(box, cat, txt, conf):
629
+ cbox = (int(box[0] - ox), int(box[1] - oy), int(box[2] - ox), int(box[3] - oy))
630
+ dets.append(Detection(t, t, cbox, cat, txt, round(float(conf), 3), (ox, oy)))
631
+
632
+ for i, (txt, box, conf) in enumerate(words):
633
+ m_card = RE_CARD.search(txt)
634
+ if m_card and _luhn_ok(re.sub(r"\D", "", m_card.group())):
635
+ add(box, "card", txt, conf)
636
+ elif RE_APIKEY.search(txt) or RE_APIKEY_GENERIC.search(txt):
637
+ add(box, "apikey", txt, conf)
638
+ elif RE_IP.search(txt):
639
+ add(box, "ipaddr", txt, conf)
640
+ elif RE_SSN.search(txt):
641
+ add(box, "ssn", txt, conf)
642
+ elif RE_EMAIL.search(txt):
643
+ add(box, "email", txt, conf)
644
+ elif RE_DATE.search(txt):
645
+ add(box, "dob", txt, conf)
646
+ elif RE_PHONE.search(txt):
647
+ add(box, "phone", txt, conf)
648
+ elif mrn_re.search(txt) or mrn_re.search(txt.strip(".,:;()[]")):
649
+ digits = re.sub(r"\D", "", txt)
650
+ near_label = any(
651
+ RE_MRN_LABEL.search(w2)
652
+ and abs((b2[1] + b2[3]) / 2 - (box[1] + box[3]) / 2) < (box[3] - box[1]) * 1.5
653
+ for w2, b2, _ in words
654
+ )
655
+ if near_label or len(digits) >= 7:
656
+ add(box, "mrn", txt, conf)
657
+
658
+ # split-across-words card: "4111" "1111" "1111" "1111" (or 3 groups + amex)
659
+ for i in range(len(words) - 2):
660
+ for span in (4, 3):
661
+ if i + span > len(words):
662
+ continue
663
+ grp = words[i:i + span]
664
+ joined = "".join(re.sub(r"\D", "", g[0]) for g in grp)
665
+ if (all(re.fullmatch(r"\d{3,6}", re.sub(r"\D", "", g[0])) for g in grp)
666
+ and RE_CARD.search(" ".join(g[0] for g in grp))
667
+ and _luhn_ok(joined)):
668
+ bx = [g[1] for g in grp]
669
+ add((min(b[0] for b in bx), min(b[1] for b in bx),
670
+ max(b[2] for b in bx), max(b[3] for b in bx)),
671
+ "card", joined, min(g[2] for g in grp))
672
+ break
673
+
674
+ # split-across-words phone: "(501)" "555-0142"
675
+ for i in range(len(words) - 1):
676
+ joined = words[i][0] + " " + words[i + 1][0]
677
+ if RE_PHONE.search(joined) and not RE_PHONE.search(words[i][0]):
678
+ b1, b2 = words[i][1], words[i + 1][1]
679
+ add((min(b1[0], b2[0]), min(b1[1], b2[1]), max(b1[2], b2[2]), max(b1[3], b2[3])),
680
+ "phone", joined, min(words[i][2], words[i + 1][2]))
681
+
682
+ # split-across-words date: "Mar" "15," "1978"
683
+ for i in range(len(words) - 2):
684
+ trio = words[i:i + 3]
685
+ joined = " ".join(w[0] for w in trio)
686
+ if RE_DATE.search(joined) and not any(RE_DATE.search(w[0]) for w in trio):
687
+ boxes = [w[1] for w in trio]
688
+ add((min(b[0] for b in boxes), min(b[1] for b in boxes),
689
+ max(b[2] for b in boxes), max(b[3] for b in boxes)),
690
+ "dob", joined, min(w[2] for w in trio))
691
+
692
+ for box, txt in namer.find(lines):
693
+ add(box, "name", txt, 1.0)
694
+
695
+ # Addresses span one to several stacked lines:
696
+ # 111 Main St
697
+ # Apt 4B (optional continuation)
698
+ # Little Rock, AR 72211
699
+ # Detect the street line, then absorb the next 1-2 lines that look like
700
+ # address continuations into ONE region, so a wrapped city/state/ZIP or a
701
+ # unit line is covered as part of the same address. A city/state/ZIP line
702
+ # standing alone (no street line above it) is still caught on its own.
703
+ def _line_box(ln):
704
+ bs = [e[1] for e in ln["words"]]
705
+ if not bs:
706
+ return None
707
+ return (min(b[0] for b in bs), min(b[1] for b in bs),
708
+ max(b[2] for b in bs), max(b[3] for b in bs))
709
+
710
+ def _vgap_ok(a, b):
711
+ # b is a plausible next line directly below a (allows ~1.8 line heights)
712
+ if not a or not b:
713
+ return False
714
+ ah = a[3] - a[1]
715
+ return 0 <= (b[1] - a[3]) <= 1.8 * max(ah, 1) and abs(b[0] - a[0]) < 6 * ah
716
+
717
+ used = set()
718
+ n = len(lines)
719
+ for i, ln in enumerate(lines):
720
+ if i in used:
721
+ continue
722
+ text = ln["text"]
723
+ is_street = bool(RE_STREET.search(text))
724
+ is_csz = bool(RE_CITYSTATEZIP.search(text))
725
+ if not (is_street or is_csz):
726
+ continue
727
+ box = _line_box(ln)
728
+ if box is None:
729
+ continue
730
+ parts_text = [text]
731
+ used.add(i)
732
+ if is_street:
733
+ # absorb up to two following continuation lines
734
+ j = i + 1
735
+ absorbed = 0
736
+ while j < n and absorbed < 2:
737
+ nb = _line_box(lines[j])
738
+ nt = lines[j]["text"]
739
+ cont = (RE_UNIT_LINE.search(nt) or RE_ZIP_LINE.search(nt)
740
+ or RE_CITYSTATEZIP.search(nt)
741
+ or RE_CITYSTATE_NOZIP.search(nt))
742
+ if cont and _vgap_ok(box, nb):
743
+ box = (min(box[0], nb[0]), min(box[1], nb[1]),
744
+ max(box[2], nb[2]), max(box[3], nb[3]))
745
+ parts_text.append(nt)
746
+ used.add(j)
747
+ absorbed += 1
748
+ # stop after we reach a city/state/ZIP (address is complete)
749
+ if RE_CITYSTATEZIP.search(nt):
750
+ break
751
+ j += 1
752
+ else:
753
+ break
754
+ add(box, "address", " / ".join(parts_text), 0.9)
755
+
756
+ return dets
757
+
758
+
759
+ # ----------------------------------------------------------------------------
760
+ # Scroll tracking
761
+ # ----------------------------------------------------------------------------
762
+
763
+ class ScrollTracker:
764
+ """Estimates cumulative global (dx, dy) content motion via phase
765
+ correlation against a KEYFRAME (the frame at the last OCR scan), not
766
+ frame-to-frame. This bounds drift to a single sub-pixel measurement per
767
+ scan epoch instead of accumulating error every frame. Sign convention
768
+ verified: content moving UP on screen => dy negative.
769
+
770
+ Call step(frame) every frame (returns cumulative offset); call anchor()
771
+ right after each OCR scan to re-key."""
772
+
773
+ def __init__(self, width=640):
774
+ self.width = width
775
+ self.key = None # keyframe gray
776
+ self.key_cum = (0.0, 0.0)
777
+ self.prev = None
778
+ self.cum = (0.0, 0.0)
779
+ self.win = None
780
+ self.inv_scale = 1.0
781
+
782
+ def _prep(self, frame):
783
+ h, w = frame.shape[:2]
784
+ scale = self.width / w
785
+ small = cv2.resize(frame, (self.width, max(2, int(h * scale))))
786
+ gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY).astype(np.float32)
787
+ if self.win is None or self.win.shape != gray.shape:
788
+ self.win = cv2.createHanningWindow(gray.shape[::-1], cv2.CV_32F)
789
+ self.inv_scale = 1.0 / scale
790
+ return gray
791
+
792
+ def step(self, frame):
793
+ gray = self._prep(frame)
794
+ if self.key is None:
795
+ self.key = gray
796
+ self.prev = gray
797
+ return self.cum
798
+
799
+ h, w = gray.shape
800
+ MAX_STEP = 250.0 # px/frame: above any smooth scroll. Bigger implied
801
+ # jumps are treated as content REPLACEMENT (dialog,
802
+ # page load) — blur boxes must NOT move for those.
803
+ last = self.cum
804
+ (dx, dy), resp = cv2.phaseCorrelate(self.key, gray, self.win)
805
+ if resp >= 0.12 and abs(dy) < 0.35 * h and abs(dx) < 0.35 * w:
806
+ ox = self.key_cum[0] + dx * self.inv_scale
807
+ oy = self.key_cum[1] + dy * self.inv_scale
808
+ if abs(ox - self.key_cum[0]) < 1.0:
809
+ ox = self.key_cum[0]
810
+ if abs(oy - self.key_cum[1]) < 1.0:
811
+ oy = self.key_cum[1]
812
+ if (abs(ox - last[0]) > MAX_STEP
813
+ or abs(oy - last[1]) > MAX_STEP):
814
+ # implausible single-frame jump: scene change, not scroll —
815
+ # hold the offset and re-key
816
+ self.key = gray
817
+ self.key_cum = self.cum
818
+ else:
819
+ self.cum = (ox, oy)
820
+ # if we've moved far from the key, re-anchor so correlation
821
+ # overlap stays healthy on long continuous scrolls
822
+ if abs(dy) > 0.28 * h or abs(dx) > 0.28 * w:
823
+ self.key = gray
824
+ self.key_cum = self.cum
825
+ else:
826
+ # keyframe correlation failed (scene cut / popup). An incremental
827
+ # frame-to-frame measurement across a visual discontinuity is
828
+ # untrustworthy: demand HIGH confidence and a plausible motion
829
+ # magnitude, otherwise treat as content replacement and hold the
830
+ # offset — spurious jumps here slide every blur box off its text.
831
+ (dx, dy), resp2 = cv2.phaseCorrelate(self.prev, gray, self.win)
832
+ mx = dx * self.inv_scale
833
+ my = dy * self.inv_scale
834
+ if (resp2 >= 0.30 and abs(mx) <= MAX_STEP
835
+ and abs(my) <= MAX_STEP):
836
+ if abs(mx) < 1.0:
837
+ mx = 0.0
838
+ if abs(my) < 1.0:
839
+ my = 0.0
840
+ self.cum = (self.cum[0] + mx, self.cum[1] + my)
841
+ self.key = gray
842
+ self.key_cum = self.cum
843
+ self.prev = gray
844
+ return self.cum
845
+
846
+ def anchor(self):
847
+ """Re-key on the current frame (call right after an OCR scan)."""
848
+ if self.prev is not None:
849
+ self.key = self.prev
850
+ self.key_cum = self.cum
851
+
852
+
853
+ # ----------------------------------------------------------------------------
854
+ # Temporal merge (in content coordinates)
855
+ # ----------------------------------------------------------------------------
856
+
857
+ def boxes_overlap(a, b, slack=12):
858
+ return not (a[2] + slack < b[0] or b[2] + slack < a[0]
859
+ or a[3] + slack < b[1] or b[3] + slack < a[1])
860
+
861
+
862
+ def _box_iou(a, b):
863
+ ix = max(0, min(a[2], b[2]) - max(a[0], b[0]))
864
+ iy = max(0, min(a[3], b[3]) - max(a[1], b[1]))
865
+ inter = ix * iy
866
+ ua = (a[2]-a[0])*(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter
867
+ return inter / ua if ua > 0 else 0.0
868
+
869
+
870
+ def merge_detections(dets, hold, scans=None, bridge_gap=4.0, fuzz=None):
871
+ """Chain detections of the same category whose content boxes overlap.
872
+
873
+ Short gaps (within `hold`) chain unconditionally, as before. Longer gaps
874
+ up to `bridge_gap` seconds are BRIDGED — kept blurred straight through —
875
+ unless an intermediate scan positively saw different, readable text in
876
+ that region (i.e. the content genuinely changed). An empty or unreadable
877
+ region during the gap is treated as an OCR miss and stays covered:
878
+ fail closed, never flash PHI."""
879
+ dets = sorted(dets, key=lambda d: d.t_start)
880
+ merged = []
881
+
882
+ def contradicted(m, t_from, t_to):
883
+ """True only if the gap contains STABLE different text — the same
884
+ different string read on two or more scans. A single divergent read
885
+ is far more likely to be the mouse cursor sitting over the word (or
886
+ another transient occlusion) garbling OCR than genuinely new content:
887
+ real replacement text reads consistently, cursor garble varies every
888
+ scan. Fail closed — an unstable read keeps the region blurred."""
889
+ if not scans:
890
+ return False
891
+ mx1, my1, mx2, my2 = m.cbox
892
+ seen = {}
893
+ for st, _cum, words in scans:
894
+ if not (t_from + 0.01 < st < t_to - 0.01):
895
+ continue
896
+ for txt, (x1, y1, x2, y2), conf in words:
897
+ if conf < 0.6:
898
+ continue
899
+ cxm = (x1 + x2) / 2
900
+ cym = (y1 + y2) / 2
901
+ if mx1 - 6 <= cxm <= mx2 + 6 and my1 - 4 <= cym <= my2 + 4:
902
+ n = PhiMemory.norm(txt)
903
+ if fuzz:
904
+ mt = PhiMemory.norm(m.text)
905
+ # partial_ratio catches cursor-occluded reads of the
906
+ # SAME word ("errin" ~ "herrin"): those are evidence
907
+ # the word is still there, never evidence it changed
908
+ same = (fuzz.ratio(n, mt) >= 70
909
+ or fuzz.partial_ratio(n, mt) >= 85)
910
+ else:
911
+ same = False
912
+ if not same and len(n) >= 3:
913
+ seen[n] = seen.get(n, 0) + 1
914
+ if seen[n] >= 2:
915
+ return True
916
+ return False
917
+
918
+ for d in dets:
919
+ d.last_seen = d.t_start
920
+ d.t_end = d.t_start + hold
921
+ for m in reversed(merged):
922
+ if m.category != d.category or not boxes_overlap(m.cbox, d.cbox):
923
+ continue
924
+ if getattr(d, "dense", False) or getattr(m, "dense", False):
925
+ # dense face boxes are per-frame position samples of a possibly
926
+ # moving face — never merge them, or the bounding box balloons
927
+ # to cover the whole path. Each stands alone with its short
928
+ # hold, so the blur rides the face frame by frame.
929
+ continue
930
+ # different readable text in the same spot is a DIFFERENT object
931
+ # (e.g. names sliding through one row of an inner-scrolling list)
932
+ # — never fuse them, or the region's text and its frames diverge
933
+ if fuzz and m.text and d.text:
934
+ _mn = PhiMemory.norm(m.text)
935
+ _dn = PhiMemory.norm(d.text)
936
+ if (fuzz.ratio(_mn, _dn) < 70
937
+ and fuzz.partial_ratio(_mn, _dn) < 85):
938
+ continue
939
+ gap = d.t_start - m.last_seen
940
+ if gap <= hold or (gap <= bridge_gap
941
+ and not contradicted(m, m.last_seen, d.t_start)):
942
+ m.last_seen = max(m.last_seen, d.t_start)
943
+ m.t_end = max(m.t_end, d.t_end)
944
+ m.cbox = (min(m.cbox[0], d.cbox[0]), min(m.cbox[1], d.cbox[1]),
945
+ max(m.cbox[2], d.cbox[2]), max(m.cbox[3], d.cbox[3]))
946
+ break
947
+ else:
948
+ merged.append(d)
949
+ return merged
950
+
951
+
952
+ # ----------------------------------------------------------------------------
953
+ # Render
954
+ # ----------------------------------------------------------------------------
955
+
956
+ def blur_region(frame, x1, y1, x2, y2, mode):
957
+ h, w = frame.shape[:2]
958
+ x1 = max(0, int(x1)); y1 = max(0, int(y1))
959
+ x2 = min(w, int(x2)); y2 = min(h, int(y2))
960
+ if x2 <= x1 or y2 <= y1:
961
+ return
962
+ if mode == "box":
963
+ frame[y1:y2, x1:x2] = 0
964
+ else:
965
+ roi = frame[y1:y2, x1:x2]
966
+ k = max(31, (((x2 - x1) // 3) | 1))
967
+ frame[y1:y2, x1:x2] = cv2.GaussianBlur(roi, (k, k), 0)
968
+
969
+
970
+ class PipelineCancelled(Exception):
971
+ """Raised internally when a Callbacks.cancelled() returns True."""
972
+
973
+
974
+ class Callbacks:
975
+ """Hooks for embedding the pipeline (GUI, batch runner, tests).
976
+ The default implementation reproduces the CLI's print behavior."""
977
+ wants_frames = False # set True to receive scan_frame() calls
978
+
979
+ def log(self, msg):
980
+ print(msg, flush=True)
981
+
982
+ def progress(self, stage, current, total):
983
+ pass # stage is "scan" or "render"
984
+
985
+ def scan_frame(self, frame_bgr, t, found):
986
+ pass # annotated copy of the frame just OCR'd (only if wants_frames)
987
+
988
+ def cancelled(self):
989
+ return False
990
+
991
+
992
+ def nvenc_available(encoder_pref, cb):
993
+ """Pick the video encoder. Pre-flight tests NVENC with a tiny encode so
994
+ we fail fast instead of discovering a broken NVENC after a full render."""
995
+ if encoder_pref == "x264":
996
+ return "libx264"
997
+ if not shutil.which("ffmpeg"):
998
+ return None
999
+ try:
1000
+ listed = subprocess.run(
1001
+ ["ffmpeg", "-hide_banner", "-encoders"],
1002
+ capture_output=True, text=True, timeout=15).stdout
1003
+ except Exception:
1004
+ listed = ""
1005
+ if "h264_nvenc" not in listed:
1006
+ cb.log(" note: h264_nvenc not present in this ffmpeg build — using libx264.\n"
1007
+ " Install a full build (e.g. `winget install Gyan.FFmpeg`) for GPU encoding.")
1008
+ return "libx264"
1009
+ try:
1010
+ # 30 frames: NVENC buffers frames internally (B-frames/lookahead),
1011
+ # so a too-short test can emit zero packets on a WORKING encoder
1012
+ test = subprocess.run(
1013
+ ["ffmpeg", "-hide_banner", "-loglevel", "error",
1014
+ "-f", "lavfi", "-i", "color=black:s=256x256:r=30", "-t", "1",
1015
+ "-c:v", "h264_nvenc", "-f", "null", "-"],
1016
+ capture_output=True, text=True, timeout=60)
1017
+ if test.returncode == 0:
1018
+ return "h264_nvenc"
1019
+ err = (test.stderr or "").strip() or "(no error output)"
1020
+ cb.log(" note: h264_nvenc failed its test encode — using libx264. ffmpeg said:")
1021
+ for line in err.splitlines()[-12:]:
1022
+ cb.log(f" {line}")
1023
+ except Exception as e:
1024
+ cb.log(f" note: NVENC test errored ({e}) — using libx264")
1025
+ return "libx264"
1026
+
1027
+
1028
+ def render(src, dst, detections, cum, bands, fps, pad, mode, preview,
1029
+ encoder="auto", band_margin=25, progress_every=60, cb=None,
1030
+ mode_map=None, draw_scores=False):
1031
+ # mode_map: {category: "blur"|"box"} overrides the global `mode` per
1032
+ # category. Lets you black-box the reversible-blur-vulnerable categories
1033
+ # (SSN, MRN, account numbers) while blurring faces, in one render.
1034
+ mode_map = mode_map or {}
1035
+ def _mode_for(cat):
1036
+ return mode_map.get(cat, mode)
1037
+ cb = cb or Callbacks()
1038
+ cap = cv2.VideoCapture(src)
1039
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
1040
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
1041
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
1042
+
1043
+ # --- output sink: single-pass ffmpeg pipe (NVENC if available) ---
1044
+ proc = None
1045
+ out = None
1046
+ tmp_video = None
1047
+ codec = nvenc_available(encoder, cb)
1048
+ if codec:
1049
+ cb.log(f" encoder: {codec}"
1050
+ + (" (GPU)" if codec == "h264_nvenc" else " (CPU)"))
1051
+ vargs = (["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "19"]
1052
+ if codec == "h264_nvenc"
1053
+ else ["-c:v", "libx264", "-crf", "18", "-preset", "fast"])
1054
+ cmd = ["ffmpeg", "-y", "-loglevel", "error",
1055
+ "-f", "rawvideo", "-pix_fmt", "bgr24",
1056
+ "-s", f"{w}x{h}", "-r", f"{fps:.6f}", "-i", "pipe:0",
1057
+ "-i", src, "-map", "0:v:0", "-map", "1:a:0?",
1058
+ *vargs, "-pix_fmt", "yuv420p", "-c:a", "copy", dst]
1059
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
1060
+ else:
1061
+ cb.log(" encoder: OpenCV mp4v (ffmpeg not found — no audio!)")
1062
+ tmp_video = dst + ".noaudio.mp4"
1063
+ out = cv2.VideoWriter(tmp_video, cv2.VideoWriter_fourcc(*"mp4v"),
1064
+ fps, (w, h))
1065
+
1066
+ buckets = {}
1067
+ for d in detections:
1068
+ for s in range(int(d.t_start), int(d.t_end) + 2):
1069
+ buckets.setdefault(s, []).append(d)
1070
+
1071
+ def cleanup_partial():
1072
+ cap.release()
1073
+ if proc is not None:
1074
+ try:
1075
+ proc.stdin.close()
1076
+ except Exception:
1077
+ pass
1078
+ proc.terminate()
1079
+ proc.wait()
1080
+ if out is not None:
1081
+ out.release()
1082
+ for p in (dst, tmp_video):
1083
+ if p and os.path.exists(p):
1084
+ try:
1085
+ os.remove(p)
1086
+ except OSError:
1087
+ pass
1088
+
1089
+ idx = 0
1090
+ while True:
1091
+ if idx % 30 == 0 and cb.cancelled():
1092
+ cleanup_partial()
1093
+ raise PipelineCancelled()
1094
+ ok, frame = cap.read()
1095
+ if not ok:
1096
+ break
1097
+ t = idx / fps
1098
+ ox, oy = cum[min(idx, len(cum) - 1)]
1099
+
1100
+ # 1. tracked PHI boxes, translated by scroll offset
1101
+ for d in buckets.get(int(t), []):
1102
+ if not (d.t_start - 0.01 <= t <= d.t_end + 0.01):
1103
+ continue
1104
+ # drift allowance: residual tracking error grows (slowly) with
1105
+ # distance scrolled since detection — expand the box to cover it
1106
+ drift = min(24.0, 0.05 * (abs(ox - d.aoff[0]) + abs(oy - d.aoff[1])))
1107
+ px = pad + drift
1108
+ x1 = d.cbox[0] + ox - px
1109
+ y1 = d.cbox[1] + oy - px
1110
+ x2 = d.cbox[2] + ox + px
1111
+ y2 = d.cbox[3] + oy + px
1112
+ if preview:
1113
+ cv2.rectangle(frame, (int(max(0, x1)), int(max(0, y1))),
1114
+ (int(min(w, x2)), int(min(h, y2))), (0, 0, 255), 2)
1115
+ label = d.category
1116
+ if draw_scores and d.category == "face":
1117
+ label = f"face {d.confidence:.2f}"
1118
+ cv2.putText(frame, label, (int(max(0, x1)), int(max(12, y1 - 4))),
1119
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1)
1120
+ else:
1121
+ blur_region(frame, x1, y1, x2, y2, _mode_for(d.category))
1122
+
1123
+ # 2. safety bands: unscanned content that scrolled into view
1124
+ bx, by = bands[min(idx, len(bands) - 1)]
1125
+ vals = set(mode_map.values()) | {mode}
1126
+ band_mode = ("box" if "box" in vals else
1127
+ "mosaic" if "mosaic" in vals else "blur") # never weaker
1128
+ def band(x1, y1, x2, y2):
1129
+ if preview:
1130
+ cv2.rectangle(frame, (int(x1), int(y1)), (int(x2 - 1), int(y2 - 1)), (0, 165, 255), 2)
1131
+ else:
1132
+ blur_region(frame, x1, y1, x2, y2, band_mode)
1133
+ if by < -2: # content moved up -> unscanned strip entering at bottom
1134
+ band(0, h - (abs(by) + band_margin), w, h)
1135
+ elif by > 2: # content moved down -> unscanned strip at top
1136
+ band(0, 0, w, by + band_margin)
1137
+ if bx < -2: # content moved left -> strip at right
1138
+ band(w - (abs(bx) + band_margin), 0, w, h)
1139
+ elif bx > 2:
1140
+ band(0, 0, bx + band_margin, h)
1141
+
1142
+ if proc is not None:
1143
+ proc.stdin.write(frame.tobytes())
1144
+ else:
1145
+ out.write(frame)
1146
+ idx += 1
1147
+ if idx % progress_every == 0:
1148
+ cb.progress("render", idx, total)
1149
+ if idx % 300 == 0:
1150
+ cb.log(f" rendering… {idx}/{total} ({100 * idx // max(total, 1)}%)")
1151
+
1152
+ cap.release()
1153
+ cb.progress("render", total, total)
1154
+ if proc is not None:
1155
+ proc.stdin.close()
1156
+ rc = proc.wait()
1157
+ if rc != 0:
1158
+ raise RuntimeError(f"ffmpeg encode failed (exit {rc}) — try --encoder x264")
1159
+ else:
1160
+ out.release()
1161
+ os.replace(tmp_video, dst)
1162
+
1163
+
1164
+ # ----------------------------------------------------------------------------
1165
+ # Face detection (clinical photos, webcam bubbles — OCR is blind to these)
1166
+ # ----------------------------------------------------------------------------
1167
+
1168
+ YUNET_URL = ("https://media.githubusercontent.com/media/opencv/opencv_zoo/"
1169
+ "main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx")
1170
+
1171
+
1172
+ def _model_dir():
1173
+ d = os.path.join(os.path.expanduser("~"), ".openscrub", "models")
1174
+ os.makedirs(d, exist_ok=True)
1175
+ return d
1176
+
1177
+
1178
+ def plate_registry_path():
1179
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)),
1180
+ "plate_models.json")
1181
+
1182
+
1183
+ def load_plate_registry():
1184
+ """Return the curated plate-model list, or [] if the registry is absent."""
1185
+ try:
1186
+ with open(plate_registry_path(), encoding="utf-8") as f:
1187
+ return json.load(f).get("models", [])
1188
+ except Exception:
1189
+ return []
1190
+
1191
+
1192
+ def download_plate_model(entry, dest_dir=None, cb=None, progress=None):
1193
+ """Download a registry model to models/<id>.onnx, verifying its SHA-256.
1194
+
1195
+ entry: a dict from load_plate_registry(). progress: optional callable
1196
+ (fraction_0_to_1). Returns the saved path. Raises on any failure
1197
+ (bad URL, hash mismatch) after removing a partial/incorrect file — a
1198
+ privacy tool must never silently run an unverified model.
1199
+ """
1200
+ import hashlib, urllib.request
1201
+ log = (cb.log if cb else print)
1202
+ url = entry.get("download_url", "")
1203
+ want = (entry.get("sha256", "") or "").lower()
1204
+ if not url or url == "TODO_VERIFY":
1205
+ raise ValueError("model '%s' has no verified download_url yet "
1206
+ "(registry entry says TODO_VERIFY)" % entry.get("id"))
1207
+ dest_dir = dest_dir or os.path.join(
1208
+ os.path.dirname(os.path.abspath(__file__)), "models")
1209
+ os.makedirs(dest_dir, exist_ok=True)
1210
+ dest = os.path.join(dest_dir, "%s.onnx" % entry.get("id", "plate_model"))
1211
+ tmp = dest + ".part"
1212
+ log(" downloading plate model: %s" % entry.get("label", entry.get("id")))
1213
+ h = hashlib.sha256()
1214
+ with urllib.request.urlopen(url) as r, open(tmp, "wb") as f:
1215
+ total = int(r.headers.get("Content-Length", 0))
1216
+ got = 0
1217
+ while True:
1218
+ chunk = r.read(65536)
1219
+ if not chunk:
1220
+ break
1221
+ f.write(chunk); h.update(chunk); got += len(chunk)
1222
+ if progress and total:
1223
+ progress(min(1.0, got / total))
1224
+ digest = h.hexdigest()
1225
+ if want and want != "todo_verify" and digest != want:
1226
+ os.remove(tmp)
1227
+ raise ValueError("SHA-256 mismatch for %s: got %s, expected %s — "
1228
+ "file rejected." % (entry.get("id"), digest, want))
1229
+ if not want or want == "todo_verify":
1230
+ # trust-on-first-use: pin the computed hash into the registry so every
1231
+ # later download of this model must match this exact file.
1232
+ log(" first download of this model: pinning sha256=%s" % digest[:16] + "…")
1233
+ try:
1234
+ with open(plate_registry_path(), encoding="utf-8") as f:
1235
+ reg = json.load(f)
1236
+ for m in reg.get("models", []):
1237
+ if m.get("id") == entry.get("id"):
1238
+ m["sha256"] = digest
1239
+ with open(plate_registry_path(), "w", encoding="utf-8") as f:
1240
+ json.dump(reg, f, indent=2)
1241
+ except Exception as e:
1242
+ log(" (could not pin hash into registry: %s)" % e)
1243
+ os.replace(tmp, dest)
1244
+ log(" saved verified model -> %s" % dest)
1245
+ return dest
1246
+
1247
+
1248
+ class PlateDetector:
1249
+ """License-plate detector using a single-class YOLOv8 ONNX model run through
1250
+ OpenCV's DNN module (no PyTorch / ultralytics dependency at runtime).
1251
+
1252
+ The model file is NOT bundled — it's downloaded/placed by the optional
1253
+ installer or the GUI model picker. Two ONNX output conventions are
1254
+ supported, auto-detected: a raw YOLOv8 detect head (1,5,8400), and an
1255
+ "end2end" export with NMS baked in that emits decoded boxes (1,N,6) —
1256
+ the latter is what open-image-models' YOLOv9 plate models produce.
1257
+ If the model is absent the detector is INERT (find() returns []), so the
1258
+ plate category simply does nothing rather than erroring. This mirrors how
1259
+ FaceDetector degrades, and keeps plates an opt-in capability.
1260
+ """
1261
+
1262
+ INPUT = 640 # YOLOv8 square input
1263
+ MODEL_ENV = "OPENSCRUB_PLATE_MODEL"
1264
+
1265
+ def __init__(self, cb=None, model_path=None, thresh=0.35, nms=0.45,
1266
+ expand=0.08, input_size=640):
1267
+ self.log = (cb.log if cb else print)
1268
+ self.INPUT = int(input_size)
1269
+ self.thresh = float(thresh)
1270
+ self.nms = float(nms)
1271
+ self.expand = float(expand)
1272
+ self.net = None
1273
+ # resolve model: explicit arg > env var > conventional locations
1274
+ candidates = []
1275
+ if model_path:
1276
+ candidates.append(model_path)
1277
+ env = os.environ.get(self.MODEL_ENV)
1278
+ if env:
1279
+ candidates.append(env)
1280
+ here = os.path.dirname(os.path.abspath(__file__))
1281
+ candidates += [
1282
+ os.path.join(here, "models", "plate_yolov8.onnx"),
1283
+ os.path.join(here, "plate_yolov8.onnx"),
1284
+ ]
1285
+ # registry-downloaded models are saved as models/<registry-id>.onnx;
1286
+ # search those too (recommended entries first), and pick up each
1287
+ # model's declared input size from the registry.
1288
+ reg_size = {}
1289
+ try:
1290
+ reg = load_plate_registry()
1291
+ for m in sorted(reg, key=lambda x: not x.get("recommended", False)):
1292
+ mp = os.path.join(here, "models", "%s.onnx" % m.get("id"))
1293
+ candidates.append(mp)
1294
+ reg_size[mp] = int(m.get("input_size", 640) or 640)
1295
+ except Exception:
1296
+ pass
1297
+ found = next((c for c in candidates if c and os.path.exists(c)), None)
1298
+ if found in reg_size:
1299
+ self.INPUT = reg_size[found]
1300
+ if not found:
1301
+ self.log(" plate detector: no model found — plate category "
1302
+ "inactive. Place a YOLOv8 plate ONNX at models/"
1303
+ "plate_yolov8.onnx or set $%s." % self.MODEL_ENV)
1304
+ return
1305
+ try:
1306
+ net = cv2.dnn.readNetFromONNX(found)
1307
+ # honour the same CPU/GPU intent the rest of the app uses
1308
+ try:
1309
+ if cv2.cuda.getCudaEnabledDeviceCount() > 0:
1310
+ net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
1311
+ net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)
1312
+ except Exception:
1313
+ pass
1314
+ self.net = net
1315
+ self.log(" plate detector: loaded %s" % os.path.basename(found))
1316
+ except Exception as e:
1317
+ self.log(" plate detector: failed to load model (%s) — plate "
1318
+ "category inactive." % e)
1319
+ self.net = None
1320
+
1321
+ def available(self):
1322
+ return self.net is not None
1323
+
1324
+ def find(self, frame, detect_scale=1.0):
1325
+ """-> [(x1,y1,x2,y2,conf)] in full-frame pixels. Empty if no model.
1326
+
1327
+ detect_scale is accepted for call-site symmetry with FaceDetector but
1328
+ intentionally unused: the letterbox below already resizes every frame
1329
+ to the model's fixed input size, so an extra pre-downscale would only
1330
+ lose detail without saving time."""
1331
+ if self.net is None:
1332
+ return []
1333
+ h, w = frame.shape[:2]
1334
+ # letterbox to a square INPUT (preserve aspect, pad 114 like YOLO)
1335
+ s = self.INPUT / max(h, w)
1336
+ nw, nh = int(round(w * s)), int(round(h * s))
1337
+ resized = cv2.resize(frame, (nw, nh))
1338
+ canvas = np.full((self.INPUT, self.INPUT, 3), 114, np.uint8)
1339
+ canvas[:nh, :nw] = resized
1340
+ blob = cv2.dnn.blobFromImage(canvas, 1 / 255.0, (self.INPUT, self.INPUT),
1341
+ swapRB=True, crop=False)
1342
+ self.net.setInput(blob)
1343
+ out = self.net.forward()
1344
+ out = np.squeeze(out)
1345
+ if out.ndim != 2:
1346
+ return []
1347
+ # Two ONNX output conventions are supported, auto-detected by shape:
1348
+ #
1349
+ # (A) raw YOLOv8 detect head: shape (5, 8400) after squeeze — rows are
1350
+ # cx,cy,w,h,score for a single class; needs decode + NMS here.
1351
+ # (B) "end2end" export (e.g. open-image-models YOLOv9): shape (N, 6) —
1352
+ # boxes are ALREADY decoded to x1,y1,x2,y2,score,class in the
1353
+ # letterboxed 640-space, with NMS baked into the graph. We just
1354
+ # scale them back to full-frame pixels.
1355
+ #
1356
+ # Distinguish by the last-axis width: 6 => end2end rows; else raw head.
1357
+ end2end = (out.shape[1] == 6) or (out.shape[0] == 6 and out.shape[1] != 8400)
1358
+ if out.shape[1] == 6:
1359
+ rows = out
1360
+ elif out.shape[0] == 6 and out.shape[1] != 8400:
1361
+ rows = out.T
1362
+ else:
1363
+ end2end = False
1364
+ rows = None
1365
+
1366
+ res = []
1367
+ if end2end:
1368
+ for r in rows:
1369
+ x1, y1, x2, y2, score = (float(r[0]), float(r[1]), float(r[2]),
1370
+ float(r[3]), float(r[4]))
1371
+ if score < self.thresh:
1372
+ continue
1373
+ # scale from letterboxed 640-space back to full frame
1374
+ bx1, by1, bx2, by2 = x1 / s, y1 / s, x2 / s, y2 / s
1375
+ bw, bh = bx2 - bx1, by2 - by1
1376
+ ex, ey = bw * self.expand, bh * self.expand
1377
+ res.append((max(0.0, bx1 - ex), max(0.0, by1 - ey),
1378
+ min(float(w), bx2 + ex), min(float(h), by2 + ey),
1379
+ round(score, 3)))
1380
+ return res
1381
+
1382
+ # raw YOLOv8 head: (5, 8400) -> transpose to per-box rows
1383
+ if out.shape[0] < out.shape[1]:
1384
+ out = out.T
1385
+ boxes, scores = [], []
1386
+ for row in out:
1387
+ score = float(row[4])
1388
+ if score < self.thresh:
1389
+ continue
1390
+ cx, cy, bw, bh = row[0], row[1], row[2], row[3]
1391
+ x = (cx - bw / 2) / s
1392
+ y = (cy - bh / 2) / s
1393
+ boxes.append([int(x), int(y), int(bw / s), int(bh / s)])
1394
+ scores.append(score)
1395
+ if not boxes:
1396
+ return []
1397
+ idxs = cv2.dnn.NMSBoxes(boxes, scores, self.thresh, self.nms)
1398
+ for i in np.array(idxs).flatten():
1399
+ bx, by, bw, bh = boxes[i]
1400
+ ex, ey = int(bw * self.expand), int(bh * self.expand)
1401
+ x1 = max(0, bx - ex); y1 = max(0, by - ey)
1402
+ x2 = min(w, bx + bw + ex); y2 = min(h, by + bh + ey)
1403
+ res.append((float(x1), float(y1), float(x2), float(y2), scores[i]))
1404
+ return res
1405
+
1406
+
1407
+ class FaceDetector:
1408
+ """YuNet DNN detector when its model is available (auto-downloaded on
1409
+ first use, ~230 KB), otherwise OpenCV's built-in Haar cascade. Boxes are
1410
+ expanded 15% so hairline/chin aren't left identifiable at the blur edge."""
1411
+
1412
+ def __init__(self, cb=None, expand=0.15, thresh=0.6):
1413
+ self.expand = expand
1414
+ self.thresh = float(thresh)
1415
+ log = (cb.log if cb else print)
1416
+ self.yunet = None
1417
+ self.haar = None
1418
+ model = os.path.join(_model_dir(), "face_detection_yunet_2023mar.onnx")
1419
+ if not os.path.exists(model) or os.path.getsize(model) < 10000:
1420
+ try:
1421
+ import urllib.request
1422
+ log(" downloading YuNet face model (~230 KB, one time)…")
1423
+ urllib.request.urlretrieve(YUNET_URL, model)
1424
+ except Exception as e:
1425
+ log(f" YuNet download failed ({e}) — using Haar cascade fallback")
1426
+ if (os.path.exists(model) and os.path.getsize(model) > 10000
1427
+ and hasattr(cv2, "FaceDetectorYN_create")):
1428
+ try:
1429
+ self.yunet = cv2.FaceDetectorYN_create(model, "", (320, 320), self.thresh)
1430
+ self.size = None
1431
+ log(" face detector: YuNet (DNN)")
1432
+ return
1433
+ except Exception as e:
1434
+ log(f" YuNet init failed ({e}) — using Haar cascade fallback")
1435
+ self.haar = cv2.CascadeClassifier(
1436
+ cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
1437
+ log(" face detector: Haar cascade (install note: YuNet is more accurate)")
1438
+
1439
+ def find(self, frame, detect_scale=1.0):
1440
+ """-> [(x1, y1, x2, y2, conf)] with 15% expansion. detect_scale<1.0
1441
+ runs detection on a downscaled copy for speed, mapping boxes back to
1442
+ full resolution (output quality is unaffected)."""
1443
+ h, w = frame.shape[:2]
1444
+ s = detect_scale if 0.2 <= detect_scale < 1.0 else 1.0
1445
+ dframe = (cv2.resize(frame, (max(1, int(w * s)), max(1, int(h * s))))
1446
+ if s < 1.0 else frame)
1447
+ dh, dw = dframe.shape[:2]
1448
+ out = []
1449
+ if self.yunet is not None:
1450
+ if self.size != (dw, dh):
1451
+ self.yunet.setInputSize((dw, dh))
1452
+ self.size = (dw, dh)
1453
+ _, faces = self.yunet.detect(dframe)
1454
+ for f in (faces if faces is not None else []):
1455
+ x, y, fw, fh, conf = f[0], f[1], f[2], f[3], float(f[-1])
1456
+ out.append((x / s, y / s, (x + fw) / s, (y + fh) / s, conf))
1457
+ else:
1458
+ gray = cv2.cvtColor(dframe, cv2.COLOR_BGR2GRAY)
1459
+ for (x, y, fw, fh) in self.haar.detectMultiScale(gray, 1.1, 5,
1460
+ minSize=(36, 36)):
1461
+ if 0.8 >= self.thresh: # Haar has no score; gate by threshold
1462
+ out.append((x / s, y / s, (x + fw) / s,
1463
+ (y + fh) / s, 0.8))
1464
+ expanded = []
1465
+ for x1, y1, x2, y2, conf in out:
1466
+ ex, ey = (x2 - x1) * self.expand, (y2 - y1) * self.expand
1467
+ expanded.append((max(0, x1 - ex), max(0, y1 - ey),
1468
+ min(w, x2 + ex), min(h, y2 + ey), conf))
1469
+ return expanded
1470
+
1471
+
1472
+ # ----------------------------------------------------------------------------
1473
+ # Config profiles, ignore regions, provenance
1474
+ # ----------------------------------------------------------------------------
1475
+
1476
+ def apply_config(args, parser):
1477
+ """Overlay a YAML config profile onto parsed args. CLI flags win: a
1478
+ config value only applies where the CLI value equals the parser default."""
1479
+ if not getattr(args, "config", None):
1480
+ return args
1481
+ import yaml
1482
+ with open(args.config, encoding="utf-8") as f:
1483
+ cfg = yaml.safe_load(f) or {}
1484
+ defaults = vars(parser.parse_args([args.video or "x"]))
1485
+ for key, val in cfg.items():
1486
+ dest = key.replace("-", "_")
1487
+ if dest == "ignore_regions":
1488
+ args.ignore_regions = [tuple(map(int, r)) for r in (val or [])]
1489
+ continue
1490
+ if dest == "zones":
1491
+ args.zones_data = {c: [tuple(float(v) for v in r) for r in rs]
1492
+ for c, rs in (val or {}).items() if rs}
1493
+ continue
1494
+ if not hasattr(args, dest):
1495
+ raise RuntimeError(f"unknown config key in {args.config}: {key}")
1496
+ if getattr(args, dest) == defaults.get(dest):
1497
+ setattr(args, dest, val)
1498
+ return args
1499
+
1500
+
1501
+ def load_zones(path):
1502
+ """Zones file: {"name": [[x1,y1,x2,y2], ...], "dob": [...]} with
1503
+ NORMALIZED 0-1 coordinates (resolution-independent). A category with no
1504
+ zones (or absent) is unrestricted — full frame."""
1505
+ with open(path, encoding="utf-8") as f:
1506
+ data = json.load(f)
1507
+ return {cat: [tuple(float(v) for v in r) for r in rects]
1508
+ for cat, rects in data.items() if rects}
1509
+
1510
+
1511
+ def zones_to_pixels(zones, w, h):
1512
+ return {cat: [(r[0] * w, r[1] * h, r[2] * w, r[3] * h) for r in rects]
1513
+ for cat, rects in zones.items()}
1514
+
1515
+
1516
+ def in_any_zone(screen_box, rects):
1517
+ cx = (screen_box[0] + screen_box[2]) / 2
1518
+ cy = (screen_box[1] + screen_box[3]) / 2
1519
+ return any(r[0] <= cx <= r[2] and r[1] <= cy <= r[3] for r in rects)
1520
+
1521
+
1522
+ def in_ignore_region(screen_box, regions):
1523
+ cx = (screen_box[0] + screen_box[2]) / 2
1524
+ cy = (screen_box[1] + screen_box[3]) / 2
1525
+ return any(r[0] <= cx <= r[2] and r[1] <= cy <= r[3] for r in regions)
1526
+
1527
+
1528
+ def sha256_file(path, chunk=1 << 20):
1529
+ h = hashlib.sha256()
1530
+ with open(path, "rb") as f:
1531
+ while True:
1532
+ b = f.read(chunk)
1533
+ if not b:
1534
+ break
1535
+ h.update(b)
1536
+ return h.hexdigest()
1537
+
1538
+
1539
+ def _settings_dict(args):
1540
+ skip = {"video", "output", "report", "from_report", "batch", "config"}
1541
+ out = {k: v for k, v in vars(args).items()
1542
+ if k not in skip and isinstance(v, (str, int, float, bool, list,
1543
+ tuple, type(None)))}
1544
+ mm = getattr(args, "mode_map", None)
1545
+ if isinstance(mm, dict) and mm:
1546
+ out["mode_map"] = ",".join(f"{k}={v}" for k, v in sorted(mm.items()))
1547
+ return out
1548
+
1549
+
1550
+ def write_report(path, args, state, output_path=None):
1551
+ prov = {
1552
+ "tool": "openscrub", "version": VERSION,
1553
+ "timestamp": datetime.datetime.now().astimezone().isoformat(),
1554
+ "input": os.path.abspath(args.video),
1555
+ "input_sha256": state.get("input_sha256"),
1556
+ "original_input": (os.path.abspath(args.original_video)
1557
+ if getattr(args, "original_video", None) else None),
1558
+ "vfr_normalized": bool(getattr(args, "original_video", None)),
1559
+ "zones": getattr(args, "zones_data", None),
1560
+ "settings": _settings_dict(args),
1561
+ }
1562
+ if output_path and os.path.exists(output_path):
1563
+ prov["output"] = os.path.abspath(output_path)
1564
+ prov["output_sha256"] = sha256_file(output_path)
1565
+ doc = {
1566
+ "provenance": prov,
1567
+ "render_state": {
1568
+ "fps": state["fps"],
1569
+ "cum": [[round(x, 1), round(y, 1)] for x, y in state["cum"]],
1570
+ "bands": [[round(x, 1), round(y, 1)] for x, y in state["bands"]],
1571
+ },
1572
+ "detections": ([dict(asdict(d), enabled=True)
1573
+ for d in state["detections"]]
1574
+ + [dict(asdict(d), enabled=False, zone_dropped=True)
1575
+ for d in state.get("zdropped", [])]),
1576
+ }
1577
+ with open(path, "w", encoding="utf-8") as f:
1578
+ json.dump(doc, f, indent=1)
1579
+ try:
1580
+ os.chmod(path, 0o600)
1581
+ except OSError:
1582
+ pass
1583
+
1584
+
1585
+ def load_report(path):
1586
+ """-> (detections, render_state, provenance). Accepts v3 plain-list
1587
+ reports (no render_state) and v4 dict reports; disabled detections are
1588
+ dropped."""
1589
+ with open(path, encoding="utf-8") as f:
1590
+ doc = json.load(f)
1591
+ if isinstance(doc, list):
1592
+ rows, state, prov = doc, None, {}
1593
+ else:
1594
+ rows, state, prov = doc.get("detections", []), doc.get("render_state"), \
1595
+ doc.get("provenance", {})
1596
+ dets = []
1597
+ for r in rows:
1598
+ if not r.get("enabled", True):
1599
+ continue
1600
+ dets.append(Detection(
1601
+ t_start=float(r["t_start"]), t_end=float(r["t_end"]),
1602
+ cbox=tuple(r["cbox"]), category=r["category"], text=r["text"],
1603
+ confidence=float(r.get("confidence", 1.0)),
1604
+ aoff=tuple(r.get("aoff", (0.0, 0.0))),
1605
+ last_seen=float(r.get("last_seen", r["t_start"]))))
1606
+ return dets, state, prov
1607
+
1608
+
1609
+ # ----------------------------------------------------------------------------
1610
+ # Variable frame rate (VFR) handling
1611
+ # ----------------------------------------------------------------------------
1612
+
1613
+ def probe_vfr(path):
1614
+ """-> (is_vfr, avg_fps). Screen recorders (OBS, Game Bar) often produce
1615
+ VFR video; the pipeline's frame->time mapping assumes CFR, so VFR input
1616
+ causes blur-timing drift and audio desync unless normalized first."""
1617
+ if not shutil.which("ffprobe"):
1618
+ return False, None
1619
+ rc, out, _ = 0, "", ""
1620
+ try:
1621
+ p = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0",
1622
+ "-show_entries",
1623
+ "stream=r_frame_rate,avg_frame_rate",
1624
+ "-of", "json", path],
1625
+ capture_output=True, text=True, timeout=30)
1626
+ rc, out = p.returncode, p.stdout
1627
+ except Exception:
1628
+ return False, None
1629
+ if rc != 0 or not out:
1630
+ return False, None
1631
+ try:
1632
+ st = json.loads(out)["streams"][0]
1633
+ def frac(s):
1634
+ a, _, b = s.partition("/")
1635
+ return float(a) / float(b or 1) if float(b or 1) else 0.0
1636
+ r, avg = frac(st.get("r_frame_rate", "0/1")), frac(st.get("avg_frame_rate", "0/1"))
1637
+ except Exception:
1638
+ return False, None
1639
+ if r <= 0 or avg <= 0:
1640
+ return False, avg or None
1641
+ return abs(r - avg) / max(r, avg) > 0.005, avg
1642
+
1643
+
1644
+ def normalize_vfr(args, cb):
1645
+ """If input is VFR, transcode to CFR next to the output and point
1646
+ args.video at it. Reuses NVENC when available. No-op for CFR input."""
1647
+ if getattr(args, "no_vfr_fix", False) or getattr(args, "vfr", "auto") == "ignore":
1648
+ return
1649
+ is_vfr, avg = probe_vfr(args.video)
1650
+ if not is_vfr:
1651
+ return
1652
+ target = int(round(avg)) if avg and 10 <= avg <= 120 else 30
1653
+ out_ref = args.output or os.path.splitext(args.video)[0] + "_redacted.mp4"
1654
+ fixed = os.path.join(os.path.dirname(os.path.abspath(out_ref)),
1655
+ os.path.splitext(os.path.basename(args.video))[0]
1656
+ + ".cfr.mp4")
1657
+ cb.log(f" input is VFR (avg {avg:.2f} fps) — normalizing to CFR "
1658
+ f"{target} fps first")
1659
+ if (os.path.exists(fixed)
1660
+ and os.path.getmtime(fixed) > os.path.getmtime(args.video)):
1661
+ cb.log(f" reusing existing {os.path.basename(fixed)}")
1662
+ else:
1663
+ codec = nvenc_available(getattr(args, "encoder", "auto"), cb)
1664
+ vargs = (["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "18"]
1665
+ if codec == "h264_nvenc"
1666
+ else ["-c:v", "libx264", "-crf", "18", "-preset", "fast"])
1667
+ for fpsflag in ("-fps_mode", "-vsync"):
1668
+ p = subprocess.run(["ffmpeg", "-y", "-loglevel", "error",
1669
+ "-i", args.video, fpsflag, "cfr",
1670
+ "-r", str(target), *vargs,
1671
+ "-pix_fmt", "yuv420p", "-c:a", "copy", fixed],
1672
+ capture_output=True, text=True)
1673
+ if p.returncode == 0:
1674
+ break
1675
+ else:
1676
+ raise RuntimeError("VFR normalization failed: "
1677
+ + (p.stderr or "").strip()[-300:])
1678
+ args.original_video = args.video
1679
+ args.video = fixed
1680
+
1681
+
1682
+ # ----------------------------------------------------------------------------
1683
+ # Pipeline
1684
+ # ----------------------------------------------------------------------------
1685
+
1686
+ def build_parser():
1687
+ ap = argparse.ArgumentParser(description="Blur PHI in screen-recording videos (scroll-aware, no patient list needed).")
1688
+ ap.add_argument("video", nargs="?", help="input video (omit only with --batch)")
1689
+ ap.add_argument("-o", "--output")
1690
+ ap.add_argument("--config", help="YAML config profile (CLI flags override it)")
1691
+ ap.add_argument("--allow-names", help="text file of provider/staff names to KEEP visible")
1692
+ ap.add_argument("--extra-names", help="text file of names to always blur")
1693
+ ap.add_argument("--engine", choices=["auto", "paddle", "tesseract"], default="auto")
1694
+ ap.add_argument("--device", choices=["auto", "cpu", "gpu"], default="auto",
1695
+ help="PaddleOCR compute device (default: gpu if available)")
1696
+ ap.add_argument("--encoder", choices=["auto", "nvenc", "x264"], default="auto",
1697
+ help="video encoder: auto = NVENC (GPU) if available, else libx264")
1698
+ ap.add_argument("--no-ner", action="store_true", help="disable spaCy NER")
1699
+ ap.add_argument("--heuristic-names", choices=["auto", "on", "off"], default="auto",
1700
+ help="capitalized-pair fallback: auto = on when NER unavailable")
1701
+ ap.add_argument("--sample-interval", type=float, default=0.5,
1702
+ help="seconds between time-based OCR samples (default 0.5)")
1703
+ ap.add_argument("--scan-trigger", type=float, default=60,
1704
+ help="also OCR after this many pixels of scroll (default 60)")
1705
+ ap.add_argument("--pad", "--blur-buffer", type=int, default=8, dest="pad",
1706
+ help="blur buffer: pixels of blur beyond the tightly-"
1707
+ "cropped word/face (default 8)")
1708
+ ap.add_argument("--no-vfr-fix", action="store_true",
1709
+ help="skip automatic CFR normalization of VFR input")
1710
+ ap.add_argument("--dense-faces", action="store_true",
1711
+ help="run the face detector on EVERY frame (not just at "
1712
+ "scan intervals) so fast-moving faces stay covered. "
1713
+ "Restricted to face detection zones when zones are "
1714
+ "set, which keeps it fast. Higher render time.")
1715
+ ap.add_argument("--dense-face-stride", type=int, default=1,
1716
+ help="with --dense-faces, detect every Nth frame "
1717
+ "(1 = every frame; 2-3 trades a little coverage for "
1718
+ "speed). Default 1.")
1719
+ ap.add_argument("--plate-model", default=None,
1720
+ help="path to a YOLOv8 license-plate ONNX model. If omitted, "
1721
+ "OpenScrub looks for models/plate_yolov8.onnx or the "
1722
+ "$OPENSCRUB_PLATE_MODEL env var. Plate category is "
1723
+ "inactive without a model.")
1724
+ ap.add_argument("--plate-threshold", type=float, default=0.35,
1725
+ help="license-plate detector confidence cutoff (0-1). "
1726
+ "Default 0.35.")
1727
+ ap.add_argument("--face-threshold", type=float, default=0.6,
1728
+ help="face detector confidence cutoff (0-1). Lower catches "
1729
+ "more faces but risks false positives; higher is "
1730
+ "stricter. Default 0.6 (YuNet).")
1731
+ ap.add_argument("--draw-scores", action="store_true",
1732
+ help="with --preview, draw each face's confidence score so "
1733
+ "you can tune --face-threshold on your own footage")
1734
+ ap.add_argument("--detect-scale", type=float, default=1.0,
1735
+ help="run FACE detection on a downscaled copy of each frame "
1736
+ "for speed (0.2-1.0; e.g. 0.5 = half resolution). "
1737
+ "Output quality is unaffected. Default 1.0 (off).")
1738
+ ap.add_argument("--face-expand", type=float, default=0.15,
1739
+ help="expand detected face boxes by this fraction before "
1740
+ "the blur buffer is applied (default 0.15)")
1741
+ ap.add_argument("--mode", choices=["blur", "box", "mosaic"],
1742
+ default="blur",
1743
+ help="default redaction style: blur (reversible-ish), "
1744
+ "box (solid black, irreversible), or mosaic "
1745
+ "(pixelation — censored look, not recoverable)")
1746
+ ap.add_argument("--mode-map", default="",
1747
+ help="per-category overrides, e.g. 'ssn=box,mrn=box' — "
1748
+ "override style per category, e.g. 'ssn=box,face=mosaic'; a "
1749
+ "risky while blurring the rest. Categories not listed "
1750
+ "use --mode.")
1751
+ ap.add_argument("--mrn-regex", default=RE_MRN_DEFAULT)
1752
+ ap.add_argument("--bridge-gap", type=float, default=4.0,
1753
+ help="max seconds to bridge blur across OCR misses when the "
1754
+ "same PHI reappears in the same region (default 4.0)")
1755
+ ap.add_argument("--no-memory", action="store_true",
1756
+ help="disable PHI text memory (recall of previously "
1757
+ "confirmed strings)")
1758
+ ap.add_argument("--preview", action="store_true",
1759
+ help="draw boxes (red=PHI, orange=unscanned band) instead of blurring")
1760
+ ap.add_argument("--report", help="write JSON audit report with provenance "
1761
+ "(contains PHI text — protect it)")
1762
+ ap.add_argument("--from-report", help="skip scanning; re-render from an "
1763
+ "(edited) audit report produced by --report")
1764
+ ap.add_argument("--batch", help="process every video in this folder; "
1765
+ "files whose output already exists are skipped (resume)")
1766
+ ap.add_argument("--overwrite", action="store_true",
1767
+ help="with --batch: reprocess even if output exists")
1768
+ ap.add_argument("--backtrack-window", type=float, default=2.5,
1769
+ help="seconds of recent frames kept for onset "
1770
+ "backtracking (RAM: ~1MB per frame at 1440p; "
1771
+ "default 2.5)")
1772
+ ap.add_argument("--no-backtrack", action="store_true",
1773
+ help="disable onset backtracking (finding the exact frame "
1774
+ "where newly detected PHI first appeared)")
1775
+ ap.add_argument("--skip-start", type=float, default=0.0,
1776
+ help="don't detect anything during the first N seconds")
1777
+ ap.add_argument("--skip-end", type=float, default=0.0,
1778
+ help="stop detecting N seconds before the end of the video")
1779
+ ap.add_argument("--zones", help="JSON file of per-category detection "
1780
+ "zones (normalized 0-1 coords). Categories with zones "
1781
+ "are ONLY detected inside them — detections outside are "
1782
+ "dropped and counted as a warning. Categories without "
1783
+ "zones remain full-frame.")
1784
+ ap.add_argument("--ignore-region", action="append", default=[], metavar="X1,Y1,X2,Y2",
1785
+ help="screen region to never blur (repeatable), e.g. taskbar clock")
1786
+ ap.add_argument("--ocr-upscale", choices=["auto", "on", "off"], default="auto",
1787
+ help="re-OCR at 2x when text is small (default auto)")
1788
+ ap.add_argument("--paranoid", action="store_true",
1789
+ help="maximum-recall preset: dense sampling, lenient "
1790
+ "matching, forced upscale — more false positives, "
1791
+ "clean them up in review")
1792
+ ap.add_argument("--vfr", choices=["auto", "ignore"], default="auto",
1793
+ help="auto: detect variable frame rate and normalize to "
1794
+ "CFR before processing (default); ignore: skip check")
1795
+ ap.add_argument("--categories", default="name,dob,phone,ssn,mrn,email,address,card,apikey,ipaddr,plate,face")
1796
+ return ap
1797
+
1798
+
1799
+ def _prep_args(args, parser):
1800
+ args = apply_config(args, parser)
1801
+ regions = []
1802
+ for r in (args.ignore_region or []):
1803
+ if isinstance(r, str):
1804
+ regions.append(tuple(int(v) for v in r.split(",")))
1805
+ else:
1806
+ regions.append(tuple(r))
1807
+ args.ignore_regions = getattr(args, "ignore_regions", []) or regions
1808
+ mm = {}
1809
+ raw_mm = getattr(args, "mode_map", "") or ""
1810
+ if isinstance(raw_mm, dict):
1811
+ mm = {k: v for k, v in raw_mm.items() if v in ("blur", "box", "mosaic")}
1812
+ elif raw_mm:
1813
+ for pair in str(raw_mm).replace(";", ",").split(","):
1814
+ if "=" in pair:
1815
+ k, v = pair.split("=", 1)
1816
+ k, v = k.strip().lower(), v.strip().lower()
1817
+ if v in ("blur", "box", "mosaic"):
1818
+ mm[k] = v
1819
+ args.mode_map = mm
1820
+ args.zones_data = None
1821
+ if getattr(args, "zones", None):
1822
+ args.zones_data = load_zones(args.zones)
1823
+ if getattr(args, "paranoid", False):
1824
+ args.sample_interval = min(args.sample_interval, 0.25)
1825
+ args.scan_trigger = min(args.scan_trigger, 30)
1826
+ args.ocr_upscale = "on"
1827
+ return args
1828
+
1829
+
1830
+ def backtrack_onset(det, buf, cx, cy, cur_small, scale=0.5,
1831
+ ncc_min=0.6):
1832
+ """A detection first seen at scan time t may have APPEARED any time since
1833
+ the previous scan — up to a full sample interval of exposed PHI. Walk
1834
+ backwards through the recent-frame buffer comparing the detection's
1835
+ region visually (no OCR needed: we know where it is and what it looks
1836
+ like) until it vanishes; return the earliest frame index where it is
1837
+ still present, or None if it wasn't present in any buffered frame."""
1838
+ x1 = int((det.cbox[0] + cx) * scale)
1839
+ y1 = int((det.cbox[1] + cy) * scale)
1840
+ x2 = int((det.cbox[2] + cx) * scale)
1841
+ y2 = int((det.cbox[3] + cy) * scale)
1842
+ h, w = cur_small.shape[:2]
1843
+ x1, y1, x2, y2 = max(0, x1), max(0, y1), min(w, x2), min(h, y2)
1844
+ if x2 - x1 < 6 or y2 - y1 < 4:
1845
+ return None
1846
+ tmpl = cur_small[y1:y2, x1:x2]
1847
+ tstd = float(tmpl.std())
1848
+ if tstd < 4:
1849
+ return None # featureless: can't match reliably
1850
+ th, tw = tmpl.shape
1851
+ M = 8 # local search margin (tolerates small
1852
+ # tracking error in buffered offsets)
1853
+
1854
+ def present_at(small, ox_s, oy_s):
1855
+ bx1 = int((det.cbox[0]) * scale + ox_s) - M
1856
+ by1 = int((det.cbox[1]) * scale + oy_s) - M
1857
+ bx2 = bx1 + tw + 2 * M
1858
+ by2 = by1 + th + 2 * M
1859
+ bx1, by1 = max(0, bx1), max(0, by1)
1860
+ bx2, by2 = min(w, bx2), min(h, by2)
1861
+ if bx2 - bx1 < tw or by2 - by1 < th:
1862
+ return False
1863
+ region = small[by1:by2, bx1:bx2]
1864
+ if float(region.std()) < max(3.0, 0.30 * tstd):
1865
+ return False
1866
+ return float(cv2.matchTemplate(region, tmpl,
1867
+ cv2.TM_CCOEFF_NORMED).max()) >= ncc_min
1868
+
1869
+ onset = None
1870
+ for fidx, fcx, fcy, small in reversed(buf):
1871
+ # two coordinate hypotheses per frame: the tracker's recorded offset,
1872
+ # and screen-static (offset at detection time). Page transitions feed
1873
+ # the tracker garbage offsets — content that never moved on screen
1874
+ # would otherwise be looked for in the wrong place and the walk would
1875
+ # stop, forfeiting the whole exposure window.
1876
+ if (present_at(small, fcx * scale, fcy * scale)
1877
+ or present_at(small, cx * scale, cy * scale)):
1878
+ onset = fidx
1879
+ else:
1880
+ break
1881
+ return onset
1882
+
1883
+
1884
+ def reverse_pass(scans, memory, cats, namer, lenient=76):
1885
+ """After the scan, re-search every OCR'd word against remembered PHI at
1886
+ a more lenient threshold. Catches near-misses (OCR misreads) of strings
1887
+ already confirmed elsewhere in the video; gating rules still apply so a
1888
+ one-off false positive can't spread."""
1889
+ from rapidfuzz import fuzz
1890
+ extra = []
1891
+ for t, cum, words in scans:
1892
+ for w, cbox, conf in words:
1893
+ n = PhiMemory.norm(w)
1894
+ if len(n) < 4 or n in STOPWORDS:
1895
+ continue
1896
+ if namer and namer._allowed(w):
1897
+ continue
1898
+ for k, cat in memory.items.items():
1899
+ if cat not in cats:
1900
+ continue
1901
+ if memory._gated(k, cat) is None:
1902
+ continue
1903
+ if k.isdigit() or n.isdigit():
1904
+ hit = (k.isdigit() and n.isdigit() and len(k) == len(n)
1905
+ and sum(a != b for a, b in zip(k, n)) <= 2)
1906
+ else:
1907
+ hit = (abs(len(k) - len(n)) <= 2
1908
+ and fuzz.ratio(k, n) >= lenient)
1909
+ if hit:
1910
+ extra.append(Detection(t, t,
1911
+ tuple(int(v) for v in cbox),
1912
+ cat, w, round(float(conf), 3),
1913
+ tuple(cum)))
1914
+ break
1915
+ return extra
1916
+
1917
+
1918
+ def run_scan(args, cb=None):
1919
+ """Scan pass only: OCR + face detection + tracking. Returns a state dict
1920
+ consumed by run_render (and serialized into --report files)."""
1921
+ cb = cb or Callbacks()
1922
+ if not args.video or not os.path.exists(args.video):
1923
+ raise RuntimeError(f"input not found: {args.video}")
1924
+ # normalize attributes that may be absent when args come from an older
1925
+ # embedder (GUI/web) rather than _prep_args
1926
+ for attr, default in (("ignore_regions", []), ("config", None),
1927
+ ("from_report", None), ("face_expand", 0.15),
1928
+ ("no_vfr_fix", False)):
1929
+ if not hasattr(args, attr):
1930
+ setattr(args, attr, default)
1931
+ normalize_vfr(args, cb)
1932
+ cats = {c.strip() for c in args.categories.split(",")}
1933
+
1934
+ cb.log(f"[1/4] OCR engine (openscrub v{VERSION})")
1935
+ ocr = make_ocr(args.engine, device=args.device)
1936
+ cb.log(f" using {type(ocr).__name__}")
1937
+
1938
+ cb.log("[2/4] Detectors")
1939
+ namer = NameDetector(allow_names=args.allow_names, extra_names=args.extra_names,
1940
+ use_ner=not args.no_ner, heuristic=args.heuristic_names)
1941
+ modes = []
1942
+ if namer.nlp is not None:
1943
+ modes.append("spaCy NER")
1944
+ modes.append("label heuristic")
1945
+ if namer.heuristic:
1946
+ modes.append("capitalized-pair heuristic")
1947
+ cb.log(f" names: {', '.join(modes)}"
1948
+ + (f" | allowlist: {len(namer.allow)} tokens" if namer.allow else ""))
1949
+ facer = (FaceDetector(cb, expand=args.face_expand,
1950
+ thresh=getattr(args, "face_threshold", 0.6))
1951
+ if "face" in cats else None)
1952
+ plater = (PlateDetector(cb, model_path=getattr(args, "plate_model", None),
1953
+ thresh=getattr(args, "plate_threshold", 0.35))
1954
+ if "plate" in cats else None)
1955
+ detect_scale = float(getattr(args, "detect_scale", 1.0) or 1.0)
1956
+ if args.ignore_regions:
1957
+ cb.log(f" ignore regions: {len(args.ignore_regions)}")
1958
+
1959
+ mrn_re = re.compile(args.mrn_regex)
1960
+
1961
+ cb.log(f"[3/4] Scanning (every {args.sample_interval}s or {args.scan_trigger}px of scroll)")
1962
+ cap = cv2.VideoCapture(args.video)
1963
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
1964
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
1965
+ vw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
1966
+ vh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
1967
+ step = max(1, int(round(fps * args.sample_interval)))
1968
+ zones_px = (zones_to_pixels(args.zones_data, vw, vh)
1969
+ if getattr(args, "zones_data", None) else None)
1970
+ duration = total / fps if fps else 0
1971
+ win_start = max(0.0, float(getattr(args, "skip_start", 0) or 0))
1972
+ win_end = duration - max(0.0, float(getattr(args, "skip_end", 0) or 0))
1973
+ if win_start > 0 or win_end < duration:
1974
+ cb.log(f" detection window: {win_start:.1f}s to {win_end:.1f}s "
1975
+ f"(of {duration:.1f}s) — nothing outside it is detected or blurred")
1976
+ zone_dropped = {}
1977
+ zdrop_raw = []
1978
+ if zones_px:
1979
+ cb.log(" detection zones active: "
1980
+ + ", ".join(f"{c} ({len(r)})" for c, r in zones_px.items()))
1981
+
1982
+ tracker = ScrollTracker()
1983
+ memory = None if args.no_memory else PhiMemory(
1984
+ threshold=78 if getattr(args, "paranoid", False) else 82)
1985
+ cum = []
1986
+ bands = []
1987
+ raw = []
1988
+ scans = []
1989
+ cx = cy = 0.0
1990
+ last_scan_idx = -10**9
1991
+ scan_cx = scan_cy = 0.0
1992
+ n_scans = 0
1993
+ n_recalled = 0
1994
+ recall_counts = {}
1995
+ from collections import deque
1996
+ BT_SCALE = 0.5
1997
+ bt_on = not getattr(args, "no_backtrack", False)
1998
+ bt_win = max(args.sample_interval + 0.6,
1999
+ float(getattr(args, "backtrack_window", 2.5) or 2.5))
2000
+ bt_buf = deque(maxlen=max(3, int(round(fps * bt_win))))
2001
+ prev_keys = {}
2002
+ bt_count, bt_gain, bt_capped = 0, 0.0, 0
2003
+ face_tracks = [] # forward face tracking: detect once, hold every frame
2004
+ dense_faces = bool(getattr(args, "dense_faces", False))
2005
+ dense_stride = max(1, int(getattr(args, "dense_face_stride", 1) or 1))
2006
+ plate_zone_px = (zones_px.get("plate") if zones_px else None)
2007
+ plate_zone_rects = plate_zone_px if plate_zone_px else None
2008
+ face_zone_px = (zones_px.get("face") if zones_px else None)
2009
+ face_zone_rects = face_zone_px if face_zone_px else None
2010
+ if dense_faces:
2011
+ cb.log(" dense faces: detecting "
2012
+ + (f"every {dense_stride} frames" if dense_stride > 1
2013
+ else "every frame")
2014
+ + (" inside face zone(s)" if face_zone_rects
2015
+ else " (whole frame — set a face zone to speed this up)"))
2016
+ idx = 0
2017
+ while True:
2018
+ if idx % 30 == 0 and cb.cancelled():
2019
+ cap.release()
2020
+ raise PipelineCancelled()
2021
+ ok, frame = cap.read()
2022
+ if not ok:
2023
+ break
2024
+ cx, cy = tracker.step(frame)
2025
+ cum.append((cx, cy))
2026
+
2027
+ t_now = idx / fps
2028
+ if t_now < win_start or t_now > win_end:
2029
+ # outside the detection window: no scans, and no safety bands
2030
+ # (the user has declared this span PHI-free)
2031
+ scan_cx, scan_cy = cx, cy
2032
+ bands.append((0.0, 0.0))
2033
+ idx += 1
2034
+ continue
2035
+ small = (cv2.resize(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY), None,
2036
+ fx=BT_SCALE, fy=BT_SCALE) if bt_on else None)
2037
+ # dense face detection: find faces on THIS frame at their true screen
2038
+ # position, independent of the OCR scan cadence — per-frame
2039
+ # re-detection (not tracking), so a face moving fast across the frame
2040
+ # stays covered because the detector re-finds it wherever it is.
2041
+ if (dense_faces and facer is not None and "face" in cats
2042
+ and idx % dense_stride == 0):
2043
+ dense_hold = 0.3 * dense_stride / fps
2044
+ for (fx1, fy1, fx2, fy2, conf) in facer.find(frame, detect_scale):
2045
+ # zone-filter by the face's screen center, exactly like every
2046
+ # other category (robust; no cropped-background detector quirks)
2047
+ if face_zone_rects is not None and not in_any_zone(
2048
+ (fx1, fy1, fx2, fy2), face_zone_rects):
2049
+ continue
2050
+ raw.append(Detection(
2051
+ t_now, t_now + dense_hold,
2052
+ (int(fx1 - cx), int(fy1 - cy),
2053
+ int(fx2 - cx), int(fy2 - cy)),
2054
+ "face", "face", round(conf, 3), (cx, cy),
2055
+ dense=True))
2056
+ # per-frame license-plate detection: plates on dashcam/CCTV footage move
2057
+ # fast, so (like dense faces) we re-detect every frame at the true
2058
+ # position rather than tracking. Only runs if a plate model is loaded.
2059
+ if (plater is not None and plater.available() and "plate" in cats
2060
+ and idx % max(1, dense_stride) == 0):
2061
+ phold = 0.3 * max(1, dense_stride) / fps
2062
+ for (px1, py1, px2, py2, pconf) in plater.find(frame, detect_scale):
2063
+ if plate_zone_rects is not None and not in_any_zone(
2064
+ (px1, py1, px2, py2), plate_zone_rects):
2065
+ continue
2066
+ raw.append(Detection(
2067
+ t_now, t_now + phold,
2068
+ (int(px1 - cx), int(py1 - cy),
2069
+ int(px2 - cx), int(py2 - cy)),
2070
+ "plate", "plate", round(pconf, 3), (cx, cy),
2071
+ dense=True))
2072
+ moved = abs(cx - scan_cx) + abs(cy - scan_cy)
2073
+ due = (idx - last_scan_idx >= step) or (moved >= args.scan_trigger)
2074
+ if due and idx - last_scan_idx >= 2:
2075
+ t = idx / fps
2076
+ words = read_adaptive(ocr, frame, getattr(args, "ocr_upscale", "auto"))
2077
+ lines = group_lines(words)
2078
+ found = detect_phi(words, lines, t, (cx, cy), namer, mrn_re)
2079
+ found = [d for d in found if d.category in cats]
2080
+
2081
+ if facer is not None:
2082
+ for (fx1, fy1, fx2, fy2, conf) in facer.find(frame, detect_scale):
2083
+ found.append(Detection(t, t,
2084
+ (int(fx1 - cx), int(fy1 - cy),
2085
+ int(fx2 - cx), int(fy2 - cy)),
2086
+ "face", "face", round(conf, 3),
2087
+ (cx, cy)))
2088
+
2089
+ if memory is not None:
2090
+ primary_found = list(found)
2091
+ flagged = {tuple(b) for _, b, _ in
2092
+ ((d.text, (d.cbox[0] + cx, d.cbox[1] + cy,
2093
+ d.cbox[2] + cx, d.cbox[3] + cy), 0)
2094
+ for d in found)}
2095
+ for w, box, conf in words:
2096
+ if tuple(box) in flagged:
2097
+ continue
2098
+ cat = memory.recall(w)
2099
+ if cat and cat in cats and not (namer and namer._allowed(w)):
2100
+ cbox = (int(box[0] - cx), int(box[1] - cy),
2101
+ int(box[2] - cx), int(box[3] - cy))
2102
+ found.append(Detection(t, t, cbox, cat, w,
2103
+ round(float(conf), 3), (cx, cy)))
2104
+ n_recalled += 1
2105
+ k = PhiMemory.norm(w)
2106
+ recall_counts[k] = recall_counts.get(k, 0) + 1
2107
+ # only primary detections build memory (recalls must not
2108
+ # self-reinforce a false positive)
2109
+ for d in primary_found:
2110
+ memory.add(d.text, d.category, primary=True)
2111
+
2112
+ if args.ignore_regions:
2113
+ found = [d for d in found if not in_ignore_region(
2114
+ (d.cbox[0] + cx, d.cbox[1] + cy,
2115
+ d.cbox[2] + cx, d.cbox[3] + cy), args.ignore_regions)]
2116
+
2117
+ if zones_px:
2118
+ kept = []
2119
+ for d in found:
2120
+ rects = zones_px.get(d.category)
2121
+ sb = (d.cbox[0] + cx, d.cbox[1] + cy,
2122
+ d.cbox[2] + cx, d.cbox[3] + cy)
2123
+ if getattr(d, "dense", False):
2124
+ kept.append(d) # dense faces pre-filtered
2125
+ elif rects and not in_any_zone(sb, rects):
2126
+ zone_dropped[d.category] = zone_dropped.get(d.category, 0) + 1
2127
+ zdrop_raw.append(d)
2128
+ else:
2129
+ kept.append(d)
2130
+ found = kept
2131
+
2132
+ if bt_on:
2133
+ # "New" must be POSITIONAL: the same patient name can already
2134
+ # be on screen in a list row when it also appears in a chart
2135
+ # banner after a click — that banner is a new appearance and
2136
+ # needs backtracking even though the text isn't new.
2137
+ def _bt_key(d):
2138
+ return (d.category,
2139
+ PhiMemory.norm(d.text) if d.text else "")
2140
+
2141
+ def _center(d):
2142
+ return ((d.cbox[0] + d.cbox[2]) / 2,
2143
+ (d.cbox[1] + d.cbox[3]) / 2)
2144
+ cur_keys = {}
2145
+ for d in found:
2146
+ cur_keys.setdefault(_bt_key(d), []).append(_center(d))
2147
+ for d in found:
2148
+ cx0, cy0 = _center(d)
2149
+ seen_near = any(
2150
+ abs(cx0 - px) < 120 and abs(cy0 - py) < 120
2151
+ for px, py in prev_keys.get(_bt_key(d), []))
2152
+ if seen_near:
2153
+ continue
2154
+ onset = backtrack_onset(d, bt_buf, cx, cy, small, BT_SCALE)
2155
+ if onset is not None:
2156
+ if bt_buf and onset == bt_buf[0][0]:
2157
+ bt_capped += 1 # visible beyond the buffer:
2158
+ # gap-bridging (layer 2/3) covers further back
2159
+ new_start = max(win_start, onset / fps - 0.12)
2160
+ if new_start < d.t_start - 0.01:
2161
+ bt_count += 1
2162
+ bt_gain += d.t_start - new_start
2163
+ d.t_start = new_start
2164
+ prev_keys = cur_keys
2165
+ if bt_on:
2166
+ for d in found:
2167
+ if d.category == "face" and dense_faces:
2168
+ continue # dense mode re-detects faces every frame
2169
+ if d.category != "face" and not (
2170
+ d.text and len(PhiMemory.norm(d.text)) >= 3):
2171
+ continue
2172
+ sx1 = int((d.cbox[0] + cx) * BT_SCALE)
2173
+ sy1 = int((d.cbox[1] + cy) * BT_SCALE)
2174
+ sx2 = int((d.cbox[2] + cx) * BT_SCALE)
2175
+ sy2 = int((d.cbox[3] + cy) * BT_SCALE)
2176
+ hh, ww = small.shape[:2]
2177
+ sx1, sy1 = max(0, sx1), max(0, sy1)
2178
+ sx2, sy2 = min(ww, sx2), min(hh, sy2)
2179
+ if sx2 - sx1 < 8 or sy2 - sy1 < 8:
2180
+ continue
2181
+ tmpl = small[sy1:sy2, sx1:sx2].copy()
2182
+
2183
+ dn = PhiMemory.norm(d.text) if d.text else ""
2184
+ dcx = (d.cbox[0] + d.cbox[2]) / 2
2185
+ dcy = (d.cbox[1] + d.cbox[3]) / 2
2186
+ for tr in face_tracks:
2187
+ if tr["cat"] != d.category:
2188
+ continue
2189
+ if tr["norm"] == dn:
2190
+ same_text = True
2191
+ elif dn and tr["norm"]:
2192
+ from rapidfuzz import fuzz as _f
2193
+ same_text = _f.ratio(dn, tr["norm"]) >= 85
2194
+ else:
2195
+ same_text = (not dn and not tr["norm"])
2196
+ near = (abs(dcx - tr["c"][0]) < 100
2197
+ and abs(dcy - tr["c"][1]) < 100)
2198
+ if same_text and near:
2199
+ tr.update(tmpl=tmpl, cbox=d.cbox, last_ok=t,
2200
+ conf=d.confidence, c=(dcx, dcy),
2201
+ text=d.text)
2202
+ break
2203
+ else:
2204
+ if len(face_tracks) < 500:
2205
+ face_tracks.append({
2206
+ "tmpl": tmpl, "cbox": d.cbox, "cat": d.category,
2207
+ "text": d.text, "norm": dn, "c": (dcx, dcy),
2208
+ "last_ok": t, "last_emit": t,
2209
+ "conf": d.confidence})
2210
+ raw.extend(found)
2211
+ scans.append((t, (cx, cy),
2212
+ [(w, (b[0] - cx, b[1] - cy, b[2] - cx, b[3] - cy), c)
2213
+ for w, b, c in words]))
2214
+ n_scans += 1
2215
+ last_scan_idx = idx
2216
+ scan_cx, scan_cy = cx, cy
2217
+ tracker.anchor()
2218
+ if found:
2219
+ cb.log(f" t={t:7.2f}s {len(found)} PHI region(s): "
2220
+ + ", ".join(sorted({d.category for d in found})))
2221
+ if cb.wants_frames:
2222
+ shown = frame.copy()
2223
+ for d in found:
2224
+ cv2.rectangle(shown,
2225
+ (int(d.cbox[0] + cx), int(d.cbox[1] + cy)),
2226
+ (int(d.cbox[2] + cx), int(d.cbox[3] + cy)),
2227
+ (0, 0, 255), 2)
2228
+ cb.scan_frame(shown, t, len(found))
2229
+ cb.progress("scan", idx, total)
2230
+ bands.append((cx - scan_cx, cy - scan_cy))
2231
+ if bt_on and face_tracks and not dense_faces:
2232
+ t_now2 = idx / fps
2233
+ hh, ww = small.shape[:2]
2234
+ MM = 6
2235
+ for tr in list(face_tracks):
2236
+ b = tr["cbox"]
2237
+ th_, tw_ = tr["tmpl"].shape
2238
+ bx1 = int((b[0] + cx) * BT_SCALE) - MM
2239
+ by1 = int((b[1] + cy) * BT_SCALE) - MM
2240
+ bx2 = bx1 + tw_ + 2 * MM
2241
+ by2 = by1 + th_ + 2 * MM
2242
+ bx1, by1 = max(0, bx1), max(0, by1)
2243
+ bx2, by2 = min(ww, bx2), min(hh, by2)
2244
+ ok = False
2245
+ if bx2 - bx1 >= tw_ and by2 - by1 >= th_:
2246
+ region = small[by1:by2, bx1:bx2]
2247
+ if float(region.std()) > 3:
2248
+ # TM_CCOEFF_NORMED is invariant to uniform dimming:
2249
+ # a Please-Wait overlay cannot break the track.
2250
+ # Text needs a STRICTER bar than faces — two different
2251
+ # short words in the same UI font correlate ~0.6, and
2252
+ # a track that keeps matching after its word was
2253
+ # replaced extends last_seen past the switch, pairing
2254
+ # the region's text with frames of a different name.
2255
+ thr = 0.58 if tr["cat"] == "face" else 0.74
2256
+ ok = float(cv2.matchTemplate(
2257
+ region, tr["tmpl"],
2258
+ cv2.TM_CCOEFF_NORMED).max()) > thr
2259
+ if not ok and tr["cat"] != "face" and tw_ >= 24:
2260
+ # partial occlusion (a cursor parked on the word):
2261
+ # either half still matching means the word is
2262
+ # still there — keep covering ALL of it
2263
+ for half in (tr["tmpl"][:, :tw_ // 2],
2264
+ tr["tmpl"][:, tw_ // 2:]):
2265
+ if (float(half.std()) > 4 and float(
2266
+ cv2.matchTemplate(
2267
+ region, half,
2268
+ cv2.TM_CCOEFF_NORMED).max())
2269
+ > thr):
2270
+ ok = True
2271
+ break
2272
+ if ok:
2273
+ tr["last_ok"] = t_now2
2274
+ if (t_now2 - tr["last_emit"] >= 0.3
2275
+ and win_start <= t_now2 <= win_end):
2276
+ raw.append(Detection(t_now2, t_now2,
2277
+ tuple(tr["cbox"]), tr["cat"],
2278
+ tr["text"],
2279
+ round(tr["conf"], 3), (cx, cy)))
2280
+ tr["last_emit"] = t_now2
2281
+ elif t_now2 - tr["last_ok"] > 0.8:
2282
+ face_tracks[:] = [x for x in face_tracks if x is not tr]
2283
+ if bt_on:
2284
+ bt_buf.append((idx, cx, cy, small))
2285
+ idx += 1
2286
+ cap.release()
2287
+
2288
+ if memory is not None:
2289
+ extra = reverse_pass(scans, memory, cats, namer,
2290
+ lenient=72 if getattr(args, "paranoid", False) else 76)
2291
+ if args.ignore_regions:
2292
+ extra = [d for d in extra if not in_ignore_region(
2293
+ (d.cbox[0] + d.aoff[0], d.cbox[1] + d.aoff[1],
2294
+ d.cbox[2] + d.aoff[0], d.cbox[3] + d.aoff[1]),
2295
+ args.ignore_regions)]
2296
+ if zones_px:
2297
+ kept = []
2298
+ for d in extra:
2299
+ rects = zones_px.get(d.category)
2300
+ sb = (d.cbox[0] + d.aoff[0], d.cbox[1] + d.aoff[1],
2301
+ d.cbox[2] + d.aoff[0], d.cbox[3] + d.aoff[1])
2302
+ if rects and not in_any_zone(sb, rects):
2303
+ zone_dropped[d.category] = zone_dropped.get(d.category, 0) + 1
2304
+ zdrop_raw.append(d)
2305
+ else:
2306
+ kept.append(d)
2307
+ extra = kept
2308
+ if extra:
2309
+ cb.log(f" reverse pass: {len(extra)} additional near-miss "
2310
+ "region(s) from remembered PHI")
2311
+ raw.extend(extra)
2312
+ hold = args.sample_interval + 0.3
2313
+ from rapidfuzz import fuzz as _fuzz
2314
+ detections = merge_detections(raw, hold=hold, scans=scans,
2315
+ bridge_gap=args.bridge_gap, fuzz=_fuzz)
2316
+ zdropped = merge_detections(zdrop_raw, hold=hold, scans=scans,
2317
+ bridge_gap=args.bridge_gap, fuzz=_fuzz) if zdrop_raw else []
2318
+ mem_note = (f" | {n_recalled} memory recalls, "
2319
+ f"{len(memory.items)} strings remembered" if memory else "")
2320
+ cb.log(f" {n_scans} OCR scans | {len(raw)} raw hits -> "
2321
+ f"{len(detections)} merged regions{mem_note}")
2322
+ if bt_count:
2323
+ cb.log(f" backtrack: {bt_count} region(s) start moved earlier "
2324
+ f"(avg {bt_gain / bt_count:.2f}s of would-be exposure closed)")
2325
+ if bt_capped:
2326
+ cb.log(f" note: {bt_capped} region(s) were visible beyond the "
2327
+ "backtrack buffer — earlier coverage relies on gap bridging "
2328
+ "(raise --bridge-gap if scans of it were sparse)")
2329
+ if zone_dropped:
2330
+ cb.log(" *** ZONE WARNING: "
2331
+ + ", ".join(f"{c} x{n}" for c, n in sorted(zone_dropped.items()))
2332
+ + " detection(s) fell OUTSIDE their category's zones and were "
2333
+ "NOT blurred. Verify your zones actually cover all PHI. ***")
2334
+ if recall_counts:
2335
+ top = sorted(recall_counts.items(), key=lambda kv: -kv[1])[:8]
2336
+ cb.log(" top recalled strings (check for false positives): "
2337
+ + ", ".join(f"'{k}'x{v}" for k, v in top))
2338
+
2339
+ return {"fps": fps, "cum": cum, "bands": bands, "detections": detections,
2340
+ "zdropped": zdropped,
2341
+ "input_sha256": sha256_file(args.video),
2342
+ "stats": {"scans": n_scans, "raw_hits": len(raw),
2343
+ "regions": len(detections), "recalls": n_recalled,
2344
+ "remembered": len(memory.items) if memory else 0,
2345
+ "zone_dropped": zone_dropped}}
2346
+
2347
+
2348
+ def run_render(args, state, cb=None):
2349
+ cb = cb or Callbacks()
2350
+ dst = args.output or os.path.splitext(args.video)[0] + (
2351
+ "_preview.mp4" if args.preview else "_redacted.mp4")
2352
+ cb.log(f"[4/4] Rendering -> {dst}")
2353
+ render(args.video, dst, state["detections"], state["cum"], state["bands"],
2354
+ state["fps"], pad=args.pad, mode=args.mode, preview=args.preview,
2355
+ mode_map=getattr(args, "mode_map", None),
2356
+ draw_scores=bool(getattr(args, "draw_scores", False)),
2357
+ encoder=args.encoder, cb=cb)
2358
+ if args.report:
2359
+ write_report(args.report, args, state, output_path=dst)
2360
+ cb.log(f" audit report: {args.report} (contains PHI text — protect it)")
2361
+ cb.log("done.")
2362
+ return dict(state["stats"], output=dst)
2363
+
2364
+
2365
+ def run_pipeline(args, cb=None):
2366
+ """Scan + render (or render-only with --from-report). Returns a summary
2367
+ dict; raises PipelineCancelled if cb cancels."""
2368
+ cb = cb or Callbacks()
2369
+ if getattr(args, "from_report", None):
2370
+ normalize_vfr(args, cb)
2371
+ cb.log(f"[1/2] Loading detections from {args.from_report}")
2372
+ dets, rstate, prov = load_report(args.from_report)
2373
+ if rstate is None:
2374
+ raise RuntimeError("report has no render_state — re-run a scan "
2375
+ "with --report using openscrub v4+")
2376
+ cb.log(f" {len(dets)} enabled detections")
2377
+ in_sha = sha256_file(args.video)
2378
+ if prov.get("input_sha256") and prov["input_sha256"] != in_sha:
2379
+ cb.log(" WARNING: input file differs from the one this report "
2380
+ "was made from (sha256 mismatch) — blur positions may be wrong")
2381
+ state = {"fps": rstate["fps"],
2382
+ "cum": [tuple(v) for v in rstate["cum"]],
2383
+ "bands": [tuple(v) for v in rstate["bands"]],
2384
+ "detections": dets, "input_sha256": in_sha,
2385
+ "stats": {"scans": 0, "raw_hits": 0, "regions": len(dets),
2386
+ "recalls": 0, "remembered": 0}}
2387
+ return run_render(args, state, cb)
2388
+ state = run_scan(args, cb)
2389
+ return run_render(args, state, cb)
2390
+
2391
+
2392
+ def _batch(args, parser):
2393
+ exts = (".mp4", ".mkv", ".mov", ".avi", ".webm")
2394
+ files = sorted(f for f in os.listdir(args.batch)
2395
+ if f.lower().endswith(exts)
2396
+ and "_redacted" not in f and "_preview" not in f)
2397
+ if not files:
2398
+ raise RuntimeError(f"no videos found in {args.batch}")
2399
+ print(f"Batch: {len(files)} video(s) in {args.batch}")
2400
+ summary = []
2401
+ for i, name in enumerate(files, 1):
2402
+ path = os.path.join(args.batch, name)
2403
+ base = os.path.splitext(path)[0]
2404
+ print(f"\n=== [{i}/{len(files)}] {name} ===")
2405
+ a = argparse.Namespace(**vars(args))
2406
+ a.video = path
2407
+ a.output = base + "_redacted.mp4"
2408
+ a.report = base + "_audit.json"
2409
+ a.batch = None
2410
+ if os.path.exists(a.output) and not args.overwrite:
2411
+ print("skipping (output exists — use --overwrite to redo)")
2412
+ summary.append({"file": name, "ok": True, "skipped": True})
2413
+ continue
2414
+ try:
2415
+ res = run_pipeline(a)
2416
+ summary.append(dict(res, file=name, ok=True))
2417
+ except Exception as e:
2418
+ print(f"FAILED: {e}")
2419
+ summary.append({"file": name, "ok": False, "error": str(e)})
2420
+ out = os.path.join(args.batch, "batch_summary.json")
2421
+ with open(out, "w", encoding="utf-8") as f:
2422
+ json.dump({"tool": "openscrub", "version": VERSION,
2423
+ "timestamp": datetime.datetime.now().astimezone().isoformat(),
2424
+ "results": summary}, f, indent=2)
2425
+ ok = sum(1 for s in summary if s.get("ok"))
2426
+ print(f"\nBatch complete: {ok}/{len(files)} succeeded. Summary: {out}")
2427
+
2428
+
2429
+ def main():
2430
+ parser = build_parser()
2431
+ args = parser.parse_args()
2432
+ try:
2433
+ args = _prep_args(args, parser)
2434
+ if args.batch:
2435
+ _batch(args, parser)
2436
+ elif not args.video:
2437
+ parser.error("provide a video file or --batch FOLDER")
2438
+ else:
2439
+ run_pipeline(args)
2440
+ except RuntimeError as e:
2441
+ sys.exit(str(e))
2442
+
2443
+
2444
+ if __name__ == "__main__":
2445
+ main()