Skip to content

Commit f072c4d

Browse files
committed
Add PGN image output format
1 parent 5b455dd commit f072c4d

3 files changed

Lines changed: 119 additions & 31 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ cxx = { version = "1.0", optional = true }
8383
delaunator = "1.0"
8484
eyre = "0.6.12"
8585
geo = "0.31"
86+
image = { version = "0.25.8", default-features = false, features = ["png"] }
8687
itertools = "0.14"
8788
kdtree = "0.7"
8889
noise = "0.9"

generative/attractor.rs

Lines changed: 98 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
use std::io::{BufWriter, Write};
22
use std::path::{Path, PathBuf};
33

4-
use geo::{Coord, LineString};
4+
use geo::{BoundingRect, Coord, LineString};
55
use wkt::ToWkt;
66

7-
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
7+
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
88
pub enum OutputFormat {
99
/// Write out each visited point as a WKT POINT
1010
Points,
1111
/// Write out each visited point in a WKT LINESTRING
1212
Line,
13-
// TODO: Image
13+
/// Write out the visited points as a PNG image
14+
Image,
1415
}
1516

1617
impl std::fmt::Display for OutputFormat {
@@ -19,27 +20,20 @@ impl std::fmt::Display for OutputFormat {
1920
// important: Should match clap::ValueEnum format
2021
OutputFormat::Points => write!(f, "points"),
2122
OutputFormat::Line => write!(f, "line"),
23+
OutputFormat::Image => write!(f, "image"),
2224
}
2325
}
2426
}
2527

26-
// TODO: A performant output writer that can handle parallelism and different output formats
27-
//
28-
// * Image: would need to share a thread-safe image buffer, and then write to the actual image
29-
// file all at once at the end. Each thread would have to keep its own local copy of the 2D
30-
// histogram, and then merge them at the end. It's fine to keep everything in-memory for the
31-
// image writer, because you have to build a 2D histogram of the hit pixels anyway, and
32-
// that's gotta be in-memory regardless. So what if we have to spin up a few copies of it
33-
// per-thread and then merge? It's not gonna be gigabytes... I hope
34-
//
35-
// But then how do you map the (x, y) coordinates to pixel coordinates without loading all of
36-
// them into memory first (to find the min/max extents)?
37-
3828
pub struct AttractorFormatter {
3929
format: OutputFormat,
4030
writer: BufWriter<Box<dyn Write>>,
31+
output: Option<PathBuf>,
4132

4233
accumulated: Vec<Coord>,
34+
35+
width: Option<u32>,
36+
height: Option<u32>,
4337
}
4438

