← Dashboard Prototyp ↗

📖 Facesnap Berechnungs-Pipeline

Naming-Hinweis: Die hier in monospace dargestellten Variablen-Namen (faceHMin, marThreshold, …) sind identisch mit den IDs im Dashboard (unter dem deutschen Label). So findest du jeden Parameter aus dieser Doku direkt im Editor.

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 aufgerufen
  • analyzeFraming(t, lm, bbox, sharpness) — Hauptfunktion, prüft alle 10 Bedingungen
  • computeIcaoScore() — gewichtete Summe der Penalties
  • captureCurrentFrame() — 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:

SetupFrame-GrößeGesicht (Höhe)faceH
MacBook Webcam (Landscape 16:9)1280×720~320 Pixel0.44
iPhone Hochformat (Portrait 9:16)720×1280~320 Pixel0.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:

ParameterWann genutztDefault
faceHMin_landscapeLandscape-Video (16:9)0.32
faceHMax_landscapeLandscape-Video0.78
faceHMin_portraitPortrait-Video (9:16)0.10
faceHMax_portraitPortrait-Video0.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_portrait bekommen ohne andere Setups zu beeinflussen
  • Konsistent mit eyeY-Schwellen die schon immer separat waren

Was wird je Orientation aufgespalten?

Aufgespalten je OrientationGilt 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 weg
  • faceH = 0.30 → mittlere Distanz
  • faceH = 0.50 → ICAO-Standard: Kopf füllt halbes Bild
  • faceH = 0.80 → sehr nah, Linsenverzerrung wahrscheinlich
  • faceH = 1.00 → Gesicht füllt ganze Frame-Höhe (mehr geht nicht)

Landmarks

Alle 468 Face-Landmarks (Iris-Punkte 468–477 nicht inkludiert)

Berechnungsformel

// BBox aus allen Landmarks
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 isPortrait = videoWidth / videoHeight < 1;
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

SetupDistanzFrame-ARfaceH (real)
MacBook + Person 50cm50 cm16:9 Landscape~0.55
iPhone Wide (26mm) hochkant + 1m1 m9:16 Portrait~0.13
iPhone Wide (26mm) hochkant + 0.5m0.5 m9:16 Portrait~0.27
iPhone Pro Tele (77mm) hochkant + 1m1 m9:16 Portrait~0.42
iPhone Pro Max Tele (120mm) + 1.5m1.5 m9:16 Portrait~0.45

Parameter im Dashboard

ParameterDefaultWirkung
faceHMin_landscape0.32Min. Gesichtshöhe bei Landscape (16:9 z.B. MacBook).
faceHMax_landscape0.78Max. Gesichtshöhe bei Landscape.
faceHMin_portrait0.10Min. bei Portrait (9:16 iPhone hochkant). Naturgemäß kleiner weil Frame höher.
faceHMax_portrait0.30Max. 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

ICAO-Pass (strict-icao Preset):
Landscape 0.40 – 0.70, Portrait 0.13 – 0.35, plus captureHeadFill: 0.72, captureEyeY: 0.38
Locker (Feldbedingungen):
Landscape 0.20 – 0.90, Portrait 0.06 – 0.45 — sehr breite Range, Trigger feuert bei fast jeder Distanz.
Diagnose: Im 📊-Panel siehst du 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

33 (rechtes Außenauge), 263 (linkes Außenauge)

Formel

eyeMidY = (lm[33].y + lm[263].y) / 2

Trigger: eyeMidY < eyeYMin ODER eyeMidY > eyeYMax

Parameter

ParameterDefaultWirkung
eyeYMin_landscape0.34Untere Grenze in Querformat (16:9).
eyeYMax_landscape0.54Obere Grenze in Querformat.
eyeYMin_portrait0.20Untere Grenze in Hochformat (9:16). Frame ist viel höher → andere Werte.
eyeYMax_portrait0.62Obere Grenze in Hochformat.
Tipp: Wenn Person zu klein im oberen Frame-Bereich erscheint (eyeMidY zu niedrig), erhöht den unteren Wert oder weist Kollegen an, das iPhone tiefer zu halten.

3. Horizontale Zentrierung — „← Links / Rechts →"

Zwei Checks gleichzeitig: Augen müssen im Bild bleiben + Gesichtsmitte zentriert.

Landmarks

33 (rechtes Außenauge), 263 (linkes Außenauge); BBox-Center cx = bbox.xmin + bbox.width/2

Formeln

// Check A: Augen am Rand?
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

ParameterDefaultWirkung
eyeMargin_frontal0.11Mindestabstand Auge zum Bildrand bei Frontal.
eyeMargin_halbprofil0.08Bei Halbprofil enger erlaubt.
cxTol_frontal0.04±-Abweichung von Mitte (0.5). Niedrig = streng.
cxTol_halbprofil0.09Halbprofil 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

1 (Nase), 33 (rechtes Außenauge), 263 (linkes Außenauge)

Formel

eyeMidX = (lm[33].x + lm[263].x) / 2
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

ParameterDefaultWirkung
expectedYaw_halbprofil0.22Soll-Drehung bei Halbprofil (0.22 ≈ 25°).
yawTolMid_frontal0.18Frontal: leichter Hinweis ab dieser Abweichung.
yawTolHigh_frontal0.32Frontal: harter Hinweis.
yawTolMid_halbprofil0.08Halbprofil: enger weil expectedYaw nicht 0.
yawTolHigh_halbprofil0.18Halbprofil: harter Hinweis.
overrotation_halbprofil0.35Schwelle für „zu stark gedreht".
Tipp: yawOffset ist relativ, kein echter Winkel. 0.22 ≈ 25° ist empirisch kalibriert. Wenn Halbprofil-Trigger zu spät feuert: yawTolMid_halbprofil senken.

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

10 (Stirn oben), 152 (Kinn), Augenmitte aus 33+263

Formel

upperHalf = eyeMidY − lm[10].y
lowerHalf = lm[152].y − eyeMidY
pitchRatio = lowerHalf / upperHalf

Trigger: pitchRatio < pitchMin → Kinn anheben
ODER pitchRatio > pitchMax → Kinn senken

Parameter

ParameterDefaultWirkung
pitchMin_frontal0.85Untergrenze Frontal. Niedriger = Kinn darf tiefer sein.
pitchMax_frontal2.70Obergrenze Frontal.
pitchMin_halbprofil0.65Halbprofil verzerrt das Verhältnis → lockerer.
pitchMax_halbprofil3.10Halbprofil-Variante.
Bekannte Limitierung: MediaPipe erkennt Gesichter mit Pitch > ±45° schlecht. Bei stark gesenktem Kopf gibt's gar keine Landmarks → kein Trigger, kein Hinweis.

6. Roll (Schief) — „Kopf gerade halten"

Winkel der Linie zwischen den beiden Augen. 0° = perfekt gerade.

Formel

rollDeg = atan2(lm[263].y − lm[33].y, lm[263].x − lm[33].x) × 180/π

Trigger: |rollDeg| > rollMax

Parameter

ParameterDefaultWirkung
rollMax_frontal12°Maximale Schieflage Frontal.
rollMax_halbprofil18°Halbprofil verträgt mehr (Augenlinie kürzer = ungenauer).

7. Schärfe — „Unscharf"

Laplacian-Varianz des Gesichts-Crops. Hoher Wert = scharfes Bild, niedriger = verwackelt.

Algorithmus

1. Aus video.drawImage(...) einen 64×64-Pixel-Crop des Gesichts ziehen
2. In Grayscale konvertieren
3. Pro Pixel: Laplacian = 4·grau[i] − grau[oben] − grau[unten] − grau[links] − grau[rechts]
4. Varianz aller Laplacian-Werte berechnen

Trigger

Schärfe < sharpnessThreshold → Hinweis „Unscharf"

Parameter

ParameterDefaultWirkung
sharpnessThreshold60Mindest-Varianz. Höher = strenger (mehr Bilder werden als unscharf abgelehnt).
Tipp: Auf Pro-Modellen mit Tele-Linse ruhig auf 90–100 setzen. Auf Standard-iPhones bei 60–70 lassen, sonst löst Auto-Trigger nie aus.

8. Mund offen (MAR — Mouth Aspect Ratio)

Verhältnis von Mundöffnung zur Mundbreite.

Landmarks

13 (obere Lippe innen), 14 (untere Lippe innen), 61 (linker Mundwinkel), 291 (rechter Mundwinkel)

Formel

vert = |lm[14].y − lm[13].y|
horiz = |lm[291].x − lm[61].x|
MAR = vert / horiz

Trigger: MAR > marThreshold

Parameter

ParameterDefaultWirkung
marThreshold0.13Niedriger = empfindlicher (Mund gilt schneller als offen).

9. Augen geschlossen (EAR — Eye Aspect Ratio)

Verhältnis Augenhöhe zu Augenbreite, pro Auge berechnet.

Landmarks

Rechtes Auge: 159 (oben), 145 (unten), 33 (außen), 133 (innen)
Linkes Auge: 386 (oben), 374 (unten), 263 (außen), 362 (innen)

Formel

vert = |bot.y − top.y|
horiz = |outer.x − inner.x|
EAR = vert / horiz

Trigger: EAR < earThreshold (für mind. ein Auge)

Parameter

ParameterDefaultWirkung
earThreshold0.15Höher = empfindlicher (Auge gilt schneller als geschlossen).

10. Iris-Versatz — „Geradeaus schauen"

Wie weit die Iris vom Augenmittelpunkt verschoben ist (links/rechts).

Landmarks

Iris-Mittelpunkte: 468 (rechts), 473 (links)
Augen-Eckpunkte: rechts 33+133, links 263+362

Formel

rOff = |iris_r.x − (outer_r.x + inner_r.x)/2| / |outer_r.x − inner_r.x|
lOff = |iris_l.x − (outer_l.x + inner_l.x)/2| / |outer_l.x − inner_l.x|

Trigger: (rOff + lOff) / 2 > irisOffsetThreshold

Parameter

ParameterDefaultWirkung
irisOffsetThreshold0.22Mittelwert 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

ParameterDefaultWirkung
countdownDuration1000 msWartezeit bis Auslösen. 0 = sofort, höher = stabilere Aufnahme nötig.
bestshotsCount5Wie viele Best-Shots gesammelt werden.
targetFps4Ziel-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

ParameterDefaultWirkung
photoQuality0.92JPEG-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

ParameterWirkung
defaultZoom_<type>Pro Aufnahmetyp: Initial-Zoom beim Wechsel zu diesem Typ. Z.B. defaultZoom_frontal: 2.5 für engeren Frontal-Auschnitt.
Workflow-Empfehlung iPhone Pro: Rückkamera (Switch-Button) + defaultZoom_frontal: 3 aktiviert die 77 mm Tele-Linse → ICAO-perfekte Portrait-Brennweite. Subjekt 1 m Distanz reicht.