CV Preview 3D (Calibrated Camera)
AR overlay that actually lines up
- model_3d
- camera_info
- bg_image
- scene_depth
- render
- silhouette
- depth
- occlusion
- depth_metric
ComfyUI core has 3D preview nodes, and they're fine for looking at a model. This one is for a different job: rendering a model through your calibrated camera, so it lands on top of a real photograph instead of floating near it.
Why the core viewer isn't enough
A generic 3D viewport draws with a standard perspective camera - one field of view, a principal point in the middle, square pixels, no lens distortion. A real camera intrinsics matrix has an off-axis principal point, possibly anamorphic pixels (different fx and fy), and a lens with radial and tangential distortion. Those differences sound academic until you composite a virtual object onto a photograph of a chessboard you calibrated against, and the object sits visibly off-target.
This node builds its projection directly from fx/fy/cx/cy, keeps the roll that core's orbit viewers drop, and replicates lens distortion - k1, k2, p1, p2, k3 - with an overscanned render plus a per-pixel Brown-Conrady warp shader. That last part is what you can't express with a 4x4 projection matrix at all. It's a Three.js viewer living inside the node, so you get to orbit and inspect, but the calibrated pose is the starting point and there's a reset button to get back to it.
Wiring it up
model_3d takes a GLB/GLTF from a 3D loader node. GLB is the format that behaves - a .gltf referencing external buffers and textures won't resolve them out of ComfyUI's temp folder. camera_info takes the camera dictionary from CV Camera Pose To 3D View, which is the bridge from an OpenCV calibration to the core 3D loader format. The underscore extras that bridge smuggles through (_intrinsics, _dist_coeffs, _image_size) are what unlock the faithful projection and the distortion pass; a plain camera dict still renders, but as a simple perspective view with distortion off and the canvas letterboxed to the calibration image's aspect.
The whole point of the node, though, is bg_image. Feed it the photo the camera pose was solved from and the render composites over it in exact register. If the virtual object doesn't sit on the photographed board, your calibration or pose is wrong, and you know immediately. That single test - does the model land where the board is - is worth more than any amount of inspecting matrices.
Outputs
Beyond the live preview it emits data, which is the part that makes it composable. render is the server-side software render (the same camera model, minus grid and gizmo) over the background - ready to composite or diff against the photo. silhouette is the model's coverage as a mask, useful for compositing or bbox extraction. depth is object-relative depth normalised 0–1 over the model's own extent - for depth ControlNets or depth-of-field. depth_metric is the rasterizer's actual z-buffer in scene units, and this is the one to remember: it's what CV Project Points (Sequence) wants in its scene_depth, so anchors on the far side of the model get hidden properly. occlusion shows where the model was cut away by a depth map you supplied.
Two optional extras earn their keep: scene_depth (a metric depth map of the photographed scene) makes real objects in front of the virtual one hide it, with scene_depth_bias to nudge the comparison; and object_transform, a JSON string the viewer's gizmo writes as you drag, so a placement survives the run. CV 3D Object Transform writes that same string from readable widgets and emits the matching 4x4 - worth using, since hand-finding a quaternion for a Y-up model is nobody's idea of fun.
Installing
ComfyUI Manager → search "ComfyUI CV", or:
cd ComfyUI/custom_nodes
git clone https://github.com/bmad4ever/comfyui_cv
# restart ComfyUI
Python ≥ 3.12, recent V3-API ComfyUI, and opencv-contrib-python-headless~=5.0.0.93. The viewer itself bundles Three.js (MIT) under the pack's web/lib, so there's nothing to fetch. You will need GLB assets - the pack's own examples load MilkTruck.glb, which ships in example_inputs/; run workflows/01_install_example_inputs.json once to copy the sample media into ComfyUI/input and reload the page.
Gotchas
It's GLB or nothing useful. GLTF with external resources quietly fails to load textures from a temp directory.
Distortion off means you're looking at the wrong picture. If _dist_coeffs is missing from the camera dict the render degrades to a pinhole view, and on a wide lens that's a visible mismatch. Check the calibration bridge is feeding the underscore fields.
Orbiting drops roll until you reset. That's in the tooltip, but people still screenshot a tilted orbit and then wonder why the next run's render output doesn't match what's on screen. The render always uses the calibrated pose.
Inputs (9)
| Name | Type | Default | Description |
|---|---|---|---|
| model_3d | FILE_3D_GLB,FILE_3D_GLTF,FILE_3D | 3D model from a 3D loader node (e.g. 'Load 3D (Advanced)'). GLB is the reliable format: a .gltf with external buffers/textures will not resolve them from the temp folder. | |
| camera_info | LOAD3D_CAMERA | Camera dict from 'CV Camera Pose To 3D View'. The underscore extras (_intrinsics/_dist_coeffs/_image_size) unlock the faithful projection and the distortion pass; a plain LOAD3D_CAMERA dict still works as a simple perspective view. | |
| bg_imageopt | IMAGE | Backdrop composited behind the render - use the very photo the pose was solved from to verify the virtual camera matches the real one (the model should sit on the photographed board). | |
| shadingopt | COMBO | lambert (headlight) | Lighting of the server-side render. 'lambert (headlight)' shades faces by their angle to a camera-side light (shows the 3D form); 'unlit (raw colors)' outputs the texture/base colors untouched - no lighting baked into the pixels, best when the render is data for further compositing. The mask output is unaffected. |
| resolution_scaleopt | FLOAT | 1.000.25–4 | Multiplies the OUTPUT resolution of render + mask (camera matrix scales with it, so the framing is identical - 2.0 renders the same view at twice the calibration image's width/height). |
| antialiasopt | COMBO | 2x supersample | Anti-aliasing of the server-side render: the scene is rasterized and lens-warped at 2x/4x the output size, then box-filtered down - smooths polygon and silhouette edges (the mask edge gets fractional values). 'off' renders 1:1 (fastest, hard edges). |
| object_transformopt | STRING | Model transform in Three.js world space, as JSON: {"position": [x, y, z], "quaternion": [x, y, z, w], "scale": [x, y, z]} (scale may be a single number; missing keys default to identity; empty = identity). The viewer's move/rotate/scale GIZMO writes this widget as you drag, so the next run renders the model where you left it - and the viewer's 'reset' button puts the model back on whatever this widget says, so a mis-drag costs one click once the text is right. Convert to an input to drive the placement from upstream instead (the gizmo then only previews): 'CV 3D Object Transform' writes this string from readable position/rotation/scale widgets - including a 'model_up' dropdown that stands a Y-up model on the target plane instead of you finding the quaternion - and emits the matching 4x4, so the mesh lane moves with the render. 'CV Parse Object Transform' reads a gizmo-authored string back into the graph. | |
| scene_depthopt | NPARRAY | Optional HxW float32 depth map of the PHOTOGRAPHED scene - metric camera-space Z, in the SAME units as the pose (metres if the calibration is). Wherever it is nearer than the model, the model is cut away, so a real car in front of the virtual one hides it instead of the overlay floating on top. Build it from stereo with cv2.reprojectImageTo3D + 'cv2 extractChannel' (coi=2), or from a metric monocular depth net. Non-finite or <= 0 means 'no measurement here' and never occludes, so the holes a matcher leaves are safe. Resolution is free - it is nearest-resampled to the render. | |
| scene_depth_biasopt | FLOAT | 0.00-1000–1000 | Added to scene_depth before the comparison, in scene units. Stereo depth is noisy and the surface the model stands ON is the one most likely to eat it: a small positive bias (0.1-0.5 m on a driving scene) pushes the scene back so the model wins ties. Negative biases it the other way. |
Outputs (5)
| Name | Type | Description |
|---|---|---|
| render | IMAGE | Server-side software render (opencv_nodes/render3d.py) of the model over bg_image, through the SAME camera model as the live widget: overscanned pinhole rasterization warped into the lens with cv2.undistortPoints. No grid/axes - model and photo only, ready for compositing or diffing against the original photo. GLB triangles only. |
| silhouette | MASK | The rendered model's coverage as a MASK (1 = model, 0 = background), same resolution as the render, lens distortion applied. With antialiasing on, edge pixels carry fractional coverage. Feeds compositing, Overlay Masks, bbox extraction... |
| depth | MASK | OBJECT-relative depth as a MASK: 1 (white) at the model's point nearest the camera, 0 (black) at its farthest point - normalized over the model's own camera-space extent (ALL vertices, occluded ones included), never an arbitrary near/far range, so the visible minimum need not reach 0 when the far side is hidden. Background is 0; same resolution/distortion as the render. Feeds depth ControlNets, DoF blurs, fog compositing... |
| occlusion | MASK | Where scene_depth HID the model: 1 on the pixels the model would have covered but the photographed scene is in front of, 0 everywhere else. All zeros when scene_depth is not wired. Use it to check the cut is landing on the right object, or to feather the contact edge. |
| depth_metric | NPARRAY | The rasterizer's own HxW float32 z-buffer: camera-space Z in SCENE UNITS - the same quantity 'CV Rasterize Mesh' calls depth, and NOT the 'depth' output above (which is normalized to [0, 1] for viewing). This is what the scene_depth input of 'CV Project Points (Sequence)' / the 'CV Annotate Model' blueprint wants, so anchors on the far side of the model get hidden without rasterizing it a second time - and unlike 'CV Rasterize Mesh' it carries the lens distortion, so it stays registered with the render. +inf where nothing was drawn, which reads as 'no surface': a point projecting off the silhouette stays visible. With scene_depth wired this is the COMPOSITE surface (the photographed scene wherever it won the depth test), so an anchor a real object hides is hidden too. |