4539
// public
@@ -48,8 +42,10 @@ impl AttractorFormatter {
4842
format: OutputFormat,
4943
output: Option<PathBuf>,
5044
expected_coords: usize,
45+
width: Option<u32>,
46+
height: Option<u32>,
5147
) -> eyre::Result<Self> {
52-
let writer: Box<dyn Write> = match output {
48+
let writer: Box<dyn Write> = match &output {
5349
Some(path) => {
5450
if path == Path::new("-") {
5551
Box::new(std::io::stdout())
@@ -61,40 +57,42 @@ impl AttractorFormatter {
6157
None => Box::new(std::io::stdout()),
6258
};
6359
let writer = BufWriter::new(writer);
64-
let accumulated = Vec::with_capacity(expected_coords);
60+
let buffer_capacity = match format {
61+
OutputFormat::Points | OutputFormat::Line => 1024,
62+
OutputFormat::Image => expected_coords,
63+
};
64+
let accumulated = Vec::with_capacity(buffer_capacity);
6565

6666
Ok(Self {
6767
format,
6868
writer,
69+
output,
6970
accumulated,
71+
width,
72+
height,
7073
})
7174
}
7275

7376
pub fn handle_point(&mut self, x: f64, y: f64) -> eyre::Result<()> {
74-
match self.format {
75-
OutputFormat::Points | OutputFormat::Line => self.accumulate_coord(Coord { x, y }),
76-
}
77+
self.accumulate_coord(Coord { x, y })
7778
}
7879

7980
pub fn flush(&mut self) -> eyre::Result<()> {
8081
self.write_accumulated()?;
8182
self.writer.flush()?;
8283
Ok(())
8384
}
84-
85-
/// In the case parallelism is used, merge another AttractorFormatter from another thread into
86-
/// this one
87-
pub fn merge(&mut self, _other: AttractorFormatter) {
88-
// TODO: This is mostly only useful for the image formatter
89-
}
9085
}
9186

9287
// private
9388
impl AttractorFormatter {
9489
fn accumulate_coord(&mut self, coord: Coord) -> eyre::Result<()> {
9590
self.accumulated.push(coord);
96-
// TODO: Don't do this for images
97-
if self.accumulated.len() > 1_000 {
91+
// Writing an image requires saving all of the points in memory so we can map them to pixel
92+
// coordinates later. For other formats, we can flush periodically to save memory.
93+
if self.format != OutputFormat::Image
94+
&& self.accumulated.len() == self.accumulated.capacity()
95+
{
9896
self.write_accumulated()?;
9997
}
10098
Ok(())
@@ -115,8 +113,80 @@ impl AttractorFormatter {
115113
writeln!(self.writer, "{}", linestring.to_wkt())?;
116114
}
117115
}
116+
OutputFormat::Image => self.write_image(accumulated)?,
117+
}
118+
119+
Ok(())
120+
}
121+
122+
fn write_image(&mut self, accumulated: Vec<Coord<f64>>) -> eyre::Result<()> {
123+
let accumulated = LineString::from(accumulated);
124+
let bbox = accumulated.bounding_rect().ok_or_else(|| {
125+
eyre::eyre!("Cannot determine bounding box of accumulated coordinates")
126+
})?;
127+
let (width, height) = self.determine_image_size(&bbox);
128+
129+
// Padding is to avoid off-by-one errors due to rounding floats -> int
130+
let mut image = image::GrayImage::new(width + 1, height + 1);
131+
for pixel in image.pixels_mut() {
132+
pixel.0[0] = 255; // white
133+
// I struggled using GrayAlphaImage and setting the alpha values correctly. Maybe I'll
134+
// revisit that later. For now, just darken the pixels on each visit.
118135
}
136+
for coord in accumulated {
137+
let (x, y) = Self::map_coordinate_to_pixel(&coord, &bbox, width, height);
138+
let pixel = image.get_pixel_mut(x, y);
139+
pixel.0[0] = pixel.0[0].saturating_sub(64); // darken the pixel, but don't wrap around!
140+
}
141+
142+
image.save_with_format(self.output.as_ref().unwrap(), image::ImageFormat::Png)?;
119143

120144
Ok(())
121145
}
146+
147+
fn determine_image_size(&self, bbox: &geo::Rect) -> (u32, u32) {
148+
let coord_width = bbox.max().x - bbox.min().x;
149+
let coord_height = bbox.max().y - bbox.min().y;
150+
let aspect_ratio = coord_width / coord_height;
151+
tracing::debug!("extents: {bbox:?}");
152+
tracing::debug!(
153+
"dimensions: {coord_width:.4} x {coord_height:.4}, aspect: {aspect_ratio:.4}"
154+
);
155+
156+
let (width, height) = match (self.width, self.height) {
157+
(Some(width), Some(height)) => (width, height),
158+
(None, None) => {
159+
let default_width = 800;
160+
let height = (default_width as f64 / aspect_ratio) as u32;
161+
(default_width, height)
162+
}
163+
(Some(width), None) => {
164+
let height = (width as f64 / aspect_ratio) as u32;
165+
(width, height)
166+
}
167+
(None, Some(height)) => {
168+
let width = (height as f64 * aspect_ratio) as u32;
169+
(width, height)
170+
}
171+
};
172+
tracing::debug!("Image size: {width}x{height}");
173+
174+
(width, height)
175+
}
176+
177+
fn map_coordinate_to_pixel(
178+
coord: &Coord<f64>,
179+
bbox: &geo::Rect,
180+
image_width: u32,
181+
image_height: u32,
182+
) -> (u32, u32) {
183+
let image_width = image_width as f64;
184+
let image_height = image_height as f64;
185+
// TODO: If this is expensive, we can precompute the scale factor
186+
let px_x = (coord.x - bbox.min().x) * image_width / (bbox.max().x - bbox.min().x);
187+
let px_y = (coord.y - bbox.min().y) * image_height / (bbox.max().y - bbox.min().y);
188+
189+
// TODO: Round? Truncate?
190+
(px_x as u32, px_y as u32)
191+
}
122192
}

tools/attractor.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,21 @@ struct CmdlineOptions {
2121
#[clap(short = 'O', long, default_value_t = OutputFormat::Points)]
2222
output_format: OutputFormat,
2323

24-
#[clap(short, long)]
24+
#[clap(short, long, required_if_eq("output_format", "image"))]
2525
output: Option<PathBuf>,
2626

27+
/// The width of the output image
28+
///
29+
/// If not given, an appropriate width will be chosen
30+
#[clap(short = 'W', long)]
31+
width: Option<u32>,
32+
33+
/// The height of the output image
34+
///
35+
/// If not given, an appropriate height will be chosen
36+
#[clap(short = 'H', long)]
37+
height: Option<u32>,
38+
2739
/// Mathematical expressions defining the dynamical system
2840
///
2941
/// The --math argument may be provided multiple times. The initial values of x and y will be
@@ -236,9 +248,14 @@ fn main() -> eyre::Result<()> {
236248
}
237249

238250
let expected_coords = (args.iterations * args.num_points) as usize;
239-
let mut formatter = AttractorFormatter::new(args.output_format, args.output, expected_coords)?;
251+
let mut formatter = AttractorFormatter::new(
252+
args.output_format,
253+
args.output,
254+
expected_coords,
255+
args.width,
256+
args.height,
257+
)?;
240258

241-
// TODO: This is a prime candidate for parallelism
242259
for (mut x, mut y) in initial_values {
243260
for _ in 0..args.iterations {
244261
(x, y) = dynamical_system(x, y);

0 commit comments

Comments
 (0)