11use std:: io:: { BufWriter , Write } ;
22use std:: path:: { Path , PathBuf } ;
33
4- use geo:: { Coord , LineString } ;
4+ use geo:: { BoundingRect , Coord , LineString } ;
55use wkt:: ToWkt ;
66
7- #[ derive( Clone , Copy , Debug , clap:: ValueEnum ) ]
7+ #[ derive( Clone , Copy , Debug , PartialEq , Eq , clap:: ValueEnum ) ]
88pub 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
1617impl 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-
3828pub 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
9388impl 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}
0 commit comments