4"""Generate the void-and-cluster blue-noise dither threshold mask (#477).
6The 16-level e-ink panel (Waveshare 6inch HD, IT8951, 4 bpp) shows 16 gray
7levels; source figures carry 256. ``libs/ra8_gfx/src/ra8_gfx_dither.c`` breaks
8up the 256->16 banding by thresholding each pixel against a precomputed
9blue-noise mask indexed purely by ``(x, y)`` position plus the pixel's own
10value -- no neighbour state, so the dither is independent per pixel/tile
11(seamless across tile boundaries), fully deterministic (ra8_emulator byte ==
12silicon, the EIL==HIL rule), and SIMD-friendly. This script bakes that mask as
13a committed C table, following the ``rabook_parity_gen.py`` /
14``rabook_gray8_fixture.py`` generate-and-commit pattern.
16Method: Ulichney's void-and-cluster algorithm ("The void-and-cluster method for
17dither array generation", Robert Ulichney, Proc. SPIE 1913, 1993). A small
18homogeneous "prototype" binary pattern is grown/shrunk one pixel at a time,
19always filling the largest void or breaking the tightest cluster (measured by a
20toroidal Gaussian filter), which assigns every cell a rank whose level sets are
21blue-noise distributed. The ranks become an 8-bit threshold texture.
23Determinism: the initial minority pixels come from ``random.Random(_SEED)``
24(Mersenne Twister, stable across CPython releases) and the Gaussian filter uses
25an INTEGER fixed-point kernel, so every argmin/argmax is an exact integer
26comparison -- the emitted bytes are identical on any host, no floating-point
27tie-breaking. Re-running the script reproduces the committed header byte for
31 python3 scripts/gen/gen_bluenoise_mask.py # rewrite the header
32 python3 scripts/gen/gen_bluenoise_mask.py --check # fail if it drifted
33 just tools::bluenoise_update # regenerate + format
36from __future__
import annotations
42from pathlib
import Path
63 Path(__file__).resolve().parents[2]
67 /
"ra8_gfx_dither_mask_internal.h"
71def _wrap(value: int) -> int:
72 """Reduce a coordinate onto the toroidal ``[0, _DIM)`` mask edge."""
76def _build_kernel() -> list[tuple[int, int, int]]:
77 """Return the integer Gaussian filter as ``(dx, dy, weight)`` offsets.
79 Weights are ``round(_KERNEL_SCALE * exp(-(dx^2+dy^2)/(2*sigma^2)))`` over
80 the truncated ``[-_RADIUS, _RADIUS]`` square, dropping zero-weight cells.
81 Keeping them integer makes the energy field exact, so no float rounding can
82 flip an argmin tie between platforms.
84 kernel: list[tuple[int, int, int]] = []
85 denom = 2.0 * _SIGMA * _SIGMA
86 for dy
in range(-_RADIUS, _RADIUS + 1):
87 for dx
in range(-_RADIUS, _RADIUS + 1):
88 weight = round(_KERNEL_SCALE * math.exp(-((dx * dx) + (dy * dy)) / denom))
90 kernel.append((dx, dy, weight))
94def _toggle(energy: list[int], kernel: list[tuple[int, int, int]], pos: int, sign: int) ->
None:
95 """Add (``sign=+1``) or remove (``sign=-1``) one minority pixel at ``pos``.
97 Splats the Gaussian kernel, wrapped toroidally, onto the energy field so
98 ``energy[q]`` always equals the filtered concentration of minority pixels
103 for dx, dy, weight
in kernel:
106 energy[(qy * _DIM) + qx] += sign * weight
109def _tightest_cluster(energy: list[int], pattern: list[bool]) -> int:
110 """Index of the set (``True``) cell with the highest surrounding energy."""
113 for pos
in range(_N):
114 if pattern[pos]
and ((best < 0)
or (energy[pos] > best_energy)):
116 best_energy = energy[pos]
120def _largest_void(energy: list[int], pattern: list[bool]) -> int:
121 """Index of the clear (``False``) cell with the lowest surrounding energy."""
124 for pos
in range(_N):
125 if (
not pattern[pos])
and ((best < 0)
or (energy[pos] < best_energy)):
127 best_energy = energy[pos]
131def _initial_pattern(kernel: list[tuple[int, int, int]]) -> tuple[list[bool], list[int]]:
132 """Build a homogeneous prototype binary pattern and its energy field.
134 Seeds ``_N // _INIT_FRACTION`` minority pixels at random, then repeatedly
135 moves the tightest cluster's pixel into the largest void until the two
136 coincide -- the fixed point at which the pattern is maximally homogeneous.
138 rng = random.Random(_SEED)
139 ones = _N // _INIT_FRACTION
140 chosen = rng.sample(range(_N), ones)
141 pattern = [
False] * _N
145 _toggle(energy, kernel, pos, 1)
149 cluster = _tightest_cluster(energy, pattern)
150 pattern[cluster] =
False
151 _toggle(energy, kernel, cluster, -1)
152 void = _largest_void(energy, pattern)
154 _toggle(energy, kernel, void, 1)
157 return pattern, energy
160def _rank_matrix() -> list[int]:
161 """Run the three void-and-cluster phases and return per-cell ranks 0.._N-1."""
162 kernel = _build_kernel()
163 prototype, proto_energy = _initial_pattern(kernel)
164 ones = sum(1
for cell
in prototype
if cell)
168 pattern = list(prototype)
169 energy = list(proto_energy)
170 for rank
in range(ones - 1, -1, -1):
171 cluster = _tightest_cluster(energy, pattern)
172 ranks[cluster] = rank
173 pattern[cluster] =
False
174 _toggle(energy, kernel, cluster, -1)
180 pattern = list(prototype)
181 energy = list(proto_energy)
182 for rank
in range(ones, _N):
183 void = _largest_void(energy, pattern)
186 _toggle(energy, kernel, void, 1)
190def _threshold_texture() -> list[int]:
191 """Map ranks 0.._N-1 to centred 8-bit thresholds uniform over [0, 255]."""
192 ranks = _rank_matrix()
193 texture: list[int] = []
195 level = (((2 * rank) + 1) * _BYTE_LEVELS) // (2 * _N)
196 texture.append(
min(level, _BYTE_LEVELS - 1))
200def _format_rows(texture: list[int]) -> str:
201 """Render the texture as clang-format-clean 16-per-row hex byte literals."""
202 lines: list[str] = []
203 for start
in range(0, _N, _ROW_BYTES):
204 cells = texture[start : start + _ROW_BYTES]
205 lines.append(
" " +
" ".join(f
"0x{value:02X}," for value
in cells))
206 return "\n".join(lines)
209def _render_header() -> str:
210 """Produce the full committed header text for the blue-noise mask table."""
211 rows = _format_rows(_threshold_texture())
213 * @file ra8_gfx_dither_mask_internal.h
214 * @brief Void-and-cluster blue-noise threshold mask for the e-ink dither (#477).
215 * @ingroup grp_ereader
217 * @generated by scripts/gen/gen_bluenoise_mask.py (just tools::bluenoise_update);
218 * do not hand-edit. Bakes the {_DIM}x{_DIM} blue-noise threshold
219 * texture ra8_gfx_dither.c thresholds gray8 samples against to
220 * quantise them to the panel's 16 gray levels without banding.
223 * Each byte is a threshold in [0, 255], blue-noise distributed so that the
224 * level set {{mask < t}} is a high-frequency (void-and-cluster) dot pattern for
225 * every t -- the property that gives smooth, grain-only dithering with no Bayer
226 * cross-hatch. The table is indexed toroidally by ``(y & {_DIM - 1}) * {_DIM} +
227 * (x & {_DIM - 1})`` at absolute panel coordinates, so abutting tiles share one
228 * continuous mask phase and never seam. Included by exactly one translation
229 * unit (ra8_gfx_dither.c); ``static`` keeps it a single private definition.
231 * @copyright Copyright (c) 2026 Brighton Sikarskie
232 * SPDX-License-Identifier: MIT
240 * @var s_ra8_gfx_dither_mask
241 * @brief {_DIM}x{_DIM} row-major blue-noise threshold texture ({_N} bytes).
242 * @details Generated; read-only. Entry ``[(y & {_DIM - 1}) * {_DIM} + (x &
243 * {_DIM - 1})]`` is the [0, 255] dither threshold for panel pixel
244 * ``(x, y)``. Do not modify -- regenerate via the script above.
245 * @note Generated, build-only data; not hand-authored.
248static const uint8_t s_ra8_gfx_dither_mask[] = {{
255 """Rewrite the committed mask header in place; return a process exit code."""
256 _OUT.write_text(_render_header(), encoding=
"ascii")
257 print(f
"gen_bluenoise_mask.py: wrote {_OUT}")
262 """Fail (exit 1) when the committed header differs from a fresh regenerate."""
263 fresh = _render_header()
265 current = _OUT.read_text(encoding=
"ascii")
267 sys.stderr.write(f
"gen_bluenoise_mask.py: {_OUT} is missing.\n")
271 "gen_bluenoise_mask.py: committed mask is stale. "
272 "Run `just tools::bluenoise_update` and commit the result.\n"
275 print(
"gen_bluenoise_mask.py: committed mask matches a fresh regenerate.")
280 """Parse arguments and either rewrite or verify the committed mask header."""
281 parser = argparse.ArgumentParser(description=
"Generate the blue-noise dither mask table.")
283 "--check", action=
"store_true", help=
"verify the committed header is up to date"
285 args = parser.parse_args()
286 return _check()
if args.check
else _write()
289if __name__ ==
"__main__":
290 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.