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