Security With Privacy.
PEW is my home defense app. Use PEW before you pew pew. A future must have for anyone that's self-reliant.
v 1.1 Pushed fixing a bug that causes the first 5s clip done by the auto-clip tool to not fire.
v 1.11 will be the sensitivity sliders which will fix the "auto-clip" reset bug. It's not a bug, it's just too sensitive.
Art by Shiny.
The average home invasion takes less than 10 minutes. The violence portion is usually in the first minute. You likely won't have time to react. Keep this app active (heyoo) at all times, even when you're home. Transparency and evidence are weapons in self-defense.
Left Camera, no anomaly detected.
Right Camera, anomaly detected.
PEW Nerd Speak:
App enables any commercially available webcam to serve as a camera input. App enables auto-creation of video evidence stored locally and in the cloud via webhook (text messages and e-mails draft, but do not send). Custom algorithm monitors every frame of every input and sends to the appropriate output. (select all, deselect all, and manual 1 by 1 allocation of input to output and ouput to input is available.) App enables custom clipping for evidence generation.
PEW Real Talk:
Set up all your home cameras to record and notify you via your preferred method when any major event happens: motion, light, or camera disconnect. It's completely offline, and since you choose the destination, no Nerd server ever sees your files.
PEW BiS:
PEW is the best in class home surveillence for a few reasons:
It works with your existing hardware.
It's completely customizable for your needs without needing a technician.
It doesn't feed any AI, so your home isn't being live-streamed to Epstein/Trump Island. (Did I hear a Flock demo defaulted to a bunch of girls gymnastics locker room, because that was what the presenters were looking at before the test?)
Frame by frame access to my algorithm, which will be posted below once approved by Apple.
My clipping engine, with both manual and automated capabilities.
All features can be enabled/disabled, including my AI/Algorithm that powers the clipping engine.
It's free.
Wired Camera Inputs
Wireless Camera Inputs
Text Message Outputs
E-mail Outputs
Webhook Outputs
Custom Local Outputs
Input/Output Flow Customization
Frame By Frame Event Detection Algorithm
Low Light Algorithm
Clip Engine
Auto-Clipping with an on/off switch.
Input/Output Persistence
Reset Button
Camera Persistence
Motion Tracker Interface
Remote Viewing
Audio
User Algorithm Customization
Low Light Algorithm Customization
Sunrise/Sunset Algorithm
Mobile Compatibility
1: Home Uses:
A neighborhood watch sets up their own homes individually.
Each home has a private free discord for their internal cameras to automatically send clips.
Each home has external cameras which auto-send clips to a full community discord server and their own private server.
A Macbook with a USB hub with a bunch of connected webcams can instantly create a private security network at any location in the world.
2: Commercial uses:
A restaurant owner connects all cameras and has instant notification when anyone is stealing. So, if you are watching your camera feed and see someone steal, just press the clip +30 button and it'll automatically save the previous 10 seconds (the theft) and the next 30 seconds to whatever output is selected.
3: Additional Nerds:
This app provides utility. What webhooks will you use, how, and what videos will come out of it? Who will be protected by transparent truth?
4: Additional Value:
Creating a draft e-mail will still upload it to the cloud, depending on your integrations setup. This app enables the creation and distribution of surveillance clips at user discretion.
I mean, you could all just make better dance videos on my security system, the bar is low.
No data is ever sent to any Nerd servers or seen by any Nerd personnel. Obviously that means no data is ever stored in any Nerd servers.
It's free. I am not trying to extract a rent on your home security.
Note: The app is designed with webhooks, and tested via Discord. So, pay them a subscription to host your community. They earned it too.
My goal is to show you the quality of my work, and hope you try my paid products.
Airhug Webcam: I chose this because it didn't have a mic and it did have a privacy screen. For the size of the camera, the screen is very rigid and tough to work with... but it is effective.
Logitech Webcam: I wanted to test a wireless USB camera. Works great :)
Discord: The current auto-clipping algorithm fires one video below the 8MB threshold for Discord's free API and a longer video that will be rejected by most webhooks. An error message will appear to alert the user when a video is rejected by the webhook API. Be sure to give them a Nitro subscription if you use it a lot.
I think this is a great story on leadership and the setting of metrics; also dumbassery.
As I was testing the app, I was finally getting the detection algorithm to somewhat work. I hammered it for about a week with several different AI tools to develop a solid comprehensive approach. I started getting really frustrated because as I was testing multiple camera feeds, the webcam kept firing. I was pissed. Why was my webcam firing? Nothing was happening. I was just sitting there.
I was sitting there.
It was detecting me. Think Drax thinking he's invisible for a moment.
Early apps, everything breaks until it doesn't. I had gotten so in the habit of everything failing that I was trying to figure out this latest failure without realizing it was my first success on this app. It's amazing how a small shift in perspective can change a failure into a success.
Currently the auto-clip algorithm struggles in low light as a shadow causes something to change from its normal appearance to black very easily. I have added a low light filter to remove false positives but this may remove some actual positives? No algorithm is perfect.
Sunrise testing has been fun... but it's forecast to rain all week. EFFFFFFFFFFFFF!
This will change over time and is imperfect. V1.11 will include a "sensitivity slider" that will allow the user to adjust a few of the coefficients thresholds. I just need to figure out where to place it, as it'll be much more difficult to have sensitivity controls be per-camera-feed as opposed to a universal control as it is now.
You can copy and paste that and make your own detection algorithm.
//
// CheckFrame.swift
// Passive Entry Warden
//
import AVFoundation
// MARK: - Events
enum CheckFrameEvent {
case lightChangeDetected(delta: Float)
case colorChangeDetected(zone: Int)
}
// MARK: - DominantColor
enum DominantColor: String {
case red = "Red"
case orange = "Orange"
case yellow = "Yellow"
case green = "Green"
case blue = "Blue"
case purple = "Purple"
case white = "White"
case gray = "Gray"
case black = "Black"
static func classify(h: Float, s: Float, v: Float) -> DominantColor {
if v < 0.15 { return .black }
if s < 0.15 {
return v > 0.75 ? .white : .gray
}
switch h {
case 0 ..< 20: return .red
case 20 ..< 45: return .orange
case 45 ..< 80: return .yellow
case 80 ..< 165: return .green
case 165 ..< 255: return .blue
case 255 ..< 330: return .purple
case 330 ..< 360: return .red
default: return .gray
}
}
/// Returns true if two colors are hue-adjacent neighbors that camera
/// auto-exposure / white-balance can flip between without a real scene change.
/// Only a jump across non-adjacent colors counts as a genuine change.
func isAdjacentNeighbor(of other: DominantColor) -> Bool {
let adjacencies: Set<Set<DominantColor>> = [
[.red, .orange],
[.orange, .yellow],
[.yellow, .green],
[.green, .blue],
[.blue, .purple],
[.purple, .red], // hue wraps around
// achromatic neighbors — small saturation shifts can cross these
[.gray, .white],
[.gray, .black],
[.white, .black],
]
return adjacencies.contains([self, other])
}
}
// MARK: - CheckFrame
struct CheckFrame {
// MARK: - Tuning Constants
/// Absolute brightness delta required to fire a global light change (0–1 scale).
static let brightnessThreshold: Float = 0.020
/// Relative threshold — delta must also be ≥ this fraction of the baseline.
static let relativeThreshold: Float = 0.50
/// Per-zone brightness threshold.
static let quadrantBrightnessThreshold: Float = 0.090
/// Minimum fraction of baseline votes a color must win to be considered
/// a confident baseline. Zones below this are skipped.
static let colorVoteConfidenceThreshold: Float = 0.75
/// If the runner-up color received at least this fraction of votes
/// relative to the winner, the zone is "contested" and skipped.
/// e.g. 0.60 → skip if 2nd-place ≥ 60% of 1st-place votes.
static let colorContestThreshold: Float = 0.60
/// Seconds between color-change events. Light changes are NOT gated.
static let colorChangeCooldown: TimeInterval = 10.0
/// Number of frames to sample when computing baselines (~5 s at 30 fps).
static let baselineFrameCount: Int = 150
/// Pixels to skip per sample (higher = faster, less precise).
static let pixelSampleStride: Int = 16
/// Grid divisions along each axis (10×10 = 100 zones).
static let gridDivisions: Int = 10
// MARK: - Public Entry Point
static func check(_ sampleBuffer: CMSampleBuffer, source: InputSource) {
let elapsed = Date().timeIntervalSince(source.lastColorChangeTime)
guard elapsed >= colorChangeCooldown else {
print("[CheckFrame] \(source.name) — skipping, cooldown \(String(format: "%.1f", elapsed))s")
return
}
guard Date().timeIntervalSince(source.lastColorChangeTime) >= colorChangeCooldown else { return }
// 1. Sample current frame.
guard let current = sample(of: sampleBuffer) else { return }
// 2. Grab baseline frames from ring buffer.
let recentFrames = source.frameRingBuffer.snapshot().suffix(baselineFrameCount)
guard recentFrames.count >= 30 else { return }
// 3. Sample ~30 evenly-spaced baseline frames.
let strideVal = max(1, recentFrames.count / 30)
let zoneCount = gridDivisions * gridDivisions
var globalSamples = [Float]()
var zoneSamples = [[Float]](repeating: [], count: zoneCount)
var zoneColorVotes = [[DominantColor: Int]](repeating: [:], count: zoneCount)
var zoneTotalVotes = [Int](repeating: 0, count: zoneCount)
for (idx, buf) in recentFrames.enumerated() {
guard idx % strideVal == 0 else { continue }
guard let result = sample(of: buf) else { continue }
globalSamples.append(result.globalBrightness)
for z in 0 ..< zoneCount {
zoneSamples[z].append(result.zoneLuma[z])
let color = result.zoneDominantColors[z]
zoneColorVotes[z][color, default: 0] += 1
zoneTotalVotes[z] += 1
}
}
guard !globalSamples.isEmpty else { return }
// 4. Global brightness check (absolute + relative).
let globalBaseline = globalSamples.reduce(0, +) / Float(globalSamples.count)
let globalDelta = abs(current.globalBrightness - globalBaseline)
let globalRelative = globalBaseline * relativeThreshold
if globalDelta >= brightnessThreshold && globalDelta >= globalRelative {
handle(.lightChangeDetected(delta: globalDelta), source: source)
return
}
// 5. Per-zone brightness check.
for z in 0 ..< zoneCount {
guard !zoneSamples[z].isEmpty else { continue }
let zBaseline = zoneSamples[z].reduce(0, +) / Float(zoneSamples[z].count)
let zDelta = abs(current.zoneLuma[z] - zBaseline)
if zDelta >= quadrantBrightnessThreshold {
handle(.lightChangeDetected(delta: zDelta), source: source)
return
}
}
// 6. Per-zone dominant color check.
//
// A zone fires only when ALL conditions are met:
// (a) Cooldown has elapsed since last color event.
// (b) Winning baseline color met the confidence threshold (≥75%).
// (c) Zone is not "contested" — runner-up < 60% of winner votes.
// (d) Current color is NOT an adjacent hue neighbor of the baseline.
// (e) Current color is not Black in a well-lit scene (exposure artifact guard).
guard Date().timeIntervalSince(source.lastColorChangeTime) >= colorChangeCooldown else { return }
for z in 0 ..< zoneCount {
guard !zoneColorVotes[z].isEmpty, zoneTotalVotes[z] > 0 else { continue }
let sorted = zoneColorVotes[z].sorted { $0.value > $1.value }
let (majorityColor, majorityCount) = sorted[0]
let confidence = Float(majorityCount) / Float(zoneTotalVotes[z])
// (b) Confidence gate.
guard confidence >= colorVoteConfidenceThreshold else { continue }
// (c) Contest gate.
if sorted.count >= 2 {
let runnerUpCount = sorted[1].value
let contestRatio = Float(runnerUpCount) / Float(majorityCount)
if contestRatio >= colorContestThreshold { continue }
}
let currentColor = current.zoneDominantColors[z]
guard currentColor != majorityColor else { continue }
// (d) Adjacent-neighbor gate — ignore boundary hue flips.
if currentColor.isAdjacentNeighbor(of: majorityColor) { continue }
// (e) Skip Black classification in well-lit scenes — likely shadow/exposure artifact.
if currentColor == .black && current.globalBrightness > 0.25 { continue }
print("[CheckFrame] \(source.name) — COLOR zone=\(z) baseline=\(majorityColor.rawValue) current=\(currentColor.rawValue) confidence=\(String(format: "%.2f", confidence)) brightness=\(String(format: "%.3f", globalBaseline))")
handle(.colorChangeDetected(zone: z), source: source)
return
}
}
// MARK: - Event Handler
private static func handle(_ event: CheckFrameEvent, source: InputSource) {
switch event {
case .lightChangeDetected(let delta):
print("[CheckFrame] \(source.name) — LIGHT delta=\(String(format: "%.4f", delta))")
DispatchQueue.main.async {
source.onLightChangeDetected?()
}
case .colorChangeDetected(let zone):
source.lastColorChangeTime = Date()
DispatchQueue.main.async {
source.onColorChangeDetected?(zone)
}
}
}
// MARK: - Sample Result
private struct SampleResult {
let globalBrightness: Float
let zoneLuma: [Float]
let zoneDominantColors: [DominantColor]
}
// MARK: - Pixel Sampling
private static func sample(of sampleBuffer: CMSampleBuffer) -> SampleResult? {
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return nil }
CVPixelBufferLockBaseAddress(imageBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly) }
let format = CVPixelBufferGetPixelFormatType(imageBuffer)
let width = CVPixelBufferGetWidth(imageBuffer)
let height = CVPixelBufferGetHeight(imageBuffer)
let gd = gridDivisions
let zoneCount = gd * gd
var globalLumaTotal: Float = 0
var globalCount: Int = 0
var zoneLumaTotals = [Float](repeating: 0, count: zoneCount)
var zoneRTotals = [Float](repeating: 0, count: zoneCount)
var zoneGTotals = [Float](repeating: 0, count: zoneCount)
var zoneBTotals = [Float](repeating: 0, count: zoneCount)
var zoneCounts = [Int](repeating: 0, count: zoneCount)
// --- YUV formats ---
if format == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange ||
format == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange ||
format == kCVPixelFormatType_420YpCbCr8Planar {
guard let yBase = CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 0),
let cbcrBase = CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 1)
else { return nil }
let yBPR = CVPixelBufferGetBytesPerRowOfPlane(imageBuffer, 0)
let cbcrBPR = CVPixelBufferGetBytesPerRowOfPlane(imageBuffer, 1)
let lumaH = CVPixelBufferGetHeightOfPlane(imageBuffer, 0)
let lumaW = width
let yPtr = yBase.assumingMemoryBound(to: UInt8.self)
let cbcrPtr = cbcrBase.assumingMemoryBound(to: UInt8.self)
for row in Swift.stride(from: 0, to: lumaH, by: pixelSampleStride) {
let zRow = min(Int(Float(row) / Float(lumaH) * Float(gd)), gd - 1)
for col in Swift.stride(from: 0, to: lumaW, by: pixelSampleStride) {
let zCol = min(Int(Float(col) / Float(lumaW) * Float(gd)), gd - 1)
let z = zRow * gd + zCol
let Y = Float(yPtr[row * yBPR + col])
let cbcrRow = (row / 2) * cbcrBPR
let cbcrCol = (col / 2) * 2
let Cb = Float(cbcrPtr[cbcrRow + cbcrCol]) - 128
let Cr = Float(cbcrPtr[cbcrRow + cbcrCol + 1]) - 128
let r = min(max(Y + 1.402 * Cr, 0), 255)
let g = min(max(Y - 0.344 * Cb - 0.714 * Cr, 0), 255)
let b = min(max(Y + 1.772 * Cb, 0), 255)
let luma = 0.299 * r + 0.587 * g + 0.114 * b
globalLumaTotal += luma
globalCount += 1
zoneLumaTotals[z] += luma
zoneRTotals[z] += r
zoneGTotals[z] += g
zoneBTotals[z] += b
zoneCounts[z] += 1
}
}
// --- BGRA / RGBA formats ---
} else if format == kCVPixelFormatType_32BGRA || format == kCVPixelFormatType_32RGBA {
guard let baseAddress = CVPixelBufferGetBaseAddress(imageBuffer) else { return nil }
let bpr = CVPixelBufferGetBytesPerRow(imageBuffer)
let ptr = baseAddress.assumingMemoryBound(to: UInt8.self)
let isBGRA = format == kCVPixelFormatType_32BGRA
for row in Swift.stride(from: 0, to: height, by: pixelSampleStride) {
let zRow = min(Int(Float(row) / Float(height) * Float(gd)), gd - 1)
for col in Swift.stride(from: 0, to: width, by: pixelSampleStride) {
let zCol = min(Int(Float(col) / Float(width) * Float(gd)), gd - 1)
let z = zRow * gd + zCol
let base = row * bpr + col * 4
let r: Float = isBGRA ? Float(ptr[base + 2]) : Float(ptr[base])
let g: Float = Float(ptr[base + 1])
let b: Float = isBGRA ? Float(ptr[base]) : Float(ptr[base + 2])
let luma = 0.299 * r + 0.587 * g + 0.114 * b
globalLumaTotal += luma
globalCount += 1
zoneLumaTotals[z] += luma
zoneRTotals[z] += r
zoneGTotals[z] += g
zoneBTotals[z] += b
zoneCounts[z] += 1
}
}
} else {
return nil
}
guard globalCount > 0 else { return nil }
let globalBrightness = (globalLumaTotal / Float(globalCount)) / 255.0
var zoneLuma = [Float](repeating: 0, count: zoneCount)
var zoneColors = [DominantColor](repeating: .gray, count: zoneCount)
for z in 0 ..< zoneCount {
let c = zoneCounts[z]
guard c > 0 else {
zoneLuma[z] = globalBrightness
zoneColors[z] = .gray
continue
}
let avgR = (zoneRTotals[z] / Float(c)) / 255.0
let avgG = (zoneGTotals[z] / Float(c)) / 255.0
let avgB = (zoneBTotals[z] / Float(c)) / 255.0
zoneLuma[z] = (zoneLumaTotals[z] / Float(c)) / 255.0
zoneColors[z] = classifyColor(r: avgR, g: avgG, b: avgB)
}
return SampleResult(
globalBrightness: globalBrightness,
zoneLuma: zoneLuma,
zoneDominantColors: zoneColors
)
}
// MARK: - Color Classification
private static func classifyColor(r: Float, g: Float, b: Float) -> DominantColor {
let maxC = max(r, g, b)
let minC = min(r, g, b)
let delta = maxC - minC
let v = maxC
let s = maxC > 0 ? delta / maxC : 0
var h: Float = 0
if delta > 0 {
if maxC == r {
h = 60 * (((g - b) / delta).truncatingRemainder(dividingBy: 6))
} else if maxC == g {
h = 60 * (((b - r) / delta) + 2)
} else {
h = 60 * (((r - g) / delta) + 4)
}
if h < 0 { h += 360 }
}
return DominantColor.classify(h: h, s: s, v: v)
}
}