1use std::{cell::LazyCell};
66use crossterm::style::{StyledContent, Stylize};
67use image::{imageops, Luma, Pixel};
68use itertools::Itertools;
69use palette::{Hsv, IntoColor};
70
71pub type Styled = StyledContent<char>;
73
74pub type RgbImage = image::RgbImage;
76
77pub type Sampler = imageops::FilterType;
79
80#[derive(Clone)]
103pub struct Typographer {
104 pub sampler: Sampler,
106 pub symbol_aspect_ratio: f32,
108 pub luma_threshold: f32,
110 pub fill: Option<Fill>,
112 pub edges: Option<Edges>,
114 pub colours: Option<Colours>,
116}
117
118impl Default for Typographer {
119 fn default() -> Self {
120 Typographer {
121 sampler: Sampler::Nearest,
122 symbol_aspect_ratio: 2.0,
123 luma_threshold: 0.05,
124 fill: Some(Fill::default()),
125 edges: Some(Edges::default()),
126 colours: None,
127 }
128 }
129}
130
131impl Typographer {
132 pub fn new() -> Typographer {
134 Typographer {
135 fill: Some(Fill::default()),
136 edges: Some(Edges::default()),
137 colours: None,
138 ..Default::default()
139 }
140 }
141
142 pub fn new_coloured() -> Typographer {
144 Typographer {
145 fill: Some(Fill::default()),
146 edges: Some(Edges::default()),
147 colours: Some(Colours::default()),
148 ..Default::default()
149 }
150 }
151
152 pub fn render(&self, image: &RgbImage, size: Size) -> AsciiImage {
154 let (width, height) = {
158 let aspect_ratio = image.height() as f32 / image.width() as f32;
159 let scale = |a, b| ((a as f32) * b) as u32;
160
161 match size {
162 Size::Width(w) => (w, scale(w, aspect_ratio / self.symbol_aspect_ratio)),
163 Size::Height(h) => (scale(h, self.symbol_aspect_ratio / aspect_ratio), h),
164 Size::Stretched(w, h) => (w, h),
165 }
166 };
167
168 let image = {
170 let clamped_multiples = |a, b| a * u32::clamp(b / a, 1, 8);
174 let width = clamped_multiples(width, image.width());
175 let height = clamped_multiples(height, image.height());
176 let mut image = imageops::resize(image, width, height, self.sampler);
177
178 for rgb in image.pixels_mut() {
179 const STRENGTH: i32 = 4;
180 *rgb = transform_hsv(rgb, |mut hsv| {
181 hsv.value = match hsv.value < self.luma_threshold {
182 true => hsv.value.powi(STRENGTH) / self.luma_threshold.powi(STRENGTH - 1),
183 false => hsv.value,
184 };
185 hsv
186 });
187 }
188 image
189 };
190
191 let output_sized = LazyCell::new(|| imageops::resize(&image, width, height, self.sampler));
192
193 let mut ascii = AsciiImage::new(width, height);
194
195 if let Some(fill) = &self.fill {
196 ascii = fill.apply(LazyCell::force(&output_sized), ascii);
197 }
198 if let Some(edges) = &self.edges {
199 ascii = edges.apply(&image, ascii, self.symbol_aspect_ratio);
200 }
201 if let Some(colour) = &self.colours {
202 ascii = colour.apply(LazyCell::force(&output_sized), ascii);
203 }
204 ascii
205 }
206}
207
208#[derive(Clone)]
212pub struct AsciiImage {
213 symbols: Vec<Styled>,
214 width: u32,
215 height: u32,
216}
217
218impl AsciiImage {
219 fn new(width: u32, height: u32) -> AsciiImage {
220 AsciiImage {
221 symbols: vec![' '.stylize(); (width * height) as usize],
222 width,
223 height,
224 }
225 }
226
227 fn with_pass<T>(mut self, iter: impl Iterator<Item = T>, mut apply: impl FnMut(Styled, T) -> Styled) -> Self {
229 for (symbol, x) in std::iter::zip(self.symbols.iter_mut(), iter) {
230 *symbol = apply(*symbol, x);
231 }
232 self
233 }
234
235 pub fn iter_rows(&self) -> impl Iterator<Item = &[Styled]> {
237 self.symbols.chunks(self.width as usize)
238 }
239
240 pub fn iter(&self) -> impl Iterator<Item = Styled> {
242 self.iter_rows()
243 .map(|row| row.iter()
244 .cloned()
245 .chain(std::iter::once('\n'.stylize()))
246 )
247 .flatten()
248 }
249
250 pub fn iter_unstyled(&self) -> impl Iterator<Item = char> {
253 self.iter().map(|symbol| symbol.content().clone())
254 }
255
256 pub fn iter_symbols(&self) -> impl Iterator<Item = Styled> {
258 self.symbols.iter().cloned()
259 }
260
261 pub fn iter_symbols_unstyled(&self) -> impl Iterator<Item = char> {
263 self.symbols.iter().map(Styled::content).cloned()
264 }
265
266 pub fn width(&self) -> u32 {
268 self.width
269 }
270
271 pub fn height(&self) -> u32 {
273 self.height
274 }
275
276 pub fn into_inner(self) -> Vec<Styled> {
278 self.symbols
279 }
280}
281
282#[derive(Clone, Copy)]
284pub enum Size {
285 Width(u32),
287 Height(u32),
289 Stretched(u32, u32),
291}
292
293#[derive(Clone)]
295pub struct Fill {
296 pub gradient: String,
298 pub light_mode: bool,
300}
301
302impl Default for Fill {
303 fn default() -> Fill {
304 Fill {
305 gradient: " .:coPO?@■".to_string(),
306 light_mode: false,
307 }
308 }
309}
310
311impl Fill {
312 fn apply(&self, input: &RgbImage, ascii: AsciiImage) -> AsciiImage {
313 debug_assert!(input.width() == ascii.width && input.height() == ascii.height);
314
315 if self.gradient.is_empty() {
316 return ascii
317 }
318
319 let gradient: Vec<char> = if self.light_mode {
320 self.gradient.chars().rev().collect()
321 } else {
322 self.gradient.chars().collect()
323 };
324 let iter = input
325 .pixels()
326 .map(|rgb| {
327 let Luma([luma]) = rgb.to_luma();
328 let luma = luma as f32 / 256.0;
329 let index = gradient.len() as f32 * luma;
330 let symbol = gradient[index as usize].stylize();
331 symbol
332 });
333 ascii.with_pass(iter, |_, symbol| symbol)
334 }
335}
336
337#[derive(Clone, Copy)]
340pub struct Edges {
341 pub angles: [char; 4],
343 pub sigma: f32,
345 pub threshold: Option<f32>,
348 pub unanimity: f32,
352 pub sensitivity: f32,
354}
355
356impl Default for Edges {
357 fn default() -> Self {
358 Edges {
359 angles: ['|', '/', '─', '\\'],
360 sigma: 1.0,
361 threshold: None,
362 unanimity: 0.95,
363 sensitivity: 1.0,
364 }
365 }
366}
367
368impl Edges {
369 fn apply(&self, input: &RgbImage, ascii: AsciiImage, symbol_aspect_ratio: f32) -> AsciiImage {
370 let sigma_scale = u32::min(input.width(), input.height()) as f32 / 300.0;
371 let sigma = self.sigma * sigma_scale;
372
373 let edges = {
374 let input = imageops::grayscale(input);
375 let (weak_threshold, strong_threshold) = smart_thresholds(&input);
376
377 edge_detection::canny(
378 input,
379 sigma,
380 strong_threshold,
381 weak_threshold,
382 )
383 };
384
385 let window_w = input.width() / ascii.width;
386 let window_h = input.height() / ascii.height;
387
388 let windows = Itertools::cartesian_product(0..ascii.height, 0..ascii.width)
390 .map(|(y, x)| (y * window_h, x * window_w))
391 .map(|(y, x)| Itertools::cartesian_product(0..window_h, 0..window_w)
392 .map(move |(dy, dx)| (y + dy, x + dx))
393 .map(|(py, px)| edges.interpolate(px as f32, py as f32))
394 );
395
396 let window_angles = windows
400 .map(|window| window.map(|edge| (edge.magnitude() != 0.0)
401 .then(|| {
402 let (dx, dy) = edge.dir_norm();
403 f32::atan2(dy, symbol_aspect_ratio * dx)
404 })
405 .map(|angle| angle / std::f32::consts::PI * 0.5 + 0.5) .map(|angle| (angle * 8.0).round() as u8) ));
408
409 let sum_threshold = match self.sensitivity {
410 0.0 => usize::MAX,
411 _ => {
412 let scale = u32::min(window_w, window_h);
413 usize::max(1, (scale as f32 / self.sensitivity) as usize)
414 }
415 };
416 let downsampled = window_angles
417 .map(|window| window
418 .flatten()
419 .map(|angle| angle % 4)
420 )
421 .map(histogram::<4>)
422 .map(|histogram| match histogram.iter().sum::<usize>() >= sum_threshold {
423 true => histogram.iter().position_max().and_then(|max_edge| {
424 let max = histogram[max_edge] as f32;
425 let opposite = histogram[(max_edge + 2) % 4] as f32;
426 let unanimity = (max - opposite) / max;
427 (unanimity >= self.unanimity).then_some(max_edge)
428 }),
429 false => None,
430 });
431
432 let iter = downsampled.map(|angle| angle
434 .map(|angle| self.angles[angle])
435 .map(Stylize::stylize)
436 );
437 ascii.with_pass(iter, |prev, symbol| symbol.unwrap_or(prev))
438 }
439}
440
441#[derive(Clone, Copy, PartialEq)]
443pub enum Colours {
444 Chromatic,
446 Greyscale,
448 Retro(u8),
450 Full,
452}
453
454impl Default for Colours {
455 fn default() -> Self {
456 Colours::Chromatic
457 }
458}
459
460impl Colours {
461 fn apply(&self, input: &RgbImage, ascii: AsciiImage) -> AsciiImage {
462 debug_assert!(input.width() == ascii.width && input.height() == ascii.height);
463
464 let iter = input
465 .pixels()
466 .map(|&rgb| match *self {
467 Colours::Chromatic => transform_hsv(&rgb, |hsv| {
468 let saturation = f32::min(hsv.saturation, 2.0 * hsv.value);
470 let value = 1.0;
471 Hsv::new(hsv.hue, saturation, value)
472 }),
473 Colours::Greyscale => {
474 let image::Luma([luma]) = rgb.to_luma();
475 image::Rgb([luma, luma, luma])
476 },
477 Colours::Retro(buckets) => {
478 debug_assert!(buckets >= 1);
479
480 let bucket_size = u8::MAX / buckets;
481 let quantise = |x| bucket_size * (x / bucket_size);
482 let image::Rgb(rgb) = rgb;
483 image::Rgb(rgb.map(quantise))
484 },
485 Colours::Full => rgb,
486 })
487 .map(|image::Rgb([r, g, b])| crossterm::style::Color::Rgb{ r, g, b });
488 ascii.with_pass(iter, StyledContent::with)
489 }
490}
491
492fn histogram<const N: usize>(data: impl IntoIterator<Item = u8>) -> [usize; N] {
494 data.into_iter().fold([0; N], |mut acc, x| {
495 acc[x as usize] += 1;
496 acc
497 })
498}
499
500fn smart_thresholds(image: &image::GrayImage) -> (f32, f32){
502 const VARIANCE_THRESHOLD: f32 = 4e8;
503
504 let histogram = histogram::<256>(image.pixels().map(|luma| luma[0]));
505 let histogram_iter = || histogram.iter()
506 .enumerate()
507 .map(|(luma, &bin_size)| (luma as f32 / 255.0, bin_size as f32));
508 let luma_sum = histogram_iter()
509 .map(|(luma, bin_size)| bin_size * luma)
510 .sum::<f32>();
511 let pixel_count = (image.width() * image.height()) as f32;
512 let fallback_thresholds = || {
513 let mean_luma = luma_sum / pixel_count;
514 let strong_threshold = mean_luma * 0.4;
515 let weak_threshold = strong_threshold * 0.2;
516 eprintln!("Using fallback thresholds");
517 (weak_threshold, strong_threshold)
518 };
519
520 let (min, max) = {
521 let non_zero = |&x| x > 0;
522 let min = histogram.iter().position(non_zero);
523 let max = histogram.iter().rposition(non_zero);
524
525 match Option::zip(min, max) {
526 Some((min, max)) => (min + 1, max - 1),
527 None => return fallback_thresholds(),
528 }
529 };
530 histogram_iter()
533 .scan((0.0, 0.0), |(pixel_sum, luma_sum), (luma, bin_size)| {
535 *pixel_sum += bin_size;
536 *luma_sum += bin_size * luma;
537 Some((*pixel_sum, *luma_sum, luma))
538 })
539 .skip(min)
541 .take(max - min)
542 .map(|(pixel_cumsum, luma_cumsum, threshold)| {
544 let lo_pixel_cumsum = pixel_cumsum;
545 let hi_pixel_cumsum = pixel_count - lo_pixel_cumsum;
546 let lo_luma_cumsum = luma_cumsum;
547 let hi_luma_cumsum = luma_sum - lo_luma_cumsum;
548
549 let lo_mean = lo_luma_cumsum / lo_pixel_cumsum;
550 let hi_mean = hi_luma_cumsum / hi_pixel_cumsum;
551 let mean_diff = lo_mean - hi_mean;
552
553 let intra_class_variance = lo_pixel_cumsum * hi_pixel_cumsum * mean_diff * mean_diff;
554
555 (intra_class_variance, threshold)
556 })
557 .max_by(|x, y| PartialOrd::partial_cmp(x, y).unwrap())
558 .and_then(|(variance, threshold)| match variance >= VARIANCE_THRESHOLD {
559 true => Some((0.5 * threshold, threshold)),
560 false => None,
561 })
562 .unwrap_or_else(fallback_thresholds)
563}
564
565fn transform_hsv(rgb: &image::Rgb<u8>, function: impl Fn(palette::Hsv) -> palette::Hsv) -> image::Rgb<u8> {
567 let [r, g, b] = rgb.0.map(|x| x as f32 / (u8::MAX as f32));
569 let hsv = palette::Srgb::new(r, g, b).into_color();
570
571 let palette::Hsv{ hue, saturation, value, .. } = function(hsv);
573 let hsv = palette::Hsv::new(hue, f32::clamp(saturation, 0.0, 1.0), f32::clamp(value, 0.0, 1.0));
574
575 let palette::Srgb{ red, green, blue, .. } = hsv.into_color();
577 let [r, g, b] = [red, green, blue].map(|x| (x * u8::MAX as f32) as u8);
578 image::Rgb([r, g, b])
579}