Skip to content

Commit 7d05b1f

Browse files
committed
feat(privacy): add pose estimation as an optional privacy filter
1 parent 5ed196d commit 7d05b1f

3 files changed

Lines changed: 141 additions & 59 deletions

File tree

AllSpark-ios/AllSpark_iosApp.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import SwiftUI
22

33
@main
44
struct AllSpark_iosApp: App {
5+
init() {
6+
print("Application directory: \(NSHomeDirectory())")
7+
}
58
var body: some Scene {
69
WindowGroup {
710
ContentView()

AllSpark-ios/CameraViewController.swift

Lines changed: 130 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
1818

1919
// Image processing
2020
private let context = CIContext()
21-
22-
// Person segmentation
21+
22+
// Privacy Filtering
23+
private var privacyMode: String = "segmentation"
2324
private var personSegmentationRequest: VNGeneratePersonSegmentationRequest!
2425
private var personMaskBuffer: CVPixelBuffer?
26+
27+
// Body Pose Detection
28+
private var bodyPoseRequest: VNDetectHumanBodyPoseRequest!
29+
private var detectedBodyPoses: [VNHumanBodyPoseObservation] = []
2530

2631
#if targetEnvironment(simulator)
2732
private var simulatorPlayer: AVPlayer?
@@ -177,34 +182,42 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
177182
iconView.contentMode = .scaleAspectFit
178183
stackView.addArrangedSubview(iconView)
179184

185+
// Text Stack View for Timer and Modes Label
186+
let textStackView = UIStackView()
187+
textStackView.translatesAutoresizingMaskIntoConstraints = false
188+
textStackView.axis = .vertical
189+
textStackView.spacing = 2
190+
textStackView.alignment = .leading
191+
180192
// Timer Label
181193
timerLabel = UILabel()
182194
timerLabel.translatesAutoresizingMaskIntoConstraints = false
183195
timerLabel.text = "00:00"
184196
timerLabel.textColor = .red // User requested red
185197
timerLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 20, weight: .bold)
186-
stackView.addArrangedSubview(timerLabel)
187-
198+
textStackView.addArrangedSubview(timerLabel)
199+
188200
captureModesLabel = UILabel()
189201
captureModesLabel.translatesAutoresizingMaskIntoConstraints = false
190202
captureModesLabel.text = ""
191203
captureModesLabel.textColor = .white
192-
captureModesLabel.font = UIFont.systemFont(ofSize: 12, weight: .medium)
204+
captureModesLabel.font = UIFont.systemFont(ofSize: 11, weight: .medium)
193205
captureModesLabel.numberOfLines = 1
194-
captureModesLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
195-
stackView.addArrangedSubview(captureModesLabel)
206+
textStackView.addArrangedSubview(captureModesLabel)
207+
208+
stackView.addArrangedSubview(textStackView)
196209

197210
NSLayoutConstraint.activate([
198211
recordingIndicatorContainer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
199212
recordingIndicatorContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor),
200-
// Height enough for padding
201-
recordingIndicatorContainer.heightAnchor.constraint(equalToConstant: 36),
213+
// Flex height based on content
214+
recordingIndicatorContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 36),
202215

203216
// StackView constraints inside container with padding
204217
stackView.leadingAnchor.constraint(equalTo: recordingIndicatorContainer.leadingAnchor, constant: 10),
205218
stackView.trailingAnchor.constraint(equalTo: recordingIndicatorContainer.trailingAnchor, constant: -10),
206-
stackView.topAnchor.constraint(equalTo: recordingIndicatorContainer.topAnchor),
207-
stackView.bottomAnchor.constraint(equalTo: recordingIndicatorContainer.bottomAnchor),
219+
stackView.topAnchor.constraint(equalTo: recordingIndicatorContainer.topAnchor, constant: 6),
220+
stackView.bottomAnchor.constraint(equalTo: recordingIndicatorContainer.bottomAnchor, constant: -6),
208221

209222
// Icon size
210223
iconView.widthAnchor.constraint(equalToConstant: 12),
@@ -491,8 +504,8 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
491504
// Get camera position for filename
492505
let cameraPosition = currentCameraPosition == .front ? "front" : "back"
493506

494-
let timestamp = Date().timeIntervalSince1970
495-
let videoName = "recording_\(deviceNameForFilename)_\(cameraPosition)_\(timestamp).\(fileExtension)"
507+
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
508+
let videoName = "tmp_recording_\(timestampMs).\(fileExtension)"
496509
videoURL = documentsPath.appendingPathComponent(videoName)
497510

498511
guard let videoURL = videoURL else { return }
@@ -682,14 +695,14 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
682695
let format = url.pathExtension
683696
let newFilename = "chunk_\(chunkTimeMs).\(format)"
684697
let finalURL = documentsPath.appendingPathComponent(newFilename)
685-
698+
686699
try? FileManager.default.removeItem(at: finalURL)
687700
do {
688701
try FileManager.default.moveItem(at: url, to: finalURL)
689702
} catch {
690703
print("Error renaming video chunk: \(error)")
691704
}
692-
705+
693706
// Write timestamps file
694707
let timestampsFilename = "timestamps_\(chunkTimeMs).txt"
695708
let timestampsURL = documentsPath.appendingPathComponent(timestampsFilename)
@@ -702,7 +715,7 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
702715
// Determine camera from original name since we drop it in final name
703716
let parts = url.deletingPathExtension().lastPathComponent.components(separatedBy: "_")
704717
let camera = parts.count > 2 ? parts[parts.count - 2] : "unknown" // "front" or "back"
705-
718+
706719
let urlToProcess = finalURL
707720

708721
DispatchQueue.global(qos: .utility).async {
@@ -960,40 +973,88 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
960973
}
961974

962975
private func setupPrivacyFiltering() {
963-
let request = VNGeneratePersonSegmentationRequest()
964-
request.qualityLevel = .fast
965-
request.outputPixelFormat = kCVPixelFormatType_OneComponent8
966-
self.personSegmentationRequest = request
976+
self.privacyMode = UserDefaults.standard.string(forKey: "privacyMode") ?? "segmentation"
977+
978+
let segRequest = VNGeneratePersonSegmentationRequest()
979+
segRequest.qualityLevel = .accurate
980+
self.personSegmentationRequest = segRequest
981+
982+
self.bodyPoseRequest = VNDetectHumanBodyPoseRequest()
967983
}
968984

969985
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)
986+
if privacyMode == "segmentation" {
987+
guard let maskBuffer = personMaskBuffer else { return image }
988+
let maskImage = CIImage(cvPixelBuffer: maskBuffer)
989+
990+
let scaleX = image.extent.width / maskImage.extent.width
991+
let scaleY = image.extent.height / maskImage.extent.height
992+
let scaledMask = maskImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))
983993

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
994+
if let blurFilter = CIFilter(name: "CIGaussianBlur") {
995+
blurFilter.setValue(image, forKey: kCIInputImageKey)
996+
blurFilter.setValue(30.0, forKey: kCIInputRadiusKey)
997+
if let blurredImage = blurFilter.outputImage {
998+
let clampedBlur = blurredImage.cropped(to: image.extent)
999+
if let blendFilter = CIFilter(name: "CIBlendWithRedMask") {
1000+
blendFilter.setValue(clampedBlur, forKey: kCIInputImageKey)
1001+
blendFilter.setValue(image, forKey: kCIInputBackgroundImageKey)
1002+
blendFilter.setValue(scaledMask, forKey: kCIInputMaskImageKey)
1003+
if let output = blendFilter.outputImage { return output }
1004+
}
1005+
}
1006+
}
1007+
return image
1008+
} else {
1009+
var outputImage = image
1010+
let imageSize = image.extent.size
1011+
1012+
for pose in detectedBodyPoses {
1013+
// Find bounding box for all confident body joints (like ankles, knees)
1014+
guard let points = try? pose.recognizedPoints(.all) else { continue }
1015+
var minX: CGFloat = 1.0, minY: CGFloat = 1.0
1016+
var maxX: CGFloat = 0.0, maxY: CGFloat = 0.0
1017+
var validPoints = 0
1018+
1019+
for (_, point) in points where point.confidence > 0.2 {
1020+
minX = min(minX, point.location.x)
1021+
minY = min(minY, point.location.y)
1022+
maxX = max(maxX, point.location.x)
1023+
maxY = max(maxY, point.location.y)
1024+
validPoints += 1
1025+
}
1026+
1027+
guard validPoints > 0 else { continue }
1028+
1029+
let x = minX * imageSize.width
1030+
let y = minY * imageSize.height
1031+
let width = (maxX - minX) * imageSize.width
1032+
let height = (maxY - minY) * imageSize.height
1033+
1034+
// Expand the box slightly for full coverage of the limbs
1035+
let expansion: CGFloat = 0.5
1036+
let expandedX = max(0, x - width * expansion)
1037+
let expandedY = max(0, y - height * expansion)
1038+
let expandedWidth = min(imageSize.width - expandedX, width * (1 + 2 * expansion))
1039+
let expandedHeight = min(imageSize.height - expandedY, height * (1 + 2 * expansion))
1040+
1041+
let poseRect = CGRect(x: expandedX, y: expandedY, width: expandedWidth, height: expandedHeight)
1042+
1043+
// Blur the bounding box
1044+
if let blurFilter = CIFilter(name: "CIGaussianBlur") {
1045+
let poseCrop = outputImage.cropped(to: poseRect)
1046+
blurFilter.setValue(poseCrop, forKey: kCIInputImageKey)
1047+
blurFilter.setValue(30.0, forKey: kCIInputRadiusKey)
1048+
1049+
if let blurredOutput = blurFilter.outputImage {
1050+
let croppedBlur = blurredOutput.cropped(to: poseRect)
1051+
outputImage = croppedBlur.composited(over: outputImage)
9921052
}
9931053
}
9941054
}
1055+
1056+
return outputImage
9951057
}
996-
return image
9971058
}
9981059

