evision [WIP]
: OS | : arch | Build Status |
---|---|---|
Ubuntu 20.04 | arm64 | |
Ubuntu 20.04 | armv7 | |
Ubuntu 20.04 | s390x | |
Ubuntu 20.04 | ppc64le | |
Ubuntu 20.04 | x86_64 | |
macOS 11 Big Sur | x86_64 |
Apple Silicon M1-series are supported, actually I coded and tested this project on my M1 Max MacBook Pro, but M1 GitHub Action runners are not yet available.
Nerves Support
Prebuilt firmwares are available here. Select the most recent run and scroll down to the Artifacts
section, download the firmware file for your board and run
fwup /path/to/the/downloaded/firmware.fw
SSH keys can be found in nerves/id_rsa[.pub]
. For obvious security reason, please use these prebuilt firmwares for evaluation only.
Description
evision
will pull OpenCV source code from GitHub, then parse and automatically generate corresponding OpenCV-Elixir bindings.
This project uses and modifies gen2.py
and hdr_parser.py
from the python
module in the OpenCV repo so that they output header files that can be used in Elixir bindings.
We hope this project can largely reduce the work of manually porting OpenCV functions/modules to Elixir.
Current available modules:
- calib3d
- core
- features2d
- flann
- highgui
- imgcodecs
- imgproc
- ml
- photo
- stitching
- ts
- video
- videoio
Note, edit config/config.exs
to enable/disable OpenCV modules and image coders.
Dependencies
Required
- Python3 (Only during the compilation, to generate binding files)
- CMake
- Erlang development library/headers.
Optional
-
curl/wget. To download OpenCV source zip file.
Optional if you put the source zip file to
3rd_party/cache/opencv-${OPENCV_VER}.zip
. -
unzip. To unzip the OpenCV source zip file.
Optional if you supply OpenCV source code at
3rd_party/opencv/opencv-${OPENCV_VER}
.
Installation
In order to use evision
, you will need Elixir installed. Then create an Elixir project via the mix
build tool:
$ mix new my_app
Then you can add evision
as dependency in your mix.exs
. At the moment you will have to use a Git dependency while we work on our first release:
def deps do
[
{:evision, "~> 0.1.0-dev", github: "cocoa-xu/evision", branch: "main"}
]
end
Note
-
Use
MAKE_BUILD_FLAGS="-j$(nproc)"
environment variable to set number of jobs for compiling.Default value:
"-j#{System.schedulers_online()}"
. Inmix.exs
. -
Use
TOOLCHAIN_FILE="/path/to/toolchain.cmake"
to set your own toolchain.Default value:
"nerves/toolchain.cmake"
. InMakefile
. -
Edit
config/config.exs
to enable/disable OpenCV modules and image coders. -
Some useful commands
MIX_ENV=dev OPENCV_VER=4.5.4 MIX_TARGET=rpi4 # delete OpenCV related CMake build caches. rm -rf "_build/${MIX_ENV}/lib/evision/cmake_opencv_${OPENCV_VER}" ## for nerves rm -rf "_build/${MIX_TARGET}_${MIX_ENV}/lib/evision/cmake_opencv_${OPENCV_VER}" # remove downloaded OpenCV source zip file. rm -f "3rd_party/cache/opencv-${OPENCV_VER}" # delete evision.so (so that `make` can rebuild it, useful when you manually modified C/C++ source code) rm -f "_build/${MIX_ENV}/lib/evision/priv/evision.so" ## for nerves rm -rf "_build/${MIX_TARGET}_${MIX_ENV}/lib/evision/priv/evision.so" # delete evision related CMake build caches. rm -rf "_build/${MIX_ENV}/lib/evision/cmake_evision" ## for nerves rm -rf "_build/${MIX_TARGET}_${MIX_ENV}/lib/evision/cmake_evision"
Current Status
{:ok, gray_mat} = OpenCV.imread("/path/to/img.png", flags: OpenCV.cv_imread_grayscale)
{:ok, gray_blur_mat} = OpenCV.blur(gray_mat, [10,10], anchor: [1,1])
{:ok, colour_mat} = OpenCV.imread("/path/to/img.png")
{:ok, colour_blur_mat} = OpenCV.blur(colour_mat, [10,10], anchor: [1,1])
:ok = OpenCV.imwrite("/path/to/img-gray-and-blur.png", gray_blur_mat)
:ok = OpenCV.imwrite("/path/to/img-colour-and-blur.png", colour_blur_mat)
{:ok, cap} = OpenCV.VideoCapture.videocapture(0)
{:ok, cap_mat} = OpenCV.VideoCapture.read(cap)
:ok = OpenCV.imwrite("/path/exists/capture-mat.png", cap_mat)
:error = OpenCV.imwrite("/path/not/exists/capture-mat.png", cap_mat)
Todo
-
Update
.py
files inpy_src
so that they output header files for Erlang bindings. -
Automatically generate
erl_cv_nif.ex
. -
Automatically generate
opencv_*.ex
files using Python. -
Automatically convert enum constants in C++ to "constants" in Elixir
-
When a C++ function's return value's type is
bool
, maptrue
to:ok
andfalse
to:error
.# not this {:ok, true} = OpenCV.imwrite("/path/to/save.png", mat, []) # but this :ok = OpenCV.imwrite("/path/to/save.png", mat, [])
-
Make optional parameters truly optional.
# not this {:ok, cap} = OpenCV.VideoCapture.videocapture(0, []) # but this {:ok, cap} = OpenCV.VideoCapture.videocapture(0) # this also changes the above example to :ok = OpenCV.imwrite("/path/to/save.png", mat)
-
Nerves support (rpi4 for now).
-
Add
OpenCV.Mat
module.{:ok, type} = OpenCV.Mat.type(mat) {:ok, {:u, 8}} {:ok, {height, weight, channel}} = OpenCV.Mat.shape(mat) {:ok, {1080, 1920, 3}} {:ok, {height, weight}} = OpenCV.Mat.shape(gray_mat) {:ok, {1080, 1920}} {:ok, bin_data} = OpenCV.Mat.to_binary(mat) {:ok, << ... binary data ... >>}
-
Add support for Nx.
nx_tensor = OpenCV.Mat.to_nx(mat) #Nx.Tensor< u8[1080][1920][3] [[ ... pixel data ... ]] > {:error, reason} = OpenCV.Mat.to_nx(invalid_mat)
-
Edit
config/config.exs
to enable/disable OpenCV modules and image coders. -
Add tests.
How does this work?
-
This project will first pull OpenCV source code from git (as git submodules).
-
Inside the OpenCV project, there is an
opencv-python
module,3rd_party/opencv/modules/python
. If theopencv-python
module is enabled,cmake ... -D PYTHON3_EXECUTABLE=$(PYTHON3_EXECUTABLE) \ ...
It will generate files for
opencv-python
bindings in cmake build dir,cmake_build_dir/modules/python_bindings_generator
.We are interested in the
headers.txt
file as it tells us which headers should we parse (this header list changes based on the enabled modules).We also need to check another file,
pyopencv_custom_headers.h
. This file includes pyopencv compatible headers from modules that need special handlings to enable interactions with Python. We will talk about this later. -
Originally, the
headers.txt
file will be passed to3rd_party/opencv/modules/python/src2/gen2.py
and that Python script will then generatepyopencv_*.h
incmake_build_dir/modules/python_bindings_generator
. Here we copy that Python script and modify it so that it outputsc_src/evision_*.h
files which usec_src/erlcompat.hpp
andc_src/nif_utils.hpp
to make everything compatible with Erlang. -
c_src/opencv.cpp
includes almost all specialised and genericevision_to
andevision_from
functions. They are used for making conversions between Erlang and C++. Some conversion functions are defined in module custom headers. -
This is where we need to make some changes to
pyopencv_custom_headers.h
. We first copy it toc_src/evision_custom_headers.h
and copy every file it includes toc_src/evision_custom_headers/
. Then we make corresponding changes toc_src/evision_custom_headers/*.hpp
files so that these types can be converted from and to Erlang terms. The header include path inc_src/evision_custom_headers.h
should be changed correspondingly. -
However, it is hard to do step 5 automatically. We can try to create a PR which puts these changed files to the original OpenCV repo's
{module_name}/mics/erlang/
directory. Now we just manually save them inc_src/evision_custom_headers
. Note that step 5 and 6 are done manually, callingpy_src/gen2.py
will not have effect onc_src/evision_custom_headers.h
and*.hpp
files inc_src/evision_custom_headers
. -
Another catch is that, while function overloading is easy in C++ and optional arguments is simple in Python, they are not quite friendly to Erlang/Elixir. There is basically no function overloading in Erlang/Elixir. Although Erlang/Elixir support optional argument (default argument), it also affects the function's arity and that can be very tricky to deal with. For example,
defmodule OpenCV.VideoCapture do def open(self, camera_index, opts \\ []), do: :nil def open(self, filename), do: :nil # ... other functions ... end
In this case,
def open(self, camera_index, opts \\ []), do: :nil
will defineopen/3
andopen/2
at the same time. This will cause conflicts withdef open(self, filename), do: :nil
which definesopen/2
.So we cannot use default arguments. Now, say we have
defmodule OpenCV.VideoCapture do def open(self, camera_index, opts), do: :nil def open(self, filename, opt), do: :nil end
Function overloading in C++ is relatively simple as compiler does that for us. In this project, we have to do this ourselves in the Erlang/Elixir way. For the example above, we can use
guards
.defmodule OpenCV.VideoCapture do def open(self, camera_index, opts) when is_integer(camera_index) do # do something end def open(self, filename, opt) when is_binary(filename) do # do something end end
But there are some cases we cannot distinguish the argument type in Erlang/Elixir becauase they are resources (instance of a certain C++ class).
defmodule OpenCV.SomeModule do # @param mat: Mat def func_name(mat) do # do something end # @param mat: UMat def func_name(mat) do # do something end end
In such cases, we only keep one definition. The overloading will be done in
c_src/opencv.cpp
(byevision_to
). -
Enum handling. Originally,
PythonWrapperGenerator.add_const
inpy_src/gen2.py
will be used to handle those enum constants. They will be saved to a map with the enum's string representation as the key, and, of course, enum's value as the value. In Python, when a user uses the enum, saycv2.COLOR_RGB2BGR
, it will perform a dynamic lookup which ends up calling correspondingevision_[to|from]
.evision_[to|from]
will take the responsibility to convert between the enum's string representation and its value. Although in Erlang/Elixir we do have the ability to both create atoms and do the similar lookups dynamically, the problem is that, if an enum is used as one of the arguments in a C++ function, it may be written asvoid func(int enum)
instead ofvoid func(ENUM_TYPE_NAME enum)
. However, to distinguish between overloaded functions, some types (int, bool, string, char, vector) will be used for guards. For example,void func(int enum)
will be translated todef func(enum) when is_integer(enum), do: :nil
. Adding these guardians help us to make some differences amongst overloaded functions in step 7. However, that prevents us froming passing an atom todef func(enum) when is_integer(enum), do: :nil
. Technically, we can add one more variantdef func(enum) when is_atom(enum), do: :nil
for this specific example, but there are tons of functions has one or moreint
s as their input arguments, which means the number of variants in Erlang will increase expoentially (for eachint
in a C++ function, it can be either a realint
or anenum
). Another way is just allow it to be either an integer or an atom:def func(enum) when is_integer(enum) or is_atom(enum) do :nil end
But in this way, atoms are created on-the-fly, users cannot get code completion feature for enums from their IDE. But, finally, we have a good news that, in Erlang/Elixir, when a function has zero arguments, you can write its name without explictly calling it, i.e.,
defmodule M do def enum_name(), do: 1 end 1 = M.enum_name 1 = M.enum_name()
So, in this project, every enum is actually transformed to a function that has zero input arguments.
How do I make it compatible with more OpenCV modules?
Because of the reason in step 6, when you enable more modules, if that module has specialised custom header for python bindings, the custom headers will be added to cmake_build_dir/modules/python_bindings_generator/pyopencv_custom_headers.h
. Then you can manually copy corresponding specialised custom headers to c_src/evision_custom_headers
and modify these conversion functions in them.
Acknowledgements
gen2.py
,hdr_parser.py
andc_src/erlcompat.hpp
were directly copied from thepython
module in the OpenCV repo. Changes applied.Makefile
,CMakeLists.txt
andc_src/nif_utils.hpp
were also copied from thetorchx
module in the elixir-nx repo. Minor changes applied.