+1 (415) 360-7596

Shipping OpenCV 5: CUDA-enabled builds, slim Docker images, and a deployment artefact you can reproduce

Every OpenCV consulting engagement eventually produces the same awkward question from the client's platform team: what exactly are we deploying? The prototype runs on a data scientist's laptop against pip install opencv-python. The production target is a GPU node in Kubernetes, or a Jetson on a factory floor, and suddenly nobody can say which OpenCV is in the image, whether CUDA is compiled in, whether GStreamer RTSP works, or why the container is 6 GB.

This tutorial is about the part of an OpenCV 5 project that never appears in a tutorial: turning the library into a reproducible, auditable deployment artefact. Build flags, Docker layout, image size, ABI traps, licensing, and the runtime self-check that stops a mis-built image from reaching production silently.

1. First decide whether you need a custom build at all

Custom builds cost real time. Before you start, be honest about whether the prebuilt wheels are enough.

The PyPI wheels (opencv-python, opencv-python-headless, and the -contrib- variants) are enough when: you run on CPU, you decode files or USB cameras rather than RTSP with hardware decode, and you do inference through ONNX Runtime or TensorRT rather than through cv2.dnn on GPU.

You need a custom build when any of these are true:

  • You want the CUDA modules (cv::cuda::*) or the CUDA backend for dnn. The official wheels ship no CUDA. This surprises people every single project.
  • You need GStreamer-backed VideoCapture for RTSP with hardware decode. The wheels bundle FFmpeg, not GStreamer.
  • You are on Jetson / L4T, or any aarch64 platform where you need the vendor's accelerated stack.
  • You need a small image and are willing to compile only the modules you use.
  • Your organisation requires a signed, source-traceable artefact with an SBOM.

One rule that saves a week of confusion: never install a pip wheel and a custom build into the same environment. Two cv2 modules on the path is one of the hardest-to-diagnose failure modes in this ecosystem — you get segfaults on import, or, worse, the wrong build silently wins and your CUDA calls raise "the function is not implemented".

pip uninstall -y opencv-python opencv-python-headless \
                 opencv-contrib-python opencv-contrib-python-headless

Run that first, every time, in every image.

2. A CMake configuration you can defend

OpenCV's default configuration builds nearly everything and pulls in whatever it happens to find on the build host. That is the opposite of reproducible: the same Dockerfile produces different libraries depending on which dev packages are cached in the base image. Be explicit — turn things off by name, not by omission.

cmake -S opencv -B build -G Ninja \
  -D CMAKE_BUILD_TYPE=Release \
  -D CMAKE_INSTALL_PREFIX=/opt/opencv \
  -D OPENCV_EXTRA_MODULES_PATH=/src/opencv_contrib/modules \
  -D OPENCV_ENABLE_NONFREE=OFF \
  \
  -D BUILD_LIST=core,imgproc,imgcodecs,videoio,dnn,objdetect,calib,3d,features2d,flann,video,highgui,python3 \
  -D BUILD_opencv_apps=OFF \
  -D BUILD_TESTS=OFF -D BUILD_PERF_TESTS=OFF -D BUILD_EXAMPLES=OFF -D BUILD_DOCS=OFF \
  \
  -D WITH_CUDA=ON -D WITH_CUDNN=ON -D OPENCV_DNN_CUDA=ON \
  -D CUDA_ARCH_BIN="8.6;8.9" -D CUDA_ARCH_PTX="" \
  -D WITH_CUBLAS=ON \
  \
  -D WITH_GSTREAMER=ON -D WITH_FFMPEG=ON \
  -D WITH_GTK=OFF -D WITH_QT=OFF -D WITH_VTK=OFF \
  -D WITH_OPENEXR=OFF -D WITH_TIFF=ON -D WITH_WEBP=OFF \
  -D WITH_IPP=ON -D WITH_TBB=ON -D WITH_OPENMP=OFF \
  \
  -D BUILD_SHARED_LIBS=ON \
  -D OPENCV_GENERATE_PKGCONFIG=ON \
  -D PYTHON3_EXECUTABLE=$(which python3) \
  -D OPENCV_PYTHON3_INSTALL_PATH=/opt/opencv/python

