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.
@@ -126,3 +126,7 @@ CLAUDE.local.md
126
126
  # Windows installer build output (windows/build_installer.bat)
127
127
  windows/output/
128
128
  model_select.json
129
+
130
+ # benchmark working dirs (regenerate with benchmark/corpus.py)
131
+ benchmark/_corpus/
132
+ benchmark/_results/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenScrub
3
- Version: 1.0.76
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
- During development the pipeline is scored against a **synthetic corpus**:
770
- a generator plants fake PII at known locations across the hard cases —
771
- static charts, schedule grids, scrolling notes, OCR-disrupting
772
- highlights, embedded face photos — and a scorer checks the rendered
773
- output against the ground truth:
774
-
775
- PII recall: 100.0% (102/102 planted samples blurred)
776
- Benign preservation: 100.0% (39/39 benign samples left readable)
777
-
778
- (measured with the Tesseract fallback engine; PaddleOCR + spaCy NER, the
779
- recommended stack, is stronger). The shipped regression suite
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
- During development the pipeline is scored against a **synthetic corpus**:
536
- a generator plants fake PII at known locations across the hard cases —
537
- static charts, schedule grids, scrolling notes, OCR-disrupting
538
- highlights, embedded face photos — and a scorer checks the rendered
539
- output against the ground truth:
540
-
541
- PII recall: 100.0% (102/102 planted samples blurred)
542
- Benign preservation: 100.0% (39/39 benign samples left readable)
543
-
544
- (measured with the Tesseract fallback engine; PaddleOCR + spaCy NER, the
545
- recommended stack, is stronger). The shipped regression suite
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.76"
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
- items = sorted(words, key=lambda w: ((w[1][1] + w[1][3]) / 2, w[1][0]))
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
- placed = False
498
+ best = None
499
+ best_gap = None
490
500
  for ln in lines:
491
- if abs(ln["yc"] - yc) < 0.7 * max(h, ln["h"]):
492
- ln["raw"].append(w)
493
- ln["yc"] = (ln["yc"] * (len(ln["raw"]) - 1) + yc) / len(ln["raw"])
494
- ln["h"] = max(ln["h"], h)
495
- placed = True
496
- break
497
- if not placed:
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
- text = ""
503
- entries = []
504
- for w, box, conf in ln["raw"]:
505
- if text:
506
- text += " "
507
- start = len(text)
508
- text += w
509
- entries.append((w, box, conf, start, len(text)))
510
- out.append({"text": text, "words": entries})
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 (fallback) ---
616
- if self.heuristic:
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
- k = max(31, (((x2 - x1) // 3) | 1))
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
- def _ctc_greedy_decode(logits, charmap):
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
- chars, confs, prev = [], [], -1
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 != 0 and i != prev and i < len(charmap):
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
- return "".join(chars), (float(np.mean(confs)) if confs else 0.0)
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 keeping aspect, normalize BGR to
4457
- [-1,1], greedy CTC decode (collapse repeats, drop blank)."""
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
- return _ctc_greedy_decode(y, self.charmap)
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
- words = txt.split()
4482
- if not words:
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 by proportional width, identical
4485
- # to PaddleBackend so per-word redaction stays tight.
4486
- total = sum(len(w) for w in words) + (len(words) - 1)
4487
- cursor = float(x1)
4488
- for w in words:
4489
- frac = (len(w) + 1) / max(total, 1)
4490
- wx2 = cursor + (float(x2) - float(x1)) * frac
4491
- out.append((w, (int(cursor), int(y1), int(wx2), int(y2)),
4492
- conf))
4493
- cursor = wx2
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)document.getElementById("upd").innerHTML=
2424
- '· <a href="#" style="color:#fbbf24" onclick="updRun();return false">v'
2425
- +d.latest+' available — update</a> ';
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
- v = np.full(n, (1.0 - p) / (n - 1))
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