Skip to content

Commit 9692d80

Browse files
committed
Add to_array/decode_into support for fg_value/bg_value, dtype, HWC output
- Extend to_array with fg_value, bg_value, dtype params (2D and 3D HWC) - Extend decode_into with fg_value, bg_value params for float32/float64 - Add fused-type Cython decode paths for float32/float64 - Deprecate 'value' param in favor of 'fg_value' with warnings - Deprecate 'thresh128' param in from_array with warning
1 parent b90c2e1 commit 9692d80

2 files changed

Lines changed: 247 additions & 30 deletions

File tree

src/rlemasklib/oop.py

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
import itertools
44
import os
5+
import warnings
56
from collections.abc import Iterable
67
from typing import Union, Sequence, Optional, Callable
78

89
import numpy as np
910
from .boolfunc import BoolFunc
1011
from .oop_cython import RLECy
1112

13+
_UNSET = object()
14+
1215

1316
class RLEMask:
1417
"""Run-length encoded mask.
@@ -129,6 +132,9 @@ def from_array(
129132
thresh128: deprecated, equivalent to threshold=128.
130133
"""
131134
if thresh128:
135+
warnings.warn(
136+
"The 'thresh128' parameter is deprecated, use 'threshold=128' instead.",
137+
DeprecationWarning, stacklevel=2)
132138
threshold = 128
133139
result = RLEMask._init()
134140
result.cy._i_from_array(mask_array, threshold, is_sparse)
@@ -3063,52 +3069,145 @@ def to_dict(self, zlevel: Optional[int] = None) -> dict:
30633069
"""
30643070
return self.cy.to_dict(zlevel)
30653071

3066-
def to_array(self, value: int = 1, order="F") -> np.ndarray:
3067-
"""Convert the RLE mask to a dense 2D uint8 numpy array.
3072+
def to_array(
3073+
self, fg_value=1, bg_value=0, dtype=np.uint8, order="F", *, value=_UNSET
3074+
) -> np.ndarray:
3075+
"""Convert the RLE mask to a dense numpy array.
3076+
3077+
Background pixels get ``bg_value`` and foreground pixels get ``fg_value``.
3078+
3079+
If either ``fg_value`` or ``bg_value`` is a tuple, list, or 1D array, the result is a
3080+
3D HWC array with one channel per element. A scalar value for the other parameter is
3081+
broadcast to all channels.
30683082
3069-
False (background) values become 0 and True (foreground) values become the specified value.
30703083
The RLE is internally stored for the Fortran order, so order='F' is faster, because
30713084
'C' requires a transpose. To improve efficiency, the transpose is done either in RLE or
30723085
in dense form, depending on the sparseness of the mask.
30733086
30743087
Args:
3075-
value: the "True" value to use in the resulting array
3088+
fg_value: the foreground value (scalar for 2D, tuple/list/array for HWC)
3089+
bg_value: the background value (scalar for 2D, tuple/list/array for HWC)
3090+
dtype: the numpy dtype of the resulting array (default: np.uint8)
30763091
order: the order of the array ('C' for row-major, 'F' for column-major)
3092+
value: deprecated alias for ``fg_value``
30773093
30783094
Returns:
3079-
An F or C-contiguous 2D numpy array of type uint8 representing the mask.
3095+
A 2D or 3D numpy array representing the mask.
30803096
30813097
See Also:
30823098
:meth:`__array__`, :meth:`from_array`
30833099
"""
3084-
return self.cy._r_to_dense_array(value, order)
3100+
if value is not _UNSET:
3101+
warnings.warn(
3102+
"The 'value' parameter is deprecated, use 'fg_value' instead.",
3103+
DeprecationWarning, stacklevel=2)
3104+
fg_value = value
3105+
3106+
fg_multi = isinstance(fg_value, (tuple, list)) or (isinstance(fg_value, np.ndarray) and fg_value.ndim == 1)
3107+
bg_multi = isinstance(bg_value, (tuple, list)) or (isinstance(bg_value, np.ndarray) and bg_value.ndim == 1)
3108+
3109+
if not fg_multi and not bg_multi:
3110+
return self.cy._r_to_dense_array(fg_value, bg_value, order, np.dtype(dtype))
3111+
3112+
# Multi-valued: produce HWC array
3113+
if fg_multi:
3114+
fg_value = np.asarray(fg_value, dtype=dtype)
3115+
n_channels = len(fg_value)
3116+
if bg_multi:
3117+
bg_value = np.asarray(bg_value, dtype=dtype)
3118+
n_channels = len(bg_value)
3119+
if n_channels == 0:
3120+
raise ValueError("fg_value/bg_value must have at least one channel")
3121+
if fg_multi and bg_multi and len(fg_value) != len(bg_value):
3122+
raise ValueError(
3123+
f"fg_value length ({len(fg_value)}) != bg_value length ({len(bg_value)})")
30853124

