Skip to content

Commit 5ed196d

Browse files
committed
feat(privacy): add blur for full person; add demo video in sim
1 parent 51958c5 commit 5ed196d

1 file changed

Lines changed: 129 additions & 53 deletions

File tree

AllSpark-ios/CameraViewController.swift

Lines changed: 129 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
1919
// Image processing
2020
private let context = CIContext()
2121

22-
// Face detection
23-
private var faceDetectionRequest: VNDetectFaceRectanglesRequest!
24-
private var detectedFaces: [VNFaceObservation] = []
22+
// Person segmentation
23+
private var personSegmentationRequest: VNGeneratePersonSegmentationRequest!
24+
private var personMaskBuffer: CVPixelBuffer?
25+
26+
#if targetEnvironment(simulator)
27+
private var simulatorPlayer: AVPlayer?
28+
private var simulatorVideoOutput: AVPlayerItemVideoOutput?
29+
private var simulatorDisplayLink: CADisplayLink?
30+
#endif
2531

2632
// Video Recording
2733
private var assetWriter: AVAssetWriter?
@@ -66,7 +72,7 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
6672
setupTimerLabel()
6773
setupConnectionStatusIcon()
6874
setupCamera()
69-
setupFaceDetection()
75+
setupPrivacyFiltering()
7076

7177
// ConnectionManager is a singleton, so we just ensure it's connected and observe it
7278
ConnectionManager.shared.connect()
@@ -93,9 +99,14 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
9399

94100
stopRecording()
95101

102+
#if targetEnvironment(simulator)
103+
simulatorPlayer?.pause()
104+
simulatorDisplayLink?.isPaused = true
105+
#else
96106
if let session = captureSession, session.isRunning {
97-
session.stopRunning()
107+
session.stopRunning()
98108
}
109+
#endif
99110

100111
// Close WebSocket connection
101112
// WebSocket connection is now managed by ConnectionManager (background), so we don't disconnect here.
@@ -397,6 +408,7 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
397408
}
398409

