Nodes/ComfyUI CV/CV Quasi-Dense Stereo
ComfyUI Node

CV Quasi-Dense Stereo

The stereo matcher with no disparity range to get wrong

By bmad4ever·Created 3 months ago·Updated 14 days ago· 1
CV Quasi-Dense Stereo
  • left
  • right
  • disparity
  • valid
  • displacement
  • points_left
  • points_right
  • seeds_left
  • seeds_right
  • dense_count
  • seed_count
  • found
◄max_seeds500►
◄seed_quality0.010►
◄seed_min_distance10►
◄correlation_window5►
◄correlation_threshold0.50►
◄texture_threshold200►
◄disparity_gradient1►
◄neighborhood_size5►
◄border15►

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) and seed_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) and disparity_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.

Categoryimage/CV/features

Inputs (11)

NameTypeDefaultDescription
leftNPARRAY,IMAGELeft 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.
rightNPARRAY,IMAGERight 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_seedsINT50010–5000How 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_qualityFLOAT0.0100.0001–1Corner quality of a seed relative to the strongest corner (gftQualityThres). Lower accepts weaker corners, i.e. more seeds in flat regions.
seed_min_distanceINT101–200Minimum pixel distance between seeds (gftMinSeperationDist) - spreads them over the frame instead of clustering on one strong texture.
correlation_windowINT53–31Half-window of the correlation test used when growing a match (corrWinSizeX/Y). Larger is more reliable and blurs across depth discontinuities.
correlation_thresholdFLOAT0.500–1Minimum normalized correlation for a grown match to be kept. Raise it to trade coverage for correctness.
texture_thresholdFLOAT2000–10000Minimum 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_gradientINT10–10How 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_sizeoptINT53–21Size of the neighbourhood a confirmed match propagates into.
borderoptINT150–100Image margin (borderX/borderY) excluded from matching, so a correlation window never runs off the frame.

Outputs (10)

NameTypeDescription
disparityNPARRAYHxW 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.
validNPARRAYuint8 0/255 mask of pixels that actually got a match (the quasi-dense support).
displacementNPARRAYHxW 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_leftNPARRAYNx1x2 float32 left-image coordinates of every DENSE match - wire straight into 'Triangulate Points', 'Find Homography (RANSAC)' or 'Draw Points'.
points_rightNPARRAYNx1x2 float32 right-image coordinates, row-aligned with points_left.
seeds_leftNPARRAYNx1x2 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_rightNPARRAYNx1x2 float32 right-image coordinates of the seeds.
dense_countINTNumber of quasi-dense matches.
seed_countINTNumber of seed matches.
foundBOOLEANFalse when the pair produced no dense match at all (blank or uncorrelated frames); the arrays are then empty and the disparity is all zero.