CV Quasi-Dense Stereo
The stereo matcher with no disparity range to get wrong
- left
- right
- disparity
- valid
- displacement
- points_left
- points_right
- seeds_left
- seeds_right
- dense_count
- seed_count
- found
If you've used CV Stereo Disparity (SGBM) or the (BM) node in this pack, you know the ritual: set num_disparities, guess block_size, run it, look at the holes, adjust, run again. Quasi-dense stereo is the third answer, and it drops the one parameter everybody gets wrong.
What it actually does
cv2.stereo.QuasiDenseStereo is a seed-and-grow matcher. Instead of searching a disparity range at every pixel independently, it picks a few hundred goodFeaturesToTrack corners in the left frame, tracks them into the right frame with Lucas-Kanade, and then grows each confirmed match into its neighbourhood for as long as the correlation holds and the local disparity gradient stays smooth.
Two consequences fall out of that, and both matter:
- There is no disparity range. A seed can land at any disparity, so a wide baseline or a close object costs you nothing parameter-wise. That's the whole appeal.
- It's quasi-dense, not dense. Propagation can't cross a textureless gap, so the sky, a blank wall or an out-of-focus background stays unmatched. The output has holes by construction, and that's not a bug to fix with settings.
It also runs on an unrectified pair - but the disparity numbers only mean something on a rectified one. Match on a raw pair and you get matched points, not measurements.
Inputs worth touching
left and right take an IMAGE or an NPARRAY directly (the pack's own array socket, which every other node in this graph speaks). Frame 0 of a batch is what gets used, and the right frame is resized to the left if the sizes differ - the underlying class asserts on mismatched frames, so the node spares you that.
Then the seed controls, which are where you'll spend your time:
max_seeds(default 500) andseed_min_distance(10) - how many corners to start from and how spread out they are. More seeds reach more regions; propagation can't invent a match in an area it never seeded.texture_threshold(200) is the one that stops the growth from flooding a blank wall with an invented disparity. Raise it and the mask gets more honest and smaller.correlation_threshold(0.5) trades coverage for correctness. Raise it when you'd rather have fewer, better matches.correlation_window(5) anddisparity_gradient(1) control how far a match spreads and how steep a slope it can follow.
Outputs
disparity is HxW float32 in the pack's usual signed convention (x_left - x_right), which is what you want - it feeds CV Disparity Interpolate, the WLS filter, and cv2.reprojectImageTo3D like any other disparity map. 0 means unmatched, so read valid (a 0/255 uint8 mask) rather than treating zero as a measurement.
There's a second map called displacement, and it's a genuine trap: that's cv2's own getDisparity(), which returns the unsigned Euclidean length of each match's motion, vertical component included. On a rectified pair the two agree; on an unrectified one they can differ by tens of pixels. Use disparity.
points_left / points_right give you the dense match set as Nx1x2 coordinates - straight into CV Triangulate Points for a sparse cloud, no disparity math in between. seeds_left / seeds_right are the sparse seeds the growth started from, handy for debugging why a region came out empty. dense_count, seed_count and found round it out; found=false on a blank or uncorrelated pair, with empty arrays instead of the assertion the class would otherwise raise.
Install
It ships in ComfyUI CV (bmad4ever/comfyui_cv), a fork of opencv-comfyui that wraps OpenCV as ComfyUI nodes. Via Manager, search the pack title; manually:
cd ComfyUI/custom_nodes
git clone https://github.com/bmad4ever/comfyui_cv
# then restart ComfyUI
Its single hard requirement is a recent contrib OpenCV:
pip install "opencv-contrib-python-headless~=5.0.0.93"
The pack needs Python ≥ 3.12 and a ComfyUI built on the V3 node API. The node exists because QuasiDenseStereo is a class, and the pack's ~470 auto-generated raw cv2.* wrappers only cover plain functions - so this is the only way to reach it from a graph.
When it goes wrong
The classic failure is a confident-looking result on a bad pair. If you didn't rectify first, disparity will be nonsense while points_left/points_right still look plausible - check that first. Second: empty regions aren't tunable away. If a region has no texture, no max_seeds value will fill it; that's the design. And if found comes back false, branch on it rather than feeding zeros downstream, because an all-zero disparity map reproduces as a perfectly flat wall.
The pack is honest that it's a personal, heavily AI-assisted project, not production software, and that its example workflows exist to demonstrate rather than to ship. There's one for this node - workflows/60_quasi_dense_stereo.json - which runs it side by side with SGBM on a synthetic pair, and that side-by-side is the fastest way to see what you're buying.
Inputs (11)
| Name | Type | Default | Description |
|---|---|---|---|
| left | NPARRAY,IMAGE | Left frame (grayscaled internally). Accepts a ComfyUI IMAGE/MASK directly (frame 0 of a batch) or an NPARRAY. Arithmetic ops (add, multiply, etc.) process the full IMAGE batch when both inputs have the same batch size. | |
| right | NPARRAY,IMAGE | Right frame; resized to left if the sizes differ - the class asserts on mismatched frames. Accepts a ComfyUI IMAGE/MASK directly (frame 0 of a batch) or an NPARRAY. Arithmetic ops (add, multiply, etc.) process the full IMAGE batch when both inputs have the same batch size. | |
| max_seeds | INT | 50010–5000 | How many goodFeaturesToTrack seeds to start from (gftMaxNumFeatures). More seeds reach more regions - propagation cannot cross a texture-less gap, so an unseeded area stays empty however good the matching is. |
| seed_quality | FLOAT | 0.0100.0001–1 | Corner quality of a seed relative to the strongest corner (gftQualityThres). Lower accepts weaker corners, i.e. more seeds in flat regions. |
| seed_min_distance | INT | 101–200 | Minimum pixel distance between seeds (gftMinSeperationDist) - spreads them over the frame instead of clustering on one strong texture. |
| correlation_window | INT | 53–31 | Half-window of the correlation test used when growing a match (corrWinSizeX/Y). Larger is more reliable and blurs across depth discontinuities. |
| correlation_threshold | FLOAT | 0.500–1 | Minimum normalized correlation for a grown match to be kept. Raise it to trade coverage for correctness. |
| texture_threshold | FLOAT | 2000–10000 | Minimum local texture (sum of squared gradients) a pixel must have before propagation continues into it. This is what stops the growth from flooding a blank wall or the sky with an invented disparity. |
| disparity_gradient | INT | 10–10 | How much the disparity may change between neighbouring pixels. 1 keeps surfaces smooth; larger follows steep slopes and lets the growth leak across object edges. |
| neighborhood_sizeopt | INT | 53–21 | Size of the neighbourhood a confirmed match propagates into. |
| borderopt | INT | 150–100 | Image margin (borderX/borderY) excluded from matching, so a correlation window never runs off the frame. |
Outputs (10)
| Name | Type | Description |
|---|---|---|
| disparity | NPARRAY | HxW float32 SIGNED horizontal disparity in pixels (x_left - x_right), the same convention as 'Stereo Disparity (SGBM)' / '(BM)' - so it feeds 'CV Disparity Interpolate', cv2.reprojectImageTo3D and the WLS filter directly. Built from the match set, NOT from cv2's own getDisparity(), which returns the UNSIGNED euclidean displacement (see 'displacement'). 0 where unmatched - read 'valid', not zero. |
| valid | NPARRAY | uint8 0/255 mask of pixels that actually got a match (the quasi-dense support). |
| displacement | NPARRAY | HxW float32 of cv2's own getDisparity(): the EUCLIDEAN length of each match's displacement, sqrt(dx^2 + dy^2), unsigned and including vertical motion. On a rectified pair it is |disparity|; on an unrectified one the two differ by tens of pixels, which is why the disparity output above is not this. NaN (cv2's 'no match') is written as 0. |
| points_left | NPARRAY | Nx1x2 float32 left-image coordinates of every DENSE match - wire straight into 'Triangulate Points', 'Find Homography (RANSAC)' or 'Draw Points'. |
| points_right | NPARRAY | Nx1x2 float32 right-image coordinates, row-aligned with points_left. |
| seeds_left | NPARRAY | Nx1x2 float32 left-image coordinates of the SPARSE seed matches the propagation started from - a few hundred against the dense set's tens of thousands. |
| seeds_right | NPARRAY | Nx1x2 float32 right-image coordinates of the seeds. |
| dense_count | INT | Number of quasi-dense matches. |
| seed_count | INT | Number of seed matches. |
| found | BOOLEAN | False when the pair produced no dense match at all (blank or uncorrelated frames); the arrays are then empty and the disparity is all zero. |