Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
22009ad
Initial python bindings
Krasner Apr 10, 2026
fa0122f
build pybindings in build-c-cpp only
Krasner Apr 10, 2026
fb4820a
finalize example
Krasner Apr 10, 2026
083131f
remove breakpoint
Krasner Apr 10, 2026
b5042ad
fix bug
Krasner Apr 10, 2026
21e1001
revert docker-compose
Krasner Apr 11, 2026
8001f65
Merge branch 'main' into feat/python
Krasner Apr 11, 2026
4ed412e
formatting
Krasner Apr 11, 2026
84e7087
build python binding to python dist-packages
Krasner Apr 11, 2026
1fb7a65
python functions do NOT modify in place, rather return a new image
Krasner Apr 14, 2026
edce188
clean up
Krasner Apr 14, 2026
8f0fda8
update docstring
Krasner Apr 14, 2026
f35a97e
remove prints
Krasner Apr 14, 2026
b1703bd
move python requirements to requirments.txt
Krasner Apr 15, 2026
86517b7
formatting
Krasner Apr 15, 2026
210307f
use c++ lib for pybind11
Krasner Apr 16, 2026
e9c9161
build(python-bindings): switch to use the uv package manager & packag…
Ryan-Millard Apr 15, 2026
2529e0b
Merge branch 'feat/python' of https://github.com/Krasner/Img2Num into…
Ryan-Millard Apr 16, 2026
1836fbb
refactor(python): migrate to uv workspace monorepo with scikit-build …
Ryan-Millard Apr 16, 2026
b80e436
refactor(python-package): remove forgotten debug print
Ryan-Millard Apr 17, 2026
bb2cafc
chore(python): reorganize uv workspace and example console app setup
Ryan-Millard Apr 17, 2026
12f3b17
build(python): disable build isolation for img2num package to allow s…
Ryan-Millard Apr 17, 2026
98c25b3
feat(python-package): use numpy image.shape to determine dimensions w…
Ryan-Millard Apr 17, 2026
97057ab
Merge branch 'main' into feat/python
Ryan-Millard Apr 18, 2026
234d98a
Merge branch 'main' into feat/python
Krasner Apr 19, 2026
6edf3f4
refactor(docker-ci): default to `main` tag for CI and require maintai…
Ryan-Millard Apr 22, 2026
651ca2b
Merge branch 'main' into feat/python
Ryan-Millard Apr 22, 2026
3ad4c93
refactor(docker-ci): default to `main` tag for CI and require maintai…
Ryan-Millard Apr 22, 2026
cfe9f42
Merge branch 'main' into feat/python
Ryan-Millard Apr 22, 2026
c0967bf
python check
Krasner Apr 22, 2026
e087c5f
GHCR authorization
Krasner Apr 22, 2026
9f462cc
GHCR authorization token
Krasner Apr 22, 2026
10e6efb
GHCR authorization
Krasner Apr 22, 2026
d5f32b8
push ghcr image
Krasner Apr 22, 2026
054d8d9
also check cache tag
Krasner Apr 22, 2026
ac352e9
try different token
Krasner Apr 22, 2026
2afef81
fix bug
Krasner Apr 22, 2026
cffa5c7
fix bug
Krasner Apr 22, 2026
6f48201
try different token
Krasner Apr 22, 2026
de24146
chore(ci): restore ci.yml and docker.yml to main's versions
Ryan-Millard Apr 22, 2026
dce1515
Merge branch 'main' into feat/python
Ryan-Millard Apr 22, 2026
2b48c4c
Merge branch 'main' into feat/python
Ryan-Millard Apr 22, 2026
91bde93
update py build
Krasner Apr 23, 2026
ff8af5e
no build isolation
Krasner Apr 23, 2026
2bf804c
install scikit-build-core
Krasner Apr 23, 2026
5279ede
install scikit-build-core
Krasner Apr 23, 2026
19a6d06
install virtual environment
Krasner Apr 23, 2026
119bdf8
install numpy as well for GA build
Krasner Apr 23, 2026
b712bf5
upload python build artifacts
Krasner Apr 23, 2026
e9cc8c4
fix typo
Krasner Apr 23, 2026
152f8da
Merge branch 'main' into feat/python
Ryan-Millard Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/cmake-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,39 @@ jobs:
with:
name: build-c-cpp
path: build-c-cpp/

