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 { r: 255, g: 255, b: 255 });
50//! assert_eq!(glyph.bg, retroglyph_core::color::Color::Rgb { r: 0, g: 0, b: 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 {
156 r: fg.0,
157 g: fg.1,
158 b: fg.2,
159 },
160 bg: Color::Rgb {
161 r: bg.0,
162 g: bg.1,
163 b: bg.2,
164 },
165 }
166}
167
168/// Posterizes a 1-wide x 2-tall pixel block (`[top, bottom]`) to `' '`/`▀`/`▄`/`█` plus two
169/// representative colors.
170///
171/// This is the lowest-fidelity, most compatible option: plain Unicode Block Elements, supported
172/// by essentially every monospace terminal font.
173///
174/// See the `16_subcell_image` example for `quantize_half_block` in action:
175/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
176///
177/// # Examples
178///
179/// ```
180/// use retroglyph_core::symbols::quantize_half_block;
181///
182/// let glyph = quantize_half_block([(255, 255, 255), (0, 0, 0)]);
183/// assert_eq!(glyph.ch, '▀'); // top half set, bottom clear
184/// ```
185///
186/// Never panics: `pixels` is a fixed-size 2-element array, so there is no length to validate
187/// and no index into it that can be out of bounds.
188#[must_use]
189pub fn quantize_half_block(pixels: [impl Into<Pixel>; 2]) -> Glyph {
190 let pixels = pixels.map(Into::into);
191 posterize(&pixels, &HALF_BLOCKS)
192}
193
194/// Posterizes a 2x2 pixel block (`[top_left, top_right, bottom_left, bottom_right]`) to one of
195/// the 16 quadrant block glyphs plus two representative colors.
196///
197/// Doubles both horizontal and vertical resolution over [`quantize_half_block`](crate::symbols::quantize_half_block).
198///
199/// On the bundled pixel backends (`retroglyph-software`, `retroglyph-gl`), rendering these
200/// glyphs correctly requires a font that actually declares coverage for the quadrant block
201/// characters: CP437 has no mapping for them. A font built with `retroglyph_window`'s
202/// `BitmapFont::new` (CP437-only) renders every quadrant glyph as a solid block. Supply quadrant
203/// coverage by passing either a primary font or a `BitmapFont::with_charset` fallback in a
204/// `FontChain` to those backends' `font()` builder method; the glyph then takes the cell's
205/// foreground color, which a tileset sprite (the other way to draw a non-CP437 shape) does not.
206///
207/// See the `16_subcell_image` example for `quantize_quadrant` in action:
208/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
209///
210/// # Examples
211///
212/// ```
213/// use retroglyph_core::symbols::quantize_quadrant;
214///
215/// let black = (0, 0, 0);
216/// let white = (255, 255, 255);
217/// let glyph = quantize_quadrant([black, white, black, black]);
218/// assert_eq!(glyph.ch, '▝'); // top-right quadrant
219/// ```
220///
221/// See [`quantize_half_block`](crate::symbols::quantize_half_block) for why this never panics.
222#[must_use]
223pub fn quantize_quadrant(pixels: [impl Into<Pixel>; 4]) -> Glyph {
224 let pixels = pixels.map(Into::into);
225 posterize(&pixels, &QUADRANTS)
226}
227
228/// Posterizes a 2-wide x 3-tall pixel block (`[top_left, top_right, mid_left, mid_right,
229/// bottom_left, bottom_right]`) to one of the 64 sextant glyphs plus two representative colors.
230///
231/// The highest-fidelity option (doubles vertical resolution again over [`quantize_quadrant`](crate::symbols::quantize_quadrant)),
232/// and the newest/least universally supported: sextant glyphs come from a 2022 Unicode addition
233/// and need a font with "Symbols for Legacy Computing" coverage to render as blocks rather than
234/// tofu/replacement characters.
235///
236/// Font coverage works the same way as [`quantize_quadrant`](crate::symbols::quantize_quadrant)'s: CP437 has no mapping for sextant
237/// glyphs either, so see that function's docs for the `FontChain` setup needed to render them.
238///
239/// See the `16_subcell_image` example for `quantize_sextant` in action:
240/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
241///
242/// # Examples
243///
244/// ```
245/// use retroglyph_core::symbols::quantize_sextant;
246///
247/// let black = (0, 0, 0);
248/// let white = (255, 255, 255);
249/// let glyph = quantize_sextant([white, black, black, black, black, black]);
250/// assert_eq!(glyph.ch, '🬀'); // top-left sextant only
251/// ```
252///
253/// See [`quantize_half_block`](crate::symbols::quantize_half_block) for why this never panics.
254#[must_use]
255pub fn quantize_sextant(pixels: [impl Into<Pixel>; 6]) -> Glyph {
256 let pixels = pixels.map(Into::into);
257 posterize(&pixels, &SEXTANTS)
258}
259
260#[cfg(test)]
261mod tests {
262 use super::{Color, average, quantize_half_block, quantize_quadrant, quantize_sextant};
263
264 const BLACK: (u8, u8, u8) = (0, 0, 0);
265 const WHITE: (u8, u8, u8) = (255, 255, 255);
266 const RED: (u8, u8, u8) = (200, 0, 0);
267
268 #[test]
269 fn distance_sq_matches_manual_euclidean() {
270 assert_eq!(gem::rgb::distance_sq(BLACK, WHITE), 255 * 255 * 3);
271 assert_eq!(gem::rgb::distance_sq(BLACK, BLACK), 0);
272 }
273
274 #[test]
275 fn average_rounds_to_nearest_and_handles_empty_mask() {
276 assert_eq!(average(&[BLACK, WHITE], 0b11), Some((128, 128, 128)));
277 assert_eq!(average(&[BLACK, WHITE], 0b01), Some(BLACK));
278 assert_eq!(average(&[BLACK, WHITE], 0b00), None);
279 }
280
281 #[test]
282 fn half_block_uniform_color_picks_space_with_that_color() {
283 // A flat block ties across every pattern (all zero error); pattern 0 (the cheapest
284 // glyph, a space) always wins that tie. `fg` still comes back populated so a caller
285 // never has to special-case a uniform block before styling with it.
286 let glyph = quantize_half_block([WHITE, WHITE]);
287 assert_eq!(glyph.ch, ' ');
288 assert_eq!(
289 glyph.bg,
290 Color::Rgb {
291 r: 255,
292 g: 255,
293 b: 255
294 }
295 );
296 assert_eq!(glyph.fg, glyph.bg);
297 }
298
299 #[test]
300 fn half_block_top_bottom_split() {
301 let glyph = quantize_half_block([WHITE, BLACK]);
302 assert_eq!(glyph.ch, '▀');
303 assert_eq!(
304 glyph.fg,
305 Color::Rgb {
306 r: 255,
307 g: 255,
308 b: 255
309 }
310 );
311 assert_eq!(glyph.bg, Color::Rgb { r: 0, g: 0, b: 0 });
312 }
313
314 #[test]
315 fn quadrant_picks_bottom_left_glyph() {
316 // Bit 2 (bottom-left) is the only set pixel.
317 let glyph = quantize_quadrant([BLACK, BLACK, WHITE, BLACK]);
318 assert_eq!(glyph.ch, '▖');
319 }
320
321 #[test]
322 fn quadrant_uniform_color_picks_space_with_that_color() {
323 let glyph = quantize_quadrant([RED, RED, RED, RED]);
324 assert_eq!(glyph.ch, ' ');
325 assert_eq!(glyph.bg, Color::Rgb { r: 200, g: 0, b: 0 });
326 assert_eq!(glyph.fg, glyph.bg);
327 }
328
329 #[test]
330 fn sextant_prefers_lower_pattern_on_exact_ties() {
331 // A perfect flat block ties every pattern at zero error; pattern 0 (space) always wins.
332 let grey = (128, 128, 128);
333 let glyph = quantize_sextant([grey, grey, grey, grey, grey, grey]);
334 assert_eq!(glyph.ch, ' ');
335 }
336
337 #[test]
338 fn sextant_single_pixel_set() {
339 let glyph = quantize_sextant([BLACK, BLACK, BLACK, BLACK, WHITE, BLACK]);
340 assert_eq!(glyph.ch, '🬏'); // bottom-left sextant only (bit 4)
341 assert_eq!(
342 glyph.fg,
343 Color::Rgb {
344 r: 255,
345 g: 255,
346 b: 255
347 }
348 );
349 assert_eq!(glyph.bg, Color::Rgb { r: 0, g: 0, b: 0 });
350 }
351
352 #[test]
353 fn quantize_fns_accept_rgb888_directly() {
354 // The whole point of `impl Into<Pixel>`: a caller holding `gem::rgb::Rgb888` (as
355 // opposed to a bare `(u8, u8, u8)`) passes it with no `.to_rgb()` call, via gem 0.2.2's
356 // `From<Rgb888> for (u8, u8, u8)`.
357 use gem::rgb::Rgb888;
358
359 let white = Rgb888::from_rgb(255, 255, 255);
360 let black = Rgb888::from_rgb(0, 0, 0);
361
362 assert_eq!(quantize_half_block([white, black]).ch, '▀');
363 assert_eq!(quantize_quadrant([black, black, white, black]).ch, '▖');
364 assert_eq!(
365 quantize_sextant([black, black, black, black, white, black]).ch,
366 '🬏'
367 );
368 }
369}