Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
33f8ba5
docs: name the runtime by role, not by repository
repentsinner Aug 30, 2026
2871ef5
style: reflow the paragraphs the rename rewrapped
repentsinner Aug 30, 2026
eb3e6b4
docs(spec): state backend portability as a catalog property
repentsinner Aug 30, 2026
2533d53
docs(roadmap): decompose backend portability into four workstreams
repentsinner Aug 30, 2026
223f073
test(backends): make the torch leg a gate instead of a skip
repentsinner Aug 30, 2026
157a8b6
docs(spec): correct the dtype rationale against measured behaviour
repentsinner Aug 30, 2026
499adfa
docs(roadmap): match the verify block to measured backend behaviour
repentsinner Aug 30, 2026
63634aa
docs: merge corrected spec and roadmap
repentsinner Aug 30, 2026
f8ae0f3
perf(fills): render the checkerboard functionally, at a caller's dtype
repentsinner Aug 30, 2026
d7dc20e
perf(counter-panel): extract bits arithmetically, from an array index
repentsinner Aug 30, 2026
0d34786
fix(counter-panel): widen the frame index annotation to array data
repentsinner Aug 30, 2026
76616d1
docs: drop the link to a private repository
repentsinner Aug 30, 2026
4e229ee
docs: merge private-link removal
repentsinner Aug 30, 2026
d59d194
docs: merge private-link removal
repentsinner Aug 30, 2026
587bd3b
docs: merge private-link removal
repentsinner Aug 30, 2026
5b5ff57
docs(backends): state on the public surface what the caller must choose
repentsinner Aug 30, 2026
9b2af2b
refactor(patterns): index the blank row alike in both render bodies
repentsinner Aug 30, 2026
3902a19
docs: name the upstream as a renderer, nothing narrower
repentsinner Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 53 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,21 @@
touches a device, a clock, or a file.

The core depends on numpy alone and renders into a caller-supplied
array namespace (numpy or torch), so it serves CLI signal generators
and GPU-resident render graphs alike. Chart authoring and TIFF export
install as extras.
array namespace (numpy or torch, CPU or GPU), so it serves CLI signal
generators and GPU-resident render graphs alike. Render bodies are
functional and free of Python-level branching on frame data, so a
consumer can compile them into its own graph. Chart authoring and TIFF
export install as extras.

