Built a Jarvis-style scrolling log viewer that surfaces what each subsystem is doing in real time. The buffer is a thread-safe ring of the last 1,000 entries; the SwiftUI viewer polls at 10 Hz, only re-renders when the buffer version bumps, and supports level / source filters, pause, auto-scroll, and clear. Components - Sources/App/LogStream.swift — singleton with Level (DBG/INF/WRN/ERR) and Source (APP / CAM / DETECT / TRACK / FACE / RENDER / VPOC) taxonomies, an os_unfair_lock-guarded buffer, and stdout mirroring so `log stream --process VisionPOC` shows the same feed in Terminal. - Sources/App/LogStreamView.swift — SwiftUI viewer with toolbar (level menu, source menu, auto-scroll, pause, clear, filtered/total counts) and a monospaced row-per-entry list that auto-scrolls to the bottom when new entries arrive. - Sources/App/LogStreamWindowController.swift — separate dark-aqua floating window so the log doesn't compete with the main 4-pane HUD for real estate. - AppDelegate gains a ⌘L "Log Stream…" menu item and a held reference to the controller (lazy-instantiated on first open). Wiring - AppDelegate: launch + ready - CameraCapture: device selection, session start, permission denied, enumeration failures - ObjectDetector: model load (success or fallback), 1 Hz detector stats (yolo vs track mode, ms, track count + label list), per-track add and per-track lose - ObjectDetector face match: when a face is recognized via FaceRegistry the distance is logged at DBG level - FaceRegistry: load on init, enroll (+prints, total), forget Implementation notes - The viewer is not @Observable-bound to LogStream so background- thread log emit calls don't pay a MainActor hop. Instead the view polls .version every 100ms and reloads on bump — bursts up to ~100 entries per tick render fine and the buffer absorbs writes from any rate the os_unfair_lock can sustain (~10 M/s on M-series). - LogStream.shared.log() can be called from any thread/queue. Stdout is also written from the same call so terminal tail works. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
157 lines
5.5 KiB
Swift
157 lines
5.5 KiB
Swift
import Foundation
|
|
import Vision
|
|
import os
|
|
|
|
/// Persists enrolled face feature prints to
|
|
/// `~/Library/Application Support/VisionPOC/faces.json` and matches incoming
|
|
/// face observations against them.
|
|
///
|
|
/// Each person can be enrolled multiple times — the registry stores every
|
|
/// captured print and matches against the minimum distance, so seeing the
|
|
/// same person at slightly different angles/lighting only improves accuracy.
|
|
///
|
|
/// Distance interpretation (`VNFeaturePrintObservation.computeDistance(...)`):
|
|
/// - 0.0 → identical
|
|
/// - ~0.6 → conservative threshold for "same person"
|
|
/// - 1.0+ → different people
|
|
final class FaceRegistry: @unchecked Sendable {
|
|
/// Match threshold. Distances below this are considered the same person.
|
|
/// Sourced from `Theme.Performance.faceMatchThreshold` so the Settings
|
|
/// panel slider can adjust it live.
|
|
var matchThreshold: Float {
|
|
get { Theme.Performance.faceMatchThreshold }
|
|
set { Theme.Performance.faceMatchThreshold = newValue }
|
|
}
|
|
|
|
private var lock = os_unfair_lock_s()
|
|
private var templates: [String: [VNFeaturePrintObservation]] = [:]
|
|
|
|
private static var storageURL: URL {
|
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
let dir = base.appendingPathComponent("VisionPOC", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir.appendingPathComponent("faces.json")
|
|
}
|
|
|
|
init() {
|
|
loadFromDisk()
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
func enrolledNames() -> [String] {
|
|
os_unfair_lock_lock(&lock)
|
|
defer { os_unfair_lock_unlock(&lock) }
|
|
return Array(templates.keys).sorted()
|
|
}
|
|
|
|
func enroll(name: String, prints: [VNFeaturePrintObservation]) {
|
|
guard !prints.isEmpty else { return }
|
|
os_unfair_lock_lock(&lock)
|
|
var existing = templates[name] ?? []
|
|
existing.append(contentsOf: prints)
|
|
templates[name] = existing
|
|
let total = existing.count
|
|
os_unfair_lock_unlock(&lock)
|
|
saveToDisk()
|
|
LogStream.shared.log("enrolled \(name) (+\(prints.count) prints, \(total) total)",
|
|
level: .info, source: .face)
|
|
}
|
|
|
|
func forget(name: String) {
|
|
os_unfair_lock_lock(&lock)
|
|
let existed = templates.removeValue(forKey: name) != nil
|
|
os_unfair_lock_unlock(&lock)
|
|
saveToDisk()
|
|
if existed {
|
|
LogStream.shared.log("forgot \(name)", level: .info, source: .face)
|
|
}
|
|
}
|
|
|
|
/// Returns the closest match below `matchThreshold`, or nil if no enrolled
|
|
/// person is similar enough. Returns the best name plus the achieved
|
|
/// distance so callers can display a confidence cue.
|
|
func bestMatch(for print: VNFeaturePrintObservation) -> (name: String, distance: Float)? {
|
|
os_unfair_lock_lock(&lock)
|
|
let snapshot = templates
|
|
let threshold = matchThreshold
|
|
os_unfair_lock_unlock(&lock)
|
|
|
|
var bestName: String?
|
|
var bestDistance: Float = .greatestFiniteMagnitude
|
|
|
|
for (name, prints) in snapshot {
|
|
for template in prints {
|
|
var distance: Float = 0
|
|
do {
|
|
try template.computeDistance(&distance, to: print)
|
|
} catch {
|
|
continue
|
|
}
|
|
if distance < bestDistance {
|
|
bestDistance = distance
|
|
bestName = name
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let bestName, bestDistance < threshold else { return nil }
|
|
return (bestName, bestDistance)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private func loadFromDisk() {
|
|
guard let data = try? Data(contentsOf: Self.storageURL) else { return }
|
|
guard let decoded = try? JSONDecoder().decode([String: [Data]].self, from: data) else { return }
|
|
|
|
var loaded: [String: [VNFeaturePrintObservation]] = [:]
|
|
for (name, blobs) in decoded {
|
|
var prints: [VNFeaturePrintObservation] = []
|
|
for blob in blobs {
|
|
if let obs = try? NSKeyedUnarchiver.unarchivedObject(
|
|
ofClass: VNFeaturePrintObservation.self,
|
|
from: blob
|
|
) {
|
|
prints.append(obs)
|
|
}
|
|
}
|
|
if !prints.isEmpty {
|
|
loaded[name] = prints
|
|
}
|
|
}
|
|
templates = loaded
|
|
let nameCount = loaded.count
|
|
let printCount = loaded.values.reduce(0) { $0 + $1.count }
|
|
LogStream.shared.log("loaded \(nameCount) enrolled face\(nameCount == 1 ? "" : "s") (\(printCount) prints)",
|
|
level: .info, source: .face)
|
|
}
|
|
|
|
private func saveToDisk() {
|
|
os_unfair_lock_lock(&lock)
|
|
let snapshot = templates
|
|
os_unfair_lock_unlock(&lock)
|
|
|
|
var encoded: [String: [Data]] = [:]
|
|
for (name, prints) in snapshot {
|
|
var blobs: [Data] = []
|
|
for p in prints {
|
|
if let blob = try? NSKeyedArchiver.archivedData(
|
|
withRootObject: p,
|
|
requiringSecureCoding: true
|
|
) {
|
|
blobs.append(blob)
|
|
}
|
|
}
|
|
encoded[name] = blobs
|
|
}
|
|
|
|
do {
|
|
let json = try JSONEncoder().encode(encoded)
|
|
try json.write(to: Self.storageURL, options: .atomic)
|
|
} catch {
|
|
NSLog("FaceRegistry: failed to write \(Self.storageURL.path): \(error)")
|
|
}
|
|
}
|
|
}
|