The flags that matter most in practice:

  • BUILD_LIST is the single biggest lever on size and build time. A pipeline that does decode → preprocess → ONNX inference → draw needs maybe eight modules, not forty. Cutting the list typically takes a CUDA build from ~50 minutes to ~20 and strips hundreds of megabytes.
  • CUDA_ARCH_BIN must match your deployment GPUs and nothing else. Every extra architecture adds compiled code and build minutes. 8.6 is Ampere (A10/A40/RTX 30xx), 8.9 Ada (L4/L40S/RTX 40xx), 9.0 Hopper (H100), 8.7 Jetson Orin. Leaving CUDA_ARCH_PTX empty removes JIT fallback — deliberate, because it makes "wrong GPU" fail loudly at load instead of costing you a ten-second JIT stall on the first frame.
  • OPENCV_ENABLE_NONFREE=OFF is a licensing decision, not a performance one. SIFT is BSD in modern OpenCV and lives in the main tree, but the nonfree group still carries patent-encumbered code. Shipping it to a commercial client without saying so is a conversation you do not want to have during their legal review. If you genuinely need something from it, flag it in writing.
  • WITH_GTK=OFF / WITH_QT=OFF for any server or container build. GUI backends drag in X11 and dozens of megabytes you will never call. Keep highgui in BUILD_LIST only if you use imwrite-adjacent helpers; drop it entirely for headless services.
  • BUILD_SHARED_LIBS=OFF (static) is worth considering for single-binary edge deployments, but it complicates the Python bindings and the licence notice work. Default to shared unless you have a concrete reason.

3. A multi-stage Dockerfile that does not ship a compiler

The classic mistake is building OpenCV in the same layer you run it from, leaving CUDA dev toolkits, source trees and object files in the final image. Separate the stages.

# ---------- builder ----------
FROM nvidia/cuda:12.6.2-cudnn-devel-ubuntu24.04 AS builder
ARG OPENCV_VERSION=5.0.0
ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential cmake ninja-build git ca-certificates \
      python3-dev python3-numpy \
      libjpeg-dev libpng-dev libtiff-dev \
      libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
      libavcodec-dev libavformat-dev libswscale-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /src
RUN git clone --depth 1 --branch ${OPENCV_VERSION} https://github.com/opencv/opencv.git && \
    git clone --depth 1 --branch ${OPENCV_VERSION} https://github.com/opencv/opencv_contrib.git

COPY configure.sh /src/configure.sh
RUN bash /src/configure.sh && cmake --build build --target install -j"$(nproc)" && \
    strip --strip-unneeded /opt/opencv/lib/libopencv_*.so.* || true

# ---------- runtime ----------
FROM nvidia/cuda:12.6.2-cudnn-runtime-ubuntu24.04
ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y --no-install-recommends \
      python3 python3-numpy \
      libjpeg8 libpng16-16 libtiff6 \
      libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 \
      gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav \
      libavcodec60 libavformat60 libswscale7 \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /opt/opencv /opt/opencv
ENV LD_LIBRARY_PATH=/opt/opencv/lib:$LD_LIBRARY_PATH \
    PYTHONPATH=/opt/opencv/python:$PYTHONPATH

COPY healthcheck.py /opt/healthcheck.py
RUN python3 /opt/healthcheck.py        # build fails here if the build is wrong

Points worth stealing:

  • The devel CUDA image builds; the runtime image ships. That difference alone is usually 2–3 GB.
  • Pin the OpenCV version as a build ARG and pin the base image by digest in anything regulated. :latest bases make "it worked last month" unreproducible.
  • Install the runtime variants of every dev package you built against, and match soname versions (libavcodec60, not "whatever apt gives me"). A missing runtime .so produces an ImportError at container start, which is at least loud — a mismatched one can be subtler.
  • Running the healthcheck at build time is the cheapest quality gate in this whole article. A broken build never gets tagged.

4. The runtime self-check

cv2.getBuildInformation() is the source of truth. Assert on it rather than eyeballing it.

# healthcheck.py
import re, sys, cv2

info = cv2.getBuildInformation()
failures = []

def require(pattern, label):
    if not re.search(pattern, info):
        failures.append(label)

print("OpenCV", cv2.__version__)

require(r"NVIDIA CUDA:\s+YES", "CUDA not compiled in")
require(r"cuDNN:\s+YES", "cuDNN not compiled in")
require(r"GStreamer:\s+YES", "GStreamer not compiled in")

if cv2.cuda.getCudaEnabledDeviceCount() < 1:
    failures.append("no CUDA device visible at runtime")

# architecture check: does a real kernel actually run on this GPU?
try:
    import numpy as np
    g = cv2.cuda_GpuMat()
    g.upload(np.zeros((64, 64), np.uint8))
    cv2.cuda.threshold(g, 10, 255, cv2.THRESH_BINARY)
except cv2.error as e:
    failures.append(f"CUDA kernel launch failed: {e}")

if failures:
    print("BUILD CHECK FAILED:", *failures, sep="\n  - ")
    sys.exit(1)
print("build check OK")

Two subtleties this catches that a version string never will. First, WITH_CUDA=ON at configure time does not guarantee CUDA was actually found — CMake happily continues with it off if the toolkit is missing, and the only evidence is a line in the configure log nobody read. Second, a binary compiled for sm_86 on an sm_90 host with no PTX fallback imports perfectly and fails at the first kernel launch. Launch one real kernel in the check.

