Inspiration

Every camera that streams over a network is trusting that network. Encryption protects the wire, but whatever's on the other end (the server, the laptop, some app) still gets the raw picture. We wanted to flip that around: what if the sensitive stuff never left the camera at all?

The long-term idea is credit cards and IDs, like someone holding a card up to a webcam to verify it. For the hackathon we started with faces, because they're the easiest thing to test on ourselves.

What it does

Privacy Screen runs on an AMD Kria KV260. The camera plugs into the board, the board finds every face and overwrites those pixels with black, and only then does the frame go out over Ethernet to the laptop. The laptop never gets an unmasked face because it's never sent one.

It's also fail-closed. If the board doesn't currently know where every face is, the whole frame goes black. A face counts as current for 250 ms after it was last detected:

$$\text{send the picture} \iff t_{\text{now}} - t_{\text{last detection}} \le 250\ \text{ms}$$

That includes when nobody's in frame, because the board can't tell "empty room" apart from "missed a face." So if you walk out of the shot, the stream goes black. That's on purpose.

How we built it

We built two versions.

Python prototype. OpenCV's Haar face detector, a scrubber that blacks out faces in place (no clean copy kept), and an MJPEG stream to a browser. We got it working on a laptop first and then on the board, where it runs at about 6 fps at 640×480 on the ARM cores.

C++ version on the DPU. This is the real one:

  1. The AR1335 camera goes through AMD's Smart Camera firmware and comes out as 1080p NV12.
  2. Each frame is copied into a private buffer, and a 640×360 thumbnail goes to AMD's densebox face model running on the DPU (the board's AI accelerator).
  3. Face boxes get padded by 25% ($w' = 1.25w$, $h' = 1.25h$) so small movements don't uncover anything. Each box is held for 250 ms to ride out frames where the detector blinks.
  4. Faces are filled black directly in NV12 ($Y = 16$, $U = V = 128$). No box means the whole frame is black.
  5. The hardware video encoder compresses it to H.264, and it streams to the Mac, which just plays it in mpv.

That gets us about 27 fps at 1080p.

We picked densebox over YOLO because it ships already compiled for this DPU. Getting a face YOLO quantized and compiled would have taken hours of the hackathon.

We also wrote a scrubber block in HLS that's meant to sit on the camera's AXI-Stream path inside the FPGA, so the masking happens in hardware before Linux ever sees the frame. It passes our testbench, but we didn't have Vivado here, so it isn't synthesized onto the board yet.

Challenges we ran into

  • The camera wouldn't talk to us. The kernel log showed NACK 0x6c (the sensor not answering). It turned out to be the ribbon cable: we had to power off, reseat it, and fix the orientation.
  • The board has no internet. No Wi-Fi, and only a direct cable to the Mac. For Python we downloaded ARM wheels on the Mac and installed them offline. For Docker we shared the Mac's Wi-Fi over the cable with NAT.
  • OpenCV segfaulted on the board. Kria Ubuntu registers AMD's XRT as an OpenCL driver, and OpenCV's face detection crashes inside it. We had to turn OpenCL off.
  • Every frame was black. Full-res Haar took about 740 ms per frame, so every face box went stale before it was used. Fail-closed was working exactly as designed, which was annoying. Running detection on a 320 px copy and scaling the boxes back up fixed it.
  • A black screen tells you nothing. It could mean the camera, the detector, the network, or the policy. We added a --test-no-blank mode with a big red "NOT FAIL-CLOSED" banner so we could aim the camera and debug.
  • The DPU model takes about a minute to load before the first frame. The first couple of times we thought it was broken.
  • Lag. Plain ffplay over TCP buffers several MB and falls way behind. Tuning the player and switching to UDP got it close to live.

Accomplishments that we're proud of

  • The full pipeline runs on real hardware: camera → face detection on the DPU → scrub → hardware H.264 → network, at 27 fps in 1080p.
  • Fail-closed actually holds. If anything goes wrong, the default answer is a black frame, never a raw one.
  • We went from a Python script on a laptop to C++ on an FPGA SoC's AI accelerator in one weekend.

What we learned

  • Most of embedded bring-up is cables, drivers, and pixel formats, not the cool part.
  • A privacy guarantee is really about where you draw the line. Right now the scrubbing happens on the board's CPU, so the network and the laptop are protected, but someone with root on the board could still read raw frames. We tried to be upfront about that instead of overclaiming.
  • Reserving a chunk of RAM hides it from apps but not from the kernel. The DPU also keeps its intermediate feature maps in DDR, and early layers are basically still an image. Real isolation means keeping everything on-chip.
  • Fail-closed costs you usability, and you have to decide that's worth it.

What's next for Privacy Screen

  • Put the HLS scrubber in the FPGA fabric so the masking is hardware, not software.
  • Move detection on-chip. Feed a downscaled copy into BRAM and run a small model built with FINN or hls4ml, so no software on the board can ever reach a raw frame. A 320×320 RGB ping-pong pair is $320 \times 320 \times 3 \times 2 \approx 600$ KB, which fits in the K26's ~648 KB of BRAM plus ~2.3 MB of URAM.
  • Cards and IDs. Same pipeline with a card detector. Cards have a fixed shape ($85.60 / 53.98 \approx 1.586$), so even a classical detector has a lot to go on. Eventually we'd mask just the number instead of the whole card.
  • Lock it down: secure boot, an encrypted bitstream, and encrypted transport to the client.

Built With

  • amd
  • c++
  • claude
  • cmake
  • fpga
Share this project:

Updates

Submission history