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 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"]