Ein eigener Sperrbildschirm — und ohne gespeichertes Passwort
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3da673662f
commit
e3b215a1dd
@@ -0,0 +1,173 @@
|
||||
import AVFoundation
|
||||
import Vision
|
||||
import CoreImage
|
||||
import Observation
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Face")
|
||||
|
||||
/// Die Kamera, und was Vision aus ihren Bildern macht.
|
||||
///
|
||||
/// Der Weg steht in `docs/spikes/G-gesichtsmerkmal.md`: Vision findet das
|
||||
/// Gesicht, der Ausschnitt bekommt einen Merkmalsvektor, verglichen wird über
|
||||
/// den Abstand. `VNGenerateFaceprintRequest` gibt es nicht — der öffentliche
|
||||
/// Bildmerkmalsvektor tut es.
|
||||
///
|
||||
/// **Die Kamera läuft nur, solange sie gebraucht wird.** Ein Sperrbildschirm,
|
||||
/// der die Kamera offen hält, ist eine leuchtende Kameraanzeige den ganzen Tag
|
||||
/// — und die zu Recht.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class FaceCapture: NSObject {
|
||||
|
||||
/// Ob die Kamera gerade läuft.
|
||||
public private(set) var isRunning = false
|
||||
|
||||
/// Der zuletzt gesehene Fehler, für die Anzeige beim Anlernen.
|
||||
public private(set) var failure: String?
|
||||
|
||||
/// Wird für jedes Bild gerufen, in dem ein Gesicht war.
|
||||
@ObservationIgnored public var onVector: (([Float]) -> Void)?
|
||||
|
||||
/// Wird gerufen, wenn ein Bild **kein** Gesicht enthielt — damit das
|
||||
/// Anlernen sagen kann „bitte ins Bild".
|
||||
@ObservationIgnored public var onNoFace: (() -> Void)?
|
||||
|
||||
/// Öffentlich nur, damit die Vorschau daran hängen kann — bedient wird sie
|
||||
/// über `start()` und `stop()`.
|
||||
@ObservationIgnored public let session = AVCaptureSession()
|
||||
@ObservationIgnored private let queue = DispatchQueue(label: "onyx.face.capture")
|
||||
/// Wird auf dem Aufnahmefaden benutzt, nicht auf dem Hauptfaden.
|
||||
/// `CIContext` ist für gleichzeitige Aufrufe ausgelegt, gilt Swift aber
|
||||
/// nicht als übertragbar — die Ausnahme steht hier und nicht als Sperre um
|
||||
/// jeden Zugriff.
|
||||
@ObservationIgnored nonisolated(unsafe) private let context = CIContext()
|
||||
|
||||
/// Ob überhaupt eine Kamera da ist.
|
||||
public static var hasCamera: Bool {
|
||||
AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) != nil
|
||||
|| AVCaptureDevice.default(for: .video) != nil
|
||||
}
|
||||
|
||||
/// Fragt die Berechtigung an. `false` heißt: ohne Kamera geht es nicht
|
||||
/// weiter, und das muss die Oberfläche sagen.
|
||||
public static func requestAccess() async -> Bool {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized: return true
|
||||
case .notDetermined: return await AVCaptureDevice.requestAccess(for: .video)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
public func start() {
|
||||
guard !isRunning else { return }
|
||||
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video,
|
||||
position: .front)
|
||||
?? AVCaptureDevice.default(for: .video) else {
|
||||
failure = String(localized: "face.error.noCamera", bundle: .module)
|
||||
return
|
||||
}
|
||||
|
||||
session.beginConfiguration()
|
||||
session.sessionPreset = .medium
|
||||
session.inputs.forEach(session.removeInput)
|
||||
session.outputs.forEach(session.removeOutput)
|
||||
|
||||
do {
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
guard session.canAddInput(input) else { throw CaptureError.cannotAddInput }
|
||||
session.addInput(input)
|
||||
} catch {
|
||||
session.commitConfiguration()
|
||||
failure = error.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
let output = AVCaptureVideoDataOutput()
|
||||
output.alwaysDiscardsLateVideoFrames = true
|
||||
output.setSampleBufferDelegate(self, queue: queue)
|
||||
guard session.canAddOutput(output) else {
|
||||
session.commitConfiguration()
|
||||
failure = String(localized: "face.error.noOutput", bundle: .module)
|
||||
return
|
||||
}
|
||||
session.addOutput(output)
|
||||
session.commitConfiguration()
|
||||
|
||||
failure = nil
|
||||
isRunning = true
|
||||
let session = session
|
||||
queue.async { session.startRunning() }
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
guard isRunning else { return }
|
||||
isRunning = false
|
||||
let session = session
|
||||
queue.async { session.stopRunning() }
|
||||
}
|
||||
|
||||
private enum CaptureError: Error { case cannotAddInput }
|
||||
}
|
||||
|
||||
extension FaceCapture: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||
|
||||
/// **Die Bildarbeit bleibt auf dem Aufnahmefaden.** Vision auf dem
|
||||
/// Hauptfaden hieße, die Oberfläche dreißigmal je Sekunde anzuhalten. Auf
|
||||
/// den Hauptfaden geht nur das Ergebnis — eine Zahlenreihe.
|
||||
///
|
||||
/// Die Warteschlange ist seriell und `alwaysDiscardsLateVideoFrames` ist
|
||||
/// gesetzt: es wird immer nur ein Bild bearbeitet, und was währenddessen
|
||||
/// anfällt, wird verworfen statt angehäuft.
|
||||
public nonisolated func captureOutput(_ output: AVCaptureOutput,
|
||||
didOutput sampleBuffer: CMSampleBuffer,
|
||||
from connection: AVCaptureConnection) {
|
||||
guard let pixels = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||
|
||||
guard let vector = vector(from: pixels) else {
|
||||
Task { @MainActor [weak self] in self?.onNoFace?() }
|
||||
return
|
||||
}
|
||||
Task { @MainActor [weak self] in self?.onVector?(vector) }
|
||||
}
|
||||
|
||||
/// Gesicht finden, ausschneiden, Merkmal rechnen. `nil`, wenn kein Gesicht
|
||||
/// im Bild war.
|
||||
///
|
||||
/// **Zuschneiden ist nicht optional.** Ein Merkmalsvektor des ganzen Bildes
|
||||
/// beschreibt zur Hälfte den Hintergrund — dann erkennt Onyx den Raum wieder
|
||||
/// und nicht die Person.
|
||||
private nonisolated func vector(from pixels: CVPixelBuffer) -> [Float]? {
|
||||
let detect = VNDetectFaceRectanglesRequest()
|
||||
try? VNImageRequestHandler(cvPixelBuffer: pixels, options: [:]).perform([detect])
|
||||
guard let face = detect.results?.first else { return nil }
|
||||
|
||||
let image = CIImage(cvPixelBuffer: pixels)
|
||||
let extent = image.extent
|
||||
// Vision rechnet in Anteilen mit Ursprung unten links.
|
||||
var box = CGRect(x: face.boundingBox.minX * extent.width,
|
||||
y: face.boundingBox.minY * extent.height,
|
||||
width: face.boundingBox.width * extent.width,
|
||||
height: face.boundingBox.height * extent.height)
|
||||
// Etwas Rand: das Rechteck endet an der Stirn, und Haaransatz und Kinn
|
||||
// tragen zur Wiedererkennung bei.
|
||||
box = box.insetBy(dx: -box.width * 0.15, dy: -box.height * 0.15).intersection(extent)
|
||||
guard !box.isEmpty else { return nil }
|
||||
|
||||
guard let cg = context.createCGImage(image.cropped(to: box), from: box) else { return nil }
|
||||
|
||||
let print = VNGenerateImageFeaturePrintRequest()
|
||||
try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([print])
|
||||
guard let observation = print.results?.first else { return nil }
|
||||
return observation.onyxVector
|
||||
}
|
||||
}
|
||||
|
||||
extension VNFeaturePrintObservation {
|
||||
/// Der Merkmalsvektor als Zahlenreihe.
|
||||
var onyxVector: [Float] {
|
||||
data.withUnsafeBytes { raw in
|
||||
Array(raw.bindMemory(to: Float.self))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user