Kameraaufnahme, Anlernen über drei Posen, Fenster je Bildschirm auf Bildschirmschoner-Ebene, Passwortrückfall. **Das Anmeldepasswort wird nicht gespeichert.** Ein Spike hat den Weg freigeräumt: `ODRecord.verifyPassword` ist öffentliche API, die Eingabe geht direkt hinein, das Ergebnis ist ein `Bool`, danach ist die Zeichenkette wieder weg. Damit fällt der Grund weg, aus dem vergleichbare Programme sich das Passwort geben lassen und in die Keychain legen. Angelernt wird über drei Posen mit je fünf Aufnahmen. Der Merkmalsvektor ist ein allgemeiner Bildmerkmalsvektor und kein Gesichtsmerkmal — er reagiert auf Kopfhaltung und Beleuchtung mit. Eine einzige Aufnahme beschreibt genau eine Haltung bei genau einem Licht. Die Vektoren liegen in der Keychain, nicht in einer Datei: aus ihnen lässt sich kein Bild zurückgewinnen, aber wer sie hat, kann prüfen, ob eine bestimmte Person vor der Kamera stand. Die Einschränkung steht auf dem Sperrbildschirm selbst und nicht im Kleingedruckten: Bequemlichkeit, keine Sicherheitsgrenze; der Vergleich läuft über die Webcam und lässt sich mit einem Foto täuschen. Wer sich darauf verlässt, tut es dort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
3.4 KiB
Swift
115 lines
3.4 KiB
Swift
import SwiftUI
|
|
import AVFoundation
|
|
import Observation
|
|
import OnyxDesign
|
|
|
|
/// Führt durch das Anlernen: mehrere Posen, mehrere Aufnahmen je Pose.
|
|
///
|
|
/// **Warum mehrere Posen.** Der Merkmalsvektor ist ein allgemeiner
|
|
/// Bildmerkmalsvektor und kein Gesichtsmerkmal — er reagiert auf Kopfhaltung
|
|
/// und Beleuchtung mit. Eine einzige Aufnahme beschreibt genau eine Haltung bei
|
|
/// genau einem Licht; wer sich danach anders hinsetzt, wird nicht erkannt.
|
|
@MainActor
|
|
@Observable
|
|
public final class FaceEnrollment {
|
|
|
|
public enum Pose: String, CaseIterable, Sendable {
|
|
case straight, left, right
|
|
|
|
public var localizedName: LocalizedStringKey {
|
|
switch self {
|
|
case .straight: "face.pose.straight"
|
|
case .left: "face.pose.left"
|
|
case .right: "face.pose.right"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wie viele Aufnahmen je Pose.
|
|
public static let samplesPerPose = 5
|
|
|
|
public private(set) var pose: Pose = .straight
|
|
public private(set) var collected = 0
|
|
public private(set) var isFinished = false
|
|
/// Ob gerade ein Gesicht im Bild ist — für die Rückmeldung „bitte ins Bild".
|
|
public private(set) var seesFace = false
|
|
|
|
@ObservationIgnored private let capture = FaceCapture()
|
|
@ObservationIgnored private let store = FaceStore()
|
|
@ObservationIgnored private var templates: [FaceTemplate] = []
|
|
/// Erst auf Knopfdruck aufnehmen: sonst sammelt es fünf Bilder derselben
|
|
/// Sekunde ein, und das ist keine Vielfalt, sondern dasselbe fünfmal.
|
|
@ObservationIgnored private var isCollecting = false
|
|
|
|
public var failure: String? { capture.failure }
|
|
|
|
/// Für die Vorschau.
|
|
public var session: AVCaptureSession { capture.session }
|
|
|
|
public init() {}
|
|
|
|
public func start() {
|
|
capture.onVector = { [weak self] vector in self?.accept(vector) }
|
|
capture.onNoFace = { [weak self] in self?.seesFace = false }
|
|
capture.start()
|
|
}
|
|
|
|
public func stop() { capture.stop() }
|
|
|
|
/// Nimmt die aktuelle Pose auf.
|
|
public func captureCurrentPose() { isCollecting = true }
|
|
|
|
private func accept(_ vector: [Float]) {
|
|
seesFace = true
|
|
guard isCollecting, !isFinished else { return }
|
|
|
|
templates.append(FaceTemplate(pose: pose.rawValue, vector: vector))
|
|
collected += 1
|
|
|
|
guard collected >= Self.samplesPerPose else { return }
|
|
isCollecting = false
|
|
collected = 0
|
|
|
|
guard let next = Pose.allCases.firstIndex(of: pose).map({ $0 + 1 }),
|
|
next < Pose.allCases.count else {
|
|
finish()
|
|
return
|
|
}
|
|
pose = Pose.allCases[next]
|
|
}
|
|
|
|
private func finish() {
|
|
capture.stop()
|
|
store.save(templates)
|
|
isFinished = true
|
|
}
|
|
|
|
/// Verwirft alles Angelernte.
|
|
public func reset() {
|
|
capture.stop()
|
|
templates = []
|
|
collected = 0
|
|
pose = .straight
|
|
isFinished = false
|
|
store.clear()
|
|
}
|
|
}
|
|
|
|
/// Die Kameravorschau.
|
|
public struct CameraPreview: NSViewRepresentable {
|
|
private let session: AVCaptureSession
|
|
|
|
public init(session: AVCaptureSession) { self.session = session }
|
|
|
|
public func makeNSView(context: Context) -> NSView {
|
|
let view = NSView()
|
|
view.wantsLayer = true
|
|
let layer = AVCaptureVideoPreviewLayer(session: session)
|
|
layer.videoGravity = .resizeAspectFill
|
|
view.layer = layer
|
|
return view
|
|
}
|
|
|
|
public func updateNSView(_ view: NSView, context: Context) {}
|
|
}
|