feat(privacy): trefwoorden met woordgrenzen, en een gezicht op een dia telt ook #363

Merged
brenno merged 5 commits from feat/privacy-quality-badges into main 2026-07-19 15:36:46 +00:00
Owner

Vervolg op #357. Vijf commits, make check groen op de huidige main.

Wat er misging in de tekstcontrole

De trefwoordmatcher was een kale indexOf zonder woordgrenzen. Gemeten op de echte scanner:

Invoer Was
De vogels vliegen over het weiland ❌ strafrechtelijk gegeven
medicatie-expertise ❌ gezondheidsgegeven
Marieke de Vries wordt verdacht van diefstal ⚠️ niets

Die laatste is de vervelendste: de lijst bevatte de verbogen vorm verdachte, waardoor de gebruikelijkste formulering van precies het geval waar artikel 10 over gaat volledig gemist werd. De matcher kent nu twee modi — korte termen (hiv, ggz, vog) alleen als héél woord, langere op woordbegin met een vrij achtervoegsel, want de Nederlandse morfologie is suffigerend.

En redactie werkte op trefwoordniveau: Jan had een diagnose bij de huisarts werd Jan had een ████████ bij de huisarts. Er wordt niets verborgen, "Jan" blijft staan, en de ontvanger concludeert uit het blok dat daar iets stond. PrivacyTermRole scheidt nu de aanwijzing van het gegeven — een onderscheid dat de Autoriteit Persoonsgegevens zelf hanteert: "Gegevens die hooguit een indicatie geven … zijn niet voldoende".

De beeldcontrole

Een afbeelding waarop iemand herkenbaar staat is een persoonsgegeven, ook zonder naam. De tekstscanner kan dat per definitie niet vinden — die leest mem:11162735-….

YuNet (OpenCV Zoo, MIT, 232 KB) via opencv_core, volledig lokaal. Kosten: 27 MB DartCvMacOS.framework, gemeten. Web verliest de functie en zegt dat via isSupported.

Aanwezigheid, nooit identiteit. Het model levert per gezicht een kader, vijf landmarks en een score; alleen het aantal blijft over. Geen sjabloon, niets opgeslagen, niets vergeleken. Dat is wat dit buiten artikel 9-biometrie houdt (EDPB 3/2019 §74-76), en er staat een expliciete grensmarkering op de regel waar dat mis zou gaan.

Drie fouten die de meting op echte foto's blootlegde

HEIC meldde nul. OpenCV leest het niet, imdecode gaf een lege matrix, en dat werd gerapporteerd als "geen gezichten" — terwijl iPhone-foto's standaard HEIC zijn. faces en readable zijn nu gescheiden.

Eén vaste breedte verloor gezichten. Per foto verschilt de beste schaal; drie schalen met het maximum eroverheen lost het op.

En de scoredrempel stond bijna op een klif:

drempel   gezichten   valse-positieven
   0,95           0                  0
   0,92           8                  0     ← stond hier
   0,85          14                  0
   0,80          14                  0     ← nu hier
   0,70          14                  0
   0,50          15                  2

Op 0,95 vindt de detector letterlijk niets. 0,80 ligt in het midden van het plateau, dus zo ver mogelijk van beide kliffen. Effect: groepsfoto van 3 naar 8 van 8, totaal van 8 naar 14, valse positieven onveranderd nul, en sneller (497 ms tegen 731 ms).

Ook gemeten en daarom niet gebouwd: extra tussenschalen en CLAHE-contrastnormalisatie leverden geen enkel extra gezicht op.

Documentatie

OCIWACHT.md §13 bevat het lexiconmodel, de meertaligheidsstrategie in drie lagen, de pijplijnvolgorde en de fasering 9-14. Inclusief twee expliciete "bewust niet gedaan"-secties (geen NER — er is geen Nederlandse parser die offline in Dart draait; geen gehashte woordenlijst — de privacy van een bloomfilter ís de min-entropie van de invoer). PRIVACY.md vertelt nu dát er een neuraal netwerk over je afbeeldingen gaat en waarom dat geen biometrie is.

Voorbehoud

