Python: Add support for importing foreign image buffers

ChangeLog: [Python] Add support for creating slint.Image objects from arrays

Fixes #9014
This commit is contained in:
Simon Hausmann 2025-08-01 13:44:02 +02:00 committed by Simon Hausmann
parent d3c0e2caf6
commit c1831158fa
5 changed files with 221 additions and 4 deletions

View file

@ -46,7 +46,7 @@ i-slint-backend-selector = { workspace = true }
i-slint-core = { workspace = true }
slint-interpreter = { workspace = true, features = ["default", "display-diagnostics", "internal"] }
i-slint-compiler = { workspace = true }
pyo3 = { version = "0.25", features = ["extension-module", "indexmap", "chrono", "abi3-py310"] }
pyo3 = { version = "0.25", features = ["extension-module", "indexmap", "chrono", "abi3-py311"] }
indexmap = { version = "2.1.0" }
chrono = "0.4"
spin_on = { workspace = true }

View file

@ -3,6 +3,7 @@
use pyo3::prelude::*;
use pyo3_stub_gen::{derive::gen_stub_pyclass, derive::gen_stub_pymethods};
use slint_interpreter::SharedPixelBuffer;
/// Image objects can be set on Slint Image elements for display. Use `Image.load_from_path` to construct Image
/// objects from a path to an image file on disk.
@ -57,6 +58,145 @@ impl PyImage {
let image = slint_interpreter::Image::load_from_svg_data(&data)?;
Ok(Self { image })
}
/// Creates a new image from an array-like object that implements the [Buffer Protocol](https://docs.python.org/3/c-api/buffer.html).
/// Use this function to import images created by third-party modules such as matplotlib or Pillow.
///
/// The array must satisfy certain contraints to represent an image:
///
/// - The buffer's format needs to be `B` (unsigned char)
/// - The shape must be a tuple of (height, width, bytes-per-pixel)
/// - If a stride is defined, the row stride must be equal to width * bytes-per-pixel, and the column stride must equal the bytes-per-pixel.
/// - A value of 3 for bytes-per-pixel is interpreted as RGB image, a value of 4 means RGBA.
///
/// The image is created by performing a deep copy of the array's data. Subsequent changes to the buffer are not automatically
/// reflected in a previously created Image.
///
/// Example of importing a matplot figure into an image:
/// ```python
/// import slint
/// import matplotlib
///
/// from matplotlib.backends.backend_agg import FigureCanvasAgg
/// from matplotlib.figure import Figure
///
/// fig = Figure(figsize=(5, 4), dpi=100)
/// canvas = FigureCanvasAgg(fig)
/// ax = fig.add_subplot()
/// ax.plot([1, 2, 3])
/// canvas.draw()
///
/// buffer = canvas.buffer_rgba()
/// img = slint.Image.load_from_array(buffer)
/// ```
///
/// Example of loading an image with Pillow:
/// ```python
/// import slint
/// from PIL import Image
/// import numpy as np
///
/// pil_img = Image.open("hello.jpeg")
/// array = np.array(pil_img)
/// img = slint.Image.load_from_array(array)
/// ```
#[staticmethod]
fn load_from_array(array: &Bound<'_, PyAny>) -> PyResult<Self> {
let buffer: pyo3::buffer::PyBuffer<u8> = pyo3::buffer::PyBuffer::get(array)?;
let shape = buffer.shape();
if shape.len() != 3 {
return Err(pyo3::exceptions::PyRuntimeError::new_err(
"Arrays must have a shape of (height, width, bpp) for image conversion",
));
}
let bpp: u32 = shape[2]
.try_into()
.map_err(|_| pyo3::exceptions::PyRuntimeError::new_err("Image bpp exceeds u32"))?;
let width = shape[1]
.try_into()
.map_err(|_| pyo3::exceptions::PyRuntimeError::new_err("Image width exceeds u32"))?;
let height = shape[0]
.try_into()
.map_err(|_| pyo3::exceptions::PyRuntimeError::new_err("Image height exceeds u32"))?;
if buffer.item_size() != 1 {
return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"Item size {} is not valid. Arrays must contain bytes for image conversion",
buffer.item_size(),
)));
}
if buffer.format() != c"B" {
return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"Unexpected buffer format {}, expected 'B' for unsigned char",
buffer.format().to_str().unwrap_or_default(),
)));
}
let strides = buffer.strides();
if strides.len() > 0 {
if strides.len() != 3 {
return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"Unexpected strides size {}. Arrays must provides stride tuple of 3 for image conversion",
strides.len(),
)));
}
let row_stride: u32 = strides[0].try_into().map_err(|_| {
pyo3::exceptions::PyRuntimeError::new_err("Image row stride cannot be negative")
})?;
let column_stride: u32 = strides[1].try_into().map_err(|_| {
pyo3::exceptions::PyRuntimeError::new_err("Image column stride cannot be negative")
})?;
let elem_stride: u32 = strides[2].try_into().map_err(|_| {
pyo3::exceptions::PyRuntimeError::new_err("Image element stride cannot be negative")
})?;
if row_stride != width * bpp {
return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"Unexpected row stride {}. Expected {}",
row_stride,
height * bpp,
)));
}
if column_stride != bpp {
return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"Unexpected column stride {}. Expected {}",
column_stride, bpp,
)));
}
if elem_stride != 1 {
return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"Unexpected element stride {}. Expected 1",
column_stride,
)));
}
}
Ok(Self {
image: match bpp {
3 => {
let mut pixel_buffer = SharedPixelBuffer::new(width, height);
buffer.copy_to_slice(array.py(), pixel_buffer.make_mut_bytes())?;
slint_interpreter::Image::from_rgb8(pixel_buffer)
}
4 => {
let mut pixel_buffer = SharedPixelBuffer::new(width, height);
buffer.copy_to_slice(array.py(), pixel_buffer.make_mut_bytes())?;
slint_interpreter::Image::from_rgba8(pixel_buffer)
}
_ => {
return Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"Unexpected bits per pixel {}. Expected 3 or 4",
bpp,
)))
}
},
})
}
}
impl From<slint_interpreter::Image> for PyImage {

View file

@ -37,10 +37,18 @@ Changelog = "https://github.com/slint-ui/slint/blob/master/CHANGELOG.md"
Tracker = "https://github.com/slint-ui/slint/issues"
[project.optional-dependencies]
dev = ["pytest"]
dev = ["pytest", "numpy>=2.3.2", "pillow>=11.3.0"]
[dependency-groups]
dev = ["mypy>=1.15.0", "nox>=2024.10.9", "pdoc>=15.0.1", "pytest>=8.3.4", "ruff>=0.9.6"]
dev = [
"mypy>=1.15.0",
"nox>=2024.10.9",
"pdoc>=15.0.1",
"pytest>=8.3.4",
"ruff>=0.9.6",
"pillow>=11.3.0",
"numpy>=2.3.2",
]
[tool.uv]
# Rebuild package when any rust files change

View file

@ -10,7 +10,7 @@ import os
import pathlib
import typing
from typing import Any, List
from collections.abc import Callable
from collections.abc import Callable, Buffer
from enum import Enum, auto
class RgbColor:
@ -81,6 +81,49 @@ class Image:
"""
...
@staticmethod
def load_from_array(array: Buffer) -> Image:
r"""
Creates a new image from an array-like object that implements the [Buffer Protocol](https://docs.python.org/3/c-api/buffer.html).
Use this function to import images created by third-party modules such as matplotlib or Pillow.
The array must satisfy certain contraints to represent an image:
- The buffer's format needs to be `B` (unsigned char)
- The shape must be a tuple of (height, width, bytes-per-pixel)
- If a stride is defined, the row stride must be equal to width * bytes-per-pixel, and the column stride must equal the bytes-per-pixel.
- A value of 3 for bytes-per-pixel is interpreted as RGB image, a value of 4 means RGBA.
Example of importing a matplot figure into an image:
```python
import slint
import matplotlib
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure
fig = Figure(figsize=(5, 4), dpi=100)
canvas = FigureCanvasAgg(fig)
ax = fig.add_subplot()
ax.plot([1, 2, 3])
canvas.draw()
buffer = canvas.buffer_rgba()
img = slint.Image.load_from_array(buffer)
```
Example of loading an image with Pillow:
```python
import slint
from PIL import Image
import numpy as np
pil_img = Image.open("hello.jpeg")
array = np.array(pil_img)
img = slint.Image.load_from_array(array)
```
"""
class TimerMode(Enum):
SingleShot = auto()
Repeated = auto()

View file

@ -0,0 +1,26 @@
# Copyright © SixtyFPS GmbH <info@slint.dev>
# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
import slint
import numpy as np
from PIL import Image
from pathlib import Path
def base_dir() -> Path:
origin = __spec__.origin
assert origin is not None
base_dir = Path(origin).parent
assert base_dir is not None
return base_dir
def test_image_loading() -> None:
image = Image.open(
base_dir() / ".." / ".." / ".." / ".." / "logo" / "slint-logo-simple-dark.png"
)
assert image.size == (282, 84)
array = np.array(image)
slint_image = slint.Image.load_from_array(array)
assert slint_image.width == 282
assert slint_image.height == 84