This is an article on how to create a Elixir wrapper of Rust crate. If you are more care about using the final library to extract dominant colors, head over to 29decibel/dominant_colors or reference this Livebook gist
I found this very nice Rust library called kmeans-colors which can extract dominant colors from a given image. It based on k means clustering algorithm. It even comes with a binary. Which you can generate color palette like this:
kmeans_colors -i gfx/mountains.jpg --no-file --palette
Command line is great, but I wish I can use this in Elixir and Livebook.
So I go ahead wrote a simple wrapper on top of it using the awesome rustler
library. In this article, I am gonna document how you can leverage the great Rust ecosystem and bring them into Elixir.
Let’s create a new library using mix new
:
mix new dominant_colors
I choose to use dominant_colors
rather than something like kmeans_colors_ex
so that it’s more general and not bind to the internal algorithm.
After the project created. We need to add {:rustler, "~> 0.30.0"}
to the dependency.
Then we can create a native Rust lib inside our elixir package:
mix rustler.new
Then when it prompted asking for the Elixir module the native module registered to. Type DominantColors
, which is the module name mix
generated.
This is the name of the Elixir module the NIF module will be registered to.
Module name > DominantColors
This is the name used for the generated Rust crate. The default is most likely fine.
Library name (dominantcolors) >
* creating native/dominantcolors/.cargo/config.toml
* creating native/dominantcolors/README.md
* creating native/dominantcolors/Cargo.toml
* creating native/dominantcolors/src/lib.rs
* creating native/dominantcolors/.gitignore
Ready to go! See /Users/mikeli/projects/dominant_colors/native/dominantcolors/README.md for further instructions.
Now let’s loading the NIF. This is what it becomes
defmodule DominantColors do
use Rustler,
otp_app: :dominant_colors,
crate: :dominantcolors
# When loading a NIF module, dummy clauses for all NIF function are required.
# NIF dummies usually just error out when called when the NIF is not loaded, as that should never normally happen.
def add(_arg1, _arg2), do: :erlang.nif_error(:nif_not_loaded)
end
Let’s try to add a simple test case to make sure the calling to Rust module works:
test "add" do
assert DominantColors.add(1, 2) == 3
end
OK now we have the basic Elixir calling Rust working through rustler
.
❯ mix test
.
Finished in 0.00 seconds (0.00s async, 0.00s sync)
1 test, 0 failures
Randomized with seed 733558
kmeans-colors
Now we can start the real work to integrate the Rust library. https://github.com/okaneco/kmeans-colors
So head over to native/dominantcolors
.
Running cargo add kmeans-colors
to add the dependency.
cargo add kmeans-colors
cargo add palette
cargo add image
Please notice we also added palette
and image
crate as well because we are going to use it to manipulate images and colors.
Here we have the first version of it. It’s heavily inspired by the kmeans-colors
’s cli
.
use kmeans_colors::{get_kmeans, Kmeans};
use palette::{FromColor, IntoColor, Lab, Srgb};
// reference
// https://github.com/okaneco/kmeans-colors/blob/master/src/bin/kmeans_colors/app.rs
#[rustler::nif]
fn dominant_colors(file_name: String) -> Result<Vec<String>, String> {
// Read file into buffer
let img = image::open(file_name);
if img.is_err() {
return Err("Can not open given file".to_string());
}
let img_vec = img.unwrap().to_rgb8().into_raw();
// Convert RGB [u8] buffer to Lab for k-means
let lab: Vec<Lab> = img_vec
.chunks(3)
.map(|chunk| {
Srgb::new(
chunk[0] as f32 / 255.0,
chunk[1] as f32 / 255.0,
chunk[2] as f32 / 255.0,
)
.into_format()
.into_color()
})
.collect();
// Perform k-means clustering
let mut best_result = Kmeans::new();
let converge = 5.0; // 5 for LAB or 0.0025 for RGB; // threshold for convergence.
let runs = 1; // default just run once
let cluster = 8; // number of clusters/colors returned
let max_iter = 20; // maximum number of iterations
let verbose = false;
for i in 0..runs {
// Run k-means multiple times
let run_result = get_kmeans(cluster, max_iter, converge, verbose, &lab, 0 + i as u64);
if run_result.score < best_result.score {
best_result = run_result;
}
}
// Convert Lab centroids back to Srgb<u8> for output
let color_codes: Vec<String> = best_result
.centroids
.iter()
.map(|x| Srgb::from_color(*x).into_format::<u8>())
.map(|color| format!("#{:02x}{:02x}{:02x}", color.red, color.green, color.blue))
.collect();
Ok(color_codes)
}
rustler::init!("Elixir.DominantColors", [dominant_colors]);
I won’t go very deep on this, but I assume we will need more tweaks later on or expose more knobs to make it more customizable. But for now it should do the basic work.
Then we can register this function in our Elixir module:
defmodule DominantColors do
use Rustler,
otp_app: :dominant_colors,
crate: :dominantcolors
def dominant_colors(_arg1), do: :erlang.nif_error(:nif_not_loaded)
end
Let’s write a test to validate it.
test "dominant_colors" do
file = "./test/fixtures/test3.png"
assert DominantColors.dominant_colors(file) == {:ok, [
"#bfbdbb",
"#2f2e2e",
"#a3a1a0",
"#d8653a",
"#eeedec",
"#171515",
"#7e7b7b",
"#4d4b4b"] }
end
Works great 🎨.
Now let’s publish it to hex and try to use it in Livebook.
In order to publish the package, we would need to add couple of info to the mix.exs
diff --git a/mix.exs b/mix.exs
index 7dd3f49..800a10c 100644
--- a/mix.exs
+++ b/mix.exs
@@ -7,7 +7,9 @@ defmodule DominantColors.MixProject do
version: "0.1.0",
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,
- deps: deps()
+ deps: deps(),
+ description: description(),
+ package: package()
]
end
@@ -18,10 +20,23 @@ defmodule DominantColors.MixProject do
]
end
+ defp description() do
+ "Extract dominant colors from given image (using kmeans), wrapper on top of rust kmeans_colors."
+ end
+
+ defp package() do
+ [
+ name: "dominant_colors",
+ licenses: ["MIT"],
+ links: %{"GitHub" => "https://github.com/29decibel/dominant_colors"}
+ ]
+ end
+
# Run "mix help deps" to learn about dependencies.
defp deps do
[
- {:rustler, "~> 0.30.0"}
+ {:rustler, "~> 0.30.0"},
+ {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
]
end
end
Making sure you are registered or logged in into hex:
mix hex.user auth
Now we can publish the package. If you got the error of following:
** (UndefinedFunctionError) function Hex.Crypto.decrypt/3 is undefined (module Hex.Crypto is not available)
Then you can try the following:
mix local.hex --force
mix hex.publish
If everything goes right, you should be able to see something like this:
Building dominant_colors 0.1.0
Dependencies:
rustler ~> 0.30.0 (app: rustler)
App: dominant_colors
Name: dominant_colors
Files:
lib
lib/dominant_colors.ex
priv
priv/native
priv/native/libdominantcolors.so
.formatter.exs
mix.exs
README.md
Version: 0.1.0
Build tools: mix
Description: Extract dominant colors from given image (using kmeans), wrapper on top of rust kmeans_colors.
Licenses: MIT
Links:
GitHub: https://github.com/29decibel/dominant_colors
Elixir: ~> 1.15
Before publishing, please read the Code of Conduct: https://hex.pm/policies/codeofconduct
Publishing package to public repository hexpm.
Proceed? [Yn] y
Building docs...
Generating docs...
View "html" docs at "doc/index.html"
View "epub" docs at "doc/dominant_colors.epub"
Local password:
Publishing package...
[#########################] 100%
Package published to https://hex.pm/packages/dominant_colors/0.1.0 (722129016b397b09a4a58349ab3f60ad12e4b24e09d1c5a301bd08a79ab18977)
Publishing docs...
[#########################] 100%
Docs published to https://hexdocs.pm/dominant_colors/0.1.0
Our package is now available.
Let’s create a dummy to consume it.
mix new test_colors
Then adding the dependency:
{:dominant_colors, "~> 0.1.0"}
Opps, we got an error:
spawn: Could not cd to native/dominantcolors
== Compilation error in file lib/dominant_colors.ex ==
** (RuntimeError) calling `cargo metadata` failed.
(rustler 0.30.0) lib/rustler/compiler/config.ex:96: Rustler.Compiler.Config.metadata!/1
(rustler 0.30.0) lib/rustler/compiler/config.ex:78: Rustler.Compiler.Config.build/1
(rustler 0.30.0) lib/rustler/compiler.ex:8: Rustler.Compiler.compile_crate/3
lib/dominant_colors.ex:2: (module)
could not compile dependency :dominant_colors, "mix compile" failed. Errors may have been logged above. You can recompile this dependency with "mix deps.compile dominant_colors --force", update it with "mix deps.update dominant_colors" or clean it with "mix deps.clean dominant_colors"
Looks like we forgot the add the rust files into the package, that’s why it can’t find certain files in the published package. To fix this, make sure to include the rust files:
defp package() do
[
files: [
"lib",
"native/dominantcolors/.cargo",
"native/dominantcolors/src",
"native/dominantcolors/Cargo*",
"mix.exs"
],
...
]
end
With this, we should be able to consume the package.
This works great if the consumer end have the Rust toolchain ready. However, in the context of Livebook this won’t work by default because Livebook do not have the access to the Rust toolchain like cargo
.
Luckily rustler_precompiled is exactly trying to solve this.
The TLDR version of rustler_precompied
is that it uses a CI server to pre-compile a bunch of binaries, then tell the Elixir to download those binary directly during installation. Think of it as a multi stage process.
First let’s create a GitHub action workflow. Heavily borrowed from rustler_precompilation_example:
name: Build precompiled NIFs
on:
push:
branches:
- main
tags:
- "*"
jobs:
build_release:
name: NIF ${{ matrix.nif }} - ${{ matrix.job.target }} (${{ matrix.job.os }})
runs-on: ${{ matrix.job.os }}
strategy:
fail-fast: false
matrix:
nif: ["2.16", "2.15"]
job:
- {
target: arm-unknown-linux-gnueabihf,
os: ubuntu-20.04,
use-cross: true,
}
- {
target: aarch64-unknown-linux-gnu,
os: ubuntu-20.04,
use-cross: true,
}
# commentted this out as I am experiencing some compilation issues
# - {
# target: aarch64-unknown-linux-musl,
# os: ubuntu-20.04,
# use-cross: true,
# }
- { target: aarch64-apple-darwin, os: macos-11 }
- {
target: riscv64gc-unknown-linux-gnu,
os: ubuntu-20.04,
use-cross: true,
}
- { target: x86_64-apple-darwin, os: macos-11 }
- { target: x86_64-unknown-linux-gnu, os: ubuntu-20.04 }
- {
target: x86_64-unknown-linux-musl,
os: ubuntu-20.04,
use-cross: true,
}
- { target: x86_64-pc-windows-gnu, os: windows-2019 }
- { target: x86_64-pc-windows-msvc, os: windows-2019 }
steps:
- name: Checkout source code
uses: actions/checkout@v3
- name: Extract project version
shell: bash
run: |
# Get the project version from mix.exs
echo "PROJECT_VERSION=$(sed -n 's/^ @version "\(.*\)"/\1/p' mix.exs | head -n1)" >> $GITHUB_ENV
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
target: ${{ matrix.job.target }}
- name: Build the project
id: build-crate
uses: philss/rustler-precompiled-action@v1.0.1
with:
project-name: dominantcolors
project-version: ${{ env.PROJECT_VERSION }}
target: ${{ matrix.job.target }}
nif-version: ${{ matrix.nif }}
use-cross: ${{ matrix.job.use-cross }}
project-dir: "native/dominantcolors"
- name: Artifact upload
uses: actions/upload-artifact@v3
with:
name: ${{ steps.build-crate.outputs.file-name }}
path: ${{ steps.build-crate.outputs.file-path }}
- name: Publish archives and packages
uses: softprops/action-gh-release@v1
with:
files: |
${{ steps.build-crate.outputs.file-path }}
if: startsWith(github.ref, 'refs/tags/')
Then add the rustler_precompiled
dependency:
{:rustler_precompiled, "~> 0.7"},
Also add Cross.toml
in the native/dominantcolors
:
[build.env]
passthrough = ["RUSTLER_NIF_VERSION"]
And the .cargo/config.toml
:
[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
# See https://github.com/rust-lang/rust/issues/59302
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "target-feature=-crt-static"]
# Provides a small build size, but takes more time to build.
[profile.release]
lto = true
Now we can push the changes to see if the build is green.
Finally after we validated its build success. We can release it.
git tag 0.1.0
git push --tags
Now we have the binaries built in the CI, we can grab them and use them instead.
In order to get that, we need to do two things:
First we need to use the precompile macro:
version = Mix.Project.config()[:version]
use RustlerPrecompiled,
otp_app: :dominant_colors,
crate: "dominantcolors",
base_url: "https://github.com/29decibel/dominant_colors/releases/download/#{version}",
force_build: System.get_env("DOMINANT_COLORS_BUILD") in ["1", "true"],
version: version
Then we need to download the checksum:
mix rustler_precompiled.download DominantColors --all
Now we should be able to use the precompiled version locally.
Please note we also provide a
DOMINANT_COLORS_BUILD
as a way to escape the pre compiled binary. It’s extremely useful for local development.
Last step, let’s publish it to hex. Before the publish, we will need to make sure the checksum*
files are included in the package, otherwise the installation will be failed with checksum not matched error
.
defp package() do
[
files: [
"lib",
"native/dominantcolors/.cargo",
"native/dominantcolors/src",
"native/dominantcolors/Cargo*",
"checksum-*.exs",
"mix.exs"
],
...
]
end
We can verify the final package contents by using mix hex.build --unpack
:
❯ mix hex.build --unpack
Building dominant_colors 0.1.1
Dependencies:
rustler ~> 0.30.0 (app: rustler)
rustler_precompiled ~> 0.7 (app: rustler_precompiled)
App: dominant_colors
Name: dominant_colors
Files:
lib
lib/dominant_colors.ex
native/dominantcolors/.cargo
native/dominantcolors/.cargo/config.toml
native/dominantcolors/src
native/dominantcolors/src/lib.rs
native/dominantcolors/Cargo.lock
native/dominantcolors/Cargo.toml
checksum-Elixir.DominantColors.exs
mix.exs
Version: 0.1.1
Build tools: mix
Description: Extract dominant colors from given image (using kmeans), wrapper on top of rust kmeans_colors.
Licenses: MIT
Links:
GitHub: https://github.com/29decibel/dominant_colors
Elixir: ~> 1.15
Saved to dominant_colors-0.1.1
Looks great! Now we can publish it.
mix hex.publish
Add dependency:
{:dominant_colors, "~> 0.1.3"}
Then some Elixir code and using Kino render:
image_path = "/Users/mikeli/Downloads/111ebfdc244345c3.png"
{:ok, colors} = DominantColors.dominant_colors(image_path)
styles = fn color ->
[
{"justify-content", "center"},
{"align-items", "center"},
{"color", "white"},
{"display", "inline-flex"},
{"width", "100px"},
{"height", "30px"},
{"background-color", color},
{"margin", "1px"}
] |> Enum.map(fn {k, v} -> k <> ": " <> v <> ";" end)
|> Enum.join(" ")
end
html =
Enum.map(colors, fn color ->
"<div style=\"#{styles.(color)}\">#{color}</div>"
end)
|> Enum.join()
Kino.HTML.new(html)
How lovely!
I love the fact that Elixir can leverage so much in the Rust land with reasonable effort. It opened a whole new world to Elixir and Livebook users.
Next time, when you can’t find a library in the Elixir land, give some Rust crate and rustler
a try.
There are lots of other things we can improve for sure. But not a bad start I would say.