Used by
[bmd-signal-gen](https://github.com/OpenDisplayEval/bmd-signal-gen),
which sends the rendered patterns to a display over SDI or HDMI.
Related:
[color-wrangler](https://github.com/Fuse-Technical-Group/color-wrangler)
(LED-surface characterization umbrella) and its component repos.
[display-measure](https://github.com/OpenDisplayEval/display-measure),
[display-report](https://github.com/OpenDisplayEval/display-report), and
[methodology](https://github.com/OpenDisplayEval/methodology), which
measure, report on, and document the characterization of a display
surface.

## Installation

Expand Down Expand Up @@ -50,6 +55,11 @@
# frame = checkerboard(..., xp=torch, device="cuda")
```

`dtype` states the output type, `uint16` by default. Any type that
carries the stated bit depth exactly is accepted; one that cannot is
refused rather than silently wrapping. See [Backends](#backends) for
when to state it.

Encode a frame counter as machine-readable bit-cells and decode it back
— render one end of a video chain, measure latency and frame skew at
the other:
Expand All @@ -62,6 +72,44 @@
assert decode_counter(overlay, geometry) == 1234
```

The counter carries 1 to 31 bits and wraps at `2**bits` — 31 bits runs
over a year at 60 Hz. Pass the frame index as a zero-dimensional array
rather than a Python integer when compiling (see below).

## Backends

Check warning on line 79 in README.md

View workflow job for this annotation

GitHub Actions / lint / lint

Section 'backends' cites no §reference — orientation needs none, but an assertion belongs in a governance file (§spec:readme-derivable)

Every core pattern renders through the `xp` namespace with an optional
`device`, and returns the same values on each. Two things are worth
knowing before putting one inside a compiled graph.

**Pass the frame index as array data.** A Python integer is a
compile-time constant, so stepping one recompiles the pattern on every
frame — and past the recompile limit `torch.compile` gives up on the
call site and falls back to eager for good:

```python
counter = torch.tensor(frame_number, dtype=torch.int32, device=device)
overlay, mask = render_counter_panel(counter, geometry, xp=torch, device=device)
```

**State a dtype your backend can compile.** `uint16` renders correctly
on MPS in eager mode, but Inductor's Metal code generator has no
mapping for it, so a `uint16` output cannot be compiled there at all.
An `int32` or `float32` output can, and carries 12-bit code values just
as exactly:

```python
frame = checkerboard(colors, ..., xp=torch, device=device, dtype=torch.int32)
```

Which types a backend supports is the backend's business and moves
between its releases, which is why the library takes the dtype from the
caller rather than fixing one.

Verified on numpy and on torch's CPU build in CI; on CUDA and MPS by
`pytest -m cuda` and `pytest -m mps`, which are deselected by default
and run where the hardware is.

With the `charts` and `io` extras, author a chart in YAML, render it,
and write a 16-bit TIFF:

Expand Down Expand Up @@ -93,7 +141,7 @@
All public functions and classes carry NumPy-style docstrings; the
package ships `py.typed` for static type checking.

## Governance

Check warning on line 144 in README.md

View workflow job for this annotation

GitHub Actions / lint / lint

Section 'governance' cites no §reference — orientation needs none, but an assertion belongs in a governance file (§spec:readme-derivable)

- [REQUIREMENTS.md](REQUIREMENTS.md) — the problem space
- [SPEC.md](SPEC.md) — design and rationale
Expand Down
12 changes: 6 additions & 6 deletions REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ real-time render graphs, and measurement pipelines.

Test-pattern math lives trapped inside device tools. bmd-signal-gen's
checkerboard and chart generation imports nothing from its DeckLink
layer, yet a consumer who wants only the patterns installs the
whole device tool. backlit_molecule re-derived its own frame-counter
panel math because no importable source existed. Each new consumer
rewrites geometry that is pure, deterministic, and identical across
delivery paths.
layer, yet a consumer who wants only the patterns installs the whole
device tool. A renderer re-derived its own frame-counter panel math
because no importable source existed. Each new consumer rewrites
geometry that is pure, deterministic, and identical across delivery
paths.

General imaging libraries fall short in the other direction: they
carry color-management and encoding opinions and offer no measurement
Expand Down Expand Up @@ -116,7 +116,7 @@ Essential, in adoption order:
extras) with bit-identical output.
3. Frame-indexed rendering signature on every pattern.
4. Temporal-alignment counter panel, encode and decode (ported from
backlit_molecule's probe math).
a renderer's probe math).

Nice-to-have, after adoption:

Expand Down
104 changes: 85 additions & 19 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ system does and why. See ROADMAP.md for work remaining.
Display test-pattern math lives trapped inside device tools
(§req:problem-statement). bmd-signal-gen's pattern and chart modules
import nothing from its DeckLink layer yet ship only inside the device
tool; backlit_molecule re-derived frame-counter panel math for want of
an importable source. General imaging libraries carry color-management
opinions and cannot make a measurement claim: driving *exact* integer
code values at a stated bit depth is the point, and a library that
rescales or quantizes behind the caller's back defeats it.
tool; a renderer re-derived frame-counter panel math for want of an
importable source. General imaging libraries carry color-management
opinions and cannot make a measurement claim: driving
*exact* integer code values at a stated bit depth is the point, and a
library that rescales or quantizes behind the caller's back defeats
it.

display-patterns is that importable source: deterministic pattern
math, device-free, exact by construction. bmd-signal-gen's SPEC
Expand Down Expand Up @@ -80,13 +81,14 @@ manufactures error. The library owns geometry; meaning stays with the
caller.

Catalog entries share one calling convention: pattern parameters, a
`frame` index, and the `xp`/`device` keywords. Rasters are HWC (height,
width, channels); integer patterns return the namespace's `uint16` at
the stated bit depth; a pattern whose output is more than one plane
(the counter panel's overlay + mask) returns a documented tuple. **Why
HWC and not the source runtime's NCHW:** these are images consumers
composite and encode, not batched graph payloads; the batch and
channel-first axes belong to the runtime that needs them.
`frame` index, and the `xp`/`device` keywords. Rasters are HWC
(height, width, channels); integer patterns return the dtype the
caller states, `uint16` by default (§spec:backend-portability); a
pattern whose output is more than one plane (the counter panel's
overlay + mask) returns a documented tuple. **Why HWC and not the
source runtime's NCHW:** these are images consumers composite and
encode, not batched graph payloads; the batch and channel-first axes
belong to the runtime that needs them.

## Catalog §spec:catalog

Expand All @@ -106,9 +108,9 @@ The core catalog, renderable with numpy alone (§req:success-criteria):
back out of the other (§req:user-stories). The geometry is
deterministic, so encoder and decoder agree on every cell from
parameters alone, and decode thresholds at the cell midpoint to
survive a lossy chain (§req:success-criteria). Ported from
backlit_molecule's probe math (`§spec:alignment-probe` there),
which retires its bespoke node in favor of generic primitives.
survive a lossy chain (§req:success-criteria). Ported from a
renderer's alignment-probe math, which retires its bespoke node in
favor of generic primitives.

The `charts` extra adds chart production (§req:user-stories): a chart
is authored as a YAML patch list carrying colorimetric values,
Expand All @@ -125,6 +127,69 @@ Catalog growth (motion material, PLUGE, ramps, zone plates) enters as
consumers need it (§req:priorities); each entry follows the rendering
model and carries a decode side when one is meaningful.

## Backend portability §spec:backend-portability

*Status: complete*

The core catalog renders the same values on every backend a consumer
brings: numpy on a CPU host, torch on CUDA, torch on Apple's MPS
(§req:quality-attributes portability). A counter panel rendered on any
of them decodes to the frame index it encodes
(§req:success-criteria).

Render bodies are functional. An entry point computes its result from
broadcast arithmetic over coordinate and bit-position arrays and
returns it, rather than allocating a buffer and writing strided slices
into it. No render body branches on array contents, and none reads a
value back to the host; parameter validation stays host-side, where
the parameters already are.

**Why functional and not in-place:** a strided scatter is a
materialized intermediate no compiler fuses away, so an in-place body
spends a pass over the frame per write where the whole pattern is one
kernel — measured at 2160p on MPS, 3.5 ms for the strided body against
0.7 ms for the functional one, before any compilation. In-place bodies
also exclude any backend whose arrays are immutable. The library never
calls `torch.compile` itself — compilation belongs to the consumer's
graph (§spec:non-goals) — so its obligation is to stay *compilable*,
and staying compilable is what vectorized means here
(§req:quality-attributes performance).

A temporal pattern's frame index is array data, not a Python integer:
`frame` accepts a scalar or a zero-dimensional array, and the
counter's bits are extracted arithmetically. **Why the frame index is
data:** to a tracing compiler a Python integer is a compile-time
constant, so a consumer compiling its frame loop would recompile the
pattern on every frame and the fused path would cost more than the
eager one it replaced. The counter is bounded at 31 bits so its
arithmetic fits the signed 32-bit integers every backend supports; at
60 Hz that counts for over a year before wrapping.

Output dtype is the caller's, defaulting to the exact-integer type the
pattern has always returned. An integer pattern renders into any dtype
that represents its stated bit depth without loss and rejects one that
cannot, so exactness is preserved by the check rather than by a fixed
type (§req:quality-attributes exactness). **Why the caller states the
dtype:** a fixed return type forces every consumer whose value space
differs to pay a conversion pass, and it collides with what a backend
can compile. `uint16` renders on MPS in eager mode but has no Metal
code-generation mapping, so a `uint16` output cannot be compiled there
at all — and which types a backend supports is the backend's business,
moving between its releases. The exactness claim is about the values
that arrive; any type wide enough carries them just as exactly.

Verification is proportional to where the hardware is
(§req:priorities). The torch leg runs on every change — torch's CPU
build is a development dependency, so backend equivalence is a gate
rather than a test that skips silently. The CUDA and MPS legs are
opt-in markers run on the hardware that has them. CI asserts the
backend contract; hardware asserts the device.

The `charts` extra is numpy-only and stays so: chart rendering draws
text through an imaging library and converts through a colorimetry
library, neither of which has a device backend. Backend portability is
a core-catalog property.

## Extraction and compatibility §spec:extraction

*Status: complete*
Expand Down Expand Up @@ -167,7 +232,8 @@ that rationale in its `§spec:verification`).
Out of scope, with their owners: device output and signaling
(bmd-signal-gen, pydecklink); playback, clocks, and frame pacing
(consumers' runtimes); color management and display characterization
(ocio-display-gen, color-wrangler); instrument I/O and measurement
sessions (color-wrangler, colour-specio); runtime graph integration
(backlit_molecule). The library defines the mapping from parameters
to image and nothing on either side of it.
(ocio-display-gen); instrument I/O and measurement sessions
(colour-specio, and the surface-characterization umbrella that drives
it); runtime graph integration (consumers' render runtimes). The
library defines the mapping from parameters to image and nothing on
either side of it.
18 changes: 18 additions & 0 deletions display_patterns/patterns/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@
placement. Stills ignore the frame index; temporal patterns use it as
their only time source. The render path performs no I/O and holds no
state.

Render bodies are functional — built from broadcast arithmetic rather
than written into an allocation — and branch on no array contents, so a
consumer may compile one into its own graph (§spec:backend-portability).
Two caller choices decide whether that compiles well:

- Pass a temporal pattern's frame index as array data. A Python integer
is a compile-time constant, so stepping one recompiles the pattern
every frame, and past the recompile limit the compiler abandons the
call site entirely.
- State an output ``dtype`` the backend can compile. ``uint16`` is the
default and renders on every backend in eager mode, but has no Metal
code-generation mapping, so a ``uint16`` output cannot be compiled on
MPS; ``int32`` and ``float32`` can, and carry the same code values
exactly.

The library never compiles anything itself — that belongs to the
consumer's graph (§spec:non-goals).
"""

from display_patterns.patterns._counter_panel import (
Expand Down
44 changes: 38 additions & 6 deletions display_patterns/patterns/_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,25 @@
The contract a namespace has to satisfy — recorded here because it is
the answer to "would backend X work?":

- ``zeros``/``full``/``asarray`` accepting ``dtype`` (and ``device``
when the caller passes one; creation calls omit the keyword
otherwise, so numpy's default signature works),
- dtype attributes ``uint16`` and ``float32``,
- **mutable arrays**: render bodies write strided slices in place, so
an immutable-array backend (JAX) is out of contract,
- ``zeros``/``full``/``asarray``/``arange`` accepting ``dtype`` (and
``device`` when the caller passes one; creation calls omit the
keyword otherwise, so numpy's default signature works),
- ``where``, ``stack``, ``zeros_like``, ``reshape`` on the array, and
the arithmetic and comparison operators used to build a raster from
coordinate arrays,
- indexing an array with an integer index array (the per-row gather),
- ``iinfo``/``finfo`` for the dtype range check,
- dtype attributes ``uint16``, ``int32``, and ``float32``,
- ``astype`` or ``to`` on the array for dtype conversion, and ``cpu``
where device-resident arrays cannot be read by numpy directly.

Render bodies are functional (§spec:backend-portability): they build
results from broadcast arithmetic rather than writing strided slices
into an allocation, so a backend whose arrays are immutable is in
contract.
"""

import math
from typing import Any

import numpy as np
Expand Down Expand Up @@ -45,6 +54,29 @@ def asarray(xp: Any, values: Any, device: Any) -> Any:
return xp.asarray(values)


def arange(xp: Any, stop: int, dtype: Any, device: Any) -> Any:
"""``[0, stop)`` as an ``xp`` array, placed on ``device`` when the
caller supplies one."""
if device is not None:
return xp.arange(stop, dtype=dtype, device=device)
return xp.arange(stop, dtype=dtype)


def max_exact_integer(xp: Any, dtype: Any) -> int:
"""The largest integer ``dtype`` represents without loss.

Integer dtypes report their own maximum. A float dtype represents
consecutive integers only up to two to the power of its mantissa
width plus one, which its epsilon states — both namespaces expose
``finfo.eps`` where neither exposes a mantissa width.
"""
try:
return int(xp.iinfo(dtype).max)
except (TypeError, ValueError):
eps = float(xp.finfo(dtype).eps)
return 2 ** (round(-math.log2(eps)) + 1)


def astype(array: Any, dtype: Any) -> Any:
"""``array`` converted to ``dtype`` — numpy's ``astype`` or torch's
``to``, whichever the array carries."""
Expand Down
Loading
Loading