From 001a78417af4c4ac00110ebb8700a9f933b1b2c7 Mon Sep 17 00:00:00 2001 From: Michal Nazarewicz Date: Sat, 18 Aug 2018 22:27:54 +0100 Subject: [PATCH 1/3] Test error made when mapping RGB colours into 256-colour palette Add measure of error introduced when mapping 24-bit colours into 8-bit ANSI palette. Having such measure makes it possible to benchmark any further changes meant to improve the approximation. --- src/terminal.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/terminal.rs b/src/terminal.rs index 64f61de4a3..59ce64ff8b 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -29,6 +29,17 @@ pub fn to_ansi_color(color: highlighting::Color, true_color: bool) -> ansi_term: } } +/// Converts index on one dimension of the 6×6×6 ANSI colour cube into value in +/// sRGB space. +#[cfg(test)] +fn cube_value(i: u8) -> u8 { + if i == 0 { + 0 + } else { + 55 + i * 40 + } +} + pub fn as_terminal_escaped( style: highlighting::Style, text: &str, @@ -77,3 +88,52 @@ fn test_rgb2ansi_color() { fn test_rgb2ansi_approx() { assert_eq!(231, rgb2ansi(0xfe, 0xfe, 0xfe)); } + +/// Calculates distance between two colours. Tries to balance speed of +/// computation and perceptual correctness. +#[cfg(test)] +fn distance(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> f64 { + let r_mean = (r1 as i32 + r2 as i32) / 2; + let r = r1 as i32 - r2 as i32; + let g = g1 as i32 - g2 as i32; + let b = b1 as i32 - b2 as i32; + // See . + let d = ((512 + r_mean) * r * r + 1024 * (g * g) + (767 - r_mean) * b * b) as u32; + (d as f64 / 2303.).sqrt() +} + +#[test] +fn test_distance() { + fn ansi2rgb(idx: u8) -> (u8, u8, u8) { + if idx >= 232 { + let v = (idx - 232) * 10 + 8; + (v, v, v) + } else { + assert!(idx >= 16); + let idx = idx - 16; + ( + cube_value(idx / 36), + cube_value(idx / 6 % 6), + cube_value(idx % 6), + ) + } + } + + let mut max_distance = 0.0; + let mut total_distance = 0.0; + for c in 0..0xffffff { + let r = (c >> 16) as u8; + let g = (c >> 8) as u8; + let b = c as u8; + let (ar, ag, ab) = ansi2rgb(rgb2ansi(r, g, b)); + let dist = distance(r, g, b, ar, ag, ab); + if dist > max_distance { + max_distance = dist; + } + total_distance += dist; + } + + let avg_distance = total_distance / 16777216.; + assert_eq!(49769, (max_distance * 1000.0).round() as u32); + assert_eq!(20027, (avg_distance * 1000.0).round() as u32); +} From f578c4d9d17b8ab5d749e5c24d1f8288a8f05e05 Mon Sep 17 00:00:00 2001 From: Michal Nazarewicz Date: Sun, 19 Aug 2018 00:05:38 +0100 Subject: [PATCH 2/3] =?UTF-8?q?Improve=20RGB=E2=86=92256-colour=20palette?= =?UTF-8?q?=20interpolation=20by=20better=20maths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve the way 24-bit colours are mapped to 8-bit ANSI palette by observing that neither grayscale ramp nor 6×6×6 colour cube have linear staps throughout. For example, sequence of values in the cube is: 0, 95, 135, 175, 215 and 255. The first step is 95 colours while the rest are only 40 colours. Similarly, grayscale ramp (plus black and white) create a sequence 0, 8, 18, 28, …, 228, 238, 255. Here, the first step is 8, the next ones are 10 until final one which is 17. Assuming the mapping is linear causes wrong results. For example, colour #1C1C1C is at index 234 but the code incorrectly mapped it to index 233 (which is 0x121212). To further improve the approximation, the new code shifts values to use rounding during division rather than truncating them. This leads #070707 to be approximated by #080808 rather than #000000. --- src/terminal.rs | 68 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/src/terminal.rs b/src/terminal.rs index 59ce64ff8b..fca755248c 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -2,21 +2,57 @@ use ansi_term::Colour::{Fixed, RGB}; use ansi_term::{self, Style}; use syntect::highlighting::{self, FontStyle}; -/// Approximate a 24 bit color value by a 8 bit ANSI code +/// Approximate a 24-bit color value by a 8 bit ANSI code fn rgb2ansi(r: u8, g: u8, b: u8) -> u8 { + if r == g && g == b { + rgb2ansi_grey(r) + } else { + rgb2ansi_cube(r, g, b) + } +} + +/// Approximate a 24-bit colour as an index in greyscale ramp of the 256-colour +/// ANSI palette. +#[inline] +fn rgb2ansi_grey(y: u8) -> u8 { const BLACK: u8 = 16; const WHITE: u8 = 231; - if r == g && g == b { - if r < 8 { - BLACK - } else if r > 248 { - WHITE - } else { - ((r - 8) as u16 * 24 / 247) as u8 + 232 - } + // The greyscale ramp starts at rgb(8, 8, 8) and steps every rgb(10, 10, 10) + // until rgb(238, 238, 238). We’re adding 6 so that division rounds to + // nearest rather than truncating. Due to asymmetry at the edges those need + // to be handled specially. + if y < 4 { + BLACK + } else if y >= 247 { + WHITE + } else { + 231 + if y >= 234 { 24 } else { ((y + 6) / 10) as u8 } + } +} + +/// Approximate a 24-bit colour as an index in 6×6×6 colour cube of the +/// 256-colour ANSI palette. +#[inline] +fn rgb2ansi_cube(r: u8, g: u8, b: u8) -> u8 { + let ri = cube_index(r); + let gi = cube_index(g); + let bi = cube_index(b); + 16 + ri * 36 + gi * 6 + bi +} + +/// Approximates single r, g or b value to an index within a single side of the +/// 6×6×6 ANSI colour cube. +fn cube_index(v: u8) -> u8 { + // Values within the cube are: 0, 95, 135, 175, 215 and 255. Except for the + // first jump they are 40 units apart. Because of this first jump we need + // a special case for the first two steps. + if v < 48 { + 0 + } else if v < 115 { + 1 } else { - 36 * (r / 51) + 6 * (g / 51) + (b / 51) + 16 + (v - 35) / 40 } } @@ -73,8 +109,10 @@ fn test_rgb2ansi_black_white() { #[test] fn test_rgb2ansi_gray() { - assert_eq!(241, rgb2ansi(0x6c, 0x6c, 0x6c)); - assert_eq!(233, rgb2ansi(0x1c, 0x1c, 0x1c)); + assert_eq!(232, rgb2ansi(0x08, 0x08, 0x08)); + assert_eq!(234, rgb2ansi(0x1c, 0x1c, 0x1c)); + assert_eq!(242, rgb2ansi(0x6c, 0x6c, 0x6c)); + assert_eq!(255, rgb2ansi(0xee, 0xee, 0xee)); } #[test] @@ -87,6 +125,8 @@ fn test_rgb2ansi_color() { #[test] fn test_rgb2ansi_approx() { assert_eq!(231, rgb2ansi(0xfe, 0xfe, 0xfe)); + // Approximate #070707 up to #080808 rather than down to #000000. + assert_eq!(232, rgb2ansi(0x07, 0x07, 0x07)); } /// Calculates distance between two colours. Tries to balance speed of @@ -134,6 +174,6 @@ fn test_distance() { } let avg_distance = total_distance / 16777216.; - assert_eq!(49769, (max_distance * 1000.0).round() as u32); - assert_eq!(20027, (avg_distance * 1000.0).round() as u32); + assert_eq!(47000, (max_distance * 1000.0).round() as u32); + assert_eq!(17206, (avg_distance * 1000.0).round() as u32); } From 6cd511d1c6eb13d4edb11c62e7c4eaba5bac1d0d Mon Sep 17 00:00:00 2001 From: Michal Nazarewicz Date: Sun, 19 Aug 2018 00:30:46 +0100 Subject: [PATCH 3/3] =?UTF-8?q?Improve=20RGB=E2=86=92256-colour=20palette?= =?UTF-8?q?=20interpolation=20by=20trying=20cueb=20for=20greys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 6×6×6 colour cube includes gray colours. Black and white are already used but the cube also includes other greys such as #5f5f5f or #d7d7d7. For those colours it’s better to use colour from the cube rather than from the grayscale ramp. Change rgb2ansi_grey to try approximation using coulours in either section in the pallette and choose the best one. This only affects 20 colours in total. --- src/terminal.rs | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/terminal.rs b/src/terminal.rs index fca755248c..12c2566ee1 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -15,20 +15,38 @@ fn rgb2ansi(r: u8, g: u8, b: u8) -> u8 { /// ANSI palette. #[inline] fn rgb2ansi_grey(y: u8) -> u8 { + // In the 256-colour ANSI palette grey colours are included in the greyscale + // ramp as well as in the colour cube. Because of this we’re trying to + // approximate grey in both and choose whichever gives better result. + const BLACK: u8 = 16; const WHITE: u8 = 231; // The greyscale ramp starts at rgb(8, 8, 8) and steps every rgb(10, 10, 10) - // until rgb(238, 238, 238). We’re adding 6 so that division rounds to - // nearest rather than truncating. Due to asymmetry at the edges those need - // to be handled specially. + // until rgb(238, 238, 238). Due to the asymmetry, edges need to be handled + // separately. if y < 4 { - BLACK + return BLACK; } else if y >= 247 { - WHITE - } else { - 231 + if y >= 234 { 24 } else { ((y + 6) / 10) as u8 } + return WHITE; + } else if y >= 234 { + return 255; + } + + // We’re adding 6 so that division rounds to nearest rather than truncating. + let gi = (y + 6) / 10; + + // There’s only a few values in which using colour cube for grey colours is + // better. + if y >= 92 && y <= 216 { + let grey = (gi * 10 - 2) as i32; + let yi = cube_index(y); + if (cube_value(yi) as i32 - y as i32).abs() < (grey - y as i32).abs() { + return 16 + (36 + 6 + 1) * yi; + } } + + gi + 231 } /// Approximate a 24-bit colour as an index in 6×6×6 colour cube of the @@ -67,7 +85,6 @@ pub fn to_ansi_color(color: highlighting::Color, true_color: bool) -> ansi_term: /// Converts index on one dimension of the 6×6×6 ANSI colour cube into value in /// sRGB space. -#[cfg(test)] fn cube_value(i: u8) -> u8 { if i == 0 { 0 @@ -122,6 +139,15 @@ fn test_rgb2ansi_color() { assert_eq!(193, rgb2ansi(0xd7, 0xff, 0xaf)); } +#[test] +fn test_rgb2ansi_grey_using_cube() { + // Even though those are grey colours they have a perfect match in the + // colour cube so use that rather than greyscale ramp. + assert_eq!(59, rgb2ansi(0x5f, 0x5f, 0x5f)); + assert_eq!(102, rgb2ansi(0x87, 0x87, 0x87)); + assert_eq!(145, rgb2ansi(0xaf, 0xaf, 0xaf)); +} + #[test] fn test_rgb2ansi_approx() { assert_eq!(231, rgb2ansi(0xfe, 0xfe, 0xfe));