Depth-aware DSLR lens bokeh for photos — real "portrait mode," with HDR — on your Mac.
lens-blur estimates per-pixel depth with Apple's ml-depth-pro,
then renders a true camera-style background blur: an aperture-shaped bokeh kernel convolved in
scene-linear light, with bright bokeh balls from out-of-focus highlights. Pick a lens (85mm portrait,
anamorphic, soap-bubble, …), click to set focus, and export HDR — Ultra HDR JPEG, PQ HDR10 HEIC, or
scene-linear TIFF.
The same out-of-focus highlights through four lens presets — bokeh-ball shape is the visible signature of a lens (subject stays sharp; synthetic test scene).
- Real lens bokeh, not a Gaussian smear — aperture-shaped kernel (disc / polygon / oval / ring), composited in linear light with occlusion-aware edges.
- Lens presets — 85mm f/1.4, 50mm f/1.8, 35mm f/2, 135mm f/2, anamorphic, soap-bubble. Each defines a bokeh shape + character, not just a blur amount.
- Click-to-focus — click any point to set the focal plane; everything nearer or farther blurs, with an in-focus depth slab so the subject stays sharp.
- End-to-end HDR — recovers real headroom from gain-map (JPEG/HEIC) and 10-bit PQ/HLG sources, renders in scene-linear, and exports genuine HDR. Bright highlights bloom into glowing bokeh balls.
- Drag-and-drop web UI with live, downscaled previews — plus a CLI and a bulk HEIC→HDR transcoder.
- macOS on Apple Silicon (uses the MPS GPU; falls back to CPU)
- Homebrew Python 3.12 —
brew install python@3.12 libultrahdrfor HDR.jpgexport —brew install libultrahdr- ~4 GB free disk (PyTorch + the 1.8 GB depth model)
git clone https://github.com/erango/lens-blur.git
cd lens-blur
./setup.sh # venv + deps + clones ml-depth-pro + downloads model weights (~1.8 GB)setup.sh doesn't commit the venv, the ml-depth-pro checkout, or the weights — they're git-ignored and
recreated locally.
./blur-ui # http://127.0.0.1:7860Drag a photo onto the stage — on-device AI estimates depth, then the result reveals. Pick a lens from the visual bokeh-shape tiles, click the photo to focus, and grab the result from the export cards (the format matching your source is flagged Recommended). The everyday control (background blur) is up front; More controls expands falloff, sharpness, glow, HDR headroom, manual focus, and the depth-map view. Hover any ⓘ for help.
| Control | What it does |
|---|---|
| Lens | Bokeh shape + character; sets sensible default blur |
| Click to focus | Click the photo to focus there; near and far blur |
| Max blur | Strongest blur radius at the farthest depth |
| Falloff | How gradually blur ramps from the focal plane |
| Sharpness keep | Bends the focus→blur curve |
| Bokeh glow | Intensity of the bright bokeh balls (0 = off) |
| HDR headroom | Highlight bloom for SDR inputs (disabled when real HDR is recovered) |
| Foreground hold | Blurs the near side more gently than the background (1× = symmetric, like a real lens) |
| Focus width | Depth of field — how thick the in-focus slab around the focal plane is |
| Show focus area | Live red overlay of what's currently in focus |
| Refine | Brush over spots the depth model got wrong: paint keep sharp or blur, with undo/redo |
iPhone portrait-mode HEICs carry their own depth map — those load instantly and with cleaner subject edges, skipping the model entirely (a badge tells you when this happened).
The depth map is computed once per photo; the live preview is downscaled so sliders/lenses stay
responsive (~½ s). Downloads render at full resolution: SDR JPEG, HDR .jpg, or HDR .heic.
| Preset | Aperture shape | Character |
|---|---|---|
| 85mm f/1.4 Portrait | 9 blades, near-circular | creamy, smooth bloom |
| 50mm f/1.8 Nifty Fifty | 7 blades → heptagonal | neutral |
| 35mm f/2 Street | 8 blades, roundish | subtle, keeps more in focus |
| 135mm f/2 Tele | 9 blades, circular | big bokeh balls, strong bloom |
| Anamorphic | oval (≈1.9:1) | vertical-oval bokeh balls |
| Soap-bubble / Petzval | ring-weighted | bright-rimmed "nervous" bokeh |
Bright bokeh balls need highlight intensity above white, which 8-bit SDR clips away. The renderer works in an unclamped scene-linear buffer; headroom is recovered from the source when present, else synthesized:
- Gain map (Apple/ISO 21496-1) in a JPEG/MPO or HEIC —
HDR = SDR · 2^(gain · log2(headroom)) - 10-bit PQ / HLG HEIC — decoded via the ST 2084 / ARIB STD-B67 transfer functions
- Synthetic — near-white pixels expanded into headroom for plain SDR inputs
For real-HDR inputs the in-focus subject keeps its headroom (genuinely HDR output), while the SDR base is composed separately at normal exposure — so the shareable JPEG matches the original and can't over-expose. Everything is color-managed to sRGB (P3 / BT.2020 converted on the way in).
Output formats
| Format | Description |
|---|---|
SDR .jpg |
Tone-mapped (soft-knee) — looks right everywhere |
HDR .jpg |
Ultra HDR gain-map JPEG — opens anywhere, HDR in Photos/iOS/Chrome. Best for sharing |
HDR .heic |
10-bit PQ / HDR10, tagged BT.2020 + ST 2084. Small |
HDR .tif |
Scene-linear float32 — for editing in HDR-capable apps |
./blur-bg photo.jpg # fast Gaussian -> photo_blur.jpg
./blur-bg photo.jpg --lens portrait85 # DSLR bokeh
./blur-bg photo.heic --lens anamorphic --hdr-out # + HDR .jpg sidecar
./blur-bg photo.jpg --lens tele135 --max-blur 50 --glow 1.5| Flag | Default | Meaning |
|---|---|---|
--lens |
none | portrait85, fifty18, street35, tele135, anamorphic, soapbubble (omit = fast Gaussian) |
--max-blur |
lens / 30 | max blur radius in px |
--focus |
auto | normalized focus depth 0 (near) .. 1 (far) |
--falloff |
lens / 0.35 | blur ramp width |
--gamma |
1.0 | >1 keeps more sharp, <1 blurs more |
--glow |
1.0 | bokeh-ball glow (0 = off) |
--headroom |
4.0 | synthetic HDR headroom for SDR inputs |
--hdr-out |
— | also write an HDR file |
--hdr-format |
jpg | jpg (Ultra HDR) · heic (PQ HDR10) · tif (scene-linear) |
--save-depth |
— | also write <name>_depth.png |
Accepts JPG, PNG, and HEIC. HEIC inputs keep their HDR.
Convert many HEICs to Ultra HDR JPEGs — a pure re-mux (no blur, no model load, parallel):
./convert-hdr ~/Photos -o ~/out # every .heic -> Ultra HDR .jpg
./convert-hdr a.heic b.heic -j 8 --overwriteml-depth-proproduces a metric depth map from a single image.- Depth → a per-pixel blur strength: distance from the focal plane (both sides), with a sharp in-focus slab and a feathered edge so the subject doesn't look cut out.
- Two-pass compositing in scene-linear light: an occlusion-aware gather for the background (sharp foreground never bleeds in) and a premultiplied-alpha scatter for the foreground (out-of-focus near objects spread over the subject). Locally-peaked highlights bloom into aperture-shaped bokeh balls.
- A soft-knee tone-map renders SDR; the HDR intent is kept for HDR export.
blur_core.py |
depth, scene-linear HDR, and all rendering |
server.py |
drag-and-drop Flask web UI (single file) |
blur-bg · blur-bg.py |
CLI |
convert-hdr · convert-hdr.py |
bulk HEIC → Ultra HDR JPEG |
setup.sh |
one-shot environment setup |
- Tuned for macOS / Apple Silicon.
- Very fine edges (stray hairs) can show faint halos — inherent to depth-driven blur; lower the blur or glow to reduce.
- Monocular depth is relative; unusual scenes may need a manual focus click.
MIT — see LICENSE. Fetched at setup (not included in this repo) and under their own licenses: Apple ml-depth-pro and Google libultrahdr.
