retroglyph_core/symbols/subcell.rs
1//! Posterizes small blocks of raw pixels to the best-matching Unicode block-element glyph.
2//!
3//! Splits a source image into one small pixel block per terminal cell, then pick whichever glyph
4//! plus foreground/background color pair best reconstructs that block. Three block shapes are
5//! supported, in increasing fidelity (and decreasing terminal compatibility):
6//!
7//! - [`quantize_half_block`](crate::symbols::quantize_half_block): 1x2 pixels -> `' '`/`▀`/`▄`/`█` (Unicode Block Elements, supported
8//! almost everywhere monospace fonts render at all).
9//! - [`quantize_quadrant`](crate::symbols::quantize_quadrant): 2x2 pixels -> the 16 quadrant block characters (`▘▝▀▖▌▞▛...`).
10//! - [`quantize_sextant`](crate::symbols::quantize_sextant): 2x3 pixels -> the 64 "Symbols for Legacy Computing" sextant
11//! characters, doubling vertical resolution again over quadrants. Newest and least
12//! universally supported of the three (a 2022 Unicode addition).
13//!
14//! Callers own the fallback chain: probe terminal/font support (or just take a caller-supplied
15//! capability flag) and call whichever function matches, sampling the source image at that
16//! function's pixel geometry. There's no single "auto-detect and degrade" entry point here,
17//! matching every other terminal-capability decision in retroglyph (e.g. `egc` support):
18//! detection policy lives with the backend, not with this pure geometry/color utility.
19//!
20//! # Algorithm
21//!
22//! For an N-pixel block, every one of the `2^N` ways to split the block into a "foreground set"
23//! and "background set" is scored: average the two sets' colors, then sum each pixel's squared
24//! distance to whichever average it was assigned to. The split with the lowest total error wins,
25//! and its bit pattern selects the glyph directly (the glyph tables below are indexed by that
26//! same pattern, foreground bits set, read row-major). This exhaustive search is cheap here (at
27//! most 64 candidates, 6 pixels each, for [`quantize_sextant`](crate::symbols::quantize_sextant)) and is the same technique
28//! notcurses' blitter chain documents using for its own 3x2 sextant solver.
29//!
30//! Ties (multiple patterns reconstructing a block with equally minimal error) resolve to the
31//! lower-numbered pattern, matching the tie-break convention `retroglyph_core::color`'s own
32//! nearest-color search already uses. Two tie shapes come up often enough to call out: a flat,
33//! single-color block ties across every pattern (all give zero error) and always resolves to
34//! pattern `0`, the cheapest glyph, a plain space colored by `bg`. And any block with exactly
35//! two distinct pixel colors has exactly two zero-error patterns, one the bitwise complement of
36//! the other (swap which color is called `fg` and which is `bg` and the reconstruction is
37//! identical); the lower pattern number wins there too.
38//!
39//! # Example
40//!
41//! ```
42//! use retroglyph_core::symbols::quantize_quadrant;
43//!
44//! // A block that's white in the top-left corner, black everywhere else.
45//! let black = (0, 0, 0);
46//! let white = (255, 255, 255);
47//! let glyph = quantize_quadrant([white, black, black, black]);
48//! assert_eq!(glyph.ch, '▘'); // top-left quadrant
49//! assert_eq!(glyph.fg, retroglyph_core::color::Color::rgb(255, 255, 255));
50//! assert_eq!(glyph.bg, retroglyph_core::color::Color::rgb(0, 0, 0));
51//! ```
52
53use crate::color::Color;
54
55/// A raw 24-bit RGB pixel sample: `(r, g, b)`, one byte per channel.
56pub type Pixel = (u8, u8, u8);
57
58/// The Unicode Block Elements glyphs for a 1-wide x 2-tall pixel block, indexed by a 2-bit
59/// pattern (bit 0 = top pixel set, bit 1 = bottom pixel set).
60pub const HALF_BLOCKS: [char; 4] = [' ', '▀', '▄', '█'];
61
62/// The 16 quadrant block glyphs for a 2x2 pixel block, indexed by a 4-bit pattern in row-major
63/// order (bit 0 = top-left, bit 1 = top-right, bit 2 = bottom-left, bit 3 = bottom-right).
64///
65/// Adapted from [ratatui-core's `symbols::pixel::QUADRANTS`][ratatui].
66///
67/// [ratatui]: https://github.com/ratatui/ratatui/blob/main/ratatui-core/src/symbols/pixel.rs
68pub const QUADRANTS: [char; 16] = [
69 ' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█',
70];
71
72/// The 64 sextant glyphs for a 2x3 pixel block.
73///
74/// Indexed by a 6-bit pattern in row-major order (bit 0 = top-left, bit 1 = top-right, bit 2 =
75/// mid-left, bit 3 = mid-right, bit 4 = bottom-left, bit 5 = bottom-right). Mostly from Unicode's
76/// "Symbols for Legacy Computing" block; adapted from [ratatui-core's
77/// `symbols::pixel::SEXTANTS`][ratatui].
78///
79/// [ratatui]: https://github.com/ratatui/ratatui/blob/main/ratatui-core/src/symbols/pixel.rs
80#[rustfmt::skip]
81pub const SEXTANTS: [char; 64] = [
82 ' ', '🬀', '🬁', '🬂', '🬃', '🬄', '🬅', '🬆', '🬇', '🬈', '🬉', '🬊', '🬋', '🬌', '🬍', '🬎',
83 '🬏', '🬐', '🬑', '🬒', '🬓', '▌', '🬔', '🬕', '🬖', '🬗', '🬘', '🬙', '🬚', '🬛', '🬜', '🬝',
84 '🬞', '🬟', '🬠', '🬡', '🬢', '🬣', '🬤', '🬥', '🬦', '🬧', '▐', '🬨', '🬩', '🬪', '🬫', '🬬',
85 '🬭', '🬮', '🬯', '🬰', '🬱', '🬲', '🬳', '🬴', '🬵', '🬶', '🬷', '🬸', '🬹', '🬺', '🬻', '█',
86];
87
88/// A posterized pixel block: the best-matching glyph plus its foreground and background colors.
89///
90/// The background color is only meaningful for glyphs that don't cover the full cell (anything
91/// but `' '` and `'█'`); the foreground color is only meaningful for glyphs other than `' '`.
92/// Both are still populated for those edge cases (as the block's overall average color) so a
93/// caller never has to special-case `Glyph` before styling a cell with it.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct Glyph {
96 /// The selected block-element/quadrant/sextant character.
97 pub ch: char,
98 /// The color assigned to the "on" pixels (those set in the glyph's bit pattern).
99 pub fg: Color,
100 /// The color assigned to the "off" pixels (those clear in the glyph's bit pattern).
101 pub bg: Color,
102}
103
104/// Averages the `pixels` selected by `mask` (bit `i` set means `pixels[i]` is included), rounding
105/// each channel to the nearest integer. Returns `None` if `mask` selects no pixels.
106fn average(pixels: &[Pixel], mask: usize) -> Option<Pixel> {
107 let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32);
108 for (i, &(pr, pg, pb)) in pixels.iter().enumerate() {
109 if mask & (1 << i) != 0 {
110 r += u32::from(pr);
111 g += u32::from(pg);
112 b += u32::from(pb);
113 n += 1;
114 }
115 }
116 if n == 0 {
117 return None;
118 }
119 let round = |sum: u32| u8::try_from((sum + n / 2) / n).unwrap_or(u8::MAX);
120 Some((round(r), round(g), round(b)))
121}
122
123/// Posterizes `pixels` to the glyph (from `table`, indexed by row-major bit pattern) and two
124/// representative colors that minimize total squared color error, by exhaustive search over
125/// every `2^N` way to split the block into a foreground and background set.
126///
127/// `table.len()` must be `2^pixels.len()`; every caller in this module upholds that by
128/// construction, so this stays a plain slice rather than a const-generic-sized array (which
129/// would need unstable `generic_const_exprs` to relate `N` to `table`'s length at the type
130/// level).
131fn posterize(pixels: &[Pixel], table: &[char]) -> Glyph {
132 let full_mask = table.len() - 1;
133 let overall = average(pixels, full_mask).unwrap_or((0, 0, 0));
134
135 let mut best_pattern = 0usize;
136 let mut best_error = u32::MAX;
137 for mask in 0..table.len() {
138 let fg = average(pixels, mask).unwrap_or(overall);
139 let bg = average(pixels, !mask & full_mask).unwrap_or(overall);
140 let mut error = 0u32;
141 for (i, &pixel) in pixels.iter().enumerate() {
142 let assigned = if mask & (1 << i) != 0 { fg } else { bg };
143 error += gem::rgb::distance_sq(pixel, assigned);
144 }
145 if error < best_error {
146 best_error = error;
147 best_pattern = mask;
148 }
149 }
150
151 let fg = average(pixels, best_pattern).unwrap_or(overall);
152 let bg = average(pixels, !best_pattern & full_mask).unwrap_or(overall);
153 Glyph {
154 ch: table[best_pattern],
155 fg: Color::rgb(fg.0, fg.1, fg.2),
156 bg: Color::rgb(bg.0, bg.1, bg.2),
157 }
158}
159
160/// Posterizes a 1-wide x 2-tall pixel block (`[top, bottom]`) to `' '`/`▀`/`▄`/`█` plus two
161/// representative colors.
162///
163/// This is the lowest-fidelity, most compatible option: plain Unicode Block Elements, supported
164/// by essentially every monospace terminal font.
165///
166/// See the `16_subcell_image` example for `quantize_half_block` in action:
167/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
168///
169/// # Examples
170///
171/// ```
172/// use retroglyph_core::symbols::quantize_half_block;
173///
174/// let glyph = quantize_half_block([(255, 255, 255), (0, 0, 0)]);
175/// assert_eq!(glyph.ch, '▀'); // top half set, bottom clear
176/// ```
177///
178/// Never panics: `pixels` is a fixed-size 2-element array, so there is no length to validate
179/// and no index into it that can be out of bounds.
180#[must_use]
181pub fn quantize_half_block(pixels: [impl Into<Pixel>; 2]) -> Glyph {
182 let pixels = pixels.map(Into::into);
183 posterize(&pixels, &HALF_BLOCKS)
184}
185
186/// Posterizes a 2x2 pixel block (`[top_left, top_right, bottom_left, bottom_right]`) to one of
187/// the 16 quadrant block glyphs plus two representative colors.
188///
189/// Doubles both horizontal and vertical resolution over [`quantize_half_block`](crate::symbols::quantize_half_block).
190///
191/// On the bundled pixel backends (`retroglyph-software`, `retroglyph-gl`), rendering these
192/// glyphs correctly requires a font that actually declares coverage for the quadrant block
193/// characters: CP437 has no mapping for them. A font built with `retroglyph_window`'s
194/// `BitmapFont::new` (CP437-only) renders every quadrant glyph as a solid block. Supply quadrant
195/// coverage by passing either a primary font or a `BitmapFont::with_charset` fallback in a
196/// `FontChain` to those backends' `font()` builder method; the glyph then takes the cell's
197/// foreground color, which a tileset sprite (the other way to draw a non-CP437 shape) does not.
198///
199/// See the `16_subcell_image` example for `quantize_quadrant` in action:
200/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
201///
202/// # Examples
203///
204/// ```
205/// use retroglyph_core::symbols::quantize_quadrant;
206///
207/// let black = (0, 0, 0);
208/// let white = (255, 255, 255);
209/// let glyph = quantize_quadrant([black, white, black, black]);
210/// assert_eq!(glyph.ch, '▝'); // top-right quadrant
211/// ```
212///
213/// See [`quantize_half_block`](crate::symbols::quantize_half_block) for why this never panics.
214#[must_use]
215pub fn quantize_quadrant(pixels: [impl Into<Pixel>; 4]) -> Glyph {
216 let pixels = pixels.map(Into::into);
217 posterize(&pixels, &QUADRANTS)
218}
219
220/// Posterizes a 2-wide x 3-tall pixel block (`[top_left, top_right, mid_left, mid_right,
221/// bottom_left, bottom_right]`) to one of the 64 sextant glyphs plus two representative colors.
222///
223/// The highest-fidelity option (doubles vertical resolution again over [`quantize_quadrant`](crate::symbols::quantize_quadrant)),
224/// and the newest/least universally supported: sextant glyphs come from a 2022 Unicode addition
225/// and need a font with "Symbols for Legacy Computing" coverage to render as blocks rather than
226/// tofu/replacement characters.
227///
228/// Font coverage works the same way as [`quantize_quadrant`](crate::symbols::quantize_quadrant)'s: CP437 has no mapping for sextant
229/// glyphs either, so see that function's docs for the `FontChain` setup needed to render them.
230///
231/// See the `16_subcell_image` example for `quantize_sextant` in action:
232/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
233///
234/// # Examples
235///
236/// ```
237/// use retroglyph_core::symbols::quantize_sextant;
238///
239/// let black = (0, 0, 0);
240/// let white = (255, 255, 255);
241/// let glyph = quantize_sextant([white, black, black, black, black, black]);
242/// assert_eq!(glyph.ch, '🬀'); // top-left sextant only
243/// ```
244///
245/// See [`quantize_half_block`](crate::symbols::quantize_half_block) for why this never panics.
246#[must_use]
247pub fn quantize_sextant(pixels: [impl Into<Pixel>; 6]) -> Glyph {
248 let pixels = pixels.map(Into::into);
249 posterize(&pixels, &SEXTANTS)
250}
251
252#[cfg(test)]
253mod tests {
254 use super::{Color, average, quantize_half_block, quantize_quadrant, quantize_sextant};
255
256 const BLACK: (u8, u8, u8) = (0, 0, 0);
257 const WHITE: (u8, u8, u8) = (255, 255, 255);
258 const RED: (u8, u8, u8) = (200, 0, 0);
259
260 #[test]
261 fn distance_sq_matches_manual_euclidean() {
262 assert_eq!(gem::rgb::distance_sq(BLACK, WHITE), 255 * 255 * 3);
263 assert_eq!(gem::rgb::distance_sq(BLACK, BLACK), 0);
264 }
265
266 #[test]
267 fn average_rounds_to_nearest_and_handles_empty_mask() {
268 assert_eq!(average(&[BLACK, WHITE], 0b11), Some((128, 128, 128)));
269 assert_eq!(average(&[BLACK, WHITE], 0b01), Some(BLACK));
270 assert_eq!(average(&[BLACK, WHITE], 0b00), None);
271 }
272
273 #[test]
274 fn half_block_uniform_color_picks_space_with_that_color() {
275 // A flat block ties across every pattern (all zero error); pattern 0 (the cheapest
276 // glyph, a space) always wins that tie. `fg` still comes back populated so a caller
277 // never has to special-case a uniform block before styling with it.
278 let glyph = quantize_half_block([WHITE, WHITE]);
279 assert_eq!(glyph.ch, ' ');
280 assert_eq!(glyph.bg, Color::rgb(255, 255, 255));
281 assert_eq!(glyph.fg, glyph.bg);
282 }
283
284 #[test]
285 fn half_block_top_bottom_split() {
286 let glyph = quantize_half_block([WHITE, BLACK]);
287 assert_eq!(glyph.ch, '▀');
288 assert_eq!(glyph.fg, Color::rgb(255, 255, 255));
289 assert_eq!(glyph.bg, Color::rgb(0, 0, 0));
290 }
291
292 #[test]
293 fn quadrant_picks_bottom_left_glyph() {
294 // Bit 2 (bottom-left) is the only set pixel.
295 let glyph = quantize_quadrant([BLACK, BLACK, WHITE, BLACK]);
296 assert_eq!(glyph.ch, '▖');
297 }
298
299 #[test]
300 fn quadrant_uniform_color_picks_space_with_that_color() {
301 let glyph = quantize_quadrant([RED, RED, RED, RED]);
302 assert_eq!(glyph.ch, ' ');
303 assert_eq!(glyph.bg, Color::rgb(200, 0, 0));
304 assert_eq!(glyph.fg, glyph.bg);
305 }
306
307 #[test]
308 fn sextant_prefers_lower_pattern_on_exact_ties() {
309 // A perfect flat block ties every pattern at zero error; pattern 0 (space) always wins.
310 let grey = (128, 128, 128);
311 let glyph = quantize_sextant([grey, grey, grey, grey, grey, grey]);
312 assert_eq!(glyph.ch, ' ');
313 }
314
315 #[test]
316 fn sextant_single_pixel_set() {
317 let glyph = quantize_sextant([BLACK, BLACK, BLACK, BLACK, WHITE, BLACK]);
318 assert_eq!(glyph.ch, '🬏'); // bottom-left sextant only (bit 4)
319 assert_eq!(glyph.fg, Color::rgb(255, 255, 255));
320 assert_eq!(glyph.bg, Color::rgb(0, 0, 0));
321 }
322
323 #[test]
324 fn quantize_fns_accept_rgb888_directly() {
325 // The whole point of `impl Into<Pixel>`: a caller holding `gem::rgb::Rgb888` (as
326 // opposed to a bare `(u8, u8, u8)`) passes it with no `.to_rgb()` call, via gem 0.2.2's
327 // `From<Rgb888> for (u8, u8, u8)`.
328 use gem::rgb::Rgb888;
329
330 let white = Rgb888::from_rgb(255, 255, 255);
331 let black = Rgb888::from_rgb(0, 0, 0);
332
333 assert_eq!(quantize_half_block([white, black]).ch, '▀');
334 assert_eq!(quantize_quadrant([black, black, white, black]).ch, '▖');
335 assert_eq!(
336 quantize_sextant([black, black, black, black, white, black]).ch,
337 '🬏'
338 );
339 }
340}