GPU Acceleration for Dense Matching in Python

Dense matching is the stage that turns a sparse reconstruction into a point cloud, and it is where a survey pipeline spends most of its wall clock. It is also the stage most amenable to a GPU: the work is a per-pixel cost-volume evaluation over rectified image pairs, which is exactly the shape graphics hardware exists to do. A well-configured GPU path routinely takes a stage from hours to tens of minutes.

It is equally the stage where a GPU is easiest to misconfigure into being slower than the CPU, or into failing at 90% completion with an out-of-memory error that names a device the operator has never looked at. This page covers what to accelerate, how to detect a usable device without assuming one, how to size work to device memory rather than host memory, and how to keep a CPU fallback that produces the same answer rather than a different one.

The stage this accelerates is the one profiled in reducing RAM usage during dense matching; moving it to a GPU changes which memory is scarce, not whether memory is scarce.

Audience and prerequisites. Python 3.10+, a working CPU pipeline, and access to a CUDA-capable device or a cloud instance with one. No GPU programming is required — everything here is configuration and capacity planning around libraries that already have device backends.

Prerequisites

Library / component Minimum version Install command Role
NVIDIA driver ≥ 535 distribution package Must be at least as new as the CUDA runtime
pynvml ≥ 11.5 pip install "nvidia-ml-py>=11.5" Device enumeration, free VRAM, utilisation
opencv-contrib-python ≥ 4.9 built with CUDA, or a vendor wheel cv2.cuda stereo and warping modules
numpy ≥ 1.26 pip install "numpy>=1.26" Host-side arrays either way
psutil ≥ 5.9 pip install "psutil>=5.9" Host memory, for the fallback path

The version constraint that catches people is the first one. A CUDA runtime newer than the installed driver fails at initialisation with a message about an insufficient driver version, and because that failure happens on the first device call rather than at import, it typically surfaces an hour into a job rather than at startup.

Conceptual architecture

The useful mental model is that a GPU is a second machine with its own memory, connected by a bus that is fast but not free. Work is worth sending there when the computation per byte transferred is high, and not otherwise.

That single criterion sorts the pipeline cleanly. Dense matching evaluates a large cost volume per image pair — enormous compute against a modest transfer — and is the clear win. Rectification and undistortion are moderate. Feature detection is borderline: real speedups exist, but descriptors must come back to the host for matching, and the round trip eats much of the gain. Bundle adjustment is a sparse linear algebra problem with a data-dependent access pattern, and general-purpose GPU implementations rarely beat a good sparse CPU solver on survey-sized blocks.

Which pipeline stages repay a transfer to device memory A chart plotting pipeline stages by arithmetic intensity, meaning compute performed per byte transferred to the device. Dense matching sits far to the high-intensity end and is marked as a clear win with a five to twelve times speed-up. Rectification and undistortion sit mid-range with a two to three times speed-up. Feature detection sits lower, marked as marginal because descriptors must return to the host for matching. Bundle adjustment sits at the low end and is marked as usually slower on a GPU because its sparse access pattern is data-dependent. A threshold line separates the stages worth offloading from those that are not. arithmetic intensity — compute per byte transferred → speed-up left of this, transfer costs exceed the gain dense matching · 5–12× rectify / undistort · 2–3× feature detection · marginal bundle adjustment · usually slower One stage carries almost all of the available gain, which makes the integration far smaller than "port the pipeline to GPU" suggests.

Figure 1 — Nearly all of the available speed-up lives in one stage. That is good news: the integration is a device backend for dense matching, not a rewrite.

Step 1: Detect a usable device, not merely a present one

cv2.cuda.getCudaEnabledDeviceCount() returning a positive number means the library was built with CUDA and a device is visible. It does not mean the device is usable by this process: another job may hold most of its memory, the driver may be older than the runtime, or the device may be in an exclusive compute mode that already has a client.

from dataclasses import dataclass


@dataclass(frozen=True)
class Device:
    index: int
    name: str
    total_mb: float
    free_mb: float


def usable_devices(min_free_mb: float = 4096) -> list[Device]:
    """Devices this process can actually allocate on, cheapest check first."""
    try:
        import cv2
        if cv2.cuda.getCudaEnabledDeviceCount() < 1:
            return []
    except (ImportError, AttributeError):
        return []                      # no CUDA build; not an error

    try:
        import pynvml
        pynvml.nvmlInit()
    except Exception:
        return []                      # driver not responding

    found: list[Device] = []
    try:
        for i in range(pynvml.nvmlDeviceGetCount()):
            h = pynvml.nvmlDeviceGetHandleByIndex(i)
            mem = pynvml.nvmlDeviceGetMemoryInfo(h)
            free_mb = mem.free / (1024 ** 2)
            if free_mb < min_free_mb:
                continue               # occupied by another job
            found.append(Device(i, pynvml.nvmlDeviceGetName(h),
                                mem.total / (1024 ** 2), free_mb))
    finally:
        pynvml.nvmlShutdown()
    return found

