Depth: Lens Blur
Fake f/1.4 in Post, With Bokeh Balls Instead of Smudge
- image
- depth
- focus_mask
- image
- coc
Phone "portrait mode" does this badly and everyone can tell: a Gaussian smear behind the subject with a jagged cut-out edge. Depth: Lens Blur (JHPixelProDepthLensBlur) does it properly, using the same model of a real lens - the circle of confusion - and a disc kernel that produces actual bokeh discs instead of fog.
This is the node the depth-estimation stack exists for. Everything else in that doc is about geometry or conditioning; this one is about simulating an f-stop.
How it works
Per pixel, coc = clamp((|depth − focus| − dof/2) / (1 − dof/2), 0, 1) × max_blur. That's your blur radius map: zero at the focus plane, growing with distance from it.
The node renders layers blur images at radii from 0 to max_blur and blends each pixel between the two nearest radii, so transitions stay smooth instead of banding into steps. Each layer is a normalised convolution - blur(image·w) / blur(w) - where the weight w shrinks for pixels sharper than that layer. That's the trick that stops a sharp foreground from smearing into a blurred background. Radii above 8 px are blurred on a downscaled copy and upsampled, so max_blur 96 doesn't try to allocate a 193-pixel kernel.
The blur map gets a small closing (window around max_blur / 8) so you don't get a thin sharp line where depth crosses the focus plane between two blurred zones. Wide in-focus areas are left alone.
The inputs, translated into photography
image, then:
focus_depth (0–1, default 0.5) - the depth that stays sharp, 1 = nearest. Ignored when focus_mask is connected.
depth_of_field (0–1, default 0.1) - the width of the sharp zone. Small = f/1.4, large = f/16. 0.05–0.15 keeps a whole face sharp rather than just the eyes, which is what you want on a portrait.
max_blur (1–96, default 24) - blur radius in pixels at the farthest point from focus. Scale it to the image: 24 is moderate at 2K, 60+ suits 6K.
bokeh (disc / gaussian) - disc for flat lens-like bokeh balls, gaussian for a soft fade. Use disc. The gaussian is there for when the discs look too literal on a busy background.
layers (2–16, default 8) - blur steps blended together. More = smoother, slower.
blur_foreground (default on) - off means only things behind the focus plane blur.
Optional inputs: depth (1 = nearest; if you don't connect it, the node estimates internally with Depth Anything V2 small at 518 px and you need onnxruntime), focus_mask (a subject mask - the median depth inside it becomes the focus depth, so a Face Detect Lite face_mask is the obvious feed), and keep_model_loaded for the internal depth model.
Outputs: image, and coc - the blur-radius map as a mask. That second output is underrated: preview it to see exactly where the blur is landing.
Install
cd ComfyUI/custom_nodes
git clone https://github.com/jetthuangai/ComfyUI-JH-PixelPro.git
cd ComfyUI-JH-PixelPro
pip install -r requirements.txt # the Python that runs ComfyUI
pip install onnxruntime-gpu # only needed if you don't feed a depth map
ComfyUI Manager: search ComfyUI-JH-PixelPro → Install → restart → Add Node → ComfyUI-JH-PixelPro → depth. Note onnxruntime is not in requirements.txt (it's commented out there), so a connected depth input is the way to avoid the extra install entirely - Depth Anything V2 in the same pack will hand you one. Speed is the good news: about 35 ms for 2048² on CUDA, around 2 s on CPU at max_blur 24.
Where it goes wrong
- Blur edges don't match the subject. That's the depth map, not the blur. Check it first; soften its edges with Mask Edge Smoother.
- Video flicker. The built-in depth estimate is normalised per frame, so the focus plane drifts. Feed a fixed
focus_depthor a steady depth map for clips. - Stiff cut-out at the subject border. Raise
depth_of_fieldslightly so the sharp zone is wider than you think it should be - real lenses don't have pixel-sharp focus boundaries. - Clean blurred areas, crunchy sharp areas. Blur destroys noise, so the two halves stop matching. Add a touch of grain after the blur. This is the same reason grain is the cheapest anti-AI-look move there is.
- Known limit: a blurred foreground doesn't spread over an in-focus background - no occlusion-aware bleed. Fine for portraits, occasionally visible with railings and branches.
Pack note: Apache-2.0, JH (@jetthuangai), v1.10.0 / 64 nodes, EN + VI docs, and essentially no Reddit footprint to compare notes against. Judge it on one thing - whether the bokeh reads as a lens or as a filter - and the disc bokeh with a focus_mask from the face detector is the combination that gets you there.
Inputs (10)
| Name | Type | Default | Description |
|---|---|---|---|
| image | IMAGE | — | |
| focus_depth | FLOAT | 0.500–1 | Depth value that stays sharp (1 = nearest). Ignored when focus_mask is connected. |
| depth_of_field | FLOAT | 0.100–1 | Width of the sharp zone around the focus depth. Small = shallow depth of field (f/1.4), large = deep (f/16). |
| max_blur | INT | 241–96 | Blur radius in pixels at the farthest distance from focus. |
| bokeh | COMBO | disc | disc = flat lens-like bokeh balls, gaussian = soft blur. |
| layers | INT | 82–16 | Number of blur steps blended together. More = smoother transitions, slower. |
| blur_foreground | BOOLEAN | true | Off: only things behind the focus plane are blurred. |
| depthopt | MASK | Depth map, 1 = nearest. Not connected: estimated internally with Depth Anything V2 small (needs onnxruntime). | |
| focus_maskopt | MASK | Subject mask (e.g. a face mask). The median depth inside it becomes the focus depth. | |
| keep_model_loadedopt | BOOLEAN | true | ON: keep the Depth Anything model in VRAM for the next run (faster). OFF: free it right after this run (ComfyUI's Free memory button cannot see it). |
Outputs (2)
| Name | Type | Description |
|---|---|---|
| image | IMAGE | — |
| coc | MASK | — |