Apples eigene Einblendung lässt sich aushängen, ohne Root und ohne SIP anzufassen: `com.apple.OSDUIHelper` liegt in der GUI-Domäne des angemeldeten Benutzers, nicht im System. `launchctl bootout` dort hinein genügt. Standardmäßig aus, denn es verschwindet **jede** Einblendung — auch Helligkeit und Feststelltaste, und für die Feststelltaste hat Onyx keinen Ersatz. Beim Beenden wird wieder eingehängt: eine Einstellung darf nicht überleben, was sie eingestellt hat. Dazu zwei Fehler im Anlernen behoben. Der Posenname stand als roher Schlüssel da — die Zeichenketten liegen im Bündel von `FaceProvider`, gesucht wurden sie im Hauptbündel. Und „Gesicht vergleichen" blieb nach dem Anlernen grau, weil `isEnrolled` bei jedem Aufruf den Schlüsselbund las: richtig, aber für SwiftUI unsichtbar, weil es nichts gab, woran eine Ansicht die Änderung hätte bemerken können. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
3.8 KiB
Swift
118 lines
3.8 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
|
|
|
|
/// Aufgelöst **hier** und nicht als Schlüssel weitergereicht: die
|
|
/// Zeichenketten liegen im Bündel dieses Moduls, und wer sie anderswo
|
|
/// nachschlägt, bekommt den Schlüssel selbst zu sehen.
|
|
public var localizedName: String {
|
|
switch self {
|
|
case .straight: String(localized: "face.pose.straight", bundle: .module)
|
|
case .left: String(localized: "face.pose.left", bundle: .module)
|
|
case .right: String(localized: "face.pose.right", bundle: .module)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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) {}
|
|
}
|