3086-
def decode_into(self, arr: np.ndarray, value: int = 1) -> None: # noqa: vulture
3087-
"""Decode the RLE mask into an existing array, only setting foreground pixels.
3125+
# Broadcast scalar to all channels
3126+
dtype = np.dtype(dtype)
3127+
if not fg_multi:
3128+
fg_value = np.full(n_channels, fg_value, dtype=dtype)
3129+
if not bg_multi:
3130+
bg_value = np.full(n_channels, bg_value, dtype=dtype)
30883131

3089-
This method sets foreground pixels to the specified value while leaving
3090-
background pixels unchanged. This is useful for overlaying multiple masks
3091-
onto a single array, e.g., creating a label map or visualization.
3132+
h, w = self.shape
3133+
arr = np.empty((h, w, n_channels), dtype=dtype, order='C')
3134+
arr[:] = bg_value # broadcasts along last axis
3135+
if dtype == np.uint8:
3136+
self.cy._decode_into(arr, fg_value)
3137+
elif dtype == np.float32 or dtype == np.float64:
3138+
self.cy._decode_typed_multi_into(arr, np.asarray(fg_value, dtype=dtype))
3139+
else:
3140+
mask01 = self.cy._r_to_dense_array(1, 0, 'C')
3141+
arr[mask01 != 0] = fg_value
3142+
3143+
if order == 'F' and not arr.flags.f_contiguous:
3144+
arr = np.asfortranarray(arr)
3145+
return arr
3146+
3147+
def decode_into( # noqa: vulture
3148+
self, arr: np.ndarray, fg_value=None, bg_value=None,
3149+
*, value=_UNSET) -> None:
3150+
"""Decode the RLE mask into an existing array.
3151+
3152+
Writes ``fg_value`` to foreground pixels and/or ``bg_value`` to background pixels.
3153+
Pixels whose corresponding parameter is ``None`` are left unchanged.
3154+
3155+
Supports uint8, float32, and float64 arrays (2D or 3D HWC).
30923156
30933157
Args:
3094-
arr: A 2D uint8 numpy array with shape matching the mask.
3095-
Must be either C-contiguous or Fortran-contiguous.
3096-
value: The value to assign to foreground pixels (default: 1).
3158+
arr: A numpy array with shape matching the mask.
3159+
Must be either C-contiguous or Fortran-contiguous (or strided for 2D uint8).
3160+
fg_value: The value to assign to foreground pixels (default: None, meaning unchanged).
3161+
bg_value: The value to assign to background pixels (default: None, meaning unchanged).
3162+
value: deprecated alias for ``fg_value``
30973163
30983164
Raises:
30993165
ValueError: If array shape doesn't match mask shape or array is not contiguous.
31003166
31013167
Example:
31023168
>>> canvas = np.zeros((100, 100), dtype=np.uint8)
3103-
>>> mask1.decode_into(canvas, value=1)
3104-
>>> mask2.decode_into(canvas, value=2)
3105-
>>> mask3.decode_into(canvas, value=3)
3169+
>>> mask1.decode_into(canvas, fg_value=1)
3170+
>>> mask2.decode_into(canvas, fg_value=2)
3171+
>>> mask3.decode_into(canvas, fg_value=3)
31063172
# canvas now contains 1, 2, 3 for respective mask regions
31073173
31083174
See Also:
31093175
:meth:`to_array`
31103176
"""
3111-
self.cy._decode_into(arr, value)
3177+
if value is not _UNSET:
3178+
warnings.warn(
3179+
"The 'value' parameter is deprecated, use 'fg_value' instead.",
3180+
DeprecationWarning, stacklevel=2)
3181+
fg_value = value
3182+
3183+
if bg_value is not None and fg_value is not None:
3184+
arr[:] = bg_value
3185+
self._decode_into_typed(arr, fg_value)
3186+
elif fg_value is not None:
3187+
self._decode_into_typed(arr, fg_value)
3188+
elif bg_value is not None:
3189+
self.complement()._decode_into_typed(arr, bg_value)
3190+
# else: both None, no-op
3191+
3192+
def _decode_into_typed(self, arr, fg_value):
3193+
"""Dispatch decode_into to the right Cython method based on array dtype."""
3194+
if arr.dtype == np.uint8:
3195+
self.cy._decode_into(arr, fg_value)
3196+
elif arr.dtype == np.float32 or arr.dtype == np.float64:
3197+
if arr.ndim == 2:
3198+
if isinstance(fg_value, (tuple, list, np.ndarray)):
3199+
raise ValueError(
3200+
"fg_value must be scalar for 2D arrays; use a 3D HWC array for multi-channel values")
3201+
self.cy._decode_typed_into(arr, fg_value)
3202+
else:
3203+
fg_arr = np.asarray(fg_value, dtype=arr.dtype)
3204+
if fg_arr.ndim == 0:
3205+
fg_arr = np.full(arr.shape[2], fg_value, dtype=arr.dtype)
3206+
self.cy._decode_typed_multi_into(arr, fg_arr)
3207+
else:
3208+
order = 'F' if arr.flags.f_contiguous else 'C'
3209+
mask01 = self.cy._r_to_dense_array(1, 0, order)
3210+
arr[mask01 != 0] = fg_value
31123211

31133212
def __reduce__(self):
31143213
"""Support for pickle serialization."""
@@ -3213,7 +3312,7 @@ def _forward_slice(slice_obj, length):
32133312
# cropped = x.crop(box_old, inplace=False)
32143313
# interp = cv2.INTER_LINEAR if fx > 1 and fy > 1 else cv2.INTER_AREA
32153314
# resized = cv2.resize(
3216-
# cropped.to_array(order='C', value=255),
3315+
# cropped.to_array(order='C', fg_value=255),
32173316
# (box_new[2], box_new[3]),
32183317
# fx=fx, fy=fy, interpolation=interp)
32193318
# resized = RLEMask.from_array(resized, thresh128=True, is_sparse=x.density < 0.04)

src/rlemasklib/oop_cython.pyx

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# cython: language_level=3
22
# distutils: language = c
33

4+
cimport cython
5+
from cython cimport floating
6+
47
import zlib
58
import numpy as np
69
cimport numpy as np
@@ -207,6 +210,40 @@ ctypedef const RLE *ConstRLEPtr
207210
ctypedef const ConstRLEPtr *ConstRLEPtrConstPtr
208211
ctypedef const RLEPtr *ConstRLEPtrPtr
209212

213+
214+
@cython.boundscheck(False)
215+
@cython.wraparound(False)
216+
cdef bint _rle_counts_valid(RLE *r) noexcept nogil:
217+
cdef siz total = 0
218+
cdef siz i
219+
for i in range(r.m):
220+
total += r.cnts[i]
221+
return total == r.h * r.w
222+
223+
224+
@cython.boundscheck(False)
225+
@cython.wraparound(False)
226+
cdef void _rle_decode_typed(RLE *r, floating[::1] out, floating fg_value) noexcept nogil:
227+
cdef siz i, j, pos = 0
228+
for i in range(r.m):
229+
if i % 2 == 1:
230+
for j in range(r.cnts[i]):
231+
out[pos + j] = fg_value
232+
pos += r.cnts[i]
233+
234+
235+
@cython.boundscheck(False)
236+
@cython.wraparound(False)
237+
cdef void _rle_decode_typed_multi(RLE *r, floating[::1] out, siz n_channels, floating[::1] fg_values) noexcept nogil:
238+
cdef siz i, j, c, pos = 0
239+
for i in range(r.m):
240+
if i % 2 == 1:
241+
for j in range(r.cnts[i]):
242+
for c in range(n_channels):
243+
out[(pos + j) * n_channels + c] = fg_values[c]
244+
pos += r.cnts[i]
245+
246+
210247
# python class to wrap RLE array in C
211248
# the class handles the memory allocation and deallocation
212249
cdef class RLECy:
@@ -867,30 +904,111 @@ cdef class RLECy:
867904
else:
868905
# Strided array (e.g., channel slice of HWC image)
869906
# Use strided C function - strides are in bytes, arr.itemsize is 1 for uint8
907+
if not _rle_counts_valid(&self.r):
908+
raise ValueError(
909+
"Invalid RLE: sum of runlengths does not match pixel count")
870910
row_stride = arr.strides[0]
871911
col_stride = arr.strides[1]
872912
rleDecodeStrided(&self.r, <byte *> np.PyArray_DATA(arr), row_stride, col_stride,
873913
value)
874914

875-
def _r_to_dense_array(self, value, order) -> np.ndarray:
915+
def _r_to_dense_array(self, fg_value, bg_value, order, dtype=np.uint8) -> np.ndarray:
916+
cdef np.ndarray arr
917+
918+
dtype = np.dtype(dtype)
919+
shape = (self.r.h, self.r.w)
876920
if self.r.h == 0 or self.r.w == 0:
877-
return np.empty((self.r.h, self.r.w), dtype=np.uint8)
921+
return np.full(shape, bg_value, dtype=dtype)
878922

879-
if order == 'F':
880-
arr = np.zeros((self.r.h, self.r.w), dtype=np.uint8, order='F')
923+
# For C-order with sparse masks, allocate C directly to skip full transpose
924+
alloc_order = order if (order == 'F' or self.r.m < self.r.h * self.r.w * 0.04) else 'F'
925+
926+
if bg_value == 0:
927+
arr = np.zeros(shape, dtype=dtype, order=alloc_order)
881928
else:
882-
is_sparse = self.r.m < self.r.h * self.r.w * 0.04
883-
if is_sparse:
884-
arr = np.zeros((self.r.h, self.r.w), dtype=np.uint8, order='C')
885-
else:
886-
arr = np.zeros((self.r.h, self.r.w), dtype=np.uint8, order='F')
929+
arr = np.full(shape, bg_value, dtype=dtype, order=alloc_order)
887930

888-
self._decode_into(arr, value)
931+
if dtype == np.uint8:
932+
self._decode_into(arr, fg_value)
933+
elif dtype == np.float32 or dtype == np.float64:
934+
self._decode_typed_into(arr, fg_value)
935+
else:
936+
mask01 = np.zeros(shape, dtype=np.uint8, order=alloc_order)
937+
self._decode_into(mask01, 1)
938+
arr[mask01 != 0] = fg_value
889939

890-
if order == 'C' and arr.flags.f_contiguous:
940+
if order == 'C' and not arr.flags.c_contiguous:
891941
return np.ascontiguousarray(arr)
892942
return arr
893943

944+
def _decode_typed_into(self, np.ndarray arr, fg_value):
945+
"""Decode RLE into a typed 2D array (float32/float64) using fused types."""
946+
cdef RLECy transp
947+
cdef float[::1] flat_f32
948+
cdef double[::1] flat_f64
949+
cdef RLE *rle_ptr
950+
951+
if arr.shape[0] != self.r.h or arr.shape[1] != self.r.w:
952+
raise ValueError(
953+
f"Array shape ({arr.shape[0]}, {arr.shape[1]}) does not match RLE shape ({self.r.h}, {self.r.w})")
954+
955+
if arr.dtype != np.float32 and arr.dtype != np.float64:
956+
raise ValueError(f"_decode_typed_into only supports float32 and float64, got {arr.dtype}")
957+
958+
if arr.flags.f_contiguous:
959+
rle_ptr = &self.r
960+
elif arr.flags.c_contiguous:
961+
transp = self._r_transpose()
962+
rle_ptr = &transp.r
963+
else:
964+
raise ValueError("Array must be C-contiguous or F-contiguous")
965+
966+
if not _rle_counts_valid(rle_ptr):
967+
raise ValueError("Invalid RLE: sum of runlengths does not match pixel count")
968+
969+
order = 'F' if arr.flags.f_contiguous else 'C'
970+
if arr.dtype == np.float32:
971+
flat_f32 = arr.ravel(order=order)
972+
_rle_decode_typed(rle_ptr, flat_f32, <float>fg_value)
973+
else:
974+
flat_f64 = arr.ravel(order=order)
975+
_rle_decode_typed(rle_ptr, flat_f64, <double>fg_value)
976+
977+
def _decode_typed_multi_into(self, np.ndarray arr, np.ndarray fg_values):
978+
"""Decode RLE into a typed 3D HWC array (float32/float64) using fused types."""
979+
cdef RLECy transp
980+
cdef float[::1] flat_f32, fgv_f32
981+
cdef double[::1] flat_f64, fgv_f64
982+
cdef RLE *rle_ptr
983+
cdef siz n_channels = arr.shape[2]
984+
985+
if arr.shape[0] != self.r.h or arr.shape[1] != self.r.w:
986+
raise ValueError(
987+
f"Array shape ({arr.shape[0]}, {arr.shape[1]}) does not match RLE shape ({self.r.h}, {self.r.w})")
988+
989+
if fg_values.shape[0] != n_channels:
990+
raise ValueError(
991+
f"fg_values length ({fg_values.shape[0]}) does not match "
992+
f"number of channels ({n_channels})")
993+
994+
if not arr.flags.c_contiguous:
995+
raise ValueError("3D array must be C-contiguous for typed multi-channel decode")
996+
997+
transp = self._r_transpose()
998+
rle_ptr = &transp.r
999+
1000+
if not _rle_counts_valid(rle_ptr):
1001+
raise ValueError("Invalid RLE: sum of runlengths does not match pixel count")
1002+
1003+
if arr.dtype == np.float32:
1004+
flat_f32 = arr.ravel(order='C')
1005+
fgv_f32 = np.ascontiguousarray(fg_values, dtype=np.float32)
1006+
_rle_decode_typed_multi(rle_ptr, flat_f32, n_channels, fgv_f32)
1007+
else:
1008+
flat_f64 = arr.ravel(order='C')
1009+
fgv_f64 = np.ascontiguousarray(fg_values, dtype=np.float64)
1010+
_rle_decode_typed_multi(rle_ptr, flat_f64, n_channels, fgv_f64)
1011+
8941012
def _i_zeros(self, shape):
8951013
rleZeros(&self.r, shape[0], shape[1])
8961014

0 commit comments

Comments
 (0)