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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f289d02c37
commit
3da673662f
@@ -0,0 +1,138 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user