Drei neue Module nach dem Bauplan von `ShelfProvider`: reine Regeln getrennt vom Zustand, versionierte Indexdatei, Inhalte in Dateien statt in `UserDefaults` — was dort steht, liest jedes Programm mit `defaults read` mit. Die Zwischenablage achtet `ConcealedType`, `TransientType` und `AutoGeneratedType`. Diese Marken sind eine Verabredung zwischen Programmen und kein Systemdienst; wer sie missachtet, sammelt fremde Passwörter ein. Der Gesichtsvergleich stand im Plan als der eine echte Unsicherheitsfaktor. Ein Spike hat ihn erledigt: `VNGenerateFaceprintRequest` gibt es nicht, aber `VNGenerateImageFeaturePrintRequest` ist öffentlich, liefert 768 Elemente und ein Abstandsmaß — Faktor 3,5 Trennschärfe. Kein eigenes CoreML-Modell nötig. `FaceMatcher` entscheidet über mehrere Posen, mehrere Einzelbilder und eine Sperre nach Fehlversuchen. Die Schwelle liegt bewusst nicht mittig: ein falsches Ja ist ein Fremder am entsperrten Rechner, ein falsches Nein nur ein Passwort, das man ohnehin kennt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
5.5 KiB
Swift
139 lines
5.5 KiB
Swift
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)
|
|
}
|
|
}
|