pixflex 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pixflex/__init__.py +39 -0
- pixflex/_backend.py +40 -0
- pixflex/canvas.py +464 -0
- pixflex/dsl.py +163 -0
- pixflex/errors.py +37 -0
- pixflex/fit.py +95 -0
- pixflex/plan.py +240 -0
- pixflex/resize.py +111 -0
- pixflex-0.1.0.dist-info/METADATA +150 -0
- pixflex-0.1.0.dist-info/RECORD +12 -0
- pixflex-0.1.0.dist-info/WHEEL +4 -0
- pixflex-0.1.0.dist-info/licenses/LICENSE +21 -0
pixflex/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Flexbox layout for image arrays, solved with Yoga."""
|
|
2
|
+
|
|
3
|
+
from .canvas import Canvas
|
|
4
|
+
from .dsl import Box, Column, Node, Row, Slot, grid
|
|
5
|
+
from .errors import (
|
|
6
|
+
BackendMismatch,
|
|
7
|
+
ChannelMismatch,
|
|
8
|
+
DeviceMismatch,
|
|
9
|
+
DTypeMismatch,
|
|
10
|
+
DuplicateKeyError,
|
|
11
|
+
ForeignSlotError,
|
|
12
|
+
MissingResizer,
|
|
13
|
+
PixflexError,
|
|
14
|
+
ShapeMismatch,
|
|
15
|
+
)
|
|
16
|
+
from .plan import Plan, Rect
|
|
17
|
+
from .resize import register_resizer
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BackendMismatch",
|
|
21
|
+
"Box",
|
|
22
|
+
"Canvas",
|
|
23
|
+
"ChannelMismatch",
|
|
24
|
+
"Column",
|
|
25
|
+
"DTypeMismatch",
|
|
26
|
+
"DeviceMismatch",
|
|
27
|
+
"DuplicateKeyError",
|
|
28
|
+
"ForeignSlotError",
|
|
29
|
+
"MissingResizer",
|
|
30
|
+
"Node",
|
|
31
|
+
"PixflexError",
|
|
32
|
+
"Plan",
|
|
33
|
+
"Rect",
|
|
34
|
+
"Row",
|
|
35
|
+
"ShapeMismatch",
|
|
36
|
+
"Slot",
|
|
37
|
+
"grid",
|
|
38
|
+
"register_resizer",
|
|
39
|
+
]
|
pixflex/_backend.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Thin helpers over array-api-compat so the rest of pixflex is array-library agnostic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from array_api_compat import array_namespace, device, is_numpy_array, is_torch_array
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def namespace(x: Any) -> Any:
|
|
11
|
+
return array_namespace(x)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def backend_name(x: Any) -> str:
|
|
15
|
+
"""Top-level module that owns the array type: ``"numpy"``, ``"torch"``, ..."""
|
|
16
|
+
return type(x).__module__.split(".")[0]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def device_of(x: Any) -> Any:
|
|
20
|
+
return device(x)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def copy(x: Any) -> Any:
|
|
24
|
+
return array_namespace(x).asarray(x, copy=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_numpy(x: Any) -> Any:
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
if is_torch_array(x):
|
|
31
|
+
return x.detach().cpu().numpy().copy()
|
|
32
|
+
if is_numpy_array(x):
|
|
33
|
+
return x.copy()
|
|
34
|
+
return np.array(x)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_namespace() -> Any:
|
|
38
|
+
import array_api_compat.numpy as xp
|
|
39
|
+
|
|
40
|
+
return xp
|
pixflex/canvas.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""A reusable output buffer you write content into, slot by slot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from collections.abc import Callable, Hashable, Iterator, Mapping
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from . import _backend as be
|
|
10
|
+
from .dsl import Box, Node, Slot
|
|
11
|
+
from .errors import (
|
|
12
|
+
BackendMismatch,
|
|
13
|
+
ChannelMismatch,
|
|
14
|
+
DeviceMismatch,
|
|
15
|
+
DTypeMismatch,
|
|
16
|
+
ForeignSlotError,
|
|
17
|
+
)
|
|
18
|
+
from .fit import Align, place
|
|
19
|
+
from .plan import Plan, Rect
|
|
20
|
+
from .resize import resize
|
|
21
|
+
|
|
22
|
+
K = TypeVar("K", bound=Hashable)
|
|
23
|
+
A = TypeVar("A")
|
|
24
|
+
|
|
25
|
+
_ALLOWED_AXES = set("HWCTN")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Canvas(Generic[A, K]):
|
|
29
|
+
"""One allocation holding the finished image; slots are written in place.
|
|
30
|
+
|
|
31
|
+
Constant parts (headers, footers...) are drawn once and stay; ``cv[k] = x`` rewrites only
|
|
32
|
+
the rectangle of slot ``k``. ``cv.array`` is the live buffer (no copy); use
|
|
33
|
+
:meth:`snapshot` when you need to keep a frame.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
plan: Plan[K],
|
|
39
|
+
like: A | None = None,
|
|
40
|
+
*,
|
|
41
|
+
axes: str = "HWC",
|
|
42
|
+
channels: int | None = None,
|
|
43
|
+
dtype: Any = None,
|
|
44
|
+
fill: float = 0,
|
|
45
|
+
lead: Mapping[str, int] | None = None,
|
|
46
|
+
static: Mapping[Any, Any] | None = None,
|
|
47
|
+
out: A | None = None,
|
|
48
|
+
xp: Any = None,
|
|
49
|
+
_array: Any = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
if not {"H", "W"} <= set(axes) or set(axes) - _ALLOWED_AXES or len(set(axes)) != len(axes):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"axes must use distinct letters from HWCTN and include H, W: {axes!r}"
|
|
54
|
+
)
|
|
55
|
+
self.plan = plan
|
|
56
|
+
self.axes = axes
|
|
57
|
+
self._fill = fill
|
|
58
|
+
self._placed: dict[Node, tuple[int, int, int, int] | None] = {}
|
|
59
|
+
self._prepared: dict[Node, tuple[Any, tuple[int, int, int, int]]] = {}
|
|
60
|
+
self._compute_overlays()
|
|
61
|
+
if _array is not None: # sub-canvas / clone: adopt an existing buffer
|
|
62
|
+
self._array = _array
|
|
63
|
+
self._xp = be.namespace(_array)
|
|
64
|
+
self._device = be.device_of(_array)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
if out is not None:
|
|
68
|
+
array = out
|
|
69
|
+
else:
|
|
70
|
+
array = self._allocate(like, channels, dtype, fill, lead or {}, xp)
|
|
71
|
+
self._array: Any = array
|
|
72
|
+
self._xp = be.namespace(array)
|
|
73
|
+
self._device = be.device_of(array)
|
|
74
|
+
if out is not None:
|
|
75
|
+
self._check_shape(out)
|
|
76
|
+
self._paint()
|
|
77
|
+
if static:
|
|
78
|
+
self.update(static)
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------ allocation
|
|
81
|
+
def _allocate(
|
|
82
|
+
self,
|
|
83
|
+
like: Any,
|
|
84
|
+
channels: int | None,
|
|
85
|
+
dtype: Any,
|
|
86
|
+
fill: float,
|
|
87
|
+
lead: Mapping[str, int],
|
|
88
|
+
xp: Any,
|
|
89
|
+
) -> Any:
|
|
90
|
+
axes = self.axes
|
|
91
|
+
if like is not None:
|
|
92
|
+
xp = be.namespace(like)
|
|
93
|
+
dtype = dtype if dtype is not None else like.dtype
|
|
94
|
+
device = be.device_of(like)
|
|
95
|
+
else:
|
|
96
|
+
xp = xp or be.default_namespace()
|
|
97
|
+
dtype = dtype if dtype is not None else xp.uint8
|
|
98
|
+
device = None
|
|
99
|
+
sizes: dict[str, int] = dict(lead)
|
|
100
|
+
if like is not None:
|
|
101
|
+
if like.ndim == len(axes):
|
|
102
|
+
for letter in "CTN":
|
|
103
|
+
if letter in axes and letter not in sizes:
|
|
104
|
+
sizes[letter] = like.shape[axes.index(letter)]
|
|
105
|
+
elif like.ndim == len(axes) - 1 and "C" in axes:
|
|
106
|
+
sizes.setdefault("C", 1)
|
|
107
|
+
if channels is not None:
|
|
108
|
+
sizes["C"] = channels
|
|
109
|
+
if "C" in axes:
|
|
110
|
+
sizes.setdefault("C", 3)
|
|
111
|
+
for letter in "TN":
|
|
112
|
+
if letter in axes and letter not in sizes:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"axes {axes!r} has {letter!r}: pass lead={{'{letter}': size}} or like="
|
|
115
|
+
)
|
|
116
|
+
sizes["H"], sizes["W"] = self.plan.size
|
|
117
|
+
shape = tuple(sizes[a] for a in axes)
|
|
118
|
+
kwargs = {"device": device} if device is not None else {}
|
|
119
|
+
return xp.full(shape, fill, dtype=dtype, **kwargs)
|
|
120
|
+
|
|
121
|
+
def _check_shape(self, arr: Any) -> None:
|
|
122
|
+
h, w = self.plan.size
|
|
123
|
+
got = (arr.shape[self.axes.index("H")], arr.shape[self.axes.index("W")])
|
|
124
|
+
if got != (h, w) or arr.ndim != len(self.axes):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"out has shape {tuple(arr.shape)}, plan needs H={h}, W={w} ({self.axes})"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------ geometry helpers
|
|
130
|
+
@property
|
|
131
|
+
def array(self) -> A:
|
|
132
|
+
"""The live buffer (no copy)."""
|
|
133
|
+
return self._array
|
|
134
|
+
|
|
135
|
+
def _idx(self, y0: int, y1: int, x0: int, x1: int) -> tuple[slice, ...]:
|
|
136
|
+
return tuple(
|
|
137
|
+
slice(y0, y1) if a == "H" else slice(x0, x1) if a == "W" else slice(None)
|
|
138
|
+
for a in self.axes
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _effective_background(self, item: Node) -> Any:
|
|
142
|
+
node: Node | None = item
|
|
143
|
+
while node is not None:
|
|
144
|
+
if node.background is not None:
|
|
145
|
+
return node.background
|
|
146
|
+
node = node.parent
|
|
147
|
+
return self._fill
|
|
148
|
+
|
|
149
|
+
def _bg_value(self, bg: Any) -> Any:
|
|
150
|
+
if not isinstance(bg, (tuple, list)):
|
|
151
|
+
return bg
|
|
152
|
+
c = self.axes.index("C") if "C" in self.axes else None
|
|
153
|
+
if c is None or len(bg) != self._array.shape[c]:
|
|
154
|
+
raise ChannelMismatch(f"background {tuple(bg)} does not match the canvas channels")
|
|
155
|
+
shape = [1] * len(self.axes)
|
|
156
|
+
shape[c] = len(bg)
|
|
157
|
+
return self._xp.reshape(
|
|
158
|
+
self._xp.asarray(list(bg), dtype=self._array.dtype, device=self._device), tuple(shape)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _fill_rect(self, rect: tuple[int, int, int, int], bg: Any) -> None:
|
|
162
|
+
y0, y1, x0, x1 = rect
|
|
163
|
+
if y1 > y0 and x1 > x0:
|
|
164
|
+
self._array[self._idx(y0, y1, x0, x1)] = self._bg_value(bg)
|
|
165
|
+
|
|
166
|
+
def _paint(self) -> None:
|
|
167
|
+
for item in self.plan._items: # pre-order: containers first, nested ones on top
|
|
168
|
+
if item.background is not None:
|
|
169
|
+
r = self.plan._rects[item]
|
|
170
|
+
self._fill_rect((r.y, r.y1, r.x, r.x1), item.background)
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------ writing content
|
|
173
|
+
def __setitem__(self, k: Node | K, content: Any) -> None:
|
|
174
|
+
self.set(k, content)
|
|
175
|
+
|
|
176
|
+
def update(self, content: Mapping[Any, Any]) -> None:
|
|
177
|
+
"""Write several slots at once: ``cv.update({a: x, b: y})``."""
|
|
178
|
+
for k, v in content.items():
|
|
179
|
+
self.set(k, v)
|
|
180
|
+
|
|
181
|
+
def set(
|
|
182
|
+
self,
|
|
183
|
+
k: Node | K,
|
|
184
|
+
content: Any,
|
|
185
|
+
*,
|
|
186
|
+
fit: str | None = None,
|
|
187
|
+
align: Align | None = None,
|
|
188
|
+
alpha: str | None = None,
|
|
189
|
+
convert: Callable[[Any], Any] | None = None,
|
|
190
|
+
to_device: bool = False,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Write ``content`` into slot ``k``.
|
|
193
|
+
|
|
194
|
+
``fit``/``align`` override the slot's own; ``alpha`` is ``"drop"`` or ``"over"`` for RGBA
|
|
195
|
+
content into an RGB canvas; ``convert`` runs first (e.g. float 0-1 -> uint8);
|
|
196
|
+
``to_device`` allows a device transfer.
|
|
197
|
+
"""
|
|
198
|
+
item = self.plan._resolve(k)
|
|
199
|
+
if not isinstance(item, Slot):
|
|
200
|
+
raise TypeError(f"{item.label} is a container; write to its slots (or use .sub())")
|
|
201
|
+
self._write(item, content, fit, align, alpha, convert, to_device)
|
|
202
|
+
self._restore_over(item)
|
|
203
|
+
|
|
204
|
+
def _write(
|
|
205
|
+
self,
|
|
206
|
+
item: Slot,
|
|
207
|
+
content: Any,
|
|
208
|
+
fit: str | None,
|
|
209
|
+
align: Align | None,
|
|
210
|
+
alpha: str | None,
|
|
211
|
+
convert: Callable[[Any], Any] | None,
|
|
212
|
+
to_device: bool,
|
|
213
|
+
) -> None:
|
|
214
|
+
rect = self.plan._rects[item]
|
|
215
|
+
if convert is not None:
|
|
216
|
+
content = convert(content)
|
|
217
|
+
src = self._normalize(content, item, to_device)
|
|
218
|
+
src = self._channels(src, rect, alpha)
|
|
219
|
+
|
|
220
|
+
hi, wi = self.axes.index("H"), self.axes.index("W")
|
|
221
|
+
# Content may lack leading axes (static content broadcast across T); H/W sit from the end.
|
|
222
|
+
offset = src.ndim - len(self.axes)
|
|
223
|
+
h0, w0 = src.shape[hi + offset], src.shape[wi + offset]
|
|
224
|
+
pl = place(
|
|
225
|
+
(h0, w0),
|
|
226
|
+
rect.hw,
|
|
227
|
+
fit or item.fit,
|
|
228
|
+
align if align is not None else item.align,
|
|
229
|
+
label=f"slot {item.label}",
|
|
230
|
+
)
|
|
231
|
+
y0, y1, x0, x1 = pl.src
|
|
232
|
+
view = src[self._src_idx(src.ndim, y0, y1, x0, x1)]
|
|
233
|
+
if pl.needs_resize:
|
|
234
|
+
view = resize(view, pl.out_hw, self._src_axes(src.ndim))
|
|
235
|
+
view = self._cast(view)
|
|
236
|
+
|
|
237
|
+
oh, ow = pl.out_hw
|
|
238
|
+
dy, dx = rect.y + pl.dst[0], rect.x + pl.dst[1]
|
|
239
|
+
placed = (dy, dy + oh, dx, dx + ow)
|
|
240
|
+
slot_rect = (rect.y, rect.y1, rect.x, rect.x1)
|
|
241
|
+
if placed != slot_rect and self._placed.get(item) != placed:
|
|
242
|
+
self._clear_strips(slot_rect, placed, self._effective_background(item))
|
|
243
|
+
self._placed[item] = placed
|
|
244
|
+
if oh and ow:
|
|
245
|
+
self._array[self._idx(*placed)] = view
|
|
246
|
+
if item in self._covered: # something is stacked above this slot: remember its pixels
|
|
247
|
+
self._prepared[item] = (view, placed)
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------ stacking order
|
|
250
|
+
def _compute_overlays(self) -> None:
|
|
251
|
+
"""Painter's algorithm: items later in tree order are drawn on top of earlier ones.
|
|
252
|
+
|
|
253
|
+
For every slot, find the later items with a background or content whose rectangles
|
|
254
|
+
overlap it; they are redrawn (clipped to the overlap) whenever the slot is written.
|
|
255
|
+
"""
|
|
256
|
+
items, rects = self.plan._items, self.plan._rects
|
|
257
|
+
self._overlays: dict[Node, list[Node]] = {}
|
|
258
|
+
self._covered: set[Node] = set()
|
|
259
|
+
for i, s in enumerate(items):
|
|
260
|
+
rs = rects[s]
|
|
261
|
+
if not isinstance(s, Slot) or not (rs.h and rs.w):
|
|
262
|
+
continue
|
|
263
|
+
over = [
|
|
264
|
+
t
|
|
265
|
+
for t in items[i + 1 :]
|
|
266
|
+
if not (isinstance(t, Box) and t.background is None)
|
|
267
|
+
and (rt := rects[t]).h
|
|
268
|
+
and rt.w
|
|
269
|
+
and rt.y < rs.y1
|
|
270
|
+
and rs.y < rt.y1
|
|
271
|
+
and rt.x < rs.x1
|
|
272
|
+
and rs.x < rt.x1
|
|
273
|
+
]
|
|
274
|
+
if over:
|
|
275
|
+
self._overlays[s] = over
|
|
276
|
+
self._covered.update(over)
|
|
277
|
+
|
|
278
|
+
def _restore_over(self, item: Node) -> None:
|
|
279
|
+
"""Redraw whatever is stacked above ``item`` inside ``item``'s rectangle."""
|
|
280
|
+
rects = self.plan._rects
|
|
281
|
+
r = rects[item]
|
|
282
|
+
for t in self._overlays.get(item, ()):
|
|
283
|
+
rt = rects[t]
|
|
284
|
+
clip = (max(r.y, rt.y), min(r.y1, rt.y1), max(r.x, rt.x), min(r.x1, rt.x1))
|
|
285
|
+
prepared = self._prepared.get(t)
|
|
286
|
+
if prepared is None:
|
|
287
|
+
if t.background is not None: # opaque container/slot without content
|
|
288
|
+
self._fill_rect(clip, t.background)
|
|
289
|
+
continue
|
|
290
|
+
self._fill_rect(clip, self._effective_background(t))
|
|
291
|
+
view, (py0, py1, px0, px1) = prepared
|
|
292
|
+
y0, y1, x0, x1 = (
|
|
293
|
+
max(clip[0], py0),
|
|
294
|
+
min(clip[1], py1),
|
|
295
|
+
max(clip[2], px0),
|
|
296
|
+
min(clip[3], px1),
|
|
297
|
+
)
|
|
298
|
+
if y1 > y0 and x1 > x0:
|
|
299
|
+
part = view[self._src_idx(view.ndim, y0 - py0, y1 - py0, x0 - px0, x1 - px0)]
|
|
300
|
+
self._array[self._idx(y0, y1, x0, x1)] = part
|
|
301
|
+
|
|
302
|
+
def _src_axes(self, ndim: int) -> str:
|
|
303
|
+
return self.axes[len(self.axes) - ndim :]
|
|
304
|
+
|
|
305
|
+
def _src_idx(self, ndim: int, y0: int, y1: int, x0: int, x1: int) -> tuple[slice, ...]:
|
|
306
|
+
return tuple(
|
|
307
|
+
slice(y0, y1) if a == "H" else slice(x0, x1) if a == "W" else slice(None)
|
|
308
|
+
for a in self._src_axes(ndim)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def _normalize(self, content: Any, item: Slot, to_device: bool) -> Any:
|
|
312
|
+
if be.backend_name(content) != be.backend_name(self._array):
|
|
313
|
+
raise BackendMismatch(
|
|
314
|
+
f"{item.label}: got a {be.backend_name(content)} array for a "
|
|
315
|
+
f"{be.backend_name(self._array)} canvas"
|
|
316
|
+
)
|
|
317
|
+
if be.device_of(content) != self._device:
|
|
318
|
+
if not to_device:
|
|
319
|
+
raise DeviceMismatch(
|
|
320
|
+
f"{item.label}: content is on {be.device_of(content)}, canvas on "
|
|
321
|
+
f"{self._device} (pass to_device=True to allow the transfer)"
|
|
322
|
+
)
|
|
323
|
+
content = self._xp.asarray(content, device=self._device)
|
|
324
|
+
n = len(self.axes)
|
|
325
|
+
if content.ndim == n - 1 and "C" in self.axes and not set("TN") & set(self.axes):
|
|
326
|
+
content = content[..., None] if self.axes.index("C") == n - 1 else content[None]
|
|
327
|
+
elif content.ndim not in (n, n - 1):
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"{item.label}: content has {content.ndim} dims, canvas axes {self.axes!r} "
|
|
330
|
+
f"expect {n}"
|
|
331
|
+
)
|
|
332
|
+
return content
|
|
333
|
+
|
|
334
|
+
def _channels(self, src: Any, rect: Rect, alpha: str | None) -> Any:
|
|
335
|
+
if "C" not in self.axes:
|
|
336
|
+
return src
|
|
337
|
+
c_axis = self.axes.index("C") - (len(self.axes) - src.ndim)
|
|
338
|
+
have, want = src.shape[c_axis], self._array.shape[self.axes.index("C")]
|
|
339
|
+
if have == want or have == 1:
|
|
340
|
+
return src
|
|
341
|
+
if have == want + 1 and alpha in ("drop", "over"):
|
|
342
|
+
|
|
343
|
+
def ch(lo: int | None, hi: int | None) -> Any:
|
|
344
|
+
idx = [slice(None)] * src.ndim
|
|
345
|
+
idx[c_axis] = slice(lo, hi)
|
|
346
|
+
return src[tuple(idx)]
|
|
347
|
+
|
|
348
|
+
rgb = ch(0, want)
|
|
349
|
+
if alpha == "drop":
|
|
350
|
+
return rgb
|
|
351
|
+
return self._over(rgb, ch(want, None), rect)
|
|
352
|
+
raise ChannelMismatch(
|
|
353
|
+
f"content has {have} channels, canvas has {want}"
|
|
354
|
+
+ (" (pass alpha='drop' or 'over' for RGBA)" if have == want + 1 else "")
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def _over(self, rgb: Any, a: Any, rect: Rect) -> Any:
|
|
358
|
+
"""Composite RGBA onto what is currently on the canvas (same size as the slot only)."""
|
|
359
|
+
xp = self._xp
|
|
360
|
+
dtype = self._array.dtype
|
|
361
|
+
top = float(xp.iinfo(dtype).max) if xp.isdtype(dtype, "integral") else 1.0
|
|
362
|
+
af = xp.astype(a, xp.float32) / top
|
|
363
|
+
dst = xp.astype(self._array[self._idx(rect.y, rect.y1, rect.x, rect.x1)], xp.float32)
|
|
364
|
+
if tuple(dst.shape) != tuple(rgb.shape):
|
|
365
|
+
raise ValueError("alpha='over' needs content with the slot's exact size")
|
|
366
|
+
out = xp.astype(rgb, xp.float32) * af + dst * (1 - af)
|
|
367
|
+
if xp.isdtype(dtype, "integral"):
|
|
368
|
+
out = xp.round(out)
|
|
369
|
+
return xp.astype(out, dtype)
|
|
370
|
+
|
|
371
|
+
def _cast(self, arr: Any) -> Any:
|
|
372
|
+
xp, want = self._xp, self._array.dtype
|
|
373
|
+
if arr.dtype == want:
|
|
374
|
+
return arr
|
|
375
|
+
if not xp.can_cast(arr.dtype, want):
|
|
376
|
+
raise DTypeMismatch(
|
|
377
|
+
f"cannot safely convert {arr.dtype} to the canvas dtype {want}; "
|
|
378
|
+
"pass convert=... (e.g. float 0-1 -> uint8) or choose a canvas dtype"
|
|
379
|
+
)
|
|
380
|
+
return xp.astype(arr, want)
|
|
381
|
+
|
|
382
|
+
def _clear_strips(
|
|
383
|
+
self, slot: tuple[int, int, int, int], placed: tuple[int, int, int, int], bg: Any
|
|
384
|
+
) -> None:
|
|
385
|
+
sy0, sy1, sx0, sx1 = slot
|
|
386
|
+
py0, py1, px0, px1 = placed
|
|
387
|
+
self._fill_rect((sy0, py0, sx0, sx1), bg) # above
|
|
388
|
+
self._fill_rect((py1, sy1, sx0, sx1), bg) # below
|
|
389
|
+
self._fill_rect((py0, py1, sx0, px0), bg) # left
|
|
390
|
+
self._fill_rect((py0, py1, px1, sx1), bg) # right
|
|
391
|
+
|
|
392
|
+
# ------------------------------------------------------------------ whole-canvas operations
|
|
393
|
+
def clear(self, k: Node | K) -> None:
|
|
394
|
+
"""Reset a slot, or a whole box subtree, to its background."""
|
|
395
|
+
item = self.plan._resolve(k)
|
|
396
|
+
r = self.plan._rects[item]
|
|
397
|
+
self._fill_rect((r.y, r.y1, r.x, r.x1), self._effective_background(item))
|
|
398
|
+
for n in _subtree(item):
|
|
399
|
+
self._placed.pop(n, None)
|
|
400
|
+
self._prepared.pop(n, None)
|
|
401
|
+
if n.background is not None and n is not item:
|
|
402
|
+
rr = self.plan._rects[n]
|
|
403
|
+
self._fill_rect((rr.y, rr.y1, rr.x, rr.x1), n.background)
|
|
404
|
+
for n in _subtree(item):
|
|
405
|
+
self._restore_over(n)
|
|
406
|
+
|
|
407
|
+
def sub(self, box: Node | K) -> Canvas[A, K]:
|
|
408
|
+
"""A canvas scoped to ``box``: its array is a view into this one; rects are box-relative."""
|
|
409
|
+
item = self.plan._resolve(box)
|
|
410
|
+
if not isinstance(item, Box):
|
|
411
|
+
raise TypeError(f"{item.label} is not a container")
|
|
412
|
+
r = self.plan._rects[item]
|
|
413
|
+
view = self._array[self._idx(r.y, r.y1, r.x, r.x1)]
|
|
414
|
+
sub_plan: Plan[K] = self.plan._subplan(item)
|
|
415
|
+
return Canvas(sub_plan, axes=self.axes, fill=self._fill, _array=view)
|
|
416
|
+
|
|
417
|
+
def view(self, k: Node | K) -> Any:
|
|
418
|
+
"""A no-copy view of the canvas inside node ``k``."""
|
|
419
|
+
r = self.plan[k]
|
|
420
|
+
return self._array[self._idx(r.y, r.y1, r.x, r.x1)]
|
|
421
|
+
|
|
422
|
+
def snapshot(self) -> A:
|
|
423
|
+
"""A copy of the current image (safe to keep while the canvas is updated)."""
|
|
424
|
+
return be.copy(self._array)
|
|
425
|
+
|
|
426
|
+
def to_numpy(self) -> Any:
|
|
427
|
+
"""A numpy copy (the single device->host transfer when saving from a GPU)."""
|
|
428
|
+
return be.to_numpy(self._array)
|
|
429
|
+
|
|
430
|
+
def ring(self, n: int) -> Iterator[Canvas[A, K]]:
|
|
431
|
+
"""Cycle through ``n`` copies of this canvas (static content already drawn in each).
|
|
432
|
+
|
|
433
|
+
Hand each to a consumer (encoder thread, async copy) and write the next frame into the
|
|
434
|
+
next buffer; a buffer is only reused after ``n`` steps.
|
|
435
|
+
"""
|
|
436
|
+
if n < 1:
|
|
437
|
+
raise ValueError("ring needs at least one buffer")
|
|
438
|
+
buffers = [self._clone() for _ in range(n)]
|
|
439
|
+
return itertools.cycle(buffers)
|
|
440
|
+
|
|
441
|
+
def _clone(self) -> Canvas[A, K]:
|
|
442
|
+
twin: Canvas[A, K] = Canvas(
|
|
443
|
+
self.plan, axes=self.axes, fill=self._fill, _array=be.copy(self._array)
|
|
444
|
+
)
|
|
445
|
+
twin._placed = dict(self._placed)
|
|
446
|
+
twin._prepared = {
|
|
447
|
+
n: (be.copy(v) if v is not None else v, placed)
|
|
448
|
+
for n, (v, placed) in self._prepared.items()
|
|
449
|
+
}
|
|
450
|
+
return twin
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def untyped(self) -> Canvas[A, Hashable]:
|
|
454
|
+
return self # type: ignore[return-value]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _subtree(item: Node) -> Iterator[Node]:
|
|
458
|
+
yield item
|
|
459
|
+
if isinstance(item, Box):
|
|
460
|
+
for c in item.children:
|
|
461
|
+
yield from _subtree(c)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
__all__ = ["Canvas", "ForeignSlotError"]
|