Zeven foto's en elf negatieven is een kleine steekproef; het plateau is geruststellend maar dit is geen benchmark. En de CI zet DARTCV_LIB_PATH nog niet — zonder die variabele staan de twee detectietests permanent op skipped.

🤖 Generated with Claude Code

Vervolg op #357. Vijf commits, `make check` groen op de huidige main. ## Wat er misging in de tekstcontrole De trefwoordmatcher was een kale `indexOf` zonder woordgrenzen. Gemeten op de echte scanner: | Invoer | Was | | --- | --- | | `De vogels vliegen over het weiland` | ❌ strafrechtelijk gegeven | | `medicatie-expertise` | ❌ gezondheidsgegeven | | `Marieke de Vries wordt verdacht van diefstal` | ⚠️ niets | Die laatste is de vervelendste: de lijst bevatte de verbogen vorm `verdachte`, waardoor de gebruikelijkste formulering van precies het geval waar artikel 10 over gaat volledig gemist werd. De matcher kent nu twee modi — korte termen (`hiv`, `ggz`, `vog`) alleen als héél woord, langere op woordbegin met een vrij achtervoegsel, want de Nederlandse morfologie is suffigerend. En redactie werkte op trefwoordniveau: `Jan had een diagnose bij de huisarts` werd `Jan had een ████████ bij de huisarts`. Er wordt niets verborgen, "Jan" blijft staan, en de ontvanger concludeert uit het blok dat daar iets stond. `PrivacyTermRole` scheidt nu de aanwijzing van het gegeven — een onderscheid dat de Autoriteit Persoonsgegevens zelf hanteert: *"Gegevens die hooguit een indicatie geven … zijn niet voldoende"*. ## De beeldcontrole Een afbeelding waarop iemand herkenbaar staat is een persoonsgegeven, ook zonder naam. De tekstscanner kan dat per definitie niet vinden — die leest `mem:11162735-…`. YuNet (OpenCV Zoo, MIT, 232 KB) via `opencv_core`, volledig lokaal. Kosten: **27 MB** `DartCvMacOS.framework`, gemeten. Web verliest de functie en zegt dat via `isSupported`. **Aanwezigheid, nooit identiteit.** Het model levert per gezicht een kader, vijf landmarks en een score; alleen het aantal blijft over. Geen sjabloon, niets opgeslagen, niets vergeleken. Dat is wat dit buiten artikel 9-biometrie houdt (EDPB 3/2019 §74-76), en er staat een expliciete grensmarkering op de regel waar dat mis zou gaan. ## Drie fouten die de meting op echte foto's blootlegde **HEIC meldde nul.** OpenCV leest het niet, `imdecode` gaf een lege matrix, en dat werd gerapporteerd als "geen gezichten" — terwijl iPhone-foto's standaard HEIC zijn. `faces` en `readable` zijn nu gescheiden. **Eén vaste breedte verloor gezichten.** Per foto verschilt de beste schaal; drie schalen met het maximum eroverheen lost het op. **En de scoredrempel stond bijna op een klif:** ``` drempel gezichten valse-positieven 0,95 0 0 0,92 8 0 ← stond hier 0,85 14 0 0,80 14 0 ← nu hier 0,70 14 0 0,50 15 2 ``` Op 0,95 vindt de detector letterlijk niets. 0,80 ligt in het midden van het plateau, dus zo ver mogelijk van beide kliffen. Effect: groepsfoto van 3 naar 8 van 8, totaal van 8 naar 14, valse positieven onveranderd nul, en sneller (497 ms tegen 731 ms). Ook gemeten en daarom *niet* gebouwd: extra tussenschalen en CLAHE-contrastnormalisatie leverden geen enkel extra gezicht op. ## Documentatie `OCIWACHT.md` §13 bevat het lexiconmodel, de meertaligheidsstrategie in drie lagen, de pijplijnvolgorde en de fasering 9-14. Inclusief twee expliciete "bewust niet gedaan"-secties (geen NER — er is geen Nederlandse parser die offline in Dart draait; geen gehashte woordenlijst — de privacy van een bloomfilter ís de min-entropie van de invoer). `PRIVACY.md` vertelt nu dát er een neuraal netwerk over je afbeeldingen gaat en waarom dat geen biometrie is. ## Voorbehoud Zeven foto's en elf negatieven is een kleine steekproef; het plateau is geruststellend maar dit is geen benchmark. En de CI zet `DARTCV_LIB_PATH` nog niet — zonder die variabele staan de twee detectietests permanent op *skipped*. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Twee fouten die samenhangen, allebei gemeten op de echte scanner.

De matcher was een kale `indexOf`. Daardoor meldde "De vogels vliegen over
het weiland" een strafrechtelijk gegeven, en "medicatie-expertise" een
gezondheidsgegeven. Er zit nu een woordgrens omheen, met twee modi: korte
termen (`hiv`, `ggz`, `vog`) matchen alleen als héél woord, langere op
woordbegin met een vrij achtervoegsel. Dat laatste is geen luxe maar
noodzaak — Nederlandse morfologie is vrijwel volledig suffigerend, en de
lijst bevatte de verbogen vorm `verdachte`. Daardoor werd "wordt verdacht
van diefstal", de gebruikelijkste formulering van precies het geval waar
artikel 10 over gaat, volledig gemist. De stam staat er nu, en dekt
`verdachte`, `verdachten` en `verdachtmaking` mee.

En redactie werkte op trefwoordniveau:

    Jan had een diagnose bij de huisarts
    Jan had een ████████ bij de huisarts

Er wordt niets verborgen, "Jan" blijft staan, en de ontvanger concludeert
uit het blok dat daar iets gevoeligs stond. Dat is misleidender dan
overbodig. `PrivacyTermRole` scheidt daarom de aanwijzing van het gegeven:
"diagnose" wijst ergens naar, `F32.1` en een parketnummer zíjn het. Een
aanwijzing gaat alleen mee als de escalator haar bereik heeft verbreed tot
de hele mededeling — want dán is het gegeven niet het woord maar de
uitspraak, en haalt redactie werkelijk iets weg.

Dat onderscheid is niet zelfbedacht. De Autoriteit Persoonsgegevens
hanteert het in haar eigen handhaving: "Gegevens die hooguit een indicatie
geven dat het om een gevoelig kenmerk zou kunnen gaan, zijn niet
voldoende" om van een rechtstreeks verband te spreken.

Wat dit niet oplost: homoniemen. `arrest` blijft een treffer op een
uitspraak van de Hoge Raad. Daar is geen woordgrens tegen bestand; dat
vraagt de persoonskoppelingspoort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
De tekstscanner kon dit per definitie nooit vinden: die leest
`mem:11162735-…` en concludeert terecht dat daar geen BSN in staat. Maar
een afbeelding waarop iemand herkenbaar staat, ís een persoonsgegeven —
ook zonder naam erbij.

YuNet (OpenCV Zoo, MIT, 232 KB) via `opencv_core`, volledig lokaal en
zonder netwerkuitgang. Kosten, gemeten en niet geschat: 27 MB
`DartCvMacOS.framework` in de macOS-bundel. Op het web bestaat FFI niet;
daar degradeert de controle, en ze zégt dat ook.

── Waarom dit geen biometrie is, en dat met opzet ──

De EDPB stelt (Richtsnoeren 3/2019 §74-76) dat beeld van een persoon pas
een artikel 9-gegeven wordt als het "specifically technically processed in
order to contribute to the identification of an individual" wordt. Wij
stellen dus aanwezigheid vast, nooit identiteit. Het model levert per
gezicht een kader, vijf landmarks en een score; daarvan houden we alléén
het aantal over. De rest wordt onmiddellijk weggegooid. Er wordt geen
sjabloon berekend, niets opgeslagen, niets vergeleken.

Dat is geen detail: een privacycontrole die gezichtssjablonen aanlegt om
je vóór gezichtssjablonen te waarschuwen, creëert precies de categorie
gegevens waar artikel 9 het zwaarst op zit. In `image_face_scan_io.dart`
staat een expliciete grensmarkering op de regel waar dat mis zou gaan.

De melding zegt "herkenbaar gezicht" en niet "persoon". Dat is eerlijkheid,
geen voorzichtigheid: een gezichtsdetector mist iemand van achteren, in
profiel, met mondkapje, of met het hoofd buiten de uitsnede.

── Prestatie ──

Dit is verreweg de duurste controle, dus staat hij achteraan en apart: een
eigen asynchrone provider naast de synchrone tekstscan, serieel per
afbeelding, met een eigen schakelaar naast de hoofdschakelaar. Wie op een
oude machine werkt hoeft de tekstcontrole niet op te geven om hier vanaf
te komen.

── Twee eigen fouten die de code nu vangt ──

De eerste modeldownload leverde een Git LFS-pointer van 131 bytes op, die
in git precies op een model lijkt. Er staat nu een groottetest op.

En de eerste testronde was groen om de verkeerde reden: de native laag
laadt niet onder `flutter test`, elke aanroep viel in de foutafhandeling
en de scanner meldde nul gezichten terwijl er níéts gedetecteerd was —
terwijl `isSupported` `true` beweerde. Er zit nu een actieve probe in. Met
`DARTCV_LIB_PATH` naar de gebouwde framework draait de test echt, en dan
leveren de kattenfoto's uit de eigen assets aantoonbaar nul gezichten op.

Nog niet aangetoond: dat de detector een gezicht wérkelijk vindt. Daar is
een fixture voor nodig die niet in de repo zit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Getest op veertien echte foto's — geen mensen, slecht zichtbare mensen,
groepen — en er kwamen twee fouten uit die allebei precies het gedrag
vertoonden waar deze controle tegen ontworpen is.

── HEIC meldde nul in plaats van "niet gekeken" ──

Alle zeven HEIC-bestanden gaven nul gezichten, in 4 tot 14 milliseconden.
Te snel voor een decode: OpenCV kent HEIC niet, `imdecode` geeft een lege
matrix, en de code rapporteerde dat als "geen gezichten". iPhone-foto's
zijn standaard HEIC, dus een deck vol portretten meldde niets.

`ImageFaceScanResult` scheidt daarom `faces` van `readable`, en een
onleesbare afbeelding levert een eigen informatieve melding op:
"Dat betekent niet dat er niemand op staat — er is niet gekeken."

── Eén vaste breedte verloor de meeste gezichten ──

Van zeven leesbare foto's vond de detector er twee. De sweep laat zien
waarom: per foto verschilt de béste breedte en geen enkele is overal goed.

    foto            orig  1920  1280   640
    startbaan          0     0     1     0
    twee personen      3     3     1     0
    portret staand     0     -     -     1

Drie schalen met het maximum eroverheen vindt er zes van zeven. De
zevende is een strandfoto waarop iemand omlaag kijkt met een zonnebril
op — die hóórt een gezichtsdetector te missen, en dat is precies waarom
de melding "herkenbaar gezicht" zegt en niet "persoon".

Kosten: ~40 tot 190 ms per afbeelding, 599 ms voor veertien. Voor een
achtergrondcontrole ruim binnen de marge.

── En het getal is nu eerlijk ──

Op een groepsfoto van acht mensen bij tegenlicht vindt de detector er
drie. Geen valse positieven — een óndertelling. Voor de waarschuwing
maakt dat niets uit, want één gezicht is al een persoonsgegeven, maar
"toont 3 herkenbare gezichten" zou liegen. Er staat nu "minstens".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OCIWACHT.md krijgt §13: de gemeten bevindingen, het lexiconmodel met de twee
assen (persoonskoppeling bepaalt óf je meldt, term-rol bepaalt hoevéél je
weglakt), de meertaligheidsstrategie in drie lagen, de pijplijnvolgorde
goedkoop-naar-duur, de beeldcontrole, en de fasering 9 tot 14.

Twee dingen staan er expliciet in als "bewust niet gedaan", omdat ze anders
opnieuw uitgezocht worden. Geen NER of taalmodel: er is geen Nederlandse
parser die offline in Dart draait, en Alpino staat op Prolog uit 2013 terwijl
Frog GPLv3 is. En geen gehashte woordenlijst: de privacy van een bloomfilter
is precies de min-entropie van de invoer, en een diagnoselijst is publiek,
opsombaar en klein — een aanvaller hasht elke kandidaatterm en herstelt bij
1% FP negenennegentig procent van de lijst. Een keyed hash helpt niet in een
desktop-app, want de sleutel zit in het binary.

PRIVACY.md vertelt nu dat er een neuraal netwerk over je afbeeldingen gaat, en
waarom dat geen biometrische verwerking is. Dat hoort er te staan vóórdat
iemand de functie gebruikt, niet erna.

De USER_GUIDE zegt waar de controle faalt in plaats van alleen wat ze kan:
gezicht en niet persoon, drie van acht op een groepsfoto bij tegenlicht, en
HEIC dat "niet gecontroleerd" meldt in plaats van "niets gevonden".

En §13.7 sluit af met het cijfer dat het hele ontwerp draagt: de menselijke
annotatoren van de Text Anonymization Benchmark maskeerden gemiddeld 67,9% van
de entiteiten met een standaarddeviatie van 8,3% en 4.299 unieke
meningsverschillen. Er is geen grondwaarheid om naartoe te convergeren. Dat
plafonneert wat elke classifier kan halen, en het is de reden dat "een
hulpmiddel, geen garantie" geen bescheidenheidsfiguur is maar de enige uitspraak
die door de meting gedekt wordt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
perf(privacy): de scoredrempel stond drie honderdsten van onbruikbaar
Some checks failed
CI / Web hardening (push) Has been cancelled
CI / Docs links (push) Has been cancelled
CI / Gate (Linux) · Format · Analyze · Coverage (pull_request) Has been cancelled
CI / Test (macos-latest) (pull_request) Has been cancelled
CI / Gate (Linux) · Format · Analyze · Coverage (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Supply-chain (Trivy · advisory) (push) Has been cancelled
CI / Test (windows-latest) (pull_request) Has been cancelled
CI / Web hardening (pull_request) Has been cancelled
CI / Docs links (pull_request) Has been cancelled
CI / Supply-chain (Trivy · advisory) (pull_request) Has been cancelled
4bf50522e1
De vraag was of de nauwkeurigheid nog beter kon. Vier knoppen gemeten —
scoredrempel, aantal schalen, contrastnormalisatie voor tegenlicht, en
opschalen. Eén ervan deed vrijwel alles.

    drempel   gezichten   valse-positieven
       0,95           0                  0
       0,92           8                  0
       0,90          11                  0
       0,85          14                  0
       0,80          14                  0
       0,70          14                  0
       0,60          15                  0
       0,50          15                  2

Gemeten op zeven echte foto's plus elf mensloze app-assets. Er stond 0,92,
door mij gekozen met het argument "liever een gezicht missen dan de
gebruiker leren dat de melding onzin is". Dat argument was niet fout, de
plaatsing wel: op 0,95 vindt de detector letterlijk niets meer. Ik zat drie
honderdsten van een kapotte functie, en ik had het niet gemeten.

Tussen 0,85 en 0,70 ligt een vlak gebied waar het aantal niet verandert en
er geen enkele valse positief bijkomt. 0,80 ligt daar in het midden en dus
zo ver mogelijk van beide kliffen — de recall-klif boven, de precisie-klif
bij 0,50.

Effect: de groepsfoto van acht mensen bij tegenlicht gaat van drie naar
**acht van acht**, en de strandfoto waarvan ik eerder schreef dat een
gezichtsdetector hem hoort te missen — hoofd omlaag, zonnebril — wordt nu
wél gevonden. Totaal over zeven foto's: van 8 naar 14. Valse positieven:
nul, ongewijzigd. En het is sneller: 497 ms tegen 731 ms.

Ook gemeten en daarom *niet* gebouwd: extra tussenschalen (960, 480) en
CLAHE-contrastnormalisatie leverden geen enkel extra gezicht op. Die
complexiteit blijft eruit.

De "minstens N"-formulering blijft staan, maar met een betere reden: niet
omdat een specifieke groepsfoto ondertelde, maar omdat een gezichtsdetector
structureel ondertelt en nooit overtelt — iemand van achteren of met het
hoofd buiten de uitsnede ontbreekt per definitie.

Voorbehoud dat in de code én in OCIWACHT.md staat: zeven foto's en elf
negatieven is een kleine steekproef. Het plateau is geruststellend, maar
dit is geen benchmark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
brenno merged commit 16cf8f4dfa into main 2026-07-19 15:36:46 +00:00
Sign in to join this conversation.
No description provided.