Log the same summary at service startup and ship it to your telemetry. When a node starts producing different results from its neighbours, the first question is always "is it the same build?", and the answer should be one query away.

5. Size, and where it actually goes

Typical numbers from real engagements, final runtime image:

ConfigurationApprox. size
python:3.12-slim + opencv-python-headless350–450 MB
Custom CPU build, trimmed BUILD_LIST, Debian slim250–350 MB
CUDA runtime base + full OpenCV CUDA build, all arches5.5–7 GB
CUDA runtime base + trimmed modules, two arches3–4 GB
L4T base + OpenCV for Jetson Orin (sm_87 only)2.5–3.5 GB

The CUDA runtime base image is most of the floor — you are not getting a GPU container under about 2 GB, and pretending otherwise wastes days. What you can control is the delta: architecture list, module list, stripping symbols, and not shipping the source tree or build/ directory. Also cache the layers sensibly: the apt-get layer and the git clone layer should sit above the cmake --build layer, so that a flag change does not re-download the world.

For edge fleets, image size is bandwidth. A 6 GB image pushed to 300 sites over industrial 4G is a different project from a 3 GB one.

6. ABI, NumPy, and the traps around the Python bindings

A few failure modes worth knowing before they bite:

  • NumPy 2.x. OpenCV's Python bindings are compiled against the NumPy headers present at build time. Build against NumPy 2 and run against NumPy 1 (or vice versa) and you get _ARRAY_API not found or an immediate crash. Pin the NumPy major version in both the builder and runtime stages, and state it in your artefact notes.
  • C++ ABI and standard. OpenCV 5 requires C++17. If your client links OpenCV into an application built with an older toolchain or a different libstdc++, you will see link errors or, worse, undefined behaviour across the boundary. Build OpenCV with the same compiler the consuming application uses.
  • Python version. The bindings are built for one interpreter. A container that installs python3.12 in the builder and runs python3.11 at runtime will not find cv2. Check both stages.
  • opencv-python sneaking back in. Any transitive dependency can pull the wheel in — ultralytics, many albumentations pins, various SDKs. Add a post-install assertion that cv2.__file__ resolves inside /opt/opencv, and fail the build if it does not.
assert "/opt/opencv" in cv2.__file__, f"wrong cv2 on path: {cv2.__file__}"

7. Provenance: what you hand over

For anything going into a regulated or security-reviewed environment, the deliverable is not just an image. Record, alongside the tag:

  • OpenCV and opencv_contrib git commit SHAs (not just tags — tags can move).
  • The exact cmake command, checked into the repo as configure.sh, not pasted into a wiki.
  • The full cv2.getBuildInformation() output, saved into the image at /opt/opencv/build_info.txt and attached to the release.
  • Base image digest, CUDA/cuDNN versions, NumPy version.
  • A licence bundle: OpenCV is Apache 2.0 from 4.5.0 onward (BSD before), but FFmpeg, GStreamer plugins and any contrib modules you enabled carry their own terms — LGPL and, for some GStreamer "bad/ugly" plugins, GPL. If you enabled it, you own explaining it.
  • An SBOM, generated in CI (syft, trivy sbom) from the final image.

Build this in CI on a schedule, not on a laptop. A build that only one engineer can reproduce is a liability you are handing to the client along with the code.

8. The five-minute version

If you take one thing away: treat the OpenCV build as a versioned product artefact with its own tests.

  1. Uninstall pip wheels before installing a custom build. Always.
  2. Configure explicitly with BUILD_LIST and named WITH_* flags; never rely on autodetection.
  3. Set CUDA_ARCH_BIN to your actual deployment GPUs, nothing more.
  4. Multi-stage Docker: devel builds, runtime ships.
  5. Assert on getBuildInformation() and launch one real CUDA kernel, at build time and at service start.
  6. Pin NumPy, the Python version, the base image digest and the OpenCV commit SHA.
  7. Keep configure.sh, the build info dump and an SBOM with the release.

Do this once, at the start, and the "which OpenCV are we actually running?" conversation never happens again. Skip it, and it happens during an incident.

Where this fits

Build and packaging problems are the most common reason a working OpenCV prototype takes an extra month to reach production — and they are entirely avoidable. Related reading: our OpenCV 4.x to 5.0 migration checklist for the API side of the same upgrade, profiling and accelerating OpenCV 5 pipelines for what to do once CUDA is actually compiled in, and testing OpenCV 5 pipelines like software for the regression gates that sit on top of a reproducible build.

SentientSight's OpenCV consultants set up build, packaging and deployment pipelines — CUDA and Jetson builds, container hardening, CI, SBOM and licence review — for teams taking computer vision from prototype to production. If your OpenCV deployment story is currently "it works on the dev box", get in touch.