3"""Deterministic integer gray4 downscale kernel -- host mirror of the firmware.
5This is a pure-Python, integer-exact port of the on-device 4-bpp transcode in
6``apps/shared_libs/rabook_compile/src/ra8_rabook_gray4.c``:
8 * :func:`gray4_output_dims` mirrors ``ra8_rabook_gray4_output_dims``.
9 * :func:`gray4_downscale` mirrors ``ra8_rabook_gray4_downscale`` (the Q16.16
10 fixed-point bilinear resample).
11 * :func:`gray4_encode` mirrors ``ra8_rabook_gray4_encode`` (round-to-nearest
12 16-level quantise + two-nibbles-per-byte pack).
14Why this file exists (issue #213): the .rabook image pipeline downscales rasters
15with the firmware's integer bilinear kernel, but the desktop compiler used to
16resample with PIL LANCZOS. The two kernels produce different pixels, so a
17downscaled image was NOT byte-identical host-vs-device. Downscale is opt-in
18(default off, issue #210), yet whenever ``--max-edge`` is requested the divergence
19was reachable. ``epub_compile.py`` now calls this module for the resample step so
20the desktop tool and the device emit the same bytes; "green means correct" needs
21one deterministic kernel, not a documented exception.
23Every integer operation here matches the C exactly, including the truncating
24(floor) final shift and the edge-repeat clamp -- there is no floating point. The
25loops are O(dst_w * dst_h); this is fine for the opt-in offline compile and the
26tiny parity fixture, and clarity (a 1:1 mirror of the C) is worth more than speed.
28@copyright Copyright (c) 2026 Brighton Sikarskie
29SPDX-License-Identifier: MIT
32from __future__
import annotations
36_FP_UNIT = 1 << _FP_SHIFT
37_FP_MASK = _FP_UNIT - 1
38_FP_NORM_SHIFT = 2 * _FP_SHIFT
61def stb_compute_y(r: int, g: int, b: int) -> int:
62 """Fold one RGB triple to an 8-bit gray value, byte-identical to stb_image.
64 Exact mirror of ``stbi__compute_y`` in
65 ``apps/shared_libs/third_party/stb/stb_image.h``:
66 ``(r*77 + g*150 + b*29) >> 8`` with a truncating shift. This is the single
67 host luma the device shares; using PIL's ``convert("L")`` here instead would
68 diverge from the on-device stb decode on most colour inputs (issue #337).
71 r: Red channel, 0-255.
72 g: Green channel, 0-255.
73 b: Blue channel, 0-255.
76 The stb gray8 value, 0-255.
78 return ((r * _LUMA_R) + (g * _LUMA_G) + (b * _LUMA_B)) >> _LUMA_SHIFT
81def gray4_output_dims(src_w: int, src_h: int, max_edge: int) -> tuple[int, int]:
82 """Scaled output dims keeping the longer edge within ``max_edge``.
84 Exact mirror of ``ra8_rabook_gray4_output_dims``: returns the source dims
85 unchanged when they already fit, otherwise scales both by
86 ``max_edge / longer_edge`` with round-half-up integer division (matching the C
87 ``(dim * max_edge + longer / 2) / longer``), clamping each result to >= 1.
89 :param src_w: Source width in pixels.
90 :param src_h: Source height in pixels.
91 :param max_edge: Maximum allowed length of the longer edge (0 -> (0, 0)).
92 :returns: ``(out_w, out_h)``; ``(0, 0)`` when any input is 0.
94 if src_w == 0
or src_h == 0
or max_edge == 0:
96 longer = max(src_w, src_h)
97 if longer <= max_edge:
99 half_longer = longer // 2
100 out_w = ((src_w * max_edge) + half_longer) // longer
101 out_h = ((src_h * max_edge) + half_longer) // longer
102 return (max(1, out_w), max(1, out_h))
105def _bilinear_sample(src: bytes, src_w: int, src_h: int, sx_fp: int, sy_fp: int) -> int:
106 """One bilinear-interpolated sample at Q16.16 source point (sx_fp, sy_fp).
108 Exact mirror of ``s_bilinear_sample``: integer bits pick the top-left corner,
109 the fractional bits weight the four neighbours, the right/bottom neighbours are
110 edge-clamped, and the accumulated 64-bit product is normalised by a truncating
113 sx0 = sx_fp >> _FP_SHIFT
114 sy0 = sy_fp >> _FP_SHIFT
115 sx1 = sx0 + 1
if sx0 + 1 < src_w
else src_w - 1
116 sy1 = sy0 + 1
if sy0 + 1 < src_h
else src_h - 1
117 fx = sx_fp & _FP_MASK
118 fy = sy_fp & _FP_MASK
123 p00 = src[row0 + sx0]
124 p10 = src[row0 + sx1]
125 p01 = src[row1 + sx0]
126 p11 = src[row1 + sx1]
127 val = (p00 * ifx * ify) + (p10 * fx * ify) + (p01 * ifx * fy) + (p11 * fx * fy)
128 return (val >> _FP_NORM_SHIFT) & _BYTE_MASK
131def gray4_downscale(src: bytes, src_w: int, src_h: int, dst_w: int, dst_h: int) -> bytes:
132 """Bilinear-resample an 8-bit gray image from (src_w x src_h) to (dst_w x dst_h).
134 Exact mirror of ``ra8_rabook_gray4_downscale``: a left-aligned sample grid
135 (``sx_fp = dx * ((src_w << 16) // dst_w)``) fed through :func:`_bilinear_sample`.
136 An all-zero source yields an all-zero destination, matching the C.
138 :param src: Source gray pixels, ``src_w * src_h`` bytes, row-major.
139 :param src_w: Source width (>= 0).
140 :param src_h: Source height (>= 0).
141 :param dst_w: Destination width (> 0).
142 :param dst_h: Destination height (> 0).
143 :returns: ``dst_w * dst_h`` gray bytes, row-major.
144 :raises ValueError: if ``dst_w`` or ``dst_h`` is 0.
146 if dst_w == 0
or dst_h == 0:
147 msg =
"dst_w and dst_h must be > 0"
148 raise ValueError(msg)
149 if src_w == 0
or src_h == 0:
150 return bytes(dst_w * dst_h)
151 x_step = (src_w << _FP_SHIFT) // dst_w
152 y_step = (src_h << _FP_SHIFT) // dst_h
153 out = bytearray(dst_w * dst_h)
154 for dy
in range(dst_h):
157 for dx
in range(dst_w):
158 out[base + dx] = _bilinear_sample(src, src_w, src_h, dx * x_step, sy_fp)
162def gray4_nibble(value: int) -> int:
163 """Quantise one 0-255 gray value to a 0-15 nibble (round-to-nearest, clamped).
165 Exact mirror of the per-pixel rule in ``s_pack_nibbles``:
166 ``n = min((v + 8) // 17, 15)``.
168 nib = (value + _ROUND_HALF) // _QUANT_DIV
169 return min(nib, _NIB_MAX)
172def gray4_encode(gray: bytes, width: int, height: int) -> bytes:
173 """Quantise + nibble-pack a gray buffer to 4-bpp (two pixels per byte).
175 Exact mirror of ``ra8_rabook_gray4_encode`` / ``s_pack_nibbles``: even pixels
176 land in the high nibble, odd pixels in the low nibble; an odd final pixel keeps
177 a zero low half. Output size is ``(width * height + 1) // 2`` bytes.
179 n_pixels = width * height
180 out = bytearray((n_pixels + 1) // _NIB_PER_BYTE)
181 for i
in range(n_pixels):
182 nib = gray4_nibble(gray[i])
183 byte_idx = i // _NIB_PER_BYTE
185 out[byte_idx] = nib << _NIB_SHIFT
191def gray4_transcode(src: bytes, src_w: int, src_h: int, max_edge: int) -> tuple[int, int, bytes]:
192 """Full opt-in transcode: output-dims -> downscale -> quantise/pack.
194 Convenience wrapper used by the parity generator to reproduce, in one call, the
195 exact bytes the device emits for a downscaled raster. ``max_edge == 0`` (or a
196 source already within it) means no resample -- the source pixels are packed as
197 is, so the default no-downscale path is covered too.
199 :param src: Source gray pixels, ``src_w * src_h`` bytes, row-major.
200 :param src_w: Source width in pixels (> 0).
201 :param src_h: Source height in pixels (> 0).
202 :param max_edge: Opt-in long-edge clamp (0 disables downscaling).
203 :returns: ``(out_w, out_h, packed_4bpp_bytes)``.
205 out_w, out_h = (src_w, src_h)
208 out_w, out_h = gray4_output_dims(src_w, src_h, max_edge)
209 if (out_w, out_h) != (src_w, src_h):
210 gray = gray4_downscale(src, src_w, src_h, out_w, out_h)
211 return (out_w, out_h, gray4_encode(gray, out_w, out_h))
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.