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-1.0.0.dist-info/METADATA +607 -0
- openscrub-1.0.0.dist-info/RECORD +8 -0
- openscrub-1.0.0.dist-info/WHEEL +4 -0
- openscrub-1.0.0.dist-info/entry_points.txt +3 -0
- openscrub-1.0.0.dist-info/licenses/LICENSE +190 -0
- openscrub.py +2445 -0
- openscrub_web.py +1859 -0
- zones_ui.py +380 -0
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()
|