Files
ScarriffleandClaude Opus 5 3da673662f Zwischenablage, Notizen und der Gesichtsvergleich
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>
2026-09-05 21:06:06 +02:00

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)
}
}