Two design choices matter. Every failure path returns an empty list rather than raising: absence of a GPU is a normal condition, and a pipeline that crashes on a CPU-only host is worse than one that runs slowly. And the free-memory floor is checked at selection time, so a device already carrying another job is simply not offered.

Step 2: Size the work to device memory

Host memory and device memory are separate budgets, and the device one is both smaller and less forgiving: there is no swap, so exceeding it is an immediate allocation failure rather than a slowdown. The cost volume dominates, and its size is predictable.

For a rectified pair of width W and height H evaluated over D disparity levels at 4 bytes per element, the volume alone is W × H × D × 4 bytes. A 4000 × 3000 pair at 256 disparities is 12.3 GB — more than many cards have, before the images, the intermediate aggregation buffers, and whatever the driver reserves.

def tile_for_device(width: int, height: int, disparities: int,
                    free_mb: float, safety: float = 0.6) -> tuple[int, int]:
    """Largest square-ish tile whose cost volume fits in the device budget.

    `safety` leaves room for the rectified images, aggregation buffers and
    driver overhead, which together commonly reach half the volume again.
    """
    budget_bytes = free_mb * (1024 ** 2) * safety
    per_pixel = disparities * 4                     # float32 cost volume
    max_pixels = int(budget_bytes // per_pixel)
    if max_pixels >= width * height:
        return width, height                        # whole pair fits

    aspect = width / height
    tile_h = int((max_pixels / aspect) ** 0.5)
    tile_w = int(tile_h * aspect)
    return max(256, tile_w), max(256, tile_h)

Tiling a dense-matching pass has a cost beyond the arithmetic: disparities near a tile edge are estimated from a truncated support region, so tiles need an overlap of at least the aggregation window, and the overlapping strips must be discarded rather than averaged. This is the same halo argument that appears when streaming LAS tiles, and it fails in the same way if omitted: a grid of faint seams in the depth map.

Device memory required by the cost volume, against disparity range A chart of cost-volume size in gigabytes against the number of disparity levels, for a full-resolution image pair and for a half-resolution pair. Both grow linearly with disparity range. Horizontal lines mark the usable memory of a twelve gigabyte card and a twenty-four gigabyte card, after a forty percent allowance for images, aggregation buffers and driver overhead. The full-resolution line crosses the twelve gigabyte usable limit at about one hundred and thirty disparities, while the half-resolution line stays under it across the whole range shown. 24 GB card — usable ≈ 14 GB 12 GB card — usable ≈ 7 GB 32 96 160 224 288 disparity levels cost volume (GB) 4000 × 3000 2000 × 1500 12 GB card runs out here Halving resolution quarters the volume, which buys far more headroom than trimming the disparity range.

Figure 2 — The cost volume is linear in disparity range and quadratic in resolution, which is why the effective lever is resolution. Compute the number before the run rather than discovering it at 90% completion.

Step 3: Keep the fallback honest

A fallback that produces a different answer is worse than no fallback, because the same survey processed on two hosts yields two point clouds and nobody can say which is right. Two rules keep it honest.

Same parameters, both paths. Window size, disparity range, uniqueness ratio and speckle filtering must be identical; where a device implementation lacks a parameter, the CPU path must be configured to match the device’s fixed behaviour rather than the reverse.

Record which path ran. The choice belongs in the run manifest next to the engine version, so a cloud comparison between two dates is not silently comparing two implementations.

import numpy as np


def compute_disparity(left: np.ndarray, right: np.ndarray, *,
                      num_disparities: int = 128, block_size: int = 5,
                      prefer_gpu: bool = True) -> tuple[np.ndarray, str]:
    """Return (disparity, backend). Identical parameters on both paths."""
    import cv2

    if prefer_gpu and usable_devices():
        try:
            matcher = cv2.cuda.createStereoSGM(
                minDisparity=0, numDisparities=num_disparities,
                P1=8 * block_size ** 2, P2=32 * block_size ** 2,
                uniquenessRatio=10,
            )
            gl, gr = cv2.cuda_GpuMat(), cv2.cuda_GpuMat()
            gl.upload(left); gr.upload(right)
            out = matcher.compute(gl, gr).download()
            return out.astype(np.float32) / 16.0, "cuda"
        except cv2.error:
            pass                                   # fall through, do not raise

    matcher = cv2.StereoSGBM_create(
        minDisparity=0, numDisparities=num_disparities, blockSize=block_size,
        P1=8 * block_size ** 2, P2=32 * block_size ** 2, uniquenessRatio=10,
    )
    return matcher.compute(left, right).astype(np.float32) / 16.0, "cpu"

The except cv2.error: pass is deliberate and narrow. A device that disappears mid-run — evicted on a shared host, or hitting an allocation limit — should degrade to the CPU path for that pair rather than failing the survey. What it must not do is swallow a parameter error, which is why the fallback keeps the same call signature and the same values.

Parameter deep-dive

Parameter Type Default Range Effect
min_free_mb float 4096 2048–16384 Device memory floor below which a card is not offered
num_disparities int 128 64–320 Search range; sets the cost volume linearly. Must be a multiple of 16
block_size int 5 3–11 Matching window; larger is smoother and blurs depth discontinuities
safety float 0.6 0.4–0.8 Fraction of free VRAM the cost volume may claim
tile_overlap_px int 64 32–128 Halo per tile; must exceed the aggregation window
uniquenessRatio int 10 5–20 Margin the best match must beat the second by, as a percentage
prefer_gpu bool True Set False to force the reference path when comparing outputs

num_disparities deserves a note: it is not a quality dial but a range. Set it below the true maximum disparity in the scene and near objects simply have no valid depth, which looks like noise rather than like a truncated range. Derive it from flight altitude and baseline rather than raising it until the artefacts go away.

Verification and output inspection

The claim worth verifying is that the two backends agree. They will not agree bit-for-bit — different aggregation orders and different sub-pixel refinements guarantee small differences — so the test is agreement within a tolerance over the pixels where both produced a valid result.

import numpy as np


def assert_backends_agree(left: np.ndarray, right: np.ndarray,
                          tol_px: float = 0.5, min_valid: float = 0.9) -> None:
    """Compare the device and host paths on one pair."""
    gpu, backend = compute_disparity(left, right, prefer_gpu=True)
    cpu, _ = compute_disparity(left, right, prefer_gpu=False)
    assert backend == "cuda", "no device available; nothing was compared"

    valid = (gpu > 0) & (cpu > 0)
    coverage = valid.mean()
    assert coverage >= min_valid * min((gpu > 0).mean(), (cpu > 0).mean()) , \
        f"backends disagree about where depth exists: {coverage:.2%} overlap"

    diff = np.abs(gpu[valid] - cpu[valid])
    p99 = float(np.percentile(diff, 99))
    assert p99 <= tol_px, f"99th percentile disparity difference {p99:.2f} px > {tol_px}"

Comparing the 99th percentile rather than the maximum is deliberate: a handful of pixels on a depth discontinuity will always differ substantially, and a maximum-based test fails on a correct implementation. Run it once per driver upgrade and once per library upgrade — those are the two events that silently change a device result.

Troubleshooting

The GPU path is slower than the CPU path. Almost always the transfer, not the compute. Uploading a pair, computing, and downloading for each of a thousand small tiles spends most of its time on the bus. Either enlarge the tiles until the compute dominates, or leave the images resident on the device across several operations rather than round-tripping per step.

Out of memory at 90% of the run, having been fine until then. Free device memory was measured once at startup and another process arrived, or the image pairs late in the block are larger than the ones the tile size was computed from. Re-measure per pair rather than per run, and derive the tile size from that pair’s dimensions.

cv2.cuda exists but every call raises. The library was built against a CUDA runtime newer than the installed driver. Check the driver version against the runtime the build requires; this fails at the first device call rather than at import, which is why it surfaces mid-job.

The point cloud has a faint grid pattern. Tiles were matched without a halo, so disparities near every tile edge were estimated from a truncated support region. Increase tile_overlap_px above the aggregation window and discard the overlapping strips instead of blending them.

Two hosts produce visibly different clouds from the same imagery. One ran the device path and the other fell back. This is exactly what recording the backend in the run manifest is for; without it the difference is indistinguishable from a data problem.

Utilisation sits at 30% and will not rise. The device is waiting on the host — usually on JPEG decode or on rectification still running on the CPU. Profile the host side before adding device work; a starved GPU is a host-side bottleneck wearing a costume.

Automated Image Alignment & Feature Matching Workflows

Backend selection, and where it is recorded A selection flow for each image pair. The pipeline asks whether a device with enough free memory is available; if not it takes the host path. If one is available it computes a tile size from that pair's dimensions and the measured free memory, runs on the device, and on a device error falls through to the host path for that pair only. Both paths converge on a single result, and the backend actually used is written into the run manifest alongside the disparity output, so a later comparison between two runs can tell whether it is comparing data or implementations. device with enough free VRAM? yes tile from this pair's size then run on device no host path identical parameters device error → this pair only disparity + backend recorded in the manifest Falling back per pair rather than per run keeps a transient device problem from costing the whole survey.

Figure 3 — The fallback is per pair, and the backend that ran is part of the output. Both properties exist so that a difference between two runs can be attributed rather than argued about.