Skip to content

Commit

Permalink
Implements natural neighbor interpolation
Browse files Browse the repository at this point in the history
  • Loading branch information
Stoeoef committed Dec 26, 2023
1 parent b5830cd commit 032c77c
Show file tree
Hide file tree
Showing 32 changed files with 2,075 additions and 41 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [2.5.0] - yyyy-mm-dd

### Added
- Implements natural neighbor interpolation. See type `NaturalNeighbor` for more on this!
### Fix
- Fixes `PointProjection::reversed()` - the method would return a projection that gives sometimes incorrect results.

## [2.4.1] - 2023-12-01

### Fix
Expand Down
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ rand = "0.8.3"
cgmath = "0.18.0"
svg = "0.14.0"
float_next_after = "1"
image = "0.24.7"
tiny-skia = "0.11.3"
criterion = { version = "0.5.1", features = ["html_reports"] }

base64 = "0.21.5"
anyhow = "1.0.75"

[[bench]]
name = "benchmarks"
Expand Down
30 changes: 11 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@

Delaunay triangulations for the rust ecosystem.

- 2D [Delaunay triangulation](https://en.wikipedia.org/wiki/Delaunay_triangulation), optionally backed by a hierarchy
structure for improved nearest neighbor and insertion performance.
- Allows both incremental and bulk loading creation of triangulations
- Support for vertex removal
- 2D constrained Delaunay triangulation (CDT)
- [Delaunay refinement](https://en.wikipedia.org/wiki/Delaunay_refinement)
- Uses precise geometric predicates to prevent incorrect geometries due to rounding issues
- Supports extracting the Voronoi diagram
- 2D [Delaunay triangulation](https://en.wikipedia.org/wiki/Delaunay_triangulation), optionally backed
by a hierarchy structure for improved nearest neighbor and insertion performance.
- Allows both incremental and bulk loading creation of triangulations
- Support for vertex removal
- 2D constrained Delaunay triangulation (CDT)
- [Delaunay refinement](https://en.wikipedia.org/wiki/Delaunay_refinement)
- Uses precise geometric predicates to prevent incorrect geometries due to rounding issues
- Supports extracting the Voronoi diagram
- Natural neighbor interpolation

---------------------------
<img src="images/basic_voronoi.svg" width=60% style="margin-left: auto;margin-right:auto;display:block">
Expand All @@ -31,19 +32,10 @@ Project goals, in the order of their importance:

# Roadmap

For Spade 2.x:
- Add back the removed interpolation methods (natural neighbor interpolation, #67)

For Spade 3:
- Possibly base `spade` on `nalgebra` as underlying vector and matrix library. Not much else planned yet!

# Project state and contributing

Looking for co-maintainers! Projects with just a single maintainer can be a little unreliable due to the single point of failure. I would love to see this burden being distributed on more shoulders! This is less about *implementing* things but rather about general maintenance tasks, e.g. package updates, minor bug fixes, reviewing PRs, etc...

If you want to contribute, please consider opening an issue first. I'm happy to help out with any questions!
- Possibly API simplification by un-supporting non-f64 outputs.

# Performance and comparison to other Delaunay crates
# Performance and comparison to other crates

Refer to [the delaunay_compare readme](./delaunay_compare/README.md) for some benchmarks and a comparison with other crates.

Expand Down
4 changes: 3 additions & 1 deletion benches/benchmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ use criterion::*;
mod benchmark_utilities;
mod bulk_load_benchmark;
mod bulk_load_vs_incremental;
mod interpolation_benchmark;
mod locate_benchmark;

criterion_group! {
name = benches;
config = Criterion::default().sample_size(50).warm_up_time(Duration::from_secs(1));
targets =
locate_benchmark::locate_benchmark,
bulk_load_benchmark::bulk_load_benchmark,
bulk_load_vs_incremental::bulk_load_vs_incremental_benchmark,
interpolation_benchmark::interpolation_benchmark,
locate_benchmark::locate_benchmark,
}

criterion_main!(benches);
47 changes: 47 additions & 0 deletions benches/interpolation_benchmark.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use criterion::*;
use spade::Triangulation;
use spade::{DelaunayTriangulation, FloatTriangulation, Point2};

use crate::benchmark_utilities::*;

pub fn interpolation_benchmark(c: &mut Criterion) {
let mut group = c.benchmark_group("interpolation benchmarks");

let points = uniform_distribution(*SEED, 1.0)
.take(50)
.collect::<Vec<_>>();
let triangulation = DelaunayTriangulation::<_>::bulk_load(points).unwrap();

let query_point = Point2::new(0.5, -0.2);

group.bench_function("nearest neighbor interpolation", |b| {
b.iter(|| {
triangulation
.nearest_neighbor(query_point)
.unwrap()
.position()
});
});

let barycentric = triangulation.barycentric();
group.bench_function("barycentric interpolation", |b| {
b.iter(|| barycentric.interpolate(|v| v.position().x + v.position().y, query_point));
});

let natural_neighbor = &triangulation.natural_neighbor();
group.bench_function("natural neighbor interpolation (c0)", |b| {
b.iter(|| natural_neighbor.interpolate(|v| v.position().x + v.position().y, query_point));
});

group.bench_function("natural neighbor interpolation (c1)", |b| {
b.iter(|| {
natural_neighbor.interpolate_gradient(
|v| v.position().x + v.position().y,
|_| [0.0, 0.0],
0.5,
query_point,
)
});
});
group.finish();
}
214 changes: 214 additions & 0 deletions examples/interpolation/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
use anyhow::*;
use image::{ImageBuffer, Rgba};
use std::io::{Cursor, Write};

use base64::Engine;
use tiny_skia::*;

use spade::{DelaunayTriangulation, FloatTriangulation, HasPosition, Point2, Triangulation};

#[derive(Debug, Copy, Clone)]
struct PointWithHeight {
position: Point2<f64>,
height: f64,
}

impl PointWithHeight {
const fn new(x: f64, y: f64, height: f64) -> Self {
Self {
position: Point2::new(x, y),
height,
}
}
}

impl HasPosition for PointWithHeight {
type Scalar = f64;

fn position(&self) -> Point2<Self::Scalar> {
self.position
}
}

type TriangulationType = DelaunayTriangulation<PointWithHeight>;

const VERTICES: &[PointWithHeight] = &[
PointWithHeight::new(20.0, 20.0, 0.9),
PointWithHeight::new(20.0, -20.0, 0.9),
PointWithHeight::new(-20.0, 20.0, 0.9),
PointWithHeight::new(-20.0, -20.0, 0.9),
PointWithHeight::new(20.0, 10.0, 0.0),
PointWithHeight::new(10.0, 10.0, 0.5),
PointWithHeight::new(0.0, 23.0, 0.1),
PointWithHeight::new(0.0, -23.0, 0.1),
PointWithHeight::new(-10.0, 10.0, 0.0),
PointWithHeight::new(-10.0, -10.0, 0.1),
PointWithHeight::new(-15.0, 20.0, 0.5),
PointWithHeight::new(-20.0, 20.0, 0.0),
PointWithHeight::new(-5.0, 0.0, 0.25),
PointWithHeight::new(5.0, 7.0, 0.75),
PointWithHeight::new(12.0, -10.0, 0.4),
PointWithHeight::new(5.0, 10.0, 0.3),
PointWithHeight::new(5.0, 1.0, 0.2),
];

pub fn main() -> anyhow::Result<()> {
let mut t = TriangulationType::default();
for vertex in VERTICES {
t.insert(*vertex)?;
}

const OFFSET: f32 = 25.0;
const SCALAR: f32 = 50.0 / 512.0;

fn px_to_coords(x: u32, y: u32) -> Point2<f64> {
Point2::new(
x as f64 * SCALAR as f64 - OFFSET as f64,
y as f64 * SCALAR as f64 - OFFSET as f64,
)
}

let dimensions = 512;
let mut nn_c0_pixmap =
Pixmap::new(dimensions, dimensions).context("Failed to allocate image")?;
let mut nn_c1_pixmap =
Pixmap::new(dimensions, dimensions).context("Failed to allocate image")?;
let mut barycentric_pixmap =
Pixmap::new(dimensions, dimensions).context("Failed to allocate image")?;
let mut nearest_neighbor_pixmap =
Pixmap::new(dimensions, dimensions).context("Failed to allocate image")?;

fn set_pixel(pixmap: &mut Pixmap, x: u32, y: u32, value: Option<f64>) {
let background_color = [255, 255, 255];
let [r, g, b] = value.map(float_to_color).unwrap_or(background_color);
let base = (y * pixmap.height() + x) as usize * 4;
pixmap.data_mut()[base] = r;
pixmap.data_mut()[base + 1] = g;
pixmap.data_mut()[base + 2] = b;
// Alpha
pixmap.data_mut()[base + 3] = 255;
}

let nn = t.natural_neighbor();
let barycentric = t.barycentric();

for y in 0..dimensions {
for x in 0..dimensions {
let coords = px_to_coords(x, y);
let value_nn_c0 = nn.interpolate(|v| v.data().height, coords);
set_pixel(&mut nn_c0_pixmap, x, y, value_nn_c0);

let value_nn_c1 =
nn.interpolate_gradient(|v| v.data().height, |_| [0.0, 0.0], 1.0, coords);
set_pixel(&mut nn_c1_pixmap, x, y, value_nn_c1);

let value_barycentric = barycentric.interpolate(|v| v.data().height, coords);
set_pixel(&mut barycentric_pixmap, x, y, value_barycentric);

let value_nearest_neighbor = t.nearest_neighbor(coords).map(|v| v.data().height);
set_pixel(&mut nearest_neighbor_pixmap, x, y, value_nearest_neighbor);
}
}

let path = {
let mut pb = PathBuilder::new();

for edge in t.undirected_edges() {
let [from, to] = edge.positions();
pb.move_to(from.x as f32, from.y as f32);
pb.line_to(to.x as f32, to.y as f32);
}

pb.finish().context("Could not build path")?
};

let stroke = Stroke {
width: 0.15,
..Default::default()
};

let mut paint = Paint::default();
paint.set_color_rgba8(0, 0, 0, 70);
paint.anti_alias = true;

for pixmap in [
&mut nn_c0_pixmap,
&mut nn_c1_pixmap,
&mut barycentric_pixmap,
&mut nearest_neighbor_pixmap,
] {
pixmap.stroke_path(
&path,
&paint,
&stroke,
Transform::from_translate(OFFSET, OFFSET).post_scale(1.0 / SCALAR, 1.0 / SCALAR),
None,
);
}

fn save_pixmap(pixmap: Pixmap, name: &str) -> anyhow::Result<()> {
// tiny_skia doesn't support jpg encoding which is required for small file size when embedding this into
// the documentation. We'll have to convert the data into ImageBuffer from the image crate and then do
// the jpeg encoding.
let (width, height) = (pixmap.width(), pixmap.height());
let data = pixmap.take();
let buffer = ImageBuffer::<Rgba<u8>, _>::from_vec(width, height, data)
.context("Failed to convert to ImageBuffer")?;

let mut data_jpeg: Cursor<Vec<u8>> = Cursor::new(Vec::new());
buffer.write_to(&mut data_jpeg, image::ImageFormat::Jpeg)?;

std::fs::write(format!("images/{}.jpg", name), data_jpeg.get_ref())?;

// Encode image as <img> tag for inclusion into the documentation
let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(data_jpeg.get_ref());
let mut file = std::fs::File::create(format!("images/{}.img", name))?;
write!(file, r#"<img src="data:image/jpg;base64,{}" />"#, encoded)?;
Ok(())
}

save_pixmap(nn_c0_pixmap, "interpolation_nn_c0")?;
save_pixmap(nn_c1_pixmap, "interpolation_nn_c1")?;
save_pixmap(barycentric_pixmap, "interpolation_barycentric")?;
save_pixmap(nearest_neighbor_pixmap, "interpolation_nearest_neighbor")?;

Ok(())
}

fn float_to_color(value: f64) -> [u8; 3] {
// mostly AI generated..
// Converts a hue value in the range 0.0 ..= 1.0 from HLS to RGB
let value = value.clamp(0.0, 1.0);

const LIGHTNESS: f64 = 0.45;
const SATURATION: f64 = 0.55;

let c = (1.0 - (2.0 * LIGHTNESS - 1.0).abs()) * SATURATION;

let hue = value * 360.0;
let hs = hue / 60.0;

let x = c * (1.0 - (hs % 2.0 - 1.0).abs());
let m = LIGHTNESS - c * 0.5;

let (r, g, b) = if (0.0..60.0).contains(&hue) {
(c, x, 0.0)
} else if (60.0..120.0).contains(&hue) {
(x, c, 0.0)
} else if (120.0..180.0).contains(&hue) {
(0.0, c, x)
} else if (180.0..240.0).contains(&hue) {
(0.0, x, c)
} else if (240.0..300.0).contains(&hue) {
(x, 0.0, c)
} else {
(c, 0.0, x)
};

// Convert RGB to 8-bit values
let r = ((r + m) * 255.0) as u8;
let g = ((g + m) * 255.0) as u8;
let b = ((b + m) * 255.0) as u8;

[r, g, b]
}
20 changes: 17 additions & 3 deletions examples/svg_renderer/main.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
pub mod quicksketch;
mod scenario;
mod scenario_list;

type Result = core::result::Result<(), Box<dyn std::error::Error>>;
use anyhow::Result;

/// Used for rendering SVGs for documentation. These are inlined (via #[doc = include_str!(...)])
/// into the doc comment of a few items. That makes sure they will be visible even for offline users.
fn main() -> Result {
fn main() -> Result<()> {
scenario_list::natural_neighbor_area_scenario(false)?.save_to_svg(
"natural_neighbor_insertion_cell",
"images/natural_neighbor_insertion_cell.svg",
)?;

scenario_list::natural_neighbor_area_scenario(true)?.save_to_svg(
"natural_neighbor_polygon",
"images/natural_neighbor_polygon.svg",
)?;

scenario_list::natural_neighbors_scenario().save_to_svg(
"natural_neighbor_scenario",
"images/natural_neighbor_scenario.svg",
)?;

scenario_list::refinement_maximum_area_scenario(None).save_to_svg(
"refinement_maximum_area_no_limit",
"images/refinement_maximum_area_no_limit.svg",
Expand Down
Loading

0 comments on commit 032c77c

Please sign in to comment.