
Until recently OpenCV Python packages were provided for Windows, Linux (x86_64 and ARM), and macOS (formerly known as OSX) for x86_64 and all was right with the world. However, in November 2020, Apple launched its M1 processor and a series of new hardware based on it followed which changed the game- macOS now needs not only x86_64 packages, but arm64 too!
Another change for opencv-python emerged at the same time – we’ve switched our build process to GitHub Actions since the other Continuous Integration (CI) platforms we’d used become too restrictive. However, Actions has no macOS M1 builders provided. This provided another wrinkle in our plans.
To help mitigate both of these problems, we went and got ourselves an M1 Mac so that we could produce builds on this new hardware the right way. It took a little extra effort, but we made it work, and here’s how!
About the author:
Grigory Serebryakov is OpenCV AI Chief Development Officer.
How to set up macOS for OpenCV development
I started with the official tutorial on building the current version of OpenCV for macOS. However, for our continuous integration machine, we need to do a few additional steps. Since we want to support multiple Python versions, chances are we’ll have more machines in the future, so we’ll want to automate the environment setup.
My tool of choice when it comes to managing python interpreters on one machine is pyenv (yes, we know there are others). Since we’re talking about automation, we need to be able to install software from the command-line to efficiently manage dependencies. The free Homebrew is great third-party package manager for macOS which we’ll use for this purpose. On top of that, automation requires some glue between different tools to configure them, provide the needed setup, and report issues in standing up new machines. Ansible is one of the most used tools in this area, and has a good track record, so we’ll go with it.
Let’s not forget we still may need some GUI applications – yes, this machine is for CI, but imagine that at some point you’d like to debug some functionality. That said, having a compiler isn’t enough, we need an IDE. Using this Ansible collection that allows you to install any GUI application from Apple store we will set up XCode which is, of course, Apple’s recommended IDE. Using the above tools, we have all we need to set up our requirements and move on to building.
Building OpenCV with Python3.9
Starting in the evening, I opened my laptop, connected via ssh to the M1 machine, checked that all the tools were ready and cloned the OpenCV repository. After that, I started doing the build – not the Python package, initially, but OpenCV itself with the Python bindings.
I installed python 3.9 from pyenv – as easy as:
$ pyenv install 3.9.5
Now we’re ready to configure the build. First, we’ll set the active version of Python to 3.9.5, make sure pip is up to date, and then install the python packages needed for the build:
$ pyenv local 3.9.5
$ python3 -m pip install --upgrade pip
$ python3 -m pip install numpy
Now, we should provide these 3 options to cmake to get python3 module built:
- PYTHON3_EXECUTABLE
- PYTHON3_INCLUDE_DIR
- PYTHON3_NUMPY_INCLUDE_DIRS
This presents a problem: Where can we find these paths, assuming we have a non-system python? (Don’t forget that if you’re doing this locally the paths can be different than those shown below.)
Pyenv itself can tell us where the binary is for Python, that’s one down:
$ pyenv which python3
/Users/xperience/.pyenv/versions/3.9.5/bin/python3
… and our Python installation provides an utility named python-config, that knows everything about libraries and include directories:
$ python3-config --includes
-I/Users/xperience/.pyenv/versions/3.9.5/include/python3.9
And the last is our numpy include directory, which we get like so:
$ python3 -c "import numpy; print(numpy.get_include())"
/Users/xperience/.local/lib/python3.9/site-packages/numpy/core/include
Voilà, now let’s create a build directory and run cmake:
$ mkdir opencv_build
$ cd opencv_build
$ cmake -DPYTHON3_EXECUTABLE=$(pyenv which python3) \
-DPYTHON3_INCLUDE_DIR=~/.pyenv/versions/3.9.5/include/python3.9 \
-DPYTHON3_NUMPY_INCLUDE_DIRS=~/.local/lib/python3.8/site-packages/numpy/core/include \
../opencv
Check cmake’s output to make sure it lists the python3 module – it should be in the list of modules to be built. If everything looks good, we’re ready to build:
$ cmake --build . -j8
Building OpenCV for Other Python Versions
I got the results described above somewhere at midnight, and went to sleep with the good feeling of a job well-done. The next day I asked a colleague of mine to check the same steps but for the other Python versions – 3.7.10 and 3.8.10, since I was planning a vacation for the next few days.
Imagine my surprise when I returned back to work and got the following message:
> Everything is bad. Python 3.8 requires patching (with the official patch, but still). And Python 3.7 can’t use the _ctypes module after install
Not something you’re expecting to get after a clean build with Python 3.9, nah? We’ll have to get our hands dirty to understand what’s going on.
Python 3.8 and macOS on M1
This version seems a bit easier to fix than 3.7 – we’re able to get the working Python, but need a patch. Why don’t we like this solution? This patch link is unique for each python version, so maintaining a list of those can become a bit unmanageable. However, after reading discussions on GitHub and Stack Overflow, I started thinking that we can avoid the patching part. I’ve found the final solution in an issue on the pyenv Github. Apple has thought about users of the new ARM machines needing to run x86_64 apps and has provided a compatibility layer to facilitate this. Unfortunately for us, the build machinery thinks that we’re building on an x86_64 machine, and not ARM due to this layer! The fix is all about setting the environment to our homebrew paths properly:
export LDFLAGS="-L/opt/homebrew/lib"
export CPPFLAGS="-I/opt/homebrew/include"
And that’s it – after this simple adjustment, we can build Python 3.8 without any need for that patch!
Python3.7 and macOS on M1
Having fixed 3.8, I was perhaps a bit overconfident. The Python 3.7 build issue was strange – we were able to build it, but python gave errors that module `_ctypes` wasn’t provided. This module is essential for us, as it is with any binary library for Python (since the data exchange relies on the C-compatible interfaces and C data types). In other words, it means we can’t use numpy or OpenCV without ctypes. First I tried to export the same variables which solved the 3.8 build, and… nothing. Looking at the build log, I found an attempt to build _ctypes. However, the code said (with an error) that it’s impossible to have macOS/OSX as an operating system and ARM as a platform (haha, not true anymore!).
Some searching told me that I can avoid building this chunk of code if I have a system-specific libffi (a library that describes the foreign function interfaces and allows data exchange with a C-compatible interface – exactly what we need!). I checked with Homebrew – and yes, libffi was here, but python didn’t recognize it.
The solution came to me from the python3.9 build logs- it had the following defines specified:
`-DMACOSX -DUSING_APPLE_OS_LIBFFI=1`.
Some research on the Python build configuration management and pyenv’s python-build module showed me that there is a flag `–with-system-ffi` that I can pass through pyenv to python build tools, like this:
CONFIGURE_OPTS='--with-system-ffi' pyenv install 3.7.10
The final check: I’ve built the Python, started the interpreter, and tried to import `_ctypes`. You can guess what has happened. Yes, I got an error. But this error was a new one:
ctypes/__init__.py
CFUNCTYPE(c_int)(lambda: None)
MemoryError
A memory error on import? That’s new. Time to fire up the search engine, again! Digging around, I found something: an old issue from python2.6. Despite being an older version, this was the source of the error we got, and more than that, the solution for 2.6 still works for 3.7: just delete the code that causes the memory error. Here is that code:
def _reset_cache(): _pointer_type_cache.clear() _c_functype_cache.clear() if _os.name == "nt": _win_functype_cache.clear() # _SimpleCData.c_wchar_p_from_param POINTER(c_wchar).from_param = c_wchar_p.from_param # _SimpleCData.c_char_p_from_param POINTER(c_char).from_param = c_char_p.from_param _pointer_type_cache[None] = c_void_p # XXX for whatever reasons, creating the first instance of a callback # function is needed for the unittests on Win64 to succeed. This MAY # be a compiler bug, since the problem occurs only when _ctypes is # compiled with the MS SDK compiler. Or an uninitialized variable? CFUNCTYPE(c_int)(lambda: None)
It’s the last line here that emits a memory error, and as the comment near it says, its author has no idea why this code is needed, but it is for Windows exclusively to make the tests happy. Of course, we’re on macOS.
So… let’s remove the last line. Frankly speaking, I’m not brave enough to rely on a Stack Overflow comment alone, so I’ve checked the sources for python3.8. Guess what? That line is not here anymore – it has been removed! You may wonder – why did they remove this code from 3.8, but not from 3.7? The answer is that 3.7 has passed its “end of life,” and so no enhancements are accepted, only bugfixes for security flaws.
Whew! With all this we’ve finally got python3.7 installed, and I was able to build opencv with it.
Is this the end of the article? No, it is not!
OpenCV-Python packages: Your OS is Too New
After all the effort on the steps above, the last one – preparing the binary wheels with opencv-python seemed trivial. But, again, I ran into some issues.
We’ve cloned the opencv-python repo, all the python versions are already in place, so we started a build. We’ve got the packages, everything seemed to be as expected, and so only one last step was in front of us: checking the functionality of the built packages. Here we found the next problem: none of the packages we were able to install successfully with pip! However, a colleague of mine, Andrey, found the solution.
The issue was that our version of macOS was too new. We have OSX 11.1, and our packages have names like `opencv_python-4.5.2-cp39-cp39-macosx_11_1_arm64.whl`. However, at this time pip knows only about macOS 11.0 – you can check it with the following command:
$ python3 -m pip debug -v | grep -A 10 'Compatible tags'
Compatible tags: 327
cp39-cp39-macosx_11_0_arm64
cp39-cp39-macosx_11_0_universal2
cp39-cp39-macosx_10_16_universal2
cp39-cp39-macosx_10_15_universal2
cp39-cp39-macosx_10_14_universal2
cp39-cp39-macosx_10_13_universal2
cp39-cp39-macosx_10_12_universal2
cp39-cp39-macosx_10_11_universal2
cp39-cp39-macosx_10_10_universal2
cp39-cp39-macosx_10_9_universal2
The following setting fixes our pip install issues:
export MACOSX_DEPLOYMENT_TARGET=11.0
python3 -m pip wheel
Bring macOS builds back to GitHub world
In the previous step, we built packages for opencv-python under macOS on the M1 architecture. Remember, however, that our final goal is to enable the seamless integration of these builds with the release process on GitHub. To do this we need a GitHub Actions runner working on our M1 host. As I’m writing these words, Github Actions runner has only the x86_64 version for macOS. It doesn’t look like a big issue – remember that Apple provides a layer of compatibility for x86_64 applications on M1, so runner works even if its target architecture differs. Unluckily for us when we build opencv-python inside the Github Actions scenario, some part of the build machinery recognizes the platform as x86_64 even after our fixes to the environment, so we get a bad result. Luckily for us, the solution is easy: with the `arch` utility we can “run a selected architecture of a universal binary”
After running this, we are all good:
arch -arm64 python${{ matrix.python-version }} -m pip wheel --wheel-dir=wheelhouse . --verbose
Welcome opencv-python for macOS on M1
Finally, after all the above struggles, we have a Pull Request which was merged recently. It adds CI for native macOS M1 packages and supports python3.7, 3.8, and 3.9 builds.
Soon we will have the packages on PyPI. Many thanks to Andrey Senyaev without whose help this could not have been possible.