9991060
#if targetEnvironment(simulator)
@@ -1017,45 +1078,50 @@ class CameraViewController: UIViewController, UINavigationControllerDelegate {
10171078
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
10181079
])
10191080
player.currentItem?.add(output)
1020-
1081+
10211082
simulatorPlayer = player
10221083
simulatorVideoOutput = output
1023-
1084+
10241085
NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
10251086
player.seek(to: .zero)
10261087
player.play()
10271088
}
10281089

10291090
simulatorDisplayLink = CADisplayLink(target: self, selector: #selector(simulatorDisplayLinkFired))
10301091
simulatorDisplayLink?.add(to: .main, forMode: .common)
1031-
1092+
10321093
player.play()
10331094
}
10341095

10351096
@objc private func simulatorDisplayLinkFired() {
10361097
guard let output = simulatorVideoOutput else { return }
10371098
let itemTime = output.itemTime(forHostTime: CACurrentMediaTime())
10381099
guard output.hasNewPixelBuffer(forItemTime: itemTime) else { return }
1039-
1100+
10401101
var presentationItemTime = CMTime.zero
10411102
guard let pixelBuffer = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: &presentationItemTime) else { return }
1042-
1103+
10431104
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
1044-
1105+
10451106
let handler = VNImageRequestHandler(ciImage: ciImage, orientation: .up, options: [:])
10461107
do {
1047-
try handler.perform([personSegmentationRequest])
1048-
if let result = personSegmentationRequest.results?.first as? VNPixelBufferObservation {
1049-
self.personMaskBuffer = result.pixelBuffer
1108+
if privacyMode == "segmentation" {
1109+
try handler.perform([personSegmentationRequest])
1110+
if let result = personSegmentationRequest.results?.first as? VNPixelBufferObservation {
1111+
self.personMaskBuffer = result.pixelBuffer
1112+
}
1113+
} else {
1114+
try handler.perform([bodyPoseRequest])
1115+
self.detectedBodyPoses = bodyPoseRequest.results ?? []
10501116
}
10511117
} catch { }
1052-
1118+
10531119
let processedImage = applyPrivacyBlur(to: ciImage)
1054-
1120+
10551121
// Use monotonic host clock because video file will randomly jump to 0.0s causing AVAssetWriter to fail
10561122
let currentHostTime = CMClockGetTime(CMClockGetHostTimeClock())
10571123
recordVideoFrame(processedImage, timestamp: currentHostTime)
1058-
1124+
10591125
if let cgImage = context.createCGImage(processedImage, from: processedImage.extent) {
10601126
let uiImage = UIImage(cgImage: cgImage)
10611127
self.imageView.image = uiImage
@@ -1201,16 +1267,21 @@ extension CameraViewController: AVCaptureVideoDataOutputSampleBufferDelegate, AV
12011267

12021268
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
12031269

1204-
// Perform person segmentation
1270+
// Perform privacy detection
12051271
let handler = VNImageRequestHandler(ciImage: ciImage, orientation: .up, options: [:])
12061272

12071273
do {
1208-
try handler.perform([personSegmentationRequest])
1209-
if let result = personSegmentationRequest.results?.first as? VNPixelBufferObservation {
1210-
self.personMaskBuffer = result.pixelBuffer
1274+
if privacyMode == "segmentation" {
1275+
try handler.perform([personSegmentationRequest])
1276+
if let result = personSegmentationRequest.results?.first as? VNPixelBufferObservation {
1277+
self.personMaskBuffer = result.pixelBuffer
1278+
}
1279+
} else {
1280+
try handler.perform([bodyPoseRequest])
1281+
self.detectedBodyPoses = bodyPoseRequest.results ?? []
12111282
}
12121283
} catch {
1213-
print("Failed to perform person segmentation: \(error)")
1284+
print("Failed to perform privacy detection: \(error)")
12141285
}
12151286

12161287
// Apply blur to humans

AllSpark-ios/SettingsView.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ struct SettingsView: View {
77
@AppStorage("serverHost") private var serverHost: String = ""
88
@AppStorage("verifyCertificate") private var verifyCertificate: Bool = true
99
@AppStorage("deviceName") private var deviceName: String = ""
10+
@AppStorage("privacyMode") private var privacyMode: String = "segmentation"
1011
@State private var displayText: String = "Awaiting remote configuration from server..."
1112
@State private var selectedEndpoint: NWEndpoint?
1213
@State private var showingInterfaces: Bool = false
@@ -168,6 +169,13 @@ struct SettingsView: View {
168169
}
169170
}
170171
}
172+
173+
Section(header: Text("Privacy Filter Mode")) {
174+
Picker("Mode", selection: $privacyMode) {
175+
Text("Person Segmentation (Default)").tag("segmentation")
176+
Text("Body Pose Detection (Limbs)").tag("pose")
177+
}
178+
}
171179
}
172180

173181
Divider()

0 commit comments

Comments
 (0)