Skip to content

Commit 6ef42b4

Browse files
committed
Bump version to 0.1.5, add CUDA interop support, and enhance documentation
1 parent d8b098f commit 6ef42b4

10 files changed

Lines changed: 210 additions & 11 deletions

File tree

‎.gitignore‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ site/
77
*.obj
88
*.lib
99
*.exp
10-
.vscode/
10+
.vscode/
11+
.claude/

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# PyImageCUDA 0.1.4
1+
# PyImageCUDA 0.1.5
22

33
[![PyPI version](https://img.shields.io/pypi/v/pyimagecuda.svg)](https://pypi.org/project/pyimagecuda/)
44
[![Build Status](https://github.com/offerrall/pyimagecuda/actions/workflows/build.yml/badge.svg)](https://github.com/offerrall/pyimagecuda/actions)
@@ -39,6 +39,7 @@ with Image(1024, 1024) as bg:
3939
* ✅ **Studio Quality:** 32-bit floating-point precision (float32) to prevent color banding.
4040
* ✅ **Advanced Memory Control:** Reuse GPU buffers across operations and resize without reallocation—critical for video processing and batch workflows.
4141
* ✅ **OpenGL Integration:** Direct GPU-to-GPU display for real-time preview widgets.
42+
* ✅ **CUDA Interop (Zero-Copy):** Native `__cuda_array_interface__` v3 support. Share GPU buffers with CuPy, PyTorch, Numba and the rest of the CUDA Python ecosystem without copies.
4243
* ✅ **API Simplicity:** Intuitive, Pythonic API designed for ease of use.
4344

4445
## Use Cases
@@ -82,6 +83,7 @@ pip install pyimagecuda
8283
* [Image & Memory](https://offerrall.github.io/pyimagecuda/image/) (Buffer management)
8384
* [IO](https://offerrall.github.io/pyimagecuda/io/) (Loading and Saving)
8485
* [OpenGL Integration](https://offerrall.github.io/pyimagecuda/opengl/) (Real-time preview, zero-copy display)
86+
* [CUDA Interop](https://offerrall.github.io/pyimagecuda/image/#cuda-interop-zero-copy) (Zero-copy with CuPy, PyTorch, Numba)
8587

8688
### Operations
8789
* [Fill](https://offerrall.github.io/pyimagecuda/fill/) (Solid colors, Gradients, Checkerboard, Grid, Stripes, Dots, Circle, Ngon, Noise, Perlin)

‎docs/image.md‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,121 @@ print(f"Current: {img.width}×{img.height}") # 1280×720
154154

155155
---
156156

157+
## CUDA Interop (Zero-Copy)
158+
159+
PyImageCUDA images implement the [`__cuda_array_interface__`](https://numba.readthedocs.io/en/stable/cuda/cuda_array_interface.html) v3 protocol. This allows any library in the CUDA Python ecosystem (CuPy, PyTorch, Numba, RAPIDS, JAX with the CUDA backend, etc.) to read **and write** the image's GPU memory **without any copies**.
160+
161+
The memory layout exposed is `(height, width, 4)` interleaved RGBA, row-major contiguous:
162+
163+
- `Image` → `float32` (typestr `<f4`)
164+
- `ImageU8` → `uint8` (typestr `|u1`)
165+
166+
!!! warning "Memory ownership"
167+
The CUDA buffer is owned by the `Image` / `ImageU8` instance. The image **must stay alive** while any external array views it, otherwise the view becomes a dangling pointer. Do **not** call `image.free()` while a CuPy / Torch tensor still references it.
168+
169+
---
170+
171+
### Raw device pointer
172+
173+
Use the `cuda_ptr` property to retrieve the raw CUDA device pointer as a Python `int`. Useful for custom kernels or low-level interop.
174+
```python
175+
from pyimagecuda import Image
176+
177+
img = Image(1920, 1080)
178+
print(hex(img.cuda_ptr)) # e.g. 0x7f1234500000
179+
```
180+
181+
---
182+
183+
### CuPy
184+
185+
The `to_cupy()` helper wraps a pyimagecuda image as a zero-copy `cupy.ndarray`. CuPy is **not** a dependency of pyimagecuda; install it separately:
186+
```bash
187+
pip install cupy-cuda12x # or cupy-cuda11x for CUDA 11
188+
```
189+
```python
190+
from pyimagecuda import Image, Fill, to_cupy, download
191+
import cupy as cp
192+
193+
img = Image(512, 512)
194+
Fill.color(img, (1, 0, 0, 1))
195+
196+
# Zero-copy view — shares the same GPU memory
197+
arr = to_cupy(img)
198+
assert arr.data.ptr == img.cuda_ptr
199+
assert arr.shape == (512, 512, 4)
200+
assert arr.dtype == cp.float32
201+
202+
# Modify via CuPy → changes are visible in the pyimagecuda image
203+
arr[:, :, 1] = 1.0 # add green channel
204+
205+
# Download to verify
206+
pixels = download(img)
207+
```
208+
209+
Because `__cuda_array_interface__` is honored, `cp.asarray(img)` works too:
210+
```python
211+
arr = cp.asarray(img) # zero-copy
212+
```
213+
214+
---
215+
216+
### Custom CUDA kernels (CuPy RawKernel)
217+
218+
You can run your own CUDA kernels directly on pyimagecuda buffers:
219+
```python
220+
from pyimagecuda import Image, Fill
221+
import cupy as cp
222+
223+
img = Image(512, 512)
224+
Fill.color(img, (0.2, 0.4, 0.8, 1.0))
225+
226+
invert_rgb = cp.RawKernel(r'''
227+
extern "C" __global__
228+
void invert_rgb(float4* data, int n) {
229+
int i = blockIdx.x * blockDim.x + threadIdx.x;
230+
if (i >= n) return;
231+
float4 p = data[i];
232+
data[i] = make_float4(1.0f - p.x, 1.0f - p.y, 1.0f - p.z, p.w);
233+
}
234+
''', 'invert_rgb')
235+
236+
arr = cp.asarray(img)
237+
n = img.width * img.height
238+
invert_rgb((n // 256 + 1,), (256,), (arr, n))
239+
```
240+
241+
---
242+
243+
### PyTorch
244+
245+
PyTorch consumes `__cuda_array_interface__` via `torch.as_tensor`:
246+
```python
247+
import torch
248+
from pyimagecuda import Image, Fill
249+
250+
img = Image(512, 512)
251+
Fill.color(img, (1, 0, 0, 1))
252+
253+
tensor = torch.as_tensor(img, device='cuda') # zero-copy
254+
# tensor.shape == torch.Size([512, 512, 4])
255+
# tensor.dtype == torch.float32
256+
```
257+
258+
---
259+
260+
### Numba CUDA
261+
262+
```python
263+
from numba import cuda
264+
from pyimagecuda import Image
265+
266+
img = Image(512, 512)
267+
device_array = cuda.as_cuda_array(img) # zero-copy
268+
```
269+
270+
---
271+
157272
## Best Practices
158273

159274
### For Simple Scripts

‎pyimagecuda/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ctypes
22
import sys
33

4-
__version__ = "0.1.4"
4+
__version__ = "0.1.5"
55

66
def _check_nvidia_driver():
77
try:
@@ -47,6 +47,7 @@ def _check_nvidia_driver():
4747
from .transform import Transform
4848
from .text import Text
4949
from .gl_interop import GLResource
50+
from .cuda_interop import to_cupy
5051
from .pyimagecuda_internal import cuda_sync # type: ignore
5152
except ImportError as e:
5253
print(f"Warning: Error importing Python wrappers: {e}")

‎pyimagecuda/cuda_interop.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from .image import ImageBase, ImageU8
2+
3+
4+
def to_cupy(image: ImageBase):
5+
"""
6+
Wraps a pyimagecuda Image or ImageU8 as a zero-copy CuPy ndarray.
7+
8+
The returned ndarray shares GPU memory with the source image. The image
9+
must remain alive while the array is in use; CuPy holds a reference to
10+
the image via UnownedMemory's owner argument.
11+
12+
Docs & Examples: https://offerrall.github.io/pyimagecuda/image/
13+
"""
14+
try:
15+
import cupy
16+
except ImportError as e:
17+
raise ImportError(
18+
"CuPy is required for to_cupy(). "
19+
"Install it with: pip install cupy-cuda12x."
20+
) from e
21+
22+
is_u8 = isinstance(image, ImageU8)
23+
bytes_per_pixel = 4 if is_u8 else 16
24+
size_bytes = image.width * image.height * bytes_per_pixel
25+
26+
unowned_mem = cupy.cuda.UnownedMemory(image.cuda_ptr, size_bytes, image)
27+
memptr = cupy.cuda.MemoryPointer(unowned_mem, 0)
28+
29+
dtype = cupy.uint8 if is_u8 else cupy.float32
30+
return cupy.ndarray((image.height, image.width, 4), dtype=dtype, memptr=memptr)

‎pyimagecuda/image.py‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .pyimagecuda_internal import create_buffer_f32, free_buffer, create_buffer_u8 #type: ignore
1+
from .pyimagecuda_internal import create_buffer_f32, free_buffer, create_buffer_u8, get_buffer_ptr # type: ignore
22

33
class Buffer:
44

@@ -86,6 +86,21 @@ def get_max_capacity(self) -> int:
8686
"""Returns the total pixel capacity of the buffer."""
8787
return self._buffer.capacity_pixels
8888

89+
@property
90+
def cuda_ptr(self) -> int:
91+
return get_buffer_ptr(self._buffer._handle)
92+
93+
@property
94+
def __cuda_array_interface__(self) -> dict:
95+
return {
96+
'shape': (self._height, self._width, 4),
97+
'typestr': self._cai_typestr,
98+
'data': (self.cuda_ptr, False),
99+
'version': 3,
100+
'strides': None,
101+
'stream': 1,
102+
}
103+
89104
def free(self) -> None:
90105
self._buffer.free()
91106

@@ -100,18 +115,22 @@ def __repr__(self) -> str:
100115
return f"{self.__class__.__name__}({self.width}×{self.height})"
101116

102117
class Image(ImageBase):
103-
118+
119+
_cai_typestr = '<f4'
120+
104121
def __init__(self, width: int, height: int):
105122
"""
106123
Creates a floating-point image with the given width and height.
107-
124+
108125
Docs & Examples: https://offerrall.github.io/pyimagecuda/image/#image-float32-precision
109126
"""
110127
super().__init__(width, height, is_u8=False)
111128

112129

113130
class ImageU8(ImageBase):
114-
131+
132+
_cai_typestr = '|u1'
133+
115134
def __init__(self, width: int, height: int):
116135
"""
117136
Creates an 8-bit unsigned integer image with the given width and height.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
44

55
[project]
66
name = "pyimagecuda"
7-
version = "0.1.4"
7+
version = "0.1.5"
88
description = "GPU-accelerated image processing library for Python"
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎src/buffer/buffer.cu‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,14 +338,43 @@ PyObject* py_copy_buffer(PyObject* self, PyObject* args) {
338338

339339
PyObject* py_cuda_sync(PyObject* self, PyObject* args) {
340340
cudaError_t err = cudaDeviceSynchronize();
341-
341+
342342
if (err != cudaSuccess) {
343-
PyErr_Format(PyExc_RuntimeError, "CUDA synchronize failed: %s",
343+
PyErr_Format(PyExc_RuntimeError, "CUDA synchronize failed: %s",
344344
cudaGetErrorString(err));
345345
return NULL;
346346
}
347-
347+
348348
Py_RETURN_NONE;
349349
}
350350

351+
PyObject* py_get_buffer_ptr(PyObject* self, PyObject* args) {
352+
PyObject* capsule;
353+
354+
if (!PyArg_ParseTuple(args, "O", &capsule)) {
355+
return NULL;
356+
}
357+
358+
if (!PyCapsule_CheckExact(capsule)) {
359+
PyErr_SetString(PyExc_TypeError, "Expected a Buffer capsule");
360+
return NULL;
361+
}
362+
363+
const char* name = PyCapsule_GetName(capsule);
364+
if (name == NULL ||
365+
(strcmp(name, BUFFER_TYPE_FLOAT32) != 0 &&
366+
strcmp(name, BUFFER_TYPE_UINT8) != 0)) {
367+
PyErr_SetString(PyExc_TypeError, "Expected a Buffer capsule");
368+
return NULL;
369+
}
370+
371+
BufferContext* ctx = (BufferContext*)PyCapsule_GetPointer(capsule, name);
372+
if (ctx == NULL || ctx->freed || ctx->ptr == NULL) {
373+
PyErr_SetString(PyExc_ValueError, "Buffer has been freed");
374+
return NULL;
375+
}
376+
377+
return PyLong_FromVoidPtr(ctx->ptr);
378+
}
379+
351380
}

‎src/include.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ PyObject* py_create_buffer_u8(PyObject* self, PyObject* args);
4444
PyObject* py_cuda_sync(PyObject* self, PyObject* args);
4545
PyObject* py_download_from_buffer(PyObject* self, PyObject* args);
4646
PyObject* py_free_buffer(PyObject* self, PyObject* args);
47+
PyObject* py_get_buffer_ptr(PyObject* self, PyObject* args);
4748
PyObject* py_upload_to_buffer(PyObject* self, PyObject* args);
4849

4950
// convert.cu

‎src/python_wrapper.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ static PyMethodDef all_methods[] = {
4141
{"cuda_sync", py_cuda_sync, METH_VARARGS, "Cuda Sync"},
4242
{"download_from_buffer", py_download_from_buffer, METH_VARARGS, "Download From Buffer"},
4343
{"free_buffer", py_free_buffer, METH_VARARGS, "Free Buffer"},
44+
{"get_buffer_ptr", py_get_buffer_ptr, METH_VARARGS, "Get raw CUDA device pointer as int"},
4445
{"upload_to_buffer", py_upload_to_buffer, METH_VARARGS, "Upload To Buffer"},
4546

4647
// convert.cu

0 commit comments

Comments
 (0)