A slim vision container should hold inference code and nothing else, yet a default pip install opencv-python quietly ships a complete Qt GUI stack into an image nobody ever displays. This guide is for engineers packaging OpenCV pipelines into Docker images that run unattended on shop-floor vision controllers. It gives the wheel to pin, the megabytes the swap saves with the arithmetic shown, a five-step switch, and the one-line test that proves no GUI code remains in the image.
Which OpenCV wheel belongs in a server-side vision container?
Use opencv-python-headless whenever the code inside the container never opens a window: the wheel is built without Qt or any other GUI component, so it keeps every capture, codec and image-processing function and omits only the highgui calls such as cv2.imshow and cv2.waitKey.
PyPI lists four wheels: opencv-python, opencv-contrib-python, and a -headless twin of each. Versions match across the family, so the headless build of a release is the same library with GUI support compiled out — the packaging project sets hard-coded CMake flags that disable every GUI dependency for that variant. The project's own guidance adds the rule that makes the swap safe: one OpenCV wheel per environment, because all four install into the same cv2 module.
That yields the decision: inside a container with no display, GUI code is dead weight, so pin opencv-python-headless and keep every interactive view on the host. The systems topic page gathers the rest of the container set-up guides if you are hardening the whole image.
How much image size does opencv-python-headless save?
Expect roughly 220 MB on a Debian slim base: in the worked example below the opencv-python route lands at about 450 MB and the headless route at about 230 MB, so the GUI-free wheel removes close to half the image before your own model code is added.
Two documented anchors frame the estimate: the PyPI listing puts the headless wheel at about 51 MB, while a current 4.x full wheel runs roughly 85-95 MB. The larger difference opens after installation: the full wheel's cv2 tree carries bundled Qt libraries, and on a Debian slim base it also forces an apt layer with libgl1 and libglib2.0-0, because the Qt plugins load the system GL at import time and fail with the familiar libGL.so.1 error otherwise.
The table is an illustrative build on python:3.12-slim, amd64, sized the way docker images reports sizes; recompute it against your own base image and wheel versions.
| Image part | opencv-python route | opencv-python-headless route |
|---|---|---|
| python:3.12-slim base | 125 MB | 125 MB |
| OpenCV layer, installed | about 285 MB | about 105 MB |
| apt GUI libraries, libgl1 plus libglib2.0-0 | about 40 MB | 0 MB |
| Image total | 450 MB | 230 MB |
Check the arithmetic: 125 + 285 + 40 = 450 MB against 125 + 105 = 230 MB, so the saving is 450 - 230 = 220 MB, close to half the image.
What moves that number: opencv-contrib-python and its headless twin are larger than the base pair, arm64 and amd64 wheels differ slightly, and on a CUDA or PyTorch base the saving stays near 220 MB in absolute terms but shrinks as a share — the multi-GB framework layers then dominate, which is where reducing GPU vision container size for embedded deployment takes over. The same layer arithmetic decides lean builds on embedded GPU targets; the guide on building custom vision containers on NVIDIA Jetson walks one end to end.
Why does opencv-python still appear after you install the headless wheel?
Because a dependency declared opencv-python in its own metadata and pip honours that declaration even when you chose headless first: imgaug 0.4.0, for example, ships a wheel that requires opencv-python, and the default install path of several inference libraries does the same.
pip resolves every requirement separately, so any library whose metadata lists opencv-python pulls the full wheel in next to your headless one. Both wheels unpack into the same cv2 module directory, the last one installed overwrites files from the other, and the choice you made in requirements.txt silently disappears. The symptom is unmistakable: hasattr(cv2, 'imshow') flips back to True, or the import breaks halfway.
Work the fixes in this order:
- Check the library's own installation docs for a headless variant or extra; several inference and annotation libraries publish one for exactly this server-side case.
- With no extra available, uninstall both wheels, install opencv-python-headless, then add the pinning library with
pip install --no-deps imgaugand list its remaining dependencies in your requirements file yourself. - Run
pip checkand treat every complaint it prints as a decision: satisfy that requirement explicitly, or drop the library. - If the library truly cannot run without the GUI build, keep opencv-python, accept the roughly 220 MB, and keep every window call out of the running code.
Whichever wheel wins, pin it to one exact version everywhere — the guide on pinning one library version everywhere shows how a single floating requirement forks a build.
How do you switch a Dockerfile to the headless wheel in five steps?
Five changes cover it: name the headless wheel alone in requirements.txt, pin one exact version, delete the apt GL lines that only OpenCV needed, rebuild with --no-cache-dir so the wheel cache never lands in a layer, and diff docker images before and after.
You need the image's requirements file, a Dockerfile that builds today, and Docker on the build host — nothing else.
-
Name one OpenCV wheel in requirements.txt. Replace opencv-python with opencv-python-headless and pin the version with
==once you have validated it:
# requirements.txt - exactly one OpenCV wheel
opencv-python-headless
- Delete the apt lines that only OpenCV needed. On a slim base the full wheel forced a layer like this:
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
Remove it when pipdeptree shows no other libGL consumer; keep it if another package links OpenGL.
- Install with the pip cache off, so the wheel itself never lands in the layer:
RUN pip install --no-cache-dir -r requirements.txt
- Build and diff the sizes:
docker build -t vision-slim .
docker images vision-slim
vision-slim is an example tag; compare the SIZE column against your previous image, then run docker history vision-slim to see which layer lost the weight.
- Run the import proof from the next section before you tag or push the image.
The minimal Dockerfile for the headless route then reads:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "pipeline.py"]
How do you prove the container really has no GUI stack?
One import test decides it: print the version and the presence of the imshow attribute together, and require the output to be a version number followed by False, because the headless build simply does not compile the highgui window functions.
docker run --rm vision-slim python -c "import cv2; print(cv2.__version__, hasattr(cv2, 'imshow'))"
The required output is a version string followed by False, for example 4.10.0 False. The headless build omits the highgui window functions from the binary, so the attribute does not exist and calling cv2.imshow raises an AttributeError. A True in that output means a GUI build is inside — go back to the uninstall in the previous section.
Three follow-up checks take a minute each:
docker run --rm vision-slim pip show opencv-python
docker run --rm vision-slim pip check
docker run --rm vision-slim sh -c "pip install -q pipdeptree && pipdeptree | grep -i opencv"
The first should report Package(s) Not Found, the second No broken requirements found, and the grep should list opencv-python-headless plus any library that depends on it — nothing else. Close by running one real inference pass on a saved frame; pure cv2 routines such as the set camera ROI in Python workflow run unchanged on the headless build.
When is opencv-python the right choice instead?
Keep the full wheel only when a window must open inside the container itself, such as an X11-forwarded debug image or a VNC demonstration station, or when a hard dependency has no headless extra and cannot be installed with --no-deps.
Three cases justify the weight: the container itself must open a window, a hard dependency breaks under --no-deps, or commissioning habits demand a live view inside the image. Most of the time the cleaner design is the one the swap pushes you toward: the container captures, infers and writes results or frames to disk or a socket, and a small viewer on the host displays them. The camera live preview in GUI applications guide builds that viewer side, and the machine vision screen tearing diagnosis guide picks up the case where the host preview itself stutters.
Decide per container: one that must display takes the full wheel and the display plumbing; every other one takes headless.
What should you check before you order the controller the container runs on?
Check RAM, LAN port count and PoE first: after this swap the container's disk footprint is only a few hundred megabytes, so memory and camera ports decide the order far more than image size does.
RAM matters more than disk here: the image unpacks into the page cache alongside frame buffers and model weights, so headroom above the model's footprint is what keeps frame rate steady under load. An entry controller such as the MV-VC3301H-128G66 carries an Intel Core i3, 8 GB of memory and a 128 GB SSD, with 5+ LAN ports and 4 built-in light channels. Where the roadmap adds GPU inference or more cameras, the MV-VC5525-256G70-NN steps up to an Intel Core i5, 32 GB of memory and a 256 GB SSD, with PoE camera ports, 8 built-in light channels and PCIe expansion for add-in cards.
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
Compare the two on your pipeline's real numbers: count the cameras that land on LAN ports, sum the model and buffer footprint, and leave the image in the 230 MB class rather than the 450 MB class. Start from the vision controllers category, then step through the system configurator with camera, lens, light and controller in one pass.
Decision, restated: headless wheel in every container that never opens a window, full wheel only where one must, and a host controller ordered for RAM and ports — because image size stopped being the constraint.
Questions
Does opencv-python-headless still include VideoCapture and the video codecs?
Yes. The headless wheel removes only the highgui GUI layer: cv2.imshow, cv2.waitKey, trackbars and the Qt window toolkit. Video I/O through cv2.VideoCapture and cv2.VideoWriter, PNG and JPEG codecs, and every image-processing call stay in the package, so a capture-and-infer pipeline runs unchanged.
What happens if opencv-python and opencv-python-headless are both installed?
Both wheels unpack into the same cv2 module directory, so whichever installed last wins and the other's files linger as debris. The result is a broken or GUI-laden import that is hard to trace. Uninstall both by name, reinstall only the headless wheel, then run the hasattr test to confirm which build you actually have.
Do I still need libgl1 and libglib2.0-0 with the headless wheel?
Not for OpenCV itself. The GUI build needed the system GL for its Qt plugins; the headless build never touches it. Remove both apt lines, rebuild, and run the import test plus one real inference pass — if both pass, nothing else in the requirements file needed OpenGL either.
Does the headless wheel work on Alpine images?
The opencv-python packages publish no prebuilt musl wheels, so on Alpine pip falls back to compiling OpenCV from source, which takes hours and bloats the build stage. Stay on a Debian slim base such as python:3.12-slim, where the manylinux headless wheel installs in seconds.
How much RAM does a slim headless container need at runtime?
Plan for the model, not the wheel. A classic CPU OpenCV pipeline fits comfortably inside 1 GB, while a deep-learning inference service needs several GB once weights and frame buffers load. The wheel choice changes disk far more than RAM, so size memory to the workload and let the small image speed pulls and restarts.

