ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
gen_bluenoise_mask.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Generate the void-and-cluster blue-noise dither threshold mask (#477).
5
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.
15
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.
22
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
28byte.
29
30Usage:
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
34"""
35
36from __future__ import annotations
37
38import argparse
39import math
40import random
41import sys
42from pathlib import Path
43
44# --- mask geometry ---------------------------------------------------------
45# 64x64 (power-of-two edge so the runtime index is a bitmask, not a modulo).
46# 4096 bytes of .rodata: bounded, small, and a tiling period the eye does not
47# resolve on a 1448x1072 panel.
48_DIM = 64
49_N = _DIM * _DIM
50
51# --- void-and-cluster tuning ----------------------------------------------
52_SEED = 0x5747_4E42 # "WGNB" -- fixed so the initial pattern is reproducible.
53_SIGMA = 1.9 # Gaussian filter std-dev (Ulichney: ~1.5-2.0).
54_RADIUS = 6 # Kernel truncation radius (~3*sigma); 13x13 support.
55_KERNEL_SCALE = 4096 # Fixed-point weight scale -> integer energy field.
56_INIT_FRACTION = 10 # Prototype minority density: 1/_INIT_FRACTION of cells.
57
58# --- emitted-table formatting ---------------------------------------------
59_BYTE_LEVELS = 256 # Threshold texture depth (8-bit).
60_ROW_BYTES = 16 # Byte literals per emitted C source row (clang-format fit).
61
62_OUT = (
63 Path(__file__).resolve().parents[2]
64 / "libs"
65 / "ra8_gfx"
66 / "src"
67 / "ra8_gfx_dither_mask_internal.h"
68)
69
70
71def _wrap(value: int) -> int:
72 """Reduce a coordinate onto the toroidal ``[0, _DIM)`` mask edge."""
73 return value % _DIM
74
75
76def _build_kernel() -> list[tuple[int, int, int]]:
77 """Return the integer Gaussian filter as ``(dx, dy, weight)`` offsets.
78
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.
83 """
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))
89 if weight > 0:
90 kernel.append((dx, dy, weight))
91 return kernel
92
93
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``.
96
97 Splats the Gaussian kernel, wrapped toroidally, onto the energy field so
98 ``energy[q]`` always equals the filtered concentration of minority pixels
99 at ``q``.
100 """
101 px = pos % _DIM
102 py = pos // _DIM
103 for dx, dy, weight in kernel:
104 qx = _wrap(px + dx)
105 qy = _wrap(py + dy)
106 energy[(qy * _DIM) + qx] += sign * weight
107
108
109def _tightest_cluster(energy: list[int], pattern: list[bool]) -> int:
110 """Index of the set (``True``) cell with the highest surrounding energy."""
111 best = -1
112 best_energy = 0
113 for pos in range(_N):
114 if pattern[pos] and ((best < 0) or (energy[pos] > best_energy)):
115 best = pos
116 best_energy = energy[pos]
117 return best
118
119
120def _largest_void(energy: list[int], pattern: list[bool]) -> int:
121 """Index of the clear (``False``) cell with the lowest surrounding energy."""
122 best = -1
123 best_energy = 0
124 for pos in range(_N):
125 if (not pattern[pos]) and ((best < 0) or (energy[pos] < best_energy)):
126 best = pos
127 best_energy = energy[pos]
128 return best
129
130
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.
133
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.
137 """
138 rng = random.Random(_SEED) # noqa: S311 -- non-crypto: deterministic mask seed
139 ones = _N // _INIT_FRACTION
140 chosen = rng.sample(range(_N), ones)
141 pattern = [False] * _N
142 energy = [0] * _N
143 for pos in chosen:
144 pattern[pos] = True
145 _toggle(energy, kernel, pos, 1)
146
147 # NASA-style bounded loop: homogenisation cannot need more swaps than cells.
148 for _ in range(_N):
149 cluster = _tightest_cluster(energy, pattern)
150 pattern[cluster] = False
151 _toggle(energy, kernel, cluster, -1)
152 void = _largest_void(energy, pattern)
153 pattern[void] = True
154 _toggle(energy, kernel, void, 1)
155 if void == cluster:
156 break
157 return pattern, energy
158
159
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)
165 ranks = [0] * _N
166
167 # Phase 1: rank the prototype's minority pixels, tightest cluster last.
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)
175
176 # Phases 2+3: fill from the prototype, always into the largest void. Because
177 # filtering the clear cells is the complement of filtering the set cells,
178 # "largest void" and "tightest cluster of the majority" are the same pick,
179 # so one loop covers both halves symmetrically.
180 pattern = list(prototype)
181 energy = list(proto_energy)
182 for rank in range(ones, _N):
183 void = _largest_void(energy, pattern)
184 ranks[void] = rank
185 pattern[void] = True
186 _toggle(energy, kernel, void, 1)
187 return ranks
188
189
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] = []
194 for rank in ranks:
195 level = (((2 * rank) + 1) * _BYTE_LEVELS) // (2 * _N)
196 texture.append(min(level, _BYTE_LEVELS - 1))
197 return texture
198
199
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)
207
208
209def _render_header() -> str:
210 """Produce the full committed header text for the blue-noise mask table."""
211 rows = _format_rows(_threshold_texture())
212 return f"""/**
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
216 *
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.
221 *
222 * @details
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.
230 *
231 * @copyright Copyright (c) 2026 Brighton Sikarskie
232 * SPDX-License-Identifier: MIT
233 * @since 0.1.0
234 */
235#pragma once
236
237#include <stdint.h>
238
239/**
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.
246 * @since 0.1.0
247 */
248static const uint8_t s_ra8_gfx_dither_mask[] = {{
249{rows}
250}};
251"""
252
253
254def _write() -> int:
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}")
258 return 0
259
260
261def _check() -> int:
262 """Fail (exit 1) when the committed header differs from a fresh regenerate."""
263 fresh = _render_header()
264 try:
265 current = _OUT.read_text(encoding="ascii")
266 except OSError:
267 sys.stderr.write(f"gen_bluenoise_mask.py: {_OUT} is missing.\n")
268 return 1
269 if current != fresh:
270 sys.stderr.write(
271 "gen_bluenoise_mask.py: committed mask is stale. "
272 "Run `just tools::bluenoise_update` and commit the result.\n"
273 )
274 return 1
275 print("gen_bluenoise_mask.py: committed mask matches a fresh regenerate.")
276 return 0
277
278
279def main() -> int:
280 """Parse arguments and either rewrite or verify the committed mask header."""
281 parser = argparse.ArgumentParser(description="Generate the blue-noise dither mask table.")
282 parser.add_argument(
283 "--check", action="store_true", help="verify the committed header is up to date"
284 )
285 args = parser.parse_args()
286 return _check() if args.check else _write()
287
288
289if __name__ == "__main__":
290 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157