aboutsummaryrefslogtreecommitdiff
path: root/src/renderer
diff options
context:
space:
mode:
Diffstat (limited to 'src/renderer')
-rw-r--r--src/renderer/heatmap.rs209
-rw-r--r--src/renderer/mod.rs90
2 files changed, 299 insertions, 0 deletions
diff --git a/src/renderer/heatmap.rs b/src/renderer/heatmap.rs
new file mode 100644
index 0000000..c4af7a6
--- /dev/null
+++ b/src/renderer/heatmap.rs
@@ -0,0 +1,209 @@
+//! Actual rendering functions for heatmaps.
+//!
+//! We begin the rendering by using [`render_heatcounter`] to turn a list of GPX tracks into a
+//! [`HeatCounter`], which is basically a grayscale heatmap, where each pixel represents the number
+//! of tracks that goes through this pixel.
+//!
+//! We then render the colored heatmap tiles using [`lazy_colorization`], which provides us with
+//! colorful PNG data.
+use color_eyre::{eyre::Result, Report};
+use crossbeam_channel::Sender;
+use image::{ImageBuffer, Luma, Pixel, RgbaImage};
+use nalgebra::{vector, Vector2};
+use rayon::iter::ParallelIterator;
+
+use super::{
+ super::{
+ gpx::Coordinates,
+ layer::{self, TileLayer},
+ },
+ RenderedTile,
+};
+
+/// Type for the intermediate heat counters.
+pub type HeatCounter = TileLayer<Luma<u8>>;
+
+fn render_circle<P: Pixel>(layer: &mut TileLayer<P>, center: (u64, u64), radius: u64, pixel: P) {
+ let topleft = (center.0 - radius, center.1 - radius);
+ let rad_32: u32 = radius.try_into().unwrap();
+ let mut circle = ImageBuffer::<P, Vec<P::Subpixel>>::new(rad_32 * 2 + 1, rad_32 * 2 + 1);
+ imageproc::drawing::draw_filled_circle_mut(
+ &mut circle,
+ (
+ i32::try_from(radius).unwrap(),
+ i32::try_from(radius).unwrap(),
+ ),
+ radius.try_into().unwrap(),
+ pixel,
+ );
+ layer.blit_nonzero(topleft.0, topleft.1, &circle);
+}
+
+fn direction_vector(a: (u64, u64), b: (u64, u64)) -> Vector2<f64> {
+ let dx = if b.0 > a.0 {
+ (b.0 - a.0) as f64
+ } else {
+ -((a.0 - b.0) as f64)
+ };
+ let dy = if b.1 > a.1 {
+ (b.1 - a.1) as f64
+ } else {
+ -((a.1 - b.1) as f64)
+ };
+ vector![dx, dy]
+}
+
+fn render_line<P: Pixel>(
+ layer: &mut TileLayer<P>,
+ start: (u64, u64),
+ end: (u64, u64),
+ thickness: u64,
+ pixel: P,
+) {
+ use imageproc::point::Point;
+
+ if start == end {
+ return;
+ }
+
+ fn unsigned_add(a: Vector2<u64>, b: Vector2<i32>) -> Vector2<u64> {
+ let x = if b[0] < 0 {
+ a[0] - u64::from(b[0].unsigned_abs())
+ } else {
+ a[0] + u64::try_from(b[0]).unwrap()
+ };
+ let y = if b[1] < 0 {
+ a[1] - u64::from(b[1].unsigned_abs())
+ } else {
+ a[1] + u64::try_from(b[1]).unwrap()
+ };
+ vector![x, y]
+ }
+
+ let r = direction_vector(start, end);
+ let normal = vector![r[1], -r[0]].normalize();
+
+ let start = vector![start.0, start.1];
+ let end = vector![end.0, end.1];
+
+ let displacement = normal * thickness as f64;
+ let displacement = displacement.map(|x| x as i32);
+ if displacement == vector![0, 0] {
+ return;
+ }
+ let polygon = [
+ unsigned_add(start, displacement),
+ unsigned_add(end, displacement),
+ unsigned_add(end, -displacement),
+ unsigned_add(start, -displacement),
+ ];
+ let min_x = polygon.iter().map(|p| p[0]).min().unwrap();
+ let min_y = polygon.iter().map(|p| p[1]).min().unwrap();
+ let max_x = polygon.iter().map(|p| p[0]).max().unwrap();
+ let max_y = polygon.iter().map(|p| p[1]).max().unwrap();
+
+ let mut overlay = ImageBuffer::<P, Vec<P::Subpixel>>::new(
+ (max_x - min_x).try_into().unwrap(),
+ (max_y - min_y).try_into().unwrap(),
+ );
+ let adjusted_poly = polygon
+ .into_iter()
+ .map(|p| Point::new((p[0] - min_x) as i32, (p[1] - min_y) as i32))
+ .collect::<Vec<_>>();
+ imageproc::drawing::draw_polygon_mut(&mut overlay, &adjusted_poly, pixel);
+
+ layer.blit_nonzero(min_x, min_y, &overlay);
+}
+
+fn merge_heat_counter(base: &mut HeatCounter, overlay: &HeatCounter) {
+ for (tx, ty, source) in overlay.enumerate_tiles() {
+ let target = base.tile_mut(tx, ty);
+ for (x, y, source) in source.enumerate_pixels() {
+ let target = target.get_pixel_mut(x, y);
+ target[0] += source[0];
+ }
+ }
+}
+
+fn colorize_tile(tile: &ImageBuffer<Luma<u8>, Vec<u8>>, max: u32) -> RgbaImage {
+ let gradient = colorgrad::yl_or_rd();
+ let mut result = ImageBuffer::from_pixel(tile.width(), tile.height(), [0, 0, 0, 0].into());
+ for (x, y, pixel) in tile.enumerate_pixels() {
+ if pixel[0] > 0 {
+ let alpha = pixel[0] as f64 / max as f64;
+ let color = gradient.at(1.0 - alpha);
+ let target = result.get_pixel_mut(x, y);
+ *target = color.to_rgba8().into();
+ }
+ }
+ result
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct Renderer;
+
+impl super::Renderer for Renderer {
+ type Prepared = HeatCounter;
+
+ /// Renders the heat counter for the given zoom level and track points.
+ ///
+ /// The given callback will be called when a track has been rendered and merged into the
+ /// accumulator, to allow for UI feedback. The passed parameter is the number of tracks that have
+ /// been rendered since the last call.
+ fn prepare(zoom: u32, tracks: &[Vec<Coordinates>], tick: Sender<()>) -> Result<HeatCounter> {
+ let mut heatcounter = TileLayer::from_pixel([0].into());
+
+ for track in tracks {
+ let mut layer = TileLayer::from_pixel([0].into());
+
+ let points = track
+ .iter()
+ .map(|coords| coords.web_mercator(zoom))
+ .collect::<Vec<_>>();
+
+ for point in points.iter() {
+ render_circle(&mut layer, *point, (zoom as u64 / 4).max(2) - 1, [1].into());
+ }
+
+ for (a, b) in points.iter().zip(points.iter().skip(1)) {
+ render_line(&mut layer, *a, *b, (zoom as u64 / 4).max(1), [1].into());
+ }
+
+ merge_heat_counter(&mut heatcounter, &layer);
+ tick.send(()).unwrap();
+ }
+ Ok(heatcounter)
+ }
+
+ /// Lazily colorizes a [`HeatCounter`] by colorizing it tile-by-tile and saving a tile before
+ /// rendering the next one.
+ ///
+ /// This function calls the given callback with each rendered tile, and the function is responsible
+ /// for saving it. If the callback returns an `Err(...)`, the error is passed through.
+ ///
+ /// Note that this function internally uses `rayon` for parallization. If you want to limit the
+ /// number of threads used, set up the global [`rayon::ThreadPool`] first.
+ fn colorize(layer: HeatCounter, tx: Sender<RenderedTile>) -> Result<()> {
+ let max = layer.pixels().map(|l| l.0[0]).max().unwrap_or_default();
+ if max == 0 {
+ return Ok(());
+ }
+
+ layer
+ .into_parallel_tiles()
+ .try_for_each_with(tx, |tx, (tile_x, tile_y, tile)| {
+ let colorized = colorize_tile(&tile, max.into());
+ let data = layer::compress_png_as_bytes(&colorized)?;
+ tx.send(RenderedTile {
+ x: tile_x,
+ y: tile_y,
+ data,
+ })?;
+ Ok::<(), Report>(())
+ })
+ }
+
+ fn tile_count(layer: &HeatCounter) -> Result<u64> {
+ Ok(layer.tile_count().try_into().unwrap())
+ }
+}
diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs
new file mode 100644
index 0000000..927c6ed
--- /dev/null
+++ b/src/renderer/mod.rs
@@ -0,0 +1,90 @@
+//! Generic "tile rendering" methods.
+use std::thread;
+
+use color_eyre::Result;
+use crossbeam_channel::Sender;
+
+use super::gpx::Coordinates;
+
+pub mod heatmap;
+
+const CHANNEL_SIZE: usize = 30;
+
+/// Represents a fully rendered tile.
+#[derive(Debug, Clone)]
+pub struct RenderedTile {
+ /// The `x` coordinate of the tile.
+ pub x: u64,
+ /// The `y` coordinate of the tile.
+ pub y: u64,
+ /// The encoded (PNG) image data, ready to be saved to disk.
+ pub data: Vec<u8>,
+}
+
+/// An object that is responsible for turning raw GPX tracks into a representation.
+///
+/// This is done in two steps, preparation and actual rendering. This allows different feedback for
+/// the user.
+pub trait Renderer {
+ type Prepared: Send;
+
+ /// Prepare the rendered data.
+ ///
+ /// The `tick` channel is used to provide user-feedback, for every finished track a tick should
+ /// be sent.
+ fn prepare(zoom: u32, tracks: &[Vec<Coordinates>], tick: Sender<()>) -> Result<Self::Prepared>;
+
+ /// Actually produce the colored tiles, using the previously prepared data.
+ ///
+ /// The `saver` channel is used to send the finished tiles to a thread that is responsible for
+ /// saving them.
+ fn colorize(prepared: Self::Prepared, saver: Sender<RenderedTile>) -> Result<()>;
+
+ /// Returns the tile count of the prepared data.
+ ///
+ /// This is used for the user interface, to scale progress bars appropriately.
+ fn tile_count(prepared: &Self::Prepared) -> Result<u64>;
+}
+
+/// A convenience wrapper to call [`Renderer::prepare`].
+///
+/// This function takes the same arguments, but provides the ability to use a callback closure
+/// instead of having to set up a channel. The callback is always called on the same thread.
+pub fn prepare<R: Renderer, F: FnMut() -> Result<()>>(
+ zoom: u32,
+ tracks: &[Vec<Coordinates>],
+ mut tick: F,
+) -> Result<R::Prepared> {
+ thread::scope(|s| {
+ let (sender, receiver) = crossbeam_channel::bounded(CHANNEL_SIZE);
+
+ let preparer = s.spawn(|| R::prepare(zoom, tracks, sender));
+
+ for _ in receiver {
+ tick()?;
+ }
+
+ preparer.join().unwrap()
+ })
+}
+
+/// A convenience wrapper to call [`Renderer::colorize`].
+///
+/// This function takes the same arguments, but provides the ability to use a callback closure
+/// instead of having to set up a channel. The callback is always called on the same thread.
+pub fn colorize<R: Renderer, F: FnMut(RenderedTile) -> Result<()>>(
+ prepared: R::Prepared,
+ mut saver: F,
+) -> Result<()> {
+ thread::scope(|s| {
+ let (sender, receiver) = crossbeam_channel::bounded(CHANNEL_SIZE);
+
+ let colorizer = s.spawn(|| R::colorize(prepared, sender));
+
+ for tile in receiver {
+ saver(tile)?;
+ }
+
+ colorizer.join().unwrap()
+ })
+}