← logs.beer

Censoring the screen in 30 ms

2026-09-09

Editorial cover for an Android on-device content blurring article, showing before-and-after phone screens and a native NDK pipeline reducing response latency to 30 ms.

The first version worked.

It saw explicit content on screen, found the offending region, and covered it with an overlay.

It also took about 100 ms to react.

One tenth of a second does not sound terrible until the user scrolls. Then the blur starts chasing the content around like a censor with bad Wi-Fi.

Moving the hot path into Android's NDK brought the end-to-end response down to roughly 30 ms.

The interesting part is that the model was not the main problem.

A classifier is the wrong primitive

If the requirement is "blur only the explicit part of the screen," a classifier can only tell you that something in the image is unsafe.

You need a detector:

screen
  ↓
screenshot
  ↓
320×320 detector
  ↓
[x, y, w, h, class, confidence]
  ↓
map boxes back to screen coordinates
  ↓
blur / pixelate those rectangles

A useful small starting point is NudeNet's 320n.onnx: the published file is about 12.2 MB. That keeps the detector comfortably mobile-sized, although accuracy claims still need to be established on the actual screen-content distribution you care about rather than borrowed from a random benchmark.

For fewer false positives, I would put a tiny verifier behind the detector: detector finds candidate regions; a MobileNet-class model decides whether the candidate is actually something worth censoring.

That matters for the internet's favorite edge cases: swimwear, fitness photos, medical diagrams, anime, tattoos, underwear ads, and skin-colored furniture that apparently wants to become a machine-learning incident.

Android 14 quietly gives us the right plumbing

On API 34+, AccessibilityService.takeScreenshotOfWindow() can capture a specific application window. Android's documentation explicitly calls out ML-based visual screen understanding as a use case, and recommends the window-specific method when an accessibility overlay is on top because the overlay itself is excluded from the captured target window.

That solves an otherwise annoying recursion problem:

capture screen
→ detect region
→ draw censorship overlay
→ capture screen again
→ accidentally detect your own overlay

Android 14 also added attachAccessibilityOverlayToWindow(...), which can attach a SurfaceControl overlay to a particular accessibility window so it follows that window as it moves and resizes.

The resulting architecture is pleasantly boring:

AccessibilityWindowInfo
        ↓
takeScreenshotOfWindow(windowId)
        ↓
HardwareBuffer
        ↓
native resize + detector
        ↓
RectF[]
        ↓
SurfaceControl overlay
        ↓
blur / pixelation

For the visual censor itself, Android 12+ supports background blur within a window. Because cross-window blur can be disabled on some devices or during power-saving conditions, the production fallback should be an opaque or pixelated rectangle. Privacy features should fail closed, not artistically.

Where the missing 70 ms was hiding

Our first implementation spent too much time treating every frame like an ordinary Android image-processing task.

The expensive path looked roughly like this:

HardwareBuffer
→ Bitmap
→ resize / format conversion
→ managed/native boundary
→ inference
→ copy boxes back
→ update overlay Views

There were allocations, image copies, format conversions, JNI transitions, and work bouncing between threads. None of those operations looked catastrophic alone. Together they consumed most of the latency budget.

The NDK version kept the hot path native:

HardwareBuffer
→ AHardwareBuffer/native access
→ reusable input buffer
→ native resize / normalization
→ native inference runtime
→ native box decode + NMS
→ minimal result handoff
→ SurfaceControl update

We reused buffers instead of allocating per frame, avoided round-tripping full images through Kotlin, batched the preprocessing work, and only crossed JNI with the small result we actually needed: a handful of rectangles.

End-to-end response went from about:

~100 ms  managed-heavy pipeline
   ↓
 ~30 ms  native hot path

The lesson is not "C++ is three times faster than Kotlin."

The lesson is do not move camera-sized pixel buffers across abstraction boundaries if you can move six rectangles instead.

You still do not need 60 FPS inference

The screen may render at 60 or 120 FPS. The detector does not need to.

A practical loop is closer to:

Detector        5–10 FPS
Box tracking    display rate
Overlay         display rate

Run inference periodically and immediately after meaningful accessibility events such as scroll or window-content changes. Between detections, track or reuse the boxes so the censorship layer moves smoothly.

This is another place where chasing benchmark FPS is the wrong optimization. The metric the user notices is time from unsafe pixels appearing to those pixels being covered.

That was ~100 ms.

Now it is ~30 ms.

And suddenly the overlay stops feeling like something following the screen and starts feeling like part of the screen.

The policy footnote

Accessibility is technically attractive here, but Google Play treats the API as sensitive. Apps that are not genuine accessibility tools need the appropriate Play Console declaration, prominent in-app disclosure, and affirmative consent. The feature should also be described accurately; setting isAccessibilityTool=true just to escape disclosure requirements is not the clever hack it may initially resemble.

The alternative is MediaProjection, but for continuous selective censorship it introduces a different set of lifecycle, consent, foreground-service, and overlay-recursion complications.

For an API 34+ implementation, the stack I would start with is:

AccessibilityService
+ takeScreenshotOfWindow()
+ ~12 MB region detector
+ optional tiny verifier
+ NDK preprocessing/inference hot path
+ SurfaceControl accessibility overlay
+ blur with pixelation fallback

The model gets most of the attention because it says "AI" on the box.

In practice, the difference between a demo and a usable product was the 70 milliseconds around the model.

References