399410
private func configureCameraSwitch() {
411+
guard captureSession != nil else { return }
400412
captureSession.beginConfiguration()
401413

402414
// Remove existing video input
@@ -750,10 +762,11 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
750762

751763
private func setupCamera() {
752764
// Camera initialization will be handled in checkAndRequestPermissions
753-
setupFaceDetection()
765+
setupPrivacyFiltering()
754766
}
755767

756768
private func setupAudioInput() {
769+
guard captureSession != nil else { return }
757770
// Check if audio input already exists
758771
let audioInputExists = captureSession.inputs.contains { input in
759772
guard let deviceInput = input as? AVCaptureDeviceInput else { return false }
@@ -835,6 +848,15 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
835848
}
836849

837850
private func initializeCameraIfNeeded() {
851+
#if targetEnvironment(simulator)
852+
guard simulatorPlayer == nil else {
853+
simulatorPlayer?.play()
854+
simulatorDisplayLink?.isPaused = false
855+
return
856+
}
857+
setupSimulatorVideoLoop()
858+
return
859+
#else
838860
guard captureSession == nil else {
839861
// Camera already initialized, just start running
840862
if !captureSession.isRunning {
@@ -886,6 +908,7 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
886908

887909
// Reconnect WebSocket logic is now handled by ConnectionManager
888910

911+
#endif
889912
}
890913

891914
private func showPermissionDeniedAlert(permissionType: String) {
@@ -900,6 +923,7 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
900923
}
901924

902925
private func updateVideoOrientation() {
926+
guard captureSession != nil else { return }
903927
// Find the video input, not just the first input (which could be audio)
904928
if let videoCaptureDevice = captureSession.inputs.compactMap({ $0 as? AVCaptureDeviceInput }).first(where: { $0.device.hasMediaType(.video) }) {
905929
// Initialize RotationCoordinator if needed
@@ -935,60 +959,109 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
935959
}
936960
}
937961

938-
private func setupFaceDetection() {
939-
faceDetectionRequest = VNDetectFaceRectanglesRequest { [weak self] request, error in
940-
guard let observations = request.results as? [VNFaceObservation] else {
941-
return
942-
}
962+
private func setupPrivacyFiltering() {
963+
let request = VNGeneratePersonSegmentationRequest()
964+
request.qualityLevel = .fast
965+
request.outputPixelFormat = kCVPixelFormatType_OneComponent8
966+
self.personSegmentationRequest = request
967+
}
943968

944-
self?.detectedFaces = observations
969+
private func applyPrivacyBlur(to image: CIImage) -> CIImage {
970+
guard let maskBuffer = personMaskBuffer else { return image }
971+
972+
let maskImage = CIImage(cvPixelBuffer: maskBuffer)
973+
974+
// Ensure accurate bounding box alignment by scaling
975+
let scaleX = image.extent.width / maskImage.extent.width
976+
let scaleY = image.extent.height / maskImage.extent.height
977+
let scaledMask = maskImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))
978+
979+
// Pixellate filter to anonymize
980+
if let blurFilter = CIFilter(name: "CIPixellate") {
981+
blurFilter.setValue(image, forKey: kCIInputImageKey)
982+
blurFilter.setValue(40.0, forKey: kCIInputScaleKey)
983+
984+
if let blurredImage = blurFilter.outputImage {
985+
if let blendFilter = CIFilter(name: "CIBlendWithMask") {
986+
blendFilter.setValue(blurredImage, forKey: kCIInputImageKey)
987+
blendFilter.setValue(image, forKey: kCIInputBackgroundImageKey)
988+
blendFilter.setValue(scaledMask, forKey: kCIInputMaskImageKey)
989+
990+
if let output = blendFilter.outputImage {
991+
return output
992+
}
993+
}
994+
}
945995
}
996+
return image
946997
}
947998

948-
private func blurFaces(in ciImage: CIImage, faces: [VNFaceObservation]) -> CIImage {
949-
var outputImage = ciImage
950-
let imageSize = ciImage.extent.size
951-
952-
for face in faces {
953-
// Convert normalized coordinates to image coordinates
954-
// Vision uses normalized coordinates (0-1) with origin at bottom-left
955-
let boundingBox = face.boundingBox
956-
957-
// Convert from Vision coordinates to CIImage coordinates
958-
let x = boundingBox.origin.x * imageSize.width
959-
let y = boundingBox.origin.y * imageSize.height
960-
let width = boundingBox.width * imageSize.width
961-
let height = boundingBox.height * imageSize.height
962-
963-
// Expand the box slightly for better coverage
964-
let expansion: CGFloat = 0.3
965-
let expandedX = max(0, x - width * expansion)
966-
let expandedY = max(0, y - height * expansion)
967-
let expandedWidth = min(imageSize.width - expandedX, width * (1 + 2 * expansion))
968-
let expandedHeight = min(imageSize.height - expandedY, height * (1 + 2 * expansion))
999+
#if targetEnvironment(simulator)
1000+
private func setupSimulatorVideoLoop() {
1001+
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
1002+
let videoURL = documentsPath.appendingPathComponent("demo_video.mp4")
9691003

970-
let faceRect = CGRect(x: expandedX, y: expandedY, width: expandedWidth, height: expandedHeight)
1004+
guard FileManager.default.fileExists(atPath: videoURL.path) else {
1005+
DispatchQueue.main.async {
1006+
let alert = UIAlertController(title: "Simulator Video Missing", message: "Please drag 'demo_video.mp4' into the iOS Simulator's AllSpark-ios folder using the macOS Finder or Simulator Files app.", preferredStyle: .alert)
1007+
alert.addAction(UIAlertAction(title: "OK", style: .default))
1008+
self.present(alert, animated: true)
1009+
}
1010+
return
1011+
}
9711012

972-
// Create blur filter
973-
if let blurFilter = CIFilter(name: "CIPixellate") {
974-
// Crop the face region
975-
let faceCrop = outputImage.cropped(to: faceRect)
1013+
let player = AVPlayer(url: videoURL)
1014+
player.actionAtItemEnd = .none
9761015

977-
blurFilter.setValue(faceCrop, forKey: kCIInputImageKey)
978-
blurFilter.setValue(40.0, forKey: kCIInputScaleKey)
1016+
let output = AVPlayerItemVideoOutput(pixelBufferAttributes: [
1017+
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
1018+
])
1019+
player.currentItem?.add(output)
1020+
1021+
simulatorPlayer = player
1022+
simulatorVideoOutput = output
1023+
1024+
NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
1025+
player.seek(to: .zero)
1026+
player.play()
1027+
}
9791028

980-
if let blurredOutput = blurFilter.outputImage {
981-
// The blur filter expands the image, so we need to crop it back
982-
let croppedBlur = blurredOutput.cropped(to: faceRect)
1029+
simulatorDisplayLink = CADisplayLink(target: self, selector: #selector(simulatorDisplayLinkFired))
1030+
simulatorDisplayLink?.add(to: .main, forMode: .common)
1031+
1032+
player.play()
1033+
}
9831034

984-
// Composite the blurred face back onto the original image
985-
outputImage = croppedBlur.composited(over: outputImage)
986-
}
1035+
@objc private func simulatorDisplayLinkFired() {
1036+
guard let output = simulatorVideoOutput else { return }
1037+
let itemTime = output.itemTime(forHostTime: CACurrentMediaTime())
1038+
guard output.hasNewPixelBuffer(forItemTime: itemTime) else { return }
1039+
1040+
var presentationItemTime = CMTime.zero
1041+
guard let pixelBuffer = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: &presentationItemTime) else { return }
1042+
1043+
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
1044+
1045+
let handler = VNImageRequestHandler(ciImage: ciImage, orientation: .up, options: [:])
1046+
do {
1047+
try handler.perform([personSegmentationRequest])
1048+
if let result = personSegmentationRequest.results?.first as? VNPixelBufferObservation {
1049+
self.personMaskBuffer = result.pixelBuffer
9871050
}
1051+
} catch { }
1052+
1053+
let processedImage = applyPrivacyBlur(to: ciImage)
1054+
1055+
// Use monotonic host clock because video file will randomly jump to 0.0s causing AVAssetWriter to fail
1056+
let currentHostTime = CMClockGetTime(CMClockGetHostTimeClock())
1057+
recordVideoFrame(processedImage, timestamp: currentHostTime)
1058+
1059+
if let cgImage = context.createCGImage(processedImage, from: processedImage.extent) {
1060+
let uiImage = UIImage(cgImage: cgImage)
1061+
self.imageView.image = uiImage
9881062
}
989-
990-
return outputImage
9911063
}
1064+
#endif
9921065

9931066
private func recordAudioFrame(_ sampleBuffer: CMSampleBuffer) {
9941067
recordingStateLock.lock()
@@ -1128,17 +1201,20 @@ extension CameraViewController: AVCaptureVideoDataOutputSampleBufferDelegate, AV
11281201

11291202
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
11301203

1131-
// Perform face detection
1204+
// Perform person segmentation
11321205
let handler = VNImageRequestHandler(ciImage: ciImage, orientation: .up, options: [:])
11331206

11341207
do {
1135-
try handler.perform([faceDetectionRequest])
1208+
try handler.perform([personSegmentationRequest])
1209+
if let result = personSegmentationRequest.results?.first as? VNPixelBufferObservation {
1210+
self.personMaskBuffer = result.pixelBuffer
1211+
}
11361212
} catch {
1137-
print("Failed to perform face detection: \(error)")
1213+
print("Failed to perform person segmentation: \(error)")
11381214
}
11391215

1140-
// Apply blur to detected faces
1141-
let processedImage = blurFaces(in: ciImage, faces: detectedFaces)
1216+
// Apply blur to humans
1217+
let processedImage = applyPrivacyBlur(to: ciImage)
11421218

11431219
// Record output
11441220
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)

0 commit comments

Comments
 (0)