build-py:
name: Build Python
runs-on: ubuntu-latest
container:
image: ${{ inputs.image }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
submodules: true

- name: Check toolchain
run: |
cmake --version
clang --version

- name: Set up Python virtual environment
run: |
uv venv
. .venv/bin/activate

- name: Install build dependencies (only for Github Actions)
run: uv pip install scikit_build_core numpy

- name: Run uv sync
run: uv sync --no-build-isolation

- name: Run img2num build
run: uv run python3 -c "import img2num;"

- name: Upload Python build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: py-build
path: packages/py/build-py/
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ node_modules/
build*/
install/

# uv (Python)
__pycache__/
*.pyc
*.pyo
*.pyd
*.egg-info/
build/
.venv/

# uv (Python) and JavaScript
dist/

# Logs
logs
*.log
Expand All @@ -27,5 +39,6 @@ logs

.env

console-py_outputs
# img2num destroy state
.img2num-state
11 changes: 11 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ project(Img2Num LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

option(BUILD_PYTHON "Build Python bindings" OFF)
option(IMG2NUM_BUILD_EXAMPLES "Build example applications" ON)

# Force a safe default to avoid the weirdness of CMake's `None`
Expand All @@ -27,6 +28,11 @@ if(EMSCRIPTEN)
# JS bindings
add_subdirectory(bindings/js)
else()
# Python bindings
if(BUILD_PYTHON)
add_subdirectory(bindings/py)
endif()

if(IMG2NUM_BUILD_EXAMPLES)
# Example console app (C++)
add_subdirectory(example-apps/console-cpp)
Expand Down Expand Up @@ -62,3 +68,8 @@ install(FILES
${CMAKE_CURRENT_BINARY_DIR}/Img2NumConfig.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Img2Num
)

install(DIRECTORY ${CMAKE_SOURCE_DIR}/packages/py/img2num/
DESTINATION img2num
FILES_MATCHING PATTERN "*.py"
)
16 changes: 15 additions & 1 deletion Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,21 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
&& rm -rf /var/lib/apt/lists/*

# --------------------------------------------------------------------------------------------------------------
# 5. Set up pnpm & JS app requirements
# 4. Set up uv & Python requirements.
# Python development setup (for bindings, not core)
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ninja-build \
python3-setuptools \
python3-wheel \
python3-pybind11 \
&& rm -rf /var/lib/apt/lists/*

RUN curl -LsSf https://astral.sh/uv/0.5.1/install.sh | sh
ENV PATH="/root/.local/bin:${PATH}"

# --------------------------------------------------------------------------------------------------------------
# 6. Set up pnpm & JS app requirements.
RUN corepack enable \
&& corepack prepare pnpm@10.29.1 --activate

Expand Down
25 changes: 25 additions & 0 deletions bindings/py/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
cmake_minimum_required(VERSION 3.16)
project(PyImg2Num LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

set(PYBIND11_FINDPYTHON ON)
find_package(Python3 REQUIRED COMPONENTS Interpreter Development NumPy)
find_package(pybind11 REQUIRED)

file(GLOB BINDING_SRC
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp"
)
pybind11_add_module(_img2num MODULE ${BINDING_SRC})

target_link_libraries(_img2num PRIVATE Img2Num)

target_include_directories(_img2num PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/include"
)

install(TARGETS _img2num
LIBRARY DESTINATION img2num
RUNTIME DESTINATION img2num
)
133 changes: 133 additions & 0 deletions bindings/py/src/img2num_pybind.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#include <img2num.h>
#include <pybind11/numpy.h>
#include <pybind11/pybind11.h>

#include <cstdlib>

PYBIND11_MODULE(_img2num, m) {
m.doc() = "Python bindings for the img2num C library";

// -----------------------------------------------------------------------
// All Functions return new image data
// -----------------------------------------------------------------------

m.def(
"gaussian_blur_fft",
[](pybind11::array_t<uint8_t, pybind11::array::c_style> image, size_t width, size_t height,
double sigma) {
pybind11::buffer_info buf = image.request();
pybind11::array_t<uint8_t, pybind11::array::c_style> out_image(buf.shape);
std::memcpy(out_image.mutable_data(), buf.ptr, buf.size * sizeof(uint8_t));

img2num::gaussian_blur_fft(out_image.mutable_data(), width, height, sigma);
return out_image;
},
pybind11::arg("image"), pybind11::arg("width"), pybind11::arg("height"), pybind11::arg("sigma"),
"Apply Gaussian blur using FFT");

m.def(
"invert_image",
[](pybind11::array_t<uint8_t, pybind11::array::c_style> image, int width, int height) {
pybind11::buffer_info buf = image.request();
pybind11::array_t<uint8_t, pybind11::array::c_style> out_image(buf.shape);
std::memcpy(out_image.mutable_data(), buf.ptr, buf.size * sizeof(uint8_t));

img2num::invert_image(out_image.mutable_data(), width, height);
return out_image;
},
pybind11::arg("image"), pybind11::arg("width"), pybind11::arg("height"),
"Invert image colors");

m.def(
"threshold_image",
[](pybind11::array_t<uint8_t, pybind11::array::c_style> image, int width, int height,
int num_thresholds) {
pybind11::buffer_info buf = image.request();
pybind11::array_t<uint8_t, pybind11::array::c_style> out_image(buf.shape);
std::memcpy(out_image.mutable_data(), buf.ptr, buf.size * sizeof(uint8_t));
img2num::threshold_image(out_image.mutable_data(), width, height, num_thresholds);
return out_image;
},
pybind11::arg("image"), pybind11::arg("width"), pybind11::arg("height"), pybind11::arg("num_thresholds"),
"Apply thresholding to the image");

m.def(
"black_threshold_image",
[](pybind11::array_t<uint8_t, pybind11::array::c_style> image, int width, int height,
int num_thresholds) {
pybind11::buffer_info buf = image.request();
pybind11::array_t<uint8_t, pybind11::array::c_style> out_image(buf.shape);
std::memcpy(out_image.mutable_data(), buf.ptr, buf.size * sizeof(uint8_t));

img2num::black_threshold_image(out_image.mutable_data(), width, height, num_thresholds);
return out_image;
},
pybind11::arg("image"), pybind11::arg("width"), pybind11::arg("height"), pybind11::arg("num_thresholds"),
"Apply black thresholding to the image");

m.def(
"bilateral_filter",
[](pybind11::array_t<uint8_t, pybind11::array::c_style> image, size_t width, size_t height,
double sigma_spatial, double sigma_range, uint8_t color_space) {
pybind11::buffer_info buf = image.request();
pybind11::array_t<uint8_t, pybind11::array::c_style> out_image(buf.shape);
std::memcpy(out_image.mutable_data(), buf.ptr, buf.size * sizeof(uint8_t));

img2num::bilateral_filter(out_image.mutable_data(), width, height, sigma_spatial,
sigma_range, color_space);
return out_image;
},
pybind11::arg("image"), pybind11::arg("width"), pybind11::arg("height"), pybind11::arg("sigma_spatial"),
pybind11::arg("sigma_range"), pybind11::arg("color_space"),
"Apply bilateral filter");

m.def(
"kmeans",
[](pybind11::array_t<uint8_t, pybind11::array::c_style> data, int32_t width, int32_t height, int32_t k,
int32_t max_iter, uint8_t color_space) {
pybind11::buffer_info data_buf = data.request();

// Allocate NumPy arrays for the outputs
auto out_data = pybind11::array_t<uint8_t>(data_buf.shape);
auto out_labels = pybind11::array_t<int32_t>({(ssize_t)height, (ssize_t)width});

auto out_data_ptr = static_cast<uint8_t*>(out_data.mutable_data());
auto out_labels_ptr = static_cast<int32_t*>(out_labels.mutable_data());

// Call C function
img2num::kmeans(static_cast<const uint8_t*>(data_buf.ptr), out_data_ptr, out_labels_ptr,
width, height, k, max_iter, color_space);

// Return a tuple of (out_data, out_labels)
return pybind11::make_tuple(out_data, out_labels);
},
pybind11::arg("data"), pybind11::arg("width"), pybind11::arg("height"), pybind11::arg("k"), pybind11::arg("max_iter"),
pybind11::arg("color_space"),
"Run K-Means clustering. Returns a tuple: (quantized_image, labels_array)");

m.def(
"labels_to_svg",
[](pybind11::array_t<uint8_t, pybind11::array::c_style> data,
pybind11::array_t<int32_t, pybind11::array::c_style> labels, int width, int height, int min_area) {

const uint8_t* data_ptr{static_cast<const uint8_t*>(data.request().ptr)};
const int32_t* labels_ptr{static_cast<const int32_t*>(labels.request().ptr)};
char* svg_c_str = img2num::labels_to_svg(data_ptr, labels_ptr, width, height, min_area);

if (!svg_c_str) {
throw std::runtime_error("img2num::labels_to_svg returned a null pointer.");
}

// Convert to Python string
pybind11::str svg_py_str(svg_c_str);

// NOTE: If your C library dynamically allocates this string using malloc/calloc,
// you MUST free it here to prevent a memory leak. If it returns a static pointer,
// remove this line.
std::free(svg_c_str);

return svg_py_str;
},
pybind11::arg("data"), pybind11::arg("labels"), pybind11::arg("width"), pybind11::arg("height"),
pybind11::arg("min_area"), "Convert labels to SVG string");
}
3 changes: 3 additions & 0 deletions core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ set(EMBEDDED_SHADERS_OUT "${CMAKE_BINARY_DIR}/embedded_shaders.h")
set_source_files_properties(${EMBEDDED_SHADERS_OUT} PROPERTIES GENERATED TRUE)

find_package(Python3 COMPONENTS Interpreter REQUIRED)

add_custom_target(Img2Num_shaders ALL
COMMAND "${Python3_EXECUTABLE}" ${CMAKE_CURRENT_SOURCE_DIR}/tools/embed_shaders.py
${CMAKE_CURRENT_SOURCE_DIR}/src/internal/resources
Expand All @@ -31,6 +32,8 @@ add_custom_target(Img2Num_shaders ALL

add_library(Img2Num ${CORE_SRC})

set_property(TARGET Img2Num PROPERTY POSITION_INDEPENDENT_CODE ON)

add_dependencies(Img2Num Img2Num_shaders)

target_sources(Img2Num PRIVATE ${EMBEDDED_SHADERS_OUT})
Expand Down
5 changes: 5 additions & 0 deletions core/include/internal/cielab_impl.h
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ void lab_to_rgb(const ImageLib::LABAPixel<Tin> &laba, ImageLib::RGBAPixel<Tout>
Tout b{rgba.blue};

lab_to_rgb<Tin, Tout>(laba.l, laba.a, laba.b, r, g, b);

rgba.red = r;
rgba.green = g;
rgba.blue = b;

rgba.alpha = static_cast<Tout>(laba.alpha);
}

Expand Down
34 changes: 34 additions & 0 deletions example-apps/console-py/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import os
import sys

import img2num

# if not preset install these with
# python3 -m pip install -r requirements.txt --break-system-packages
import numpy as np
import cv2

"""
Note: Images sent to img2num functions must be RGBA
"""
def main():
OUTDIR = "console-py_outputs"
os.makedirs(OUTDIR, exist_ok=True)

img = cv2.imread(sys.argv[1])
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGBA) # VERY IMPORTANT!!!

# bilateral filter in-place
img_bf = img2num.bilateral_filter(img, 3, 50, 0)
cv2.imwrite(os.path.join(OUTDIR, "bilateral_image.png"), cv2.cvtColor(img_bf, cv2.COLOR_RGBA2BGR))

# kmeans
img_kmeans, labels = img2num.kmeans(img_bf, 16, 100, 0)
cv2.imwrite(os.path.join(OUTDIR, "kmeans_image.png"), cv2.cvtColor(img_kmeans, cv2.COLOR_RGBA2BGR))
# svg file
res_svg = img2num.labels_to_svg(img, labels, 100)
with open(os.path.join(OUTDIR, "result.svg"),"w") as f:
f.writelines(res_svg)

if __name__ == "__main__":
main()
14 changes: 14 additions & 0 deletions example-apps/console-py/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[project]
name = "console-py"
version = "0.0.0"
description = "A simple console example app that demonstrates how to use Img2Num"
dependencies = [
"numpy",
"opencv-python"
]

[tool.uv]
package = true

[project.scripts]
dev = "main:main"
4 changes: 4 additions & 0 deletions packages/py/img2num/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Python cache
__pycache__/
*.py[cod]
*.pyo
1 change: 1 addition & 0 deletions packages/py/img2num/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .api import *
Loading
Loading