OpenScrub 1.0.76__tar.gz → 1.0.78__tar.gz
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.76 → openscrub-1.0.78}/.gitignore +4 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/PKG-INFO +37 -12
- {openscrub-1.0.76 → openscrub-1.0.78}/README.md +36 -11
- openscrub-1.0.78/benchmark/README.md +159 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/openscrub.py +275 -49
- {openscrub-1.0.76 → openscrub-1.0.78}/openscrub_web.py +28 -5
- {openscrub-1.0.76 → openscrub-1.0.78}/test_openscrub.py +234 -1
- {openscrub-1.0.76 → openscrub-1.0.78}/LICENSE +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/NOTICE +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/assets/openscrub.ico +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/deploy/README.md +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/face_models.json +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/install.py +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/openscrub_setup.py +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/openscrub_update.py +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/openscrub_vault.py +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/person_models.json +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/plate_models.json +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/pyproject.toml +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/requirements.txt +0 -0
- {openscrub-1.0.76 → openscrub-1.0.78}/zones_ui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: OpenScrub
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.78
|
|
4
4
|
Summary: Local, GPU-accelerated redaction for video and screen recordings — blur, black-box, or mosaic faces, names, and any regex-matchable PII, with human review.
|
|
5
5
|
Project-URL: Homepage, https://github.com/austinmabry/OpenScrub
|
|
6
6
|
Project-URL: Repository, https://github.com/austinmabry/OpenScrub
|
|
@@ -261,6 +261,17 @@ rather than a half-second late. Defaults are tuned for the hardest case —
|
|
|
261
261
|
dense, scrolling records screens (medical, financial, CRM, support
|
|
262
262
|
consoles) — but the engine is general-purpose.
|
|
263
263
|
|
|
264
|
+
<p align="center">
|
|
265
|
+
<picture>
|
|
266
|
+
<source srcset="https://raw.githubusercontent.com/austinmabry/OpenScrub/main/assets/screenshots/redaction-demo.webp" type="image/webp">
|
|
267
|
+
<img src="https://raw.githubusercontent.com/austinmabry/OpenScrub/main/assets/screenshots/redaction-demo.gif"
|
|
268
|
+
alt="Split-screen wipe: a skier carving down a slope, one side redacted by OpenScrub with a body-shaped silhouette blur that tracks them through the turn while the snow, skis and poles stay sharp"
|
|
269
|
+
width="900">
|
|
270
|
+
</picture>
|
|
271
|
+
</p>
|
|
272
|
+
<p align="center"><em>Real output. The wipe reveals the same frames redacted and untouched: the silhouette mask tracks the skier through a fast carve, and only the body is covered — the mountain stays sharp.<br>
|
|
273
|
+
<sub>Compressed for GitHub — OpenScrub itself renders at full source quality and resolution (see <a href="https://openscrub.app">openscrub.app</a>). Footage: <a href="https://www.pexels.com/video/15155922/">Pexels</a>.</sub></em></p>
|
|
274
|
+
|
|
264
275
|
<p align="center">
|
|
265
276
|
<img src="https://raw.githubusercontent.com/austinmabry/OpenScrub/main/assets/screenshots/editor-demo.gif"
|
|
266
277
|
alt="OpenScrub editor in action: a video loaded on the timeline with stackable detection windows, per-window categories and zones, and audio-track lanes"
|
|
@@ -766,17 +777,31 @@ This software is provided "AS IS" without warranty of any kind — see the
|
|
|
766
777
|
|
|
767
778
|
## Validation
|
|
768
779
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
+
OpenScrub ships a **reproducible redaction benchmark**
|
|
781
|
+
([`benchmark/`](benchmark/)) — you run the same commands and get your
|
|
782
|
+
own numbers instead of trusting ours. It measures what a reader of the
|
|
783
|
+
output actually cares about: is the planted PII still *readable*, and
|
|
784
|
+
can a face recognizer still *identify* the person?
|
|
785
|
+
|
|
786
|
+
Text PII (v1.0.78, CPU, corpus v2 — 8 scenarios including dark mode,
|
|
787
|
+
sensor noise, 2.5° rotation and 120 kbps re-compression; 64 planted
|
|
788
|
+
values × 5 sampled frames):
|
|
789
|
+
|
|
790
|
+
PII recall: 100.0% (64/64 planted values never readable in the output)
|
|
791
|
+
Frame leak rate: 0.00% (0/320 sampled region-frames readable)
|
|
792
|
+
Benign preservation: 100.0% (43/43 non-PII samples left readable)
|
|
793
|
+
|
|
794
|
+
Face re-identification (v1.0.78, default blur, SFace matcher, valid
|
|
795
|
+
same-person controls): **0 of 511 redacted faces re-identified** across
|
|
796
|
+
a handheld party clip at 1080p and native 4K, a public-domain close-up
|
|
797
|
+
interview, and a mosaic-mode run — with post-redaction similarity below the
|
|
798
|
+
*cross-person chance floor*, i.e. a redacted face matches its own
|
|
799
|
+
original no better than a stranger's face does.
|
|
800
|
+
|
|
801
|
+
Numbers on a fixed corpus mean the corpus no longer finds leaks — not
|
|
802
|
+
that leaks are impossible. The benchmark README lists every defect the
|
|
803
|
+
harness caught (all fixed and regression-pinned), and the rules for
|
|
804
|
+
quoting these numbers honestly. The shipped test suite
|
|
780
805
|
(`pytest test_openscrub.py`) exercises the same end-to-end pipeline on
|
|
781
806
|
synthetic videos — for this tool a regression is not a bug, it's a leak,
|
|
782
807
|
so the suite must stay green on every change.
|
|
@@ -27,6 +27,17 @@ rather than a half-second late. Defaults are tuned for the hardest case —
|
|
|
27
27
|
dense, scrolling records screens (medical, financial, CRM, support
|
|
28
28
|
consoles) — but the engine is general-purpose.
|
|
29
29
|
|
|
30
|
+
<p align="center">
|
|
31
|
+
<picture>
|
|
32
|
+
<source srcset="https://raw.githubusercontent.com/austinmabry/OpenScrub/main/assets/screenshots/redaction-demo.webp" type="image/webp">
|
|
33
|
+
<img src="https://raw.githubusercontent.com/austinmabry/OpenScrub/main/assets/screenshots/redaction-demo.gif"
|
|
34
|
+
alt="Split-screen wipe: a skier carving down a slope, one side redacted by OpenScrub with a body-shaped silhouette blur that tracks them through the turn while the snow, skis and poles stay sharp"
|
|
35
|
+
width="900">
|
|
36
|
+
</picture>
|
|
37
|
+
</p>
|
|
38
|
+
<p align="center"><em>Real output. The wipe reveals the same frames redacted and untouched: the silhouette mask tracks the skier through a fast carve, and only the body is covered — the mountain stays sharp.<br>
|
|
39
|
+
<sub>Compressed for GitHub — OpenScrub itself renders at full source quality and resolution (see <a href="https://openscrub.app">openscrub.app</a>). Footage: <a href="https://www.pexels.com/video/15155922/">Pexels</a>.</sub></em></p>
|
|
40
|
+
|
|
30
41
|
<p align="center">
|
|
31
42
|
<img src="https://raw.githubusercontent.com/austinmabry/OpenScrub/main/assets/screenshots/editor-demo.gif"
|
|
32
43
|
alt="OpenScrub editor in action: a video loaded on the timeline with stackable detection windows, per-window categories and zones, and audio-track lanes"
|
|
@@ -532,17 +543,31 @@ This software is provided "AS IS" without warranty of any kind — see the
|
|
|
532
543
|
|
|
533
544
|
## Validation
|
|
534
545
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
+
OpenScrub ships a **reproducible redaction benchmark**
|
|
547
|
+
([`benchmark/`](benchmark/)) — you run the same commands and get your
|
|
548
|
+
own numbers instead of trusting ours. It measures what a reader of the
|
|
549
|
+
output actually cares about: is the planted PII still *readable*, and
|
|
550
|
+
can a face recognizer still *identify* the person?
|
|
551
|
+
|
|
552
|
+
Text PII (v1.0.78, CPU, corpus v2 — 8 scenarios including dark mode,
|
|
553
|
+
sensor noise, 2.5° rotation and 120 kbps re-compression; 64 planted
|
|
554
|
+
values × 5 sampled frames):
|
|
555
|
+
|
|
556
|
+
PII recall: 100.0% (64/64 planted values never readable in the output)
|
|
557
|
+
Frame leak rate: 0.00% (0/320 sampled region-frames readable)
|
|
558
|
+
Benign preservation: 100.0% (43/43 non-PII samples left readable)
|
|
559
|
+
|
|
560
|
+
Face re-identification (v1.0.78, default blur, SFace matcher, valid
|
|
561
|
+
same-person controls): **0 of 511 redacted faces re-identified** across
|
|
562
|
+
a handheld party clip at 1080p and native 4K, a public-domain close-up
|
|
563
|
+
interview, and a mosaic-mode run — with post-redaction similarity below the
|
|
564
|
+
*cross-person chance floor*, i.e. a redacted face matches its own
|
|
565
|
+
original no better than a stranger's face does.
|
|
566
|
+
|
|
567
|
+
Numbers on a fixed corpus mean the corpus no longer finds leaks — not
|
|
568
|
+
that leaks are impossible. The benchmark README lists every defect the
|
|
569
|
+
harness caught (all fixed and regression-pinned), and the rules for
|
|
570
|
+
quoting these numbers honestly. The shipped test suite
|
|
546
571
|
(`pytest test_openscrub.py`) exercises the same end-to-end pipeline on
|
|
547
572
|
synthetic videos — for this tool a regression is not a bug, it's a leak,
|
|
548
573
|
so the suite must stay green on every change.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# OpenScrub redaction benchmark
|
|
2
|
+
|
|
3
|
+
There is no industry certification for video redaction — nothing like
|
|
4
|
+
NIST FRVT for face recognition. So instead of quoting a number nobody
|
|
5
|
+
can check, everything here is **reproducible**: you run the same
|
|
6
|
+
commands and get your own numbers.
|
|
7
|
+
|
|
8
|
+
Two things are measured, because they answer different questions.
|
|
9
|
+
|
|
10
|
+
| Harness | Question |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `corpus.py` + `score.py` | Is planted PII still **readable** in the output? |
|
|
13
|
+
| `reid.py` | Can a face recognizer still **identify** the person? |
|
|
14
|
+
|
|
15
|
+
## Why not detection mAP
|
|
16
|
+
|
|
17
|
+
Detection benchmarks optimise a precision/recall balance. For redaction,
|
|
18
|
+
precision barely matters — over-blur is acceptable by design (fail
|
|
19
|
+
closed) — and per-frame recall understates the problem, because a face
|
|
20
|
+
missed in *one frame out of three hundred* is still a leak. So the
|
|
21
|
+
scorer never asks "did a detection fire?". It crops the rendered output
|
|
22
|
+
and asks whether the sensitive string can still be **read**, and whether
|
|
23
|
+
the face can still be **matched**. A leak counts as a leak even when the
|
|
24
|
+
report claims the region was covered.
|
|
25
|
+
|
|
26
|
+
## Text PII: corpus + scorer
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
python benchmark/corpus.py --out benchmark/_corpus
|
|
30
|
+
python benchmark/score.py --corpus benchmark/_corpus --engine onnx
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The corpus is synthetic and deterministic — same seed, same layout, same
|
|
34
|
+
footage for everyone. Every planted value comes from a **reserved or
|
|
35
|
+
documentation range**: 555 phone numbers, `example.com` (RFC 2606),
|
|
36
|
+
`192.0.2.0/24` (RFC 5737), the public `4111…` test card. The benchmark
|
|
37
|
+
for a privacy tool must not itself be a privacy problem.
|
|
38
|
+
|
|
39
|
+
Eight scenarios (corpus v2), 64 PII + 43 benign samples: a static
|
|
40
|
+
record screen, small text (needs the 2x re-OCR pass), PII on coloured
|
|
41
|
+
highlight bars (the classic OCR disruptor), scrolling notes at a known
|
|
42
|
+
rate, dark mode (light text on dark), heavy sensor noise, a 2.5°
|
|
43
|
+
rotated page (photographed-document tilt), and a brutally re-compressed
|
|
44
|
+
copy (120 kbps — screen-share/messenger quality).
|
|
45
|
+
|
|
46
|
+
Reported: **PII recall** (samples never readable in any sampled frame),
|
|
47
|
+
**frame leak rate** (region-frames that were readable), and **benign
|
|
48
|
+
preservation** (how much readable non-PII survived — the over-blur cost).
|
|
49
|
+
|
|
50
|
+
## Faces: re-identification attack
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
python benchmark/reid.py --video yourclip.mp4
|
|
54
|
+
python benchmark/reid.py --video yourclip.mp4 --mode mosaic --coverage concealed
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Finds faces in the original with a deliberately low threshold (the
|
|
58
|
+
oracle should over-find, never grade generously), redacts, then at each
|
|
59
|
+
oracle location asks whether a face is still detectable and whether its
|
|
60
|
+
SFace embedding still matches the identity taken from the original.
|
|
61
|
+
|
|
62
|
+
**Read the control line before quoting any result.** The harness also
|
|
63
|
+
compares original faces against *other original frames of the same
|
|
64
|
+
footage*. If the recognizer cannot link an unredacted face to another
|
|
65
|
+
unredacted frame of itself, then "the redacted face did not match"
|
|
66
|
+
proves nothing about the redaction — and the tool says so, loudly,
|
|
67
|
+
instead of letting you publish the number.
|
|
68
|
+
|
|
69
|
+
Supply your own footage or public-domain clips. Do not run this on real
|
|
70
|
+
people's faces and publish the frames.
|
|
71
|
+
|
|
72
|
+
## Measured: text PII
|
|
73
|
+
|
|
74
|
+
OpenScrub v1.0.78, CPU only (no GPU), corpus v2 (8 scenarios), 5 frames
|
|
75
|
+
sampled per planted value. Higher is better for recall and benign
|
|
76
|
+
preservation; lower is better for leak rate:
|
|
77
|
+
|
|
78
|
+
| OCR engine | PII recall | Frame leak rate | Benign preserved |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| ONNX PP-OCRv5 (the default on CPU/Intel/Windows) | 100.0 % | 0.00 % | 100.0 % |
|
|
81
|
+
| Tesseract | 100.0 % | 0.00 % | 97.7 % |
|
|
82
|
+
|
|
83
|
+
Every planted value — names, SSNs, phone numbers, addresses, cards,
|
|
84
|
+
emails, dates of birth, IP addresses — was unreadable in every sampled
|
|
85
|
+
frame of the rendered output, across all eight scenarios, on both
|
|
86
|
+
engines. The one benign loss (Tesseract) is "Follow-up in 6 weeks"
|
|
87
|
+
garbled under the 120 kbps compression — over-blur, the failure
|
|
88
|
+
direction this tool chooses on purpose.
|
|
89
|
+
|
|
90
|
+
A 100 % row on a fixed corpus means the corpus no longer finds leaks —
|
|
91
|
+
not that leaks are impossible. The honest reading is the history below.
|
|
92
|
+
|
|
93
|
+
### What building the benchmark caught (all fixed in 1.0.78)
|
|
94
|
+
|
|
95
|
+
The first runs of this harness measured the ONNX backend at **40.6 %
|
|
96
|
+
recall with a 57.5 % frame leak rate**. Every point of the gap traced to
|
|
97
|
+
a real, previously-invisible defect; each is now fixed and pinned by a
|
|
98
|
+
regression test:
|
|
99
|
+
|
|
100
|
+
- **Lost spaces.** PP-OCRv5's space class routinely loses the argmax to
|
|
101
|
+
blank at word gaps, welding `SSN 123-45-6789` into `SSN123-45-6789` —
|
|
102
|
+
every multi-token pattern silently stopped matching.
|
|
103
|
+
- **Full-width punctuation.** The Chinese-first vocabulary returned
|
|
104
|
+
`(555)013-8842` — visually identical to a reader, matched by no regex.
|
|
105
|
+
- **Shredded card numbers.** Compression and noise fragment a spaced
|
|
106
|
+
PAN into arbitrary digit chunks (`41 11 11 …`, `4111. 1111-1111`);
|
|
107
|
+
the fixed-format matcher missed them. Fragments now join under a
|
|
108
|
+
Luhn + brand-prefix gate.
|
|
109
|
+
- **Column-spanning over-blur.** Two-column layouts merged into one OCR
|
|
110
|
+
line, so an address detection's blur swallowed unrelated text a page
|
|
111
|
+
width away (benign preservation cost, found by the corpus's benign
|
|
112
|
+
samples).
|
|
113
|
+
- **Tilted-line grouping.** On the 2.5° rotated page, line grouping by
|
|
114
|
+
average y-center split a first name from its surname and the name was
|
|
115
|
+
never detected at all.
|
|
116
|
+
- **Uncovered tails.** Under heavy compression the last scans read only
|
|
117
|
+
fragments of a card number — real evidence the string was still on
|
|
118
|
+
screen, but not a full match, so the blur ended ~0.7 s early.
|
|
119
|
+
|
|
120
|
+
## Measured: face re-identification
|
|
121
|
+
|
|
122
|
+
OpenScrub v1.0.78, default blur mode, SFace matcher, same-person
|
|
123
|
+
threshold 0.55. "Re-identified" = the redacted face still matched the
|
|
124
|
+
identity embedding taken from the unredacted original. All three rows
|
|
125
|
+
have a valid control (the recognizer reliably re-identifies the same
|
|
126
|
+
faces in the *unredacted* footage, so a 0 % result is meaningful):
|
|
127
|
+
|
|
128
|
+
| Footage | Faces attacked | Re-identified | Still detected as a face | Control |
|
|
129
|
+
|---|---|---|---|---|
|
|
130
|
+
| Handheld 1080p party clip, five subjects, constant motion ([Pexels 7100826](https://www.pexels.com/video/7100826/), downscaled from 4K) | 125 | **0 (0.0 %)** | 0.0 % | 0.888 |
|
|
131
|
+
| The same party clip at native 4K (3840×2160 — larger faces, more recoverable detail) | 128 | **0 (0.0 %)** | 0.0 % | 0.887 |
|
|
132
|
+
| Public-domain interview, large frontal close-up faces, 1080×1920 ([Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Interview_with_a_Marshall_Center_professor_(999984).webm)) | 133 | **0 (0.0 %)** | 0.7 % | 0.691 |
|
|
133
|
+
| Same party clip, `--mode mosaic` | 125 | **0 (0.0 %)** | 4.8 % | 0.888 |
|
|
134
|
+
|
|
135
|
+
Mean post-redaction similarity sat **below the cross-person chance
|
|
136
|
+
floor** in all three runs — a redacted face matches its own original no
|
|
137
|
+
better than a random stranger's face does.
|
|
138
|
+
|
|
139
|
+
This table also records a caught defect: the interview clip's close-up
|
|
140
|
+
faces (200–750 px) were initially re-identified at **8.3 % straight
|
|
141
|
+
through the old blur** — a large face keeps enough low-frequency
|
|
142
|
+
identity when the blur kernel is 1/3 of the region. The kernel is now
|
|
143
|
+
2/3 of the region (pinned by test), which also drove
|
|
144
|
+
"still detected as a face" from 99.2 % to 0 % on the party clip.
|
|
145
|
+
|
|
146
|
+
Two candidate clips (a street scene with small oblique faces, a
|
|
147
|
+
multi-person interview) produced **invalid controls** — the recognizer
|
|
148
|
+
could not re-identify unredacted faces in them — and the harness
|
|
149
|
+
refused to grade them. That refusal is the feature.
|
|
150
|
+
|
|
151
|
+
## Rules for quoting these numbers
|
|
152
|
+
|
|
153
|
+
- Always state **version, engine, corpus version, and hardware**. A
|
|
154
|
+
number without them is not a claim, it is a vibe.
|
|
155
|
+
- Never say "HIPAA compliant" or "GDPR compliant" — tools are not
|
|
156
|
+
compliant, processes are.
|
|
157
|
+
- Never say 100 %, "guaranteed", or "fully anonymised".
|
|
158
|
+
- Publish the failures next to the successes. A tool that says where it
|
|
159
|
+
struggles is the one worth believing.
|
|
@@ -36,7 +36,7 @@ from dataclasses import dataclass, asdict
|
|
|
36
36
|
import cv2
|
|
37
37
|
import numpy as np
|
|
38
38
|
|
|
39
|
-
VERSION = "1.0.
|
|
39
|
+
VERSION = "1.0.78"
|
|
40
40
|
|
|
41
41
|
# ----------------------------------------------------------------------------
|
|
42
42
|
# OCR backends
|
|
@@ -481,33 +481,65 @@ def group_lines(words):
|
|
|
481
481
|
{text, words: [(word, box, conf, char_start, char_end)]}"""
|
|
482
482
|
if not words:
|
|
483
483
|
return []
|
|
484
|
-
|
|
484
|
+
# Sweep words left-to-right and chain each onto the line whose
|
|
485
|
+
# RIGHTMOST member's y-center is within 0.7x line height. Comparing to
|
|
486
|
+
# the local neighbour (not the line's average y) makes grouping follow
|
|
487
|
+
# tilted text: on a 2.5deg-rotated page the two ends of one text row
|
|
488
|
+
# differ by ~50px — far more than a line height — so an average-y band
|
|
489
|
+
# seeded at one end rejected words at the other ('Marguerite' was
|
|
490
|
+
# orphaned from 'Vandersloot' and the name went undetected on the
|
|
491
|
+
# rotated benchmark), while adjacent words shear by only ~1px per 25px
|
|
492
|
+
# of gap and always chain.
|
|
493
|
+
items = sorted(words, key=lambda w: (w[1][0], (w[1][1] + w[1][3]) / 2))
|
|
485
494
|
lines = []
|
|
486
495
|
for w in items:
|
|
487
496
|
yc = (w[1][1] + w[1][3]) / 2
|
|
488
497
|
h = max(w[1][3] - w[1][1], 1)
|
|
489
|
-
|
|
498
|
+
best = None
|
|
499
|
+
best_gap = None
|
|
490
500
|
for ln in lines:
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
501
|
+
m = ln["raw"][-1] # rightmost so far
|
|
502
|
+
myc = (m[1][1] + m[1][3]) / 2
|
|
503
|
+
mh = max(m[1][3] - m[1][1], 1)
|
|
504
|
+
if abs(yc - myc) < 0.7 * max(h, mh):
|
|
505
|
+
gap = w[1][0] - m[1][2]
|
|
506
|
+
if best is None or gap < best_gap:
|
|
507
|
+
best, best_gap = ln, gap
|
|
508
|
+
if best is not None:
|
|
509
|
+
best["raw"].append(w)
|
|
510
|
+
best["h"] = max(best["h"], h)
|
|
511
|
+
else:
|
|
498
512
|
lines.append({"raw": [w], "yc": yc, "h": h})
|
|
513
|
+
lines.sort(key=lambda ln: sum((m[1][1] + m[1][3]) / 2
|
|
514
|
+
for m in ln["raw"]) / len(ln["raw"]))
|
|
499
515
|
out = []
|
|
500
516
|
for ln in lines:
|
|
501
517
|
ln["raw"].sort(key=lambda w: w[1][0])
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
518
|
+
# split at column-sized horizontal gaps: two-column layouts land on
|
|
519
|
+
# the same y-band, and a merged line makes every line-level match
|
|
520
|
+
# (address box, spaCy NER context) span BOTH columns — the blur then
|
|
521
|
+
# swallows unrelated text a page-width away. A gap wider than 2.5x
|
|
522
|
+
# the line height (same scale as the name-pair adjacency rule) is a
|
|
523
|
+
# column boundary, not a word gap. PII values never span columns,
|
|
524
|
+
# so splitting can only tighten boxes, never lose a match.
|
|
525
|
+
segments = [[]]
|
|
526
|
+
prev_x2 = None
|
|
527
|
+
for w in ln["raw"]:
|
|
528
|
+
if (prev_x2 is not None
|
|
529
|
+
and w[1][0] - prev_x2 > 2.5 * max(ln["h"], 10)):
|
|
530
|
+
segments.append([])
|
|
531
|
+
segments[-1].append(w)
|
|
532
|
+
prev_x2 = w[1][2]
|
|
533
|
+
for seg in segments:
|
|
534
|
+
text = ""
|
|
535
|
+
entries = []
|
|
536
|
+
for w, box, conf in seg:
|
|
537
|
+
if text:
|
|
538
|
+
text += " "
|
|
539
|
+
start = len(text)
|
|
540
|
+
text += w
|
|
541
|
+
entries.append((w, box, conf, start, len(text)))
|
|
542
|
+
out.append({"text": text, "words": entries})
|
|
511
543
|
return out
|
|
512
544
|
|
|
513
545
|
|
|
@@ -612,11 +644,32 @@ class NameDetector:
|
|
|
612
644
|
if w.strip(".,:;()[]").lower() in self.extra:
|
|
613
645
|
hits.append((box, w))
|
|
614
646
|
|
|
615
|
-
# --- 4. capitalized-pair heuristic
|
|
616
|
-
|
|
647
|
+
# --- 4. capitalized-pair heuristic ---
|
|
648
|
+
# UNION, not fallback: spaCy's small model is blind to
|
|
649
|
+
# uncommon names with no sentence context — on a records
|
|
650
|
+
# screen "Marguerite Vandersloot" returned NO entities at
|
|
651
|
+
# all (a real benchmarked leak), so NER availability must
|
|
652
|
+
# never disable this detector (same union principle as the
|
|
653
|
+
# face models: an extra detector may only ADD coverage).
|
|
654
|
+
# With NER present the pair test tightens to non-ALLCAPS
|
|
655
|
+
# tokens so headers ("PATIENT RECORD") don't flood review;
|
|
656
|
+
# without NER the historical broader behavior stands.
|
|
657
|
+
if self.heuristic or self.nlp is not None:
|
|
658
|
+
strict = self.nlp is not None and not self.heuristic
|
|
617
659
|
for i in range(len(words) - 1):
|
|
618
660
|
w1, b1 = words[i][0], words[i][1]
|
|
619
661
|
w2, b2 = words[i + 1][0], words[i + 1][1]
|
|
662
|
+
if strict and (w1.strip(".,:;()[]").isupper()
|
|
663
|
+
or w2.strip(".,:;()[]").isupper()):
|
|
664
|
+
continue
|
|
665
|
+
# a real "First Last" pair sits within a couple of
|
|
666
|
+
# character-heights; line reconstruction also glues
|
|
667
|
+
# COLUMNS together, and the resulting fake pairs
|
|
668
|
+
# ("Vandersloot | Ward", "Road | Priority") blurred
|
|
669
|
+
# benign text two columns away (benchmarked)
|
|
670
|
+
gap = b2[0] - b1[2]
|
|
671
|
+
if gap > 2.5 * max(b1[3] - b1[1], b2[3] - b2[1], 8):
|
|
672
|
+
continue
|
|
620
673
|
pair = False
|
|
621
674
|
if self._namey(w1) and self._namey(w2):
|
|
622
675
|
pair = True
|
|
@@ -810,6 +863,55 @@ def detect_phi(words, lines, t, offset, namer, mrn_re, custom_res=()):
|
|
|
810
863
|
"card", joined, min(g[2] for g in grp))
|
|
811
864
|
break
|
|
812
865
|
|
|
866
|
+
# shredded card: OCR can fragment a spaced PAN into arbitrary digit
|
|
867
|
+
# chunks ("41 11 11 11 1 1 11 11 11" — a real read of a highlighted
|
|
868
|
+
# card row under video compression), which the fixed 3/4-group join
|
|
869
|
+
# above cannot see. Join maximal runs of consecutive same-row digit
|
|
870
|
+
# tokens; any 13-16 digit concatenation that carries a card-brand
|
|
871
|
+
# prefix AND passes Luhn is a card. Luhn is the false-positive
|
|
872
|
+
# gate — an arbitrary digit run fails it 9 times out of 10.
|
|
873
|
+
digit_run = []
|
|
874
|
+
for w in words + [("", (0, 0, 0, 0), 0.0)]: # sentinel flushes
|
|
875
|
+
tok = re.sub(r"\D", "", w[0])
|
|
876
|
+
# digits with incidental punctuation still count: motion blur
|
|
877
|
+
# turned one group into "1111." and the stray period broke every
|
|
878
|
+
# join (a real scrolling-card leak), and dark/noisy footage welds
|
|
879
|
+
# neighbouring groups into "1111.1111" (8 digits, 9 chars) — the
|
|
880
|
+
# old {1,8}/6-digit caps split the run right there and leaked the
|
|
881
|
+
# card. Any punctuated token up to a full welded PAN (16 digits)
|
|
882
|
+
# may join; Luhn + brand prefix stay the false-positive gate.
|
|
883
|
+
digity = (bool(tok) and len(tok) <= 16
|
|
884
|
+
and bool(re.fullmatch(r"[\d.,:;\-]{1,19}", w[0])))
|
|
885
|
+
same_row = (digit_run
|
|
886
|
+
and abs((w[1][1] + w[1][3]) - (digit_run[-1][1][1]
|
|
887
|
+
+ digit_run[-1][1][3])) / 2
|
|
888
|
+
< max(8, digit_run[-1][1][3] - digit_run[-1][1][1]))
|
|
889
|
+
if digity and (not digit_run or same_row):
|
|
890
|
+
digit_run.append(w)
|
|
891
|
+
continue
|
|
892
|
+
if digit_run:
|
|
893
|
+
for a in range(len(digit_run)):
|
|
894
|
+
digits = ""
|
|
895
|
+
for z in range(a, len(digit_run)):
|
|
896
|
+
digits += re.sub(r"\D", "", digit_run[z][0])
|
|
897
|
+
if len(digits) > 16:
|
|
898
|
+
break
|
|
899
|
+
if len(digits) >= 13:
|
|
900
|
+
grouped = " ".join(digits[k:k + 4]
|
|
901
|
+
for k in range(0, len(digits), 4))
|
|
902
|
+
if RE_CARD.search(grouped) and _luhn_ok(digits):
|
|
903
|
+
bx = [q[1] for q in digit_run[a:z + 1]]
|
|
904
|
+
add((min(b[0] for b in bx),
|
|
905
|
+
min(b[1] for b in bx),
|
|
906
|
+
max(b[2] for b in bx),
|
|
907
|
+
max(b[3] for b in bx)), "card", digits,
|
|
908
|
+
min(q[2] for q in digit_run[a:z + 1]))
|
|
909
|
+
a = None
|
|
910
|
+
break
|
|
911
|
+
if a is None:
|
|
912
|
+
break
|
|
913
|
+
digit_run = [w] if digity else []
|
|
914
|
+
|
|
813
915
|
# split-across-words phone: "(501)" "555-0142"
|
|
814
916
|
for i in range(len(words) - 1):
|
|
815
917
|
joined = words[i][0] + " " + words[i + 1][0]
|
|
@@ -2510,6 +2612,46 @@ def merge_detections(dets, hold, scans=None, bridge_gap=4.0, fuzz=None,
|
|
|
2510
2612
|
break
|
|
2511
2613
|
else:
|
|
2512
2614
|
merged.append(d)
|
|
2615
|
+
|
|
2616
|
+
# Tail extension: a span ends `hold` after its LAST positive detection,
|
|
2617
|
+
# but the text often outlives that detection — under heavy compression
|
|
2618
|
+
# the final scans read only fragments ('4111.' '1111-1111' '1111'),
|
|
2619
|
+
# which fail the full pattern yet are strong evidence the string is
|
|
2620
|
+
# still on screen (the benchmark's compressed card leaked exactly this
|
|
2621
|
+
# tail). Walk the scans PAST each span's last sighting: while a scan's
|
|
2622
|
+
# words in the region fuzzy-match the span text (the same same-word
|
|
2623
|
+
# test contradicted() uses), the content verifiably persisted — extend
|
|
2624
|
+
# coverage through that scan. A scan with no matching read stops the
|
|
2625
|
+
# walk, so a real scene change never accretes stale blur.
|
|
2626
|
+
if scans and fuzz:
|
|
2627
|
+
for m in merged:
|
|
2628
|
+
if getattr(m, "dense", False) or not m.text:
|
|
2629
|
+
continue
|
|
2630
|
+
mx1, my1, mx2, my2 = m.cbox
|
|
2631
|
+
mt = PhiMemory.norm(m.text)
|
|
2632
|
+
for st, _cum, words in scans:
|
|
2633
|
+
if st <= m.last_seen + 0.01:
|
|
2634
|
+
continue
|
|
2635
|
+
if st - m.last_seen > bridge_gap:
|
|
2636
|
+
break
|
|
2637
|
+
support = False
|
|
2638
|
+
for txt, (x1, y1, x2, y2), conf in words:
|
|
2639
|
+
if conf < 0.5:
|
|
2640
|
+
continue
|
|
2641
|
+
cxm = (x1 + x2) / 2
|
|
2642
|
+
cym = (y1 + y2) / 2
|
|
2643
|
+
if not (mx1 - 6 <= cxm <= mx2 + 6
|
|
2644
|
+
and my1 - 4 <= cym <= my2 + 4):
|
|
2645
|
+
continue
|
|
2646
|
+
n = PhiMemory.norm(txt)
|
|
2647
|
+
if len(n) >= 3 and (fuzz.ratio(n, mt) >= 70
|
|
2648
|
+
or fuzz.partial_ratio(n, mt) >= 85):
|
|
2649
|
+
support = True
|
|
2650
|
+
break
|
|
2651
|
+
if not support:
|
|
2652
|
+
break
|
|
2653
|
+
m.last_seen = st
|
|
2654
|
+
m.t_end = max(m.t_end, st + hold)
|
|
2513
2655
|
return merged
|
|
2514
2656
|
|
|
2515
2657
|
|
|
@@ -2594,7 +2736,14 @@ def blur_region(frame, x1, y1, x2, y2, mode, shape="rect"):
|
|
|
2594
2736
|
filled = cv2.resize(small, (x2 - x1, y2 - y1),
|
|
2595
2737
|
interpolation=cv2.INTER_NEAREST)
|
|
2596
2738
|
else:
|
|
2597
|
-
|
|
2739
|
+
# kernel = 2/3 region width (was 1/3): the re-identification
|
|
2740
|
+
# benchmark showed close-up faces (200-750px) surviving a 1/3-width
|
|
2741
|
+
# blur at SFace similarity 0.57-0.69 — above the 0.55 same-person
|
|
2742
|
+
# bar. At 2/3 width the same faces measure 0.11-0.23, at or below
|
|
2743
|
+
# the cross-person chance floor: a blurred face carries no more
|
|
2744
|
+
# identity than a random stranger's. Small faces were already safe
|
|
2745
|
+
# (the 112px embed crop upsamples them); this closes the close-up.
|
|
2746
|
+
k = max(31, ((2 * (x2 - x1) // 3) | 1))
|
|
2598
2747
|
filled = _gauss_big(roi, k)
|
|
2599
2748
|
if shape == "ellipse":
|
|
2600
2749
|
# elliptical mask hugs a face: no smeared background corners, which
|
|
@@ -2673,7 +2822,7 @@ def blur_silhouette(frame, x1, y1, x2, y2, mode, polys, poly_box,
|
|
|
2673
2822
|
interpolation=cv2.INTER_LINEAR)
|
|
2674
2823
|
filled = cv2.resize(small, (rw, rh), interpolation=cv2.INTER_NEAREST)
|
|
2675
2824
|
else:
|
|
2676
|
-
k = max(31, ((rw // 3) | 1))
|
|
2825
|
+
k = max(31, ((2 * rw // 3) | 1))
|
|
2677
2826
|
filled = _gauss_big(roi, k)
|
|
2678
2827
|
cv2.copyTo(filled, mask, roi)
|
|
2679
2828
|
|
|
@@ -2981,7 +3130,7 @@ def _blur_yuv10(y, u, v, x1, y1, x2, y2, mode, shape="rect"):
|
|
|
2981
3130
|
filled = cv2.resize(small, (px2 - px1, py2 - py1),
|
|
2982
3131
|
interpolation=cv2.INTER_NEAREST)
|
|
2983
3132
|
else:
|
|
2984
|
-
k = max(kmin, (((px2 - px1) // 3) | 1))
|
|
3133
|
+
k = max(kmin, ((2 * (px2 - px1) // 3) | 1))
|
|
2985
3134
|
filled = _gauss_big(roi, k)
|
|
2986
3135
|
if shape == "ellipse":
|
|
2987
3136
|
rw, rh = px2 - px1, py2 - py1
|
|
@@ -3061,7 +3210,7 @@ def _blur_silhouette_yuv10(y, u, v, x1, y1, x2, y2, mode, polys,
|
|
|
3061
3210
|
filled = cv2.resize(small, (px2 - px1, py2 - py1),
|
|
3062
3211
|
interpolation=cv2.INTER_NEAREST)
|
|
3063
3212
|
else:
|
|
3064
|
-
k = max(kmin, (((px2 - px1) // 3) | 1))
|
|
3213
|
+
k = max(kmin, ((2 * (px2 - px1) // 3) | 1))
|
|
3065
3214
|
filled = _gauss_big(roi, k)
|
|
3066
3215
|
m = mask
|
|
3067
3216
|
if m.shape != roi.shape: # chroma planes are half-res
|
|
@@ -4289,21 +4438,83 @@ class QRDetector:
|
|
|
4289
4438
|
return _nms_boxes(out, 0.4)
|
|
4290
4439
|
|
|
4291
4440
|
|
|
4292
|
-
|
|
4441
|
+
_FULLWIDTH = {ord(c): ord(a) for c, a in zip(
|
|
4442
|
+
"0123456789()-.,:/@#",
|
|
4443
|
+
"0123456789()-.,:/@#")}
|
|
4444
|
+
_FULLWIDTH[0x3000] = 0x20 # ideographic space
|
|
4445
|
+
|
|
4446
|
+
|
|
4447
|
+
def _ascii_forms(s):
|
|
4448
|
+
"""Map full-width/CJK punctuation and digits to their ASCII forms
|
|
4449
|
+
(pure, unit-tested). PP-OCRv5's vocabulary is Chinese-first and it
|
|
4450
|
+
returns '(555)013-8842' for a plain US phone number — no PII regex
|
|
4451
|
+
matches that, so a real phone number sailed through untouched. The
|
|
4452
|
+
glyphs are visually identical to a reader; only the code points
|
|
4453
|
+
differ, so normalizing costs nothing and closes the hole."""
|
|
4454
|
+
return s.translate(_FULLWIDTH)
|
|
4455
|
+
|
|
4456
|
+
|
|
4457
|
+
def _ctc_greedy_decode(logits, charmap, space_thresh=0.01, with_pos=False):
|
|
4293
4458
|
"""Greedy CTC decode (pure, unit-tested) for the PP-OCR recognizer.
|
|
4294
4459
|
Per-timestep argmax, collapse consecutive repeats, drop blank
|
|
4295
4460
|
(index 0), map indices through charmap. `charmap[0]` is the blank.
|
|
4296
|
-
Returns (text, mean confidence over the kept, non-blank steps).
|
|
4461
|
+
Returns (text, mean confidence over the kept, non-blank steps).
|
|
4462
|
+
|
|
4463
|
+
SPACE RECOVERY: PP-OCRv5's space class routinely loses the argmax to
|
|
4464
|
+
blank at real word gaps, so a pure argmax decode welds words
|
|
4465
|
+
together — "SSN 123-45-6789" came back as "SSN123-45-6789" and
|
|
4466
|
+
"4210 Kestrel Hollow Road" as "4210KestrelHollowRoad", which breaks
|
|
4467
|
+
EVERY multi-token PII regex (address/phone/ssn silently stopped
|
|
4468
|
+
matching once this backend became the CPU/Intel/Windows default).
|
|
4469
|
+
The signal is there underneath: measured on real frames, the peak
|
|
4470
|
+
space-class probability inside a word-gap blank run ranges 0.013 to
|
|
4471
|
+
0.165 while inside a word it stays at or below 0.001 — at least a
|
|
4472
|
+
10x separation everywhere it was measured (the first threshold,
|
|
4473
|
+
0.05, sat INSIDE the word-gap range and welded exactly the rows
|
|
4474
|
+
whose gap signal was weakest: small text — "SSN123-45-6789" again).
|
|
4475
|
+
So when a blank run between two characters carries space probability
|
|
4476
|
+
above space_thresh, emit the space the argmax dropped. Languages
|
|
4477
|
+
that do not use spaces are unaffected (space probability stays flat).
|
|
4478
|
+
|
|
4479
|
+
with_pos=True additionally returns the per-character TIMESTEP of
|
|
4480
|
+
each emitted char (recovered spaces get their blank run's start).
|
|
4481
|
+
Timesteps map linearly onto the crop width, which gives true
|
|
4482
|
+
per-word boxes downstream — the proportional character-count split
|
|
4483
|
+
used before drifted off the real word positions and blur landed
|
|
4484
|
+
beside the text it was covering."""
|
|
4297
4485
|
idx = logits.argmax(1)
|
|
4298
4486
|
prob = logits.max(1)
|
|
4299
|
-
|
|
4487
|
+
sp = (len(charmap) - 1) if (charmap and charmap[-1] == " ") else -1
|
|
4488
|
+
chars, confs, pos, prev = [], [], [], -1
|
|
4489
|
+
run = None # start index of the current blank run
|
|
4300
4490
|
for t in range(len(idx)):
|
|
4301
4491
|
i = int(idx[t])
|
|
4302
|
-
if i
|
|
4492
|
+
if i == 0:
|
|
4493
|
+
if run is None:
|
|
4494
|
+
run = t
|
|
4495
|
+
prev = i
|
|
4496
|
+
continue
|
|
4497
|
+
if i != prev and i < len(charmap):
|
|
4498
|
+
# any nonempty blank run can carry a space: a tight det crop
|
|
4499
|
+
# compresses the word gap to a SINGLE timestep (measured
|
|
4500
|
+
# p=0.02-0.065 there, still ~10x above the within-word
|
|
4501
|
+
# ceiling of ~0.001) — requiring 2+ steps welded exactly
|
|
4502
|
+
# those rows
|
|
4503
|
+
if (sp > 0 and chars and run is not None and t - run >= 1
|
|
4504
|
+
and charmap[i] != " " and chars[-1] != " "
|
|
4505
|
+
and float(logits[run:t, sp].max()) > space_thresh):
|
|
4506
|
+
chars.append(" ")
|
|
4507
|
+
pos.append(run)
|
|
4303
4508
|
chars.append(charmap[i])
|
|
4304
4509
|
confs.append(float(prob[t]))
|
|
4510
|
+
pos.append(t)
|
|
4511
|
+
run = None
|
|
4305
4512
|
prev = i
|
|
4306
|
-
|
|
4513
|
+
text = "".join(chars)
|
|
4514
|
+
conf = float(np.mean(confs)) if confs else 0.0
|
|
4515
|
+
if with_pos:
|
|
4516
|
+
return text, conf, pos
|
|
4517
|
+
return text, conf
|
|
4307
4518
|
|
|
4308
4519
|
|
|
4309
4520
|
def _decode_db(prob, w, h, nw, nh, thresh=0.3, box_thresh=0.5, pad_frac=0.55):
|
|
@@ -4452,12 +4663,14 @@ class OnnxOcrBackend(OcrBackend):
|
|
|
4452
4663
|
return _decode_db(prob, w, h, nw, nh, 0.3, 0.5, pad_frac=0.12)
|
|
4453
4664
|
|
|
4454
4665
|
def _read_line(self, crop):
|
|
4455
|
-
"""Recognize one text-line crop (BGR) -> (text, mean confidence
|
|
4456
|
-
PP-OCR rec: resize to H=48
|
|
4457
|
-
[-1,1], greedy CTC decode
|
|
4666
|
+
"""Recognize one text-line crop (BGR) -> (text, mean confidence,
|
|
4667
|
+
char timesteps, total timesteps). PP-OCR rec: resize to H=48
|
|
4668
|
+
keeping aspect, normalize BGR to [-1,1], greedy CTC decode
|
|
4669
|
+
(collapse repeats, drop blank). The timesteps map linearly onto
|
|
4670
|
+
the crop width, so callers can place each WORD exactly."""
|
|
4458
4671
|
h, w = crop.shape[:2]
|
|
4459
4672
|
if h < 2 or w < 2:
|
|
4460
|
-
return "", 0.0
|
|
4673
|
+
return "", 0.0, [], 1
|
|
4461
4674
|
rw = max(16, min(2000, int(round(48.0 * w / h))))
|
|
4462
4675
|
img = cv2.resize(crop, (rw, 48)).astype(np.float32)
|
|
4463
4676
|
img = img.transpose(2, 0, 1) / 255.0
|
|
@@ -4466,8 +4679,9 @@ class OnnxOcrBackend(OcrBackend):
|
|
|
4466
4679
|
y = self.rec.run(None, {self._rin:
|
|
4467
4680
|
img[None].astype(np.float32)})[0][0]
|
|
4468
4681
|
except Exception:
|
|
4469
|
-
return "", 0.0
|
|
4470
|
-
|
|
4682
|
+
return "", 0.0, [], 1
|
|
4683
|
+
txt, conf, pos = _ctc_greedy_decode(y, self.charmap, with_pos=True)
|
|
4684
|
+
return txt, conf, pos, max(1, len(y))
|
|
4471
4685
|
|
|
4472
4686
|
def read(self, frame):
|
|
4473
4687
|
out = []
|
|
@@ -4477,20 +4691,32 @@ class OnnxOcrBackend(OcrBackend):
|
|
|
4477
4691
|
x2 = min(W, int(x2)); y2 = min(H, int(y2))
|
|
4478
4692
|
if x2 - x1 < 6 or y2 - y1 < 6:
|
|
4479
4693
|
continue
|
|
4480
|
-
txt, conf = self._read_line(frame[y1:y2, x1:x2])
|
|
4481
|
-
|
|
4482
|
-
if not
|
|
4694
|
+
txt, conf, pos, T = self._read_line(frame[y1:y2, x1:x2])
|
|
4695
|
+
txt = _ascii_forms(txt) # 1:1 mapping — positions stay aligned
|
|
4696
|
+
if not txt.strip():
|
|
4483
4697
|
continue
|
|
4484
|
-
# line box -> per-word boxes
|
|
4485
|
-
#
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4698
|
+
# line box -> per-word boxes from the CTC TIMESTEPS: the
|
|
4699
|
+
# recognizer says exactly where each character sits across
|
|
4700
|
+
# the crop, so each word gets its true x-range (the old
|
|
4701
|
+
# proportional character-count split drifted off the real
|
|
4702
|
+
# positions and blur landed beside the text). Half a
|
|
4703
|
+
# timestep cell of margin on each side covers glyph edges.
|
|
4704
|
+
span = float(x2) - float(x1)
|
|
4705
|
+
cell = span / T
|
|
4706
|
+
i = 0
|
|
4707
|
+
while i < len(txt):
|
|
4708
|
+
if txt[i] == " ":
|
|
4709
|
+
i += 1
|
|
4710
|
+
continue
|
|
4711
|
+
j = i
|
|
4712
|
+
while j < len(txt) and txt[j] != " ":
|
|
4713
|
+
j += 1
|
|
4714
|
+
w = txt[i:j]
|
|
4715
|
+
wx1 = x1 + pos[i] * cell - 0.8 * cell
|
|
4716
|
+
wx2 = x1 + (pos[j - 1] + 1) * cell + 0.8 * cell
|
|
4717
|
+
out.append((w, (int(max(x1, wx1)), int(y1),
|
|
4718
|
+
int(min(x2, wx2)), int(y2)), conf))
|
|
4719
|
+
i = j
|
|
4494
4720
|
return out
|
|
4495
4721
|
|
|
4496
4722
|
|
|
@@ -2420,9 +2420,17 @@ async function removeCerts(){
|
|
|
2420
2420
|
async function updCheck(){
|
|
2421
2421
|
try{
|
|
2422
2422
|
const d=await (await fetch("/api/update_check")).json();
|
|
2423
|
-
if(d.available)
|
|
2424
|
-
|
|
2425
|
-
|
|
2423
|
+
if(!d.available)return;
|
|
2424
|
+
const el=document.getElementById("upd");
|
|
2425
|
+
if(d.can_update===false){
|
|
2426
|
+
// Docker/Podman: the in-place updater would write into the ephemeral
|
|
2427
|
+
// container and be lost on recreate — inform only, never offer.
|
|
2428
|
+
el.innerHTML='· <span style="color:#fbbf24" title="Pull the new image tag and recreate the container — job data lives in the mounted volume and is preserved.">v'
|
|
2429
|
+
+d.latest+' available — pull the new image to update</span> ';
|
|
2430
|
+
}else{
|
|
2431
|
+
el.innerHTML='· <a href="#" style="color:#fbbf24" onclick="updRun();return false">v'
|
|
2432
|
+
+d.latest+' available — update</a> ';
|
|
2433
|
+
}
|
|
2426
2434
|
}catch(e){}
|
|
2427
2435
|
}
|
|
2428
2436
|
async function updRun(){
|
|
@@ -2546,11 +2554,21 @@ UPD = {"running": False, "log": [], "ok": None, "cache": (0.0, None)}
|
|
|
2546
2554
|
UPD_LOCK = threading.Lock()
|
|
2547
2555
|
|
|
2548
2556
|
|
|
2557
|
+
def _in_container():
|
|
2558
|
+
"""True inside Docker/Podman. The in-place updater writes into the
|
|
2559
|
+
container's ephemeral filesystem — the "update" would vanish on the
|
|
2560
|
+
next recreate while the UI claimed success. Containers update by
|
|
2561
|
+
pulling the new image, so the web UI must inform, never offer."""
|
|
2562
|
+
return (os.environ.get("OPENSCRUB_IN_DOCKER", "") not in ("", "0")
|
|
2563
|
+
or os.path.exists("/.dockerenv")
|
|
2564
|
+
or os.path.exists("/run/.containerenv"))
|
|
2565
|
+
|
|
2566
|
+
|
|
2549
2567
|
@app.route("/api/update_check")
|
|
2550
2568
|
def update_check():
|
|
2551
2569
|
if openscrub_update is None:
|
|
2552
2570
|
return jsonify({"current": openscrub.VERSION, "latest": None,
|
|
2553
|
-
"available": False})
|
|
2571
|
+
"available": False, "can_update": False})
|
|
2554
2572
|
now = time.time()
|
|
2555
2573
|
with UPD_LOCK:
|
|
2556
2574
|
ts, latest = UPD["cache"]
|
|
@@ -2564,11 +2582,16 @@ def update_check():
|
|
|
2564
2582
|
openscrub.VERSION))
|
|
2565
2583
|
return jsonify({"current": openscrub.VERSION,
|
|
2566
2584
|
"latest": latest["version"] if latest else None,
|
|
2567
|
-
"available": avail
|
|
2585
|
+
"available": avail,
|
|
2586
|
+
"can_update": not _in_container()})
|
|
2568
2587
|
|
|
2569
2588
|
|
|
2570
2589
|
@app.route("/api/update_run", methods=["POST"])
|
|
2571
2590
|
def update_run():
|
|
2591
|
+
if _in_container():
|
|
2592
|
+
abort(400, "running in a container — update by pulling the new "
|
|
2593
|
+
"image and recreating the container (job data lives in "
|
|
2594
|
+
"the mounted volume and is preserved)")
|
|
2572
2595
|
if openscrub_update is None:
|
|
2573
2596
|
abort(400, "updater module not available")
|
|
2574
2597
|
with JOBS_LOCK:
|
|
@@ -2203,7 +2203,12 @@ def test_ctc_greedy_decode():
|
|
|
2203
2203
|
missed repeat-collapse turns recognized text into garbage."""
|
|
2204
2204
|
charmap = ["", "A", "B", "C", " "] # 0=blank, 4=space
|
|
2205
2205
|
def step(i, n=5, p=0.9):
|
|
2206
|
-
|
|
2206
|
+
# off-target mass stays at 0.001 — the measured within-word
|
|
2207
|
+
# ceiling of the real model. Spreading (1-p) evenly put 0.025 on
|
|
2208
|
+
# the space class at every step, which no real frame does, and
|
|
2209
|
+
# tripped the space-recovery threshold (a fixture bug, not an
|
|
2210
|
+
# engine bug).
|
|
2211
|
+
v = np.full(n, 0.001)
|
|
2207
2212
|
v[i] = p
|
|
2208
2213
|
return v
|
|
2209
2214
|
# A A blank B -> "AB": the repeated A collapses, the blank drops
|
|
@@ -2280,6 +2285,234 @@ def test_docker_prefetch_matches_engine_pins():
|
|
|
2280
2285
|
assert pf.MODELS == expect
|
|
2281
2286
|
|
|
2282
2287
|
|
|
2288
|
+
def test_ocr_space_recovery_and_ascii_forms():
|
|
2289
|
+
"""Two PP-OCRv5 quirks that silently broke PII detection on the
|
|
2290
|
+
ONNX OCR backend — the DEFAULT on CPU/Intel/Windows since 1.0.69 —
|
|
2291
|
+
found by the benchmark harness:
|
|
2292
|
+
|
|
2293
|
+
1. The space class routinely loses the argmax to blank at real word
|
|
2294
|
+
gaps, welding "SSN 123-45-6789" into "SSN123-45-6789" and
|
|
2295
|
+
"4210 Kestrel Hollow Road" into one token, so every multi-token
|
|
2296
|
+
regex (address/phone/ssn) stopped matching. The space signal is
|
|
2297
|
+
still present in the blank run and must be recovered.
|
|
2298
|
+
2. Its Chinese-first vocabulary returns FULL-WIDTH punctuation:
|
|
2299
|
+
"(555)013-8842" matches no phone pattern even though it is
|
|
2300
|
+
visually identical to the ASCII form."""
|
|
2301
|
+
# --- full-width normalization
|
|
2302
|
+
assert openscrub._ascii_forms("(555)013-8842") == "(555)013-8842"
|
|
2303
|
+
assert openscrub._ascii_forms("123-45-6789") == "123-45-6789"
|
|
2304
|
+
assert openscrub._ascii_forms("already ascii 1-2") == "already ascii 1-2"
|
|
2305
|
+
|
|
2306
|
+
# --- space recovery: charmap[-1] is the space class
|
|
2307
|
+
charmap = [""] + list("AB") + [" "] # blank, A, B, space
|
|
2308
|
+
SP = len(charmap) - 1
|
|
2309
|
+
n = len(charmap)
|
|
2310
|
+
|
|
2311
|
+
def step(i, space_p=0.0):
|
|
2312
|
+
v = np.full(n, 0.001, np.float32)
|
|
2313
|
+
v[i] = 1.0 - space_p
|
|
2314
|
+
v[SP] = max(v[SP], space_p)
|
|
2315
|
+
return v
|
|
2316
|
+
|
|
2317
|
+
# "A" <blank run carrying space probability> "B" -> "A B"
|
|
2318
|
+
hi = np.array([step(1), step(0, 0.4), step(0, 0.4), step(2)])
|
|
2319
|
+
assert openscrub._ctc_greedy_decode(hi, charmap)[0] == "A B"
|
|
2320
|
+
# same shape, but the blank run has NO space probability -> "AB"
|
|
2321
|
+
lo = np.array([step(1), step(0), step(0), step(2)])
|
|
2322
|
+
assert openscrub._ctc_greedy_decode(lo, charmap)[0] == "AB"
|
|
2323
|
+
# a ONE-step gap still carries a space when its space probability is
|
|
2324
|
+
# clear — tight det crops compress real word gaps to single steps
|
|
2325
|
+
# (space_p stays below blank's argmax share, the case this exists for)
|
|
2326
|
+
short = np.array([step(1), step(0, 0.4), step(2)])
|
|
2327
|
+
assert openscrub._ctc_greedy_decode(short, charmap)[0] == "A B"
|
|
2328
|
+
# ...but a one-step gap with a flat space signal is not a word break
|
|
2329
|
+
short_lo = np.array([step(1), step(0, 0.001), step(2)])
|
|
2330
|
+
assert openscrub._ctc_greedy_decode(short_lo, charmap)[0] == "AB"
|
|
2331
|
+
# no leading space, and repeats still collapse
|
|
2332
|
+
lead = np.array([step(0, 0.4), step(0, 0.4), step(1), step(1)])
|
|
2333
|
+
assert openscrub._ctc_greedy_decode(lead, charmap)[0] == "A"
|
|
2334
|
+
# with_pos: timesteps come back aligned with the emitted characters
|
|
2335
|
+
txt, conf, pos = openscrub._ctc_greedy_decode(hi, charmap, with_pos=True)
|
|
2336
|
+
assert txt == "A B" and pos == [0, 1, 3]
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
def test_merge_tail_extends_through_supporting_scans():
|
|
2340
|
+
"""A span ends `hold` after its last full detection, but heavily
|
|
2341
|
+
compressed footage keeps showing the string while OCR reads only
|
|
2342
|
+
fragments — the benchmark's compressed card was exposed for its last
|
|
2343
|
+
0.7s exactly this way. Scans whose region words fuzzy-match the span
|
|
2344
|
+
text extend coverage; a scan reading different text stops the walk."""
|
|
2345
|
+
from rapidfuzz import fuzz
|
|
2346
|
+
def mk():
|
|
2347
|
+
return openscrub.Detection(t_start=0.0, t_end=0.0,
|
|
2348
|
+
cbox=(100, 100, 300, 120),
|
|
2349
|
+
category="card", text="4111111111111111",
|
|
2350
|
+
confidence=0.9)
|
|
2351
|
+
frag = lambda t, txt: (t, 0.0, [(txt, (110, 102, 220, 118), 0.9)])
|
|
2352
|
+
scans = [frag(1.0, "4111."), frag(2.0, "1111-1111"),
|
|
2353
|
+
frag(3.0, "TOTALLY DIFFERENT")]
|
|
2354
|
+
m = openscrub.merge_detections([mk()], hold=0.8, scans=scans,
|
|
2355
|
+
fuzz=fuzz)[0]
|
|
2356
|
+
assert m.t_end == pytest.approx(2.8), m.t_end # 2.0 + hold, stopped at 3.0
|
|
2357
|
+
# no supporting scans -> tail stays at plain hold
|
|
2358
|
+
m = openscrub.merge_detections([mk()], hold=0.8,
|
|
2359
|
+
scans=[frag(1.0, "UNRELATED")],
|
|
2360
|
+
fuzz=fuzz)[0]
|
|
2361
|
+
assert m.t_end == pytest.approx(0.8), m.t_end
|
|
2362
|
+
|
|
2363
|
+
|
|
2364
|
+
def test_blur_kernel_is_two_thirds_region():
|
|
2365
|
+
"""Re-identification benchmark, close-up faces (200-750px): a
|
|
2366
|
+
1/3-width Gaussian left SFace similarity at 0.57-0.69 — above the
|
|
2367
|
+
0.55 same-person bar, so 11/133 faces re-identified through the
|
|
2368
|
+
blur. 2/3 width measures 0.11-0.23 on the same faces, at or below
|
|
2369
|
+
cross-person chance. Pin the kernel so a 'cosmetic' weakening of the
|
|
2370
|
+
blur can't silently reopen the leak."""
|
|
2371
|
+
seen = []
|
|
2372
|
+
orig = openscrub._gauss_big
|
|
2373
|
+
openscrub._gauss_big = lambda roi, k: (seen.append(k), orig(roi, k))[1]
|
|
2374
|
+
try:
|
|
2375
|
+
frame = np.full((400, 400, 3), 128, np.uint8)
|
|
2376
|
+
openscrub.blur_region(frame, 50, 50, 350, 350, "blur")
|
|
2377
|
+
finally:
|
|
2378
|
+
openscrub._gauss_big = orig
|
|
2379
|
+
assert seen and seen[0] >= (2 * 300 // 3) | 1, seen
|
|
2380
|
+
|
|
2381
|
+
|
|
2382
|
+
def test_group_lines_splits_columns():
|
|
2383
|
+
"""Two-column layouts share a y-band; a merged line made the address
|
|
2384
|
+
detection's line box (and spaCy's NER context) span BOTH columns, so
|
|
2385
|
+
the blur swallowed unrelated right-column text ('Priority normal' sat
|
|
2386
|
+
on the address row and was over-blurred in 7 of 8 benchmark
|
|
2387
|
+
scenarios). A gap wider than 2.5x line height is a column boundary."""
|
|
2388
|
+
def w(txt, x, y=100):
|
|
2389
|
+
return (txt, (x, y, x + 12 * len(txt), y + 120 - 100 + y), 0.9)
|
|
2390
|
+
words = [("4210", (100, 100, 148, 120), 0.9),
|
|
2391
|
+
("Kestrel", (160, 100, 244, 120), 0.9),
|
|
2392
|
+
("Road", (256, 100, 304, 120), 0.9),
|
|
2393
|
+
("Priority", (820, 100, 916, 120), 0.9),
|
|
2394
|
+
("normal", (928, 100, 1000, 120), 0.9)]
|
|
2395
|
+
lines = openscrub.group_lines(words)
|
|
2396
|
+
texts = [ln["text"] for ln in lines]
|
|
2397
|
+
assert "4210 Kestrel Road" in texts and "Priority normal" in texts, texts
|
|
2398
|
+
# normal word gaps must NOT split
|
|
2399
|
+
lines = openscrub.group_lines(words[:3])
|
|
2400
|
+
assert [ln["text"] for ln in lines] == ["4210 Kestrel Road"]
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
def test_shredded_card_fragment_join():
|
|
2404
|
+
"""OCR can fragment a spaced PAN into arbitrary digit chunks
|
|
2405
|
+
("41 11 11 11 1 1 11 11 11" — a real read of a highlighted card row
|
|
2406
|
+
under video compression) or attach stray punctuation ("1111." from
|
|
2407
|
+
motion blur). Both defeated the fixed 3/4-group join and the card
|
|
2408
|
+
leaked. Any same-row run of digit tokens whose 13-16 digit
|
|
2409
|
+
concatenation has a card prefix AND passes Luhn must fire."""
|
|
2410
|
+
def w(txt, x):
|
|
2411
|
+
return (txt, (x, 100, x + 12 * max(1, len(txt)), 120), 0.9)
|
|
2412
|
+
shredded = [w(t, 60 + i * 40) for i, t in enumerate(
|
|
2413
|
+
["41", "11", "11", "11", "1", "1", "11", "11", "11"])]
|
|
2414
|
+
dets = openscrub.detect_phi(shredded, [], 0.0, (0, 0), None, None)
|
|
2415
|
+
assert any(d.category == "card" and d.text == "4111111111111111"
|
|
2416
|
+
for d in dets), [(d.category, d.text) for d in dets]
|
|
2417
|
+
dotted = [w("4111", 60), w("1111", 130), w("1111.", 200), w("1111", 270)]
|
|
2418
|
+
dets = openscrub.detect_phi(dotted, [], 0.0, (0, 0), None, None)
|
|
2419
|
+
assert any(d.category == "card" for d in dets)
|
|
2420
|
+
# dark/noisy footage welds neighbouring groups into one big token
|
|
2421
|
+
# ("4111" ".1111." "1111.1111" — the exact PP-OCRv5 read of the
|
|
2422
|
+
# dark-mode benchmark card row that leaked in 1.0.78 dev): tokens up
|
|
2423
|
+
# to a full welded PAN must still join
|
|
2424
|
+
welded = [w("4111", 60), w(".1111.", 130), w("1111.1111", 210)]
|
|
2425
|
+
dets = openscrub.detect_phi(welded, [], 0.0, (0, 0), None, None)
|
|
2426
|
+
assert any(d.category == "card" and d.text == "4111111111111111"
|
|
2427
|
+
for d in dets), [(d.category, d.text) for d in dets]
|
|
2428
|
+
# and a single fully-welded punctuated PAN fires on its own
|
|
2429
|
+
solo = [w("4111.1111.1111.1111", 60)]
|
|
2430
|
+
dets = openscrub.detect_phi(solo, [], 0.0, (0, 0), None, None)
|
|
2431
|
+
assert any(d.category == "card" for d in dets)
|
|
2432
|
+
# welded junk that fails Luhn stays silent
|
|
2433
|
+
wj = [w("1234", 60), w("5678.9012", 130), w("3456", 240)]
|
|
2434
|
+
dets = openscrub.detect_phi(wj, [], 0.0, (0, 0), None, None)
|
|
2435
|
+
assert not any(d.category == "card" for d in dets)
|
|
2436
|
+
# a random digit run that fails Luhn must NOT fire
|
|
2437
|
+
junk = [w("4111", 60), w("1111", 130), w("1111", 200), w("1112", 270)]
|
|
2438
|
+
dets = openscrub.detect_phi(junk, [], 0.0, (0, 0), None, None)
|
|
2439
|
+
assert not any(d.category == "card" for d in dets)
|
|
2440
|
+
# SHREDDED fragments on different rows never join (the fragment
|
|
2441
|
+
# join is noise-driven, so it stays same-row; the classic 4-group
|
|
2442
|
+
# join may still bridge a wrapped PAN — that one is Luhn-gated)
|
|
2443
|
+
split_rows = ([w(t, 60 + i * 30) for i, t in enumerate(
|
|
2444
|
+
["41", "11", "11", "11"])]
|
|
2445
|
+
+ [("1", (60 + i * 30, 300, 80 + i * 30, 320), 0.9)
|
|
2446
|
+
for i in range(8)])
|
|
2447
|
+
dets = openscrub.detect_phi(split_rows, [], 0.0, (0, 0), None, None)
|
|
2448
|
+
assert not any(d.category == "card" for d in dets)
|
|
2449
|
+
|
|
2450
|
+
|
|
2451
|
+
def test_name_pair_heuristic_union_and_adjacency():
|
|
2452
|
+
"""The capitalized-pair name heuristic is a UNION with NER, not a
|
|
2453
|
+
fallback: spaCy's small model returned zero entities for a real
|
|
2454
|
+
two-token name on a records screen, and NER availability silently
|
|
2455
|
+
disabled the heuristic that would have caught it. In strict (union)
|
|
2456
|
+
mode ALLCAPS headers stay exempt, and pairs glued together from
|
|
2457
|
+
different COLUMNS by line reconstruction (big x gap) never pair —
|
|
2458
|
+
that fake adjacency blurred benign text two columns away."""
|
|
2459
|
+
class FakeDoc:
|
|
2460
|
+
ents = ()
|
|
2461
|
+
|
|
2462
|
+
class FakeNLP:
|
|
2463
|
+
def __call__(self, text):
|
|
2464
|
+
return FakeDoc()
|
|
2465
|
+
|
|
2466
|
+
nd = openscrub.NameDetector(use_ner=False)
|
|
2467
|
+
nd.nlp = FakeNLP() # NER "available", finds nothing
|
|
2468
|
+
nd.heuristic = False # auto-mode: heuristic off pre-fix
|
|
2469
|
+
|
|
2470
|
+
def line(pairs):
|
|
2471
|
+
words, text, x = [], "", None
|
|
2472
|
+
entries = []
|
|
2473
|
+
for wtxt, bx in pairs:
|
|
2474
|
+
s = len(text) + (1 if text else 0)
|
|
2475
|
+
text = (text + " " + wtxt) if text else wtxt
|
|
2476
|
+
entries.append((wtxt, bx, 0.9, s, s + len(wtxt)))
|
|
2477
|
+
return [{"text": text, "words": entries}]
|
|
2478
|
+
|
|
2479
|
+
# adjacent Titlecase pair -> caught even though NER found nothing
|
|
2480
|
+
hits = nd.find(line([("Marguerite", (100, 50, 220, 70)),
|
|
2481
|
+
("Vandersloot", (228, 50, 360, 70))]))
|
|
2482
|
+
assert {h[1] for h in hits} == {"Marguerite", "Vandersloot"}
|
|
2483
|
+
# ALLCAPS header exempt in strict mode
|
|
2484
|
+
assert nd.find(line([("PATIENT", (100, 50, 200, 70)),
|
|
2485
|
+
("RECORD", (208, 50, 300, 70))])) == []
|
|
2486
|
+
# cross-column fake pair (huge x gap) never pairs
|
|
2487
|
+
assert nd.find(line([("Vandersloot", (100, 50, 220, 70)),
|
|
2488
|
+
("Ward", (600, 50, 660, 70))])) == []
|
|
2489
|
+
|
|
2490
|
+
|
|
2491
|
+
def test_container_update_notice_is_inform_only(monkeypatch):
|
|
2492
|
+
"""Inside Docker the in-place updater writes into the ephemeral
|
|
2493
|
+
container filesystem — the "update" would vanish on the next recreate
|
|
2494
|
+
while the UI claimed success (a real CUDA-image user saw the footer
|
|
2495
|
+
offer an update it could not deliver). In a container the API must
|
|
2496
|
+
say can_update=false (footer shows "pull the new image") and
|
|
2497
|
+
update_run must refuse with the pull instruction."""
|
|
2498
|
+
import openscrub_web
|
|
2499
|
+
monkeypatch.setenv("OPENSCRUB_IN_DOCKER", "1")
|
|
2500
|
+
assert openscrub_web._in_container() is True
|
|
2501
|
+
client = openscrub_web.app.test_client()
|
|
2502
|
+
d = client.get("/api/update_check").get_json()
|
|
2503
|
+
assert d["can_update"] is False
|
|
2504
|
+
r = client.post("/api/update_run")
|
|
2505
|
+
assert r.status_code == 400
|
|
2506
|
+
assert b"pulling the new image" in r.data
|
|
2507
|
+
# env cleared -> the env-var gate no longer forces container mode
|
|
2508
|
+
monkeypatch.delenv("OPENSCRUB_IN_DOCKER", raising=False)
|
|
2509
|
+
import os as _os
|
|
2510
|
+
if not (_os.path.exists("/.dockerenv")
|
|
2511
|
+
or _os.path.exists("/run/.containerenv")):
|
|
2512
|
+
assert openscrub_web._in_container() is False
|
|
2513
|
+
assert client.get("/api/update_check").get_json()["can_update"] is True
|
|
2514
|
+
|
|
2515
|
+
|
|
2283
2516
|
def test_quality_tiers_align_with_registries():
|
|
2284
2517
|
"""Every model id a Detection-quality tier selects must exist in its
|
|
2285
2518
|
registry with a real download_url — a tier pointing at a missing or
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|