import Foundation /// Ein hinterlegtes Gesicht — ein Merkmalsvektor aus einer Aufnahme. /// /// Mehrere davon je Person, aus verschiedenen Posen. Ein einzelner Vektor /// beschreibt eine Kopfhaltung bei einer Beleuchtung, nicht ein Gesicht. public struct FaceTemplate: Equatable, Sendable, Codable { public let id: UUID /// Wofür die Aufnahme gedacht war — nur zur Anzeige beim Anlernen. public let pose: String /// Der Merkmalsvektor. 768 Elemente aus /// `VNGenerateImageFeaturePrintRequest`; siehe `docs/spikes/G-gesichtsmerkmal.md`. public let vector: [Float] public init(id: UUID = UUID(), pose: String, vector: [Float]) { self.id = id self.pose = pose self.vector = vector } } /// Wie ein Vergleich ausgegangen ist. public enum FaceDecision: Equatable, Sendable { /// Erkannt — genug übereinstimmende Einzelbilder hintereinander. case accepted /// Noch nicht genug. Der Aufrufer soll weiter Bilder liefern. case undecided(confirmations: Int) /// Abgelehnt und gesperrt: ab hier hilft nur noch das Passwort. case lockedOut } /// Der Vergleich — reine Rechnung, ohne Kamera und ohne Vision. /// /// **Warum das ein eigener Typ ist und keine Methode am Kameracode.** Genau hier /// entscheidet sich, ob ein Fremder hereinkommt. Das muss man mit abgelegten /// Vektoren durchspielen können, ohne sich vor eine Kamera zu setzen — mit /// Grenzfällen, die man vor einer Kamera gar nicht herstellen könnte. /// /// **Und warum die Schwelle streng ist.** Ein falsches Ja ist ein Fremder am /// entsperrten Rechner. Ein falsches Nein ist ein Passwort, das man ohnehin /// kennt. Die beiden Fehler sind nicht gleich teuer, also ist die Schwelle nicht /// mittig. public struct FaceMatcher: Sendable { /// Ab welchem Abstand zwei Vektoren als dieselbe Person gelten. /// /// Aus dem Spike: ähnliche Bilder lagen bei 0,13, deutlich verschiedene bei /// 0,47. Die Schwelle liegt bewusst näher am Ähnlichen — siehe oben. public static let defaultThreshold: Float = 0.22 /// Wie viele Einzelbilder hintereinander passen müssen. /// /// Eines genügt nicht: die Kamera liefert dreißig Bilder je Sekunde, und /// unter dreißig ist irgendwann eines dabei, das zufällig passt. public static let defaultConfirmations = 3 /// Nach wie vielen Fehlversuchen Schluss ist. public static let defaultAttempts = 10 public let threshold: Float public let requiredConfirmations: Int public let maximumAttempts: Int private let templates: [FaceTemplate] public init(templates: [FaceTemplate], threshold: Float = defaultThreshold, requiredConfirmations: Int = defaultConfirmations, maximumAttempts: Int = defaultAttempts) { self.templates = templates self.threshold = threshold self.requiredConfirmations = requiredConfirmations self.maximumAttempts = maximumAttempts } /// Ob überhaupt ein Gesicht hinterlegt ist. public var isEnrolled: Bool { !templates.isEmpty } /// Der kleinste Abstand zu allen hinterlegten Vektoren. /// /// Der kleinste und nicht der mittlere: die Posen sind absichtlich /// verschieden, und ein Mittelwert über verschiedene Posen bestraft genau /// die Vielfalt, für die sie aufgenommen wurden. public func distance(to vector: [Float]) -> Float? { templates.compactMap { Self.distance($0.vector, vector) }.min() } /// Euklidischer Abstand. `nil` bei ungleicher Länge — zwei verschieden /// lange Vektoren zu vergleichen ergäbe eine Zahl ohne Bedeutung. public static func distance(_ a: [Float], _ b: [Float]) -> Float? { guard a.count == b.count, !a.isEmpty else { return nil } var sum: Float = 0 for i in a.indices { let d = a[i] - b[i] sum += d * d } return sum.squareRoot() } /// Der laufende Versuch: zählt Bestätigungen und Fehlversuche mit. public struct Session: Sendable { public private(set) var confirmations = 0 public private(set) var attempts = 0 public private(set) var isLockedOut = false public init() {} fileprivate mutating func confirm() { confirmations += 1 } fileprivate mutating func fail(limit: Int) { attempts += 1 // Bestätigungen laufen **nicht** über Fehlversuche hinweg weiter: // sonst summierten sich zufällige Treffer über Minuten zu einem Ja. confirmations = 0 if attempts >= limit { isLockedOut = true } } } /// Nimmt ein Einzelbild entgegen und sagt, wie es weitergeht. public func evaluate(_ vector: [Float], in session: inout Session) -> FaceDecision { guard !session.isLockedOut else { return .lockedOut } // Ohne hinterlegtes Gesicht gibt es nichts zu vergleichen — dann ist // jeder Versuch ein Fehlversuch und nicht etwa ein Freifahrtschein. guard isEnrolled, let distance = distance(to: vector) else { session.fail(limit: maximumAttempts) return session.isLockedOut ? .lockedOut : .undecided(confirmations: 0) } if distance <= threshold { session.confirm() return session.confirmations >= requiredConfirmations ? .accepted : .undecided(confirmations: session.confirmations) } session.fail(limit: maximumAttempts) return session.isLockedOut ? .lockedOut : .undecided(confirmations: 0) } }