Pipeline-Überblick
Pro Frame durchläuft die App folgende Schritte:
1. Video-Frame aus <video> → MediaPipe FaceLandmarker
2. 478 Landmarks (468 Gesicht + 10 Iris) → analyzeFraming()
3. Pro Hint-Typ: Bedingung prüfen → in hints[] sammeln
4. Sortieren nach Priorität, Hinweis-Chips rendern
5. Wenn hints.length === 0 → grünes Oval, Auto-Trigger startet
6. Nach countdownDuration ms ohne Verlust → captureCurrentFrame()
7. ICAO-Score berechnen, in Best-Shot-Liste sortieren
Code-Pfad
Alles in ui-sollbild/prototype.html:
handleDetectionResults()— wird pro MediaPipe-Frame aufgerufenanalyzeFraming(t, lm, bbox, sharpness)— Hauptfunktion, prüft alle 10 BedingungencomputeIcaoScore()— gewichtete Summe der PenaltiescaptureCurrentFrame()— JPEG-Capture mit 2:3-Crop
Orientation: Landscape vs Portrait — separate Werte
Update: Bisher gab es eine globale arScale-Korrektur. Sie war unsauber (besonders auf iPhone-Pro mit Tele-Linse), daher seit Version 0.7.4 ersetzt durch separate Parameter je Orientation.
Das Problem
MediaPipe Face Landmarker liefert alle Landmark-Koordinaten normalisiert auf die Frame-Dimensionen [0..1]. Eine x=0.5 bedeutet immer „in der Mitte des Frames", egal ob der Frame 1280×720 oder 720×1280 Pixel hat.
Das hat eine wichtige Konsequenz: derselbe Mensch im selben Abstand liefert je nach Video-Orientierung sehr unterschiedliche faceH-Werte:
| Setup | Frame-Größe | Gesicht (Höhe) | faceH |
|---|---|---|---|
| MacBook Webcam (Landscape 16:9) | 1280×720 | ~320 Pixel | 0.44 |
| iPhone Hochformat (Portrait 9:16) | 720×1280 | ~320 Pixel | 0.14 |
Selbe Person, selber Abstand — aber faceH unterscheidet sich um den Faktor ~3. Das liegt nur daran, dass der Portrait-Frame 3× höher ist als der Landscape-Frame.
Die Lösung: 4 separate Werte
Statt einer globalen Skalierung gibt es jetzt explizite Schwellenwerte je Orientation:
| Parameter | Wann genutzt | Default |
|---|---|---|
faceHMin_landscape | Landscape-Video (16:9) | 0.32 |
faceHMax_landscape | Landscape-Video | 0.78 |
faceHMin_portrait | Portrait-Video (9:16) | 0.10 |
faceHMax_portrait | Portrait-Video | 0.30 |
const isPortrait = videoWidth / videoHeight < 1;
const faceHMin = isPortrait ? PARAMS.faceHMin_portrait : PARAMS.faceHMin_landscape;
const faceHMax = isPortrait ? PARAMS.faceHMax_portrait : PARAMS.faceHMax_landscape;
Keine Multiplikation, kein arScale — was im Dashboard steht ist genau was geprüft wird.
Vorteil dieses Designs
- Transparent: Was im Dashboard steht ist effektiver Wert. Kein versteckter Multiplikator.
- Lens-Aware: iPhone-Pro mit Tele-Linse füllt das Gesicht mehr — kann eigene
faceHMax_portraitbekommen ohne andere Setups zu beeinflussen - Konsistent mit eyeY-Schwellen die schon immer separat waren
Was wird je Orientation aufgespalten?
| Aufgespalten je Orientation | Gilt für beide |
|---|---|
faceHMin_landscape/_portrait |
cxTol_frontal/halbprofil (horizontal — Frame-Breite ändert sich anders) |
faceHMax_landscape/_portrait |
eyeMargin_frontal/halbprofil |
eyeYMin/Max_landscape/_portrait |
Pose-Toleranzen (yawTol, pitchMin/Max, rollMax) |
Im Diagnose-Panel ablesen
Das 📊-Diag-Panel zeigt sowohl den gemessenen Wert als auch den passenden Range (basierend auf erkannter Orientation):
Distanz active: false
value: 0.142 ← gemessener faceH (vom MediaPipe)
range: 0.10 – 0.30 ← faceHMin_portrait – faceHMax_portrait (Portrait erkannt)
Wert im Range → Distanz-Chip grün.
Mirror & Front/Rückkamera
Das Video wird per CSS-Transform scaleX(-1) gespiegelt wenn die Frontkamera aktiv ist (Selfie-Ansicht). MediaPipe sieht aber den un-gespiegelten Frame.
→ Hinweis-Richtungen wie „Rechts →" werden bei Frontkamera invertiert vs. Rückkamera, damit aus Sicht des Operators richtig.
Overlay-Canvas wird mathematisch gespiegelt via buildLandmarkMappers(..., mirror=true), nicht per CSS.
1. Distanz (faceH) — „Näher ran" / „Zurücktreten"
Was wird gemessen?
faceH ist die Höhe der Bounding-Box, die alle 468 erkannten Face-Landmarks umschließt — als Anteil der Video-Frame-Höhe.
Werte zwischen 0 und 1:
faceH = 0.00→ unmöglich (Gesicht hat immer eine Höhe)faceH = 0.10→ sehr klein, Person weit wegfaceH = 0.30→ mittlere DistanzfaceH = 0.50→ ICAO-Standard: Kopf füllt halbes BildfaceH = 0.80→ sehr nah, Linsenverzerrung wahrscheinlichfaceH = 1.00→ Gesicht füllt ganze Frame-Höhe (mehr geht nicht)
Landmarks
Berechnungsformel
let xMin = 1, yMin = 1, xMax = 0, yMax = 0;
for (let i = 0; i < 468; i++) {
if (lm[i].y < yMin) yMin = lm[i].y;
if (lm[i].y > yMax) yMax = lm[i].y;
// (gleiches für x)
}
bbox.height = yMax − yMin
faceH = bbox.height
Trigger-Bedingung
const min = isPortrait ? faceHMin_portrait : faceHMin_landscape;
const max = isPortrait ? faceHMax_portrait : faceHMax_landscape;
Trigger "Näher ran": faceH < min
Trigger "Zurücktreten": faceH > max
Direkter Vergleich, keine Skalierung. Werte im Dashboard sind 1:1 die effektiven Schwellen — siehe Orientation: Landscape vs Portrait.
Praxis: typische faceH-Werte je iPhone-Setup
| Setup | Distanz | Frame-AR | faceH (real) |
|---|---|---|---|
| MacBook + Person 50cm | 50 cm | 16:9 Landscape | ~0.55 |
| iPhone Wide (26mm) hochkant + 1m | 1 m | 9:16 Portrait | ~0.13 |
| iPhone Wide (26mm) hochkant + 0.5m | 0.5 m | 9:16 Portrait | ~0.27 |
| iPhone Pro Tele (77mm) hochkant + 1m | 1 m | 9:16 Portrait | ~0.42 |
| iPhone Pro Max Tele (120mm) + 1.5m | 1.5 m | 9:16 Portrait | ~0.45 |
Parameter im Dashboard
| Parameter | Default | Wirkung |
|---|---|---|
faceHMin_landscape | 0.32 | Min. Gesichtshöhe bei Landscape (16:9 z.B. MacBook). |
faceHMax_landscape | 0.78 | Max. Gesichtshöhe bei Landscape. |
faceHMin_portrait | 0.10 | Min. bei Portrait (9:16 iPhone hochkant). Naturgemäß kleiner weil Frame höher. |
faceHMax_portrait | 0.30 | Max. bei Portrait. Auf iPhone-Pro mit Tele-Linse evtl. höher (~0.50) setzen. |
Bezug zum Capture (face-zentrierter Crop)
Der Distanz-Hint feuert über die Trigger-Bereitschaft (Auto-Modus). Aber:
- Mit aktivem captureFaceCrop: das Foto wird nach Aufnahme um die Face-BBox gecroppt → Kopffüllung im Foto =
captureHeadFill(z.B. 0.72), unabhängig vom faceH-Wert beim Trigger - Ohne captureFaceCrop: Foto enthält den ganzen Frame → Kopfgröße im Foto entspricht direkt faceH
Die Distanz-Schwellen kontrollieren also nur den Trigger, nicht die finale Kopfgröße — die kommt aus den capture*-Parametern.
Tuning-Anleitungen
Landscape
0.40 – 0.70, Portrait 0.13 – 0.35, plus captureHeadFill: 0.72, captureEyeY: 0.38
Landscape
0.20 – 0.90, Portrait 0.06 – 0.45 — sehr breite Range, Trigger feuert bei fast jeder Distanz.
value (gemessenes faceH) und range (Schwellen je erkannter Orientation). Wert im Range → grün.
2. Vertikale Position (eyeMidY) — „Kamera höher / tiefer"
Misst die Y-Position der Augenmitte. Ziel: Augen im oberen Drittel des Bildes (ICAO).
Landmarks
Formel
Trigger: eyeMidY < eyeYMin ODER eyeMidY > eyeYMax
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
eyeYMin_landscape | 0.34 | Untere Grenze in Querformat (16:9). |
eyeYMax_landscape | 0.54 | Obere Grenze in Querformat. |
eyeYMin_portrait | 0.20 | Untere Grenze in Hochformat (9:16). Frame ist viel höher → andere Werte. |
eyeYMax_portrait | 0.62 | Obere Grenze in Hochformat. |
3. Horizontale Zentrierung — „← Links / Rechts →"
Zwei Checks gleichzeitig: Augen müssen im Bild bleiben + Gesichtsmitte zentriert.
Landmarks
Formeln
rEyeOut = lm[33].x < eyeMargin OR lm[33].x > 1−eyeMargin
lEyeOut = lm[263].x < eyeMargin OR lm[263].x > 1−eyeMargin
// Check B: Mittelpunkt-Toleranz
|cx − 0.5| > cxTol
Trigger: rEyeOut OR lEyeOut OR (Mittelpunkt-Toleranz überschritten)
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
eyeMargin_frontal | 0.11 | Mindestabstand Auge zum Bildrand bei Frontal. |
eyeMargin_halbprofil | 0.08 | Bei Halbprofil enger erlaubt. |
cxTol_frontal | 0.04 | ±-Abweichung von Mitte (0.5). Niedrig = streng. |
cxTol_halbprofil | 0.09 | Halbprofil verträgt mehr Versatz. |
4. Yaw (Kopfdrehung) — „Kopf → rechts" / „Kopf mehr drehen"
Berechnet wie weit der Kopf horizontal gedreht ist. Für Halbprofil asymmetrisch je Seite.
Landmarks
Formel
eyeDist = √((lm[263].x − lm[33].x)² + (lm[263].y − lm[33].y)²)
yawOffset = (lm[1].x − eyeMidX) / eyeDist
yawDelta = yawOffset − expectedYaw
Frontal:
|yawDelta| > yawTolMid_frontal → mittlerer Hinweis
|yawDelta| > yawTolHigh_frontal → harter Hinweis
Halbprofil_links (expectedYaw = +0.22):
yawDelta < −yawTolMid_halbprofil → noch zu frontal
yawDelta > +overrotation_halbprofil → zu stark
Halbprofil_rechts (expectedYaw = −0.22):
yawDelta > +yawTolMid_halbprofil → noch zu frontal
yawDelta < −overrotation_halbprofil → zu stark
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
expectedYaw_halbprofil | 0.22 | Soll-Drehung bei Halbprofil (0.22 ≈ 25°). |
yawTolMid_frontal | 0.18 | Frontal: leichter Hinweis ab dieser Abweichung. |
yawTolHigh_frontal | 0.32 | Frontal: harter Hinweis. |
yawTolMid_halbprofil | 0.08 | Halbprofil: enger weil expectedYaw nicht 0. |
yawTolHigh_halbprofil | 0.18 | Halbprofil: harter Hinweis. |
overrotation_halbprofil | 0.35 | Schwelle für „zu stark gedreht". |
5. Pitch (Kinn) — „Kinn hoch / runter"
Verhältnis von Kinn-Hälfte zu Stirn-Hälfte des Gesichts. Frontaler Mensch hat ratio ≈ 1.4–1.8.
Landmarks
Formel
lowerHalf = lm[152].y − eyeMidY
pitchRatio = lowerHalf / upperHalf
Trigger: pitchRatio < pitchMin → Kinn anheben
ODER pitchRatio > pitchMax → Kinn senken
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
pitchMin_frontal | 0.85 | Untergrenze Frontal. Niedriger = Kinn darf tiefer sein. |
pitchMax_frontal | 2.70 | Obergrenze Frontal. |
pitchMin_halbprofil | 0.65 | Halbprofil verzerrt das Verhältnis → lockerer. |
pitchMax_halbprofil | 3.10 | Halbprofil-Variante. |
6. Roll (Schief) — „Kopf gerade halten"
Winkel der Linie zwischen den beiden Augen. 0° = perfekt gerade.
Formel
Trigger: |rollDeg| > rollMax
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
rollMax_frontal | 12° | Maximale Schieflage Frontal. |
rollMax_halbprofil | 18° | Halbprofil verträgt mehr (Augenlinie kürzer = ungenauer). |
8. Mund offen (MAR — Mouth Aspect Ratio)
Verhältnis von Mundöffnung zur Mundbreite.
Landmarks
Formel
horiz = |lm[291].x − lm[61].x|
MAR = vert / horiz
Trigger: MAR > marThreshold
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
marThreshold | 0.13 | Niedriger = empfindlicher (Mund gilt schneller als offen). |
9. Augen geschlossen (EAR — Eye Aspect Ratio)
Verhältnis Augenhöhe zu Augenbreite, pro Auge berechnet.
Landmarks
Linkes Auge: 386 (oben), 374 (unten), 263 (außen), 362 (innen)
Formel
horiz = |outer.x − inner.x|
EAR = vert / horiz
Trigger: EAR < earThreshold (für mind. ein Auge)
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
earThreshold | 0.15 | Höher = empfindlicher (Auge gilt schneller als geschlossen). |
10. Iris-Versatz — „Geradeaus schauen"
Wie weit die Iris vom Augenmittelpunkt verschoben ist (links/rechts).
Landmarks
Augen-Eckpunkte: rechts 33+133, links 263+362
Formel
lOff = |iris_l.x − (outer_l.x + inner_l.x)/2| / |outer_l.x − inner_l.x|
Trigger: (rOff + lOff) / 2 > irisOffsetThreshold
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
irisOffsetThreshold | 0.22 | Mittelwert beider Augen-Versätze. |
Auto-Trigger-Logik
Sobald analyzeFraming() ein leeres hints[] liefert (alle 10 Bedingungen ok), startet ein Countdown. Bleibt die Pose countdownDuration ms stabil grün, wird ausgelöst.
Frame N: hints.length === 0 → state = 'green'
Countdown startet
Frame N+x: hints.length === 0 weiterhin
Wenn Countdown abgelaufen → captureCurrentFrame()
Im Best-Shot-Buffer speichern
Frame mit Hinweis: Countdown reset
Nach bestshotsCount Aufnahmen: Review-Sheet öffnen
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
countdownDuration | 1000 ms | Wartezeit bis Auslösen. 0 = sofort, höher = stabilere Aufnahme nötig. |
bestshotsCount | 5 | Wie viele Best-Shots gesammelt werden. |
targetFps | 4 | Ziel-FPS für MediaPipe (CPU-Last). |
ICAO-Score (Best-Shot-Auswahl)
Alle gesammelten Frames bekommen einen Score zwischen 0 und 5000. Der Shot mit dem höchsten Score wird vorausgewählt.
score = 5000
− rollPenalty // 0..800 (linear ab rollMax)
− pitchPenalty // 0..600
− yawPenalty // 0..1000
− distPenalty // 0..800 (Abweichung von ideal-faceH)
− eyePenalty // 0..400 (Auge zu)
− sharpPenalty // 0..600
− irisPenalty // 0..200
− mouthPenalty // 0..200
Score-Level: > 4500 = Exzellent, > 3500 = Gut, > 2000 = Ausreichend, sonst Mangelhaft.
Hinweis: ICAO-Penalty-Gewichte sind aktuell nicht im Parameter-System. Falls gewünscht, kann das nachgereicht werden.
2:3-Crop & Capture
Beim Auslösen wird der aktuelle Video-Frame auf 2:3-Seitenverhältnis (Breite:Höhe) beschnitten — Standard für Lichtbildausweise.
vw, vh = video.videoWidth, video.videoHeight
cssZoom = currentZoom (nur wenn KEIN nativer Zoom)
// Sichtbarer Bereich (bei CSS-Zoom kleiner als Frame)
visW = vw / cssZoom
visH = vh / cssZoom
// Innerhalb des sichtbaren Bereichs auf 2:3 croppen
if (visW / visH > 2/3):
sh = visH; sw = visH × 2/3; // Breite begrenzt
else:
sw = visW; sh = visW × 3/2; // Höhe begrenzt
canvas.drawImage(video, sx, sy, sw, sh, 0, 0, sw, sh)
return canvas.toDataURL('image/jpeg', photoQuality)
Parameter
| Parameter | Default | Wirkung |
|---|---|---|
photoQuality | 0.92 | JPEG-Qualität (0.5 = stark komprimiert, 1.0 = unkomprimiert). |
Zoom-System
Zwei Modi je nach Geräte-Capability:
Native Zoom (iPhone Pro mit Multi-Lens)
Bei track.getCapabilities().zoom verfügbar → echter Hardware-Zoom via track.applyConstraints({zoom: val}). Wechselt zwischen den physischen Linsen (Wide/Tele/Ultraweit). Perfekte Qualität, kein Pixel-Stretch.
CSS-Fallback (alle anderen Geräte)
Per video.style.transform = scale(zoom). Digitaler Zoom, Qualitätsverlust mit höherem Faktor. Werte < 1 funktionieren nicht (Bild würde nur verkleinert).
Parameter
| Parameter | Wirkung |
|---|---|
defaultZoom_<type> | Pro Aufnahmetyp: Initial-Zoom beim Wechsel zu diesem Typ. Z.B. defaultZoom_frontal: 2.5 für engeren Frontal-Auschnitt. |
defaultZoom_frontal: 3 aktiviert die 77 mm Tele-Linse → ICAO-perfekte Portrait-Brennweite. Subjekt 1 m Distanz reicht.