├── .gitignore
├── README_files
└── figure-gfm
│ ├── unnamed-chunk-3-1.png
│ ├── unnamed-chunk-4-1.png
│ ├── unnamed-chunk-6-1.png
│ ├── unnamed-chunk-9-1.png
│ ├── unnamed-chunk-11-1.png
│ ├── unnamed-chunk-12-1.png
│ ├── unnamed-chunk-12-2.png
│ ├── unnamed-chunk-13-1.png
│ ├── unnamed-chunk-13-2.png
│ ├── unnamed-chunk-15-1.png
│ ├── unnamed-chunk-15-2.png
│ ├── unnamed-chunk-15-3.png
│ ├── unnamed-chunk-15-4.png
│ ├── unnamed-chunk-16-1.png
│ ├── unnamed-chunk-16-2.png
│ ├── unnamed-chunk-16-3.png
│ ├── unnamed-chunk-16-4.png
│ ├── unnamed-chunk-17-1.png
│ ├── unnamed-chunk-18-1.png
│ ├── unnamed-chunk-18-2.png
│ ├── unnamed-chunk-19-1.png
│ ├── unnamed-chunk-20-1.png
│ └── unnamed-chunk-21-1.png
├── README.Rmd
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .Rproj.user
2 | .Rhistory
3 | .RData
4 | .Ruserdata
5 |
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-3-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-3-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-4-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-4-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-6-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-6-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-9-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-9-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-11-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-11-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-12-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-12-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-12-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-12-2.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-13-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-13-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-13-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-13-2.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-15-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-15-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-15-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-15-2.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-15-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-15-3.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-15-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-15-4.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-16-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-16-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-16-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-16-2.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-16-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-16-3.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-16-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-16-4.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-17-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-17-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-18-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-18-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-18-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-18-2.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-19-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-19-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-20-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-20-1.png
--------------------------------------------------------------------------------
/README_files/figure-gfm/unnamed-chunk-21-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cj-holmes/photos-on-spirals/HEAD/README_files/figure-gfm/unnamed-chunk-21-1.png
--------------------------------------------------------------------------------
/README.Rmd:
--------------------------------------------------------------------------------
1 | ---
2 | output: github_document
3 | editor_options:
4 | chunk_output_type: console
5 | ---
6 |
7 |
8 |
9 | ```{r, include = FALSE}
10 | knitr::opts_chunk$set(
11 | collapse = TRUE,
12 | comment = "#>"
13 | )
14 | ```
15 |
16 | # Photos on spirals
17 |
18 | ## Introduction
19 |
20 | * A while ago I saw an example of a cool effect where a photo was recreated on a single line spiral. The image was rendered by the spiral line getting thicker (to create darker areas) and thinner (to create lighter areas).
21 | * I can't find the original example I saw, but I have since found this website [https://spiralbetty.com/](https://spiralbetty.com/) - which seems to do the same thing in a really nice way!
22 | * So this is my attempt at a janky implementation of this effect in R using spatial intersections, driven by the `{sf}` package
23 | * This implementation doesn't create a truly continuous single line image, but rather sections that are plotted with different thickness values
24 | * This code is inefficient and could contain errors - its just for fun to try and create these images!
25 |
26 | ```{r warning=FALSE, message=FALSE}
27 | library(magick)
28 | library(sf)
29 | library(tidyverse)
30 | ```
31 |
32 | ## Read an image
33 | * Read a test image. I choose Audrey!
34 | ```{r fig.width = 1}
35 | i <-
36 | image_read('https://images.photowall.com/products/59143/audrey-hepburn-3.jpg?h=699&q=85') |>
37 | image_resize("300x")
38 |
39 | i
40 | ```
41 |
42 | ## Process image
43 | * Crop it square
44 | * Convert it to greyscale
45 | * Reduce the number of grey shades
46 | * Flip it to be upside down (more on this later!)
47 | ```{r}
48 | # Define some variables for this step
49 | size <- 300
50 | n_shades <- 16
51 |
52 | i_processed <-
53 | i |>
54 | image_resize(paste0(size,"x",size,"^")) |>
55 | image_crop(geometry = paste0(size,"x",size), gravity = "center") |>
56 | image_convert(type = "grayscale") |>
57 | image_quantize(max = n_shades, dither=FALSE) |>
58 | image_flip()
59 |
60 | i_processed
61 | ```
62 |
63 | ## Convert image to polygons
64 | * Convert the image to a dataframe
65 | * Extract the red green and blue values for each pixel (they're the same as I previously converted the image to greyscale)
66 | * Rescale the red channel (could be done with green or blue) to be between 1 and 0
67 | * Convert the dataframe to a `{stars}` raster object
68 | * Convert the `{stars}` raster to an `{sf}` polygon set merging cells with identical values
69 | * Make the polygons valid and normalise to be between 0 and 1 (this works because I cropped the image to be square originally)
70 | * This whole step feels clunky. I'd love to know if there is a more efficient/elegant way of converting an image raster to a set of polygons
71 | ```{r}
72 | i_sf <-
73 | i_processed |>
74 | image_raster() |>
75 | mutate(
76 | col2rgb(col) |> t() |> as_tibble(),
77 | col = scales::rescale(red, to = c(1,0))) |>
78 | select(-green, -blue, -red) |>
79 | stars::st_as_stars() |>
80 | st_as_sf(as_points = FALSE, merge = TRUE) |>
81 | st_make_valid() |>
82 | st_set_agr("constant") |>
83 | st_normalize()
84 | ```
85 |
86 | * Visualise the `{sf}` polygons
87 | * Notice that the image is not plotting upside down (this is because we flipped it earlier)
88 | * It would of course be possible to reverse the y axis instead - but then this will cause problems when adding other (non inverted) layers to the plot
89 | ```{r}
90 | ggplot() +
91 | geom_sf(data = i_sf, col = NA, aes(fill = col)) +
92 | scale_fill_viridis_c(direction = -1)
93 | ```
94 |
95 | ## Generate spiral
96 | * A function for a parameterised spiral that I cobbled together from various online sources and some of my own ropey maths
97 | * This is probably not great but works well enough for this project
98 | ```{r}
99 | #' Create spiral coordinates
100 | #'
101 | #' @param xo Spiral origin x coordinate
102 | #' @param yo Spiral origin y coordinate
103 | #' @param n_points Number of points on whole spiral (equally spaced in angle)
104 | #' @param n_turns Number of turns in spiral
105 | #' @param r0 Spiral inner radius
106 | #' @param r1 Spiral outer radius
107 | #' @param offset_angle Offset angle for start of spiral (in degrees)
108 | spiral_coords <- function(xo, yo, n_points, n_turns, r0, r1, offset_angle){
109 |
110 | b <- (r1 - r0)/(2*pi*n_turns)
111 | l <- seq(0, 2*pi*n_turns, l=n_points)
112 |
113 | tibble(
114 | x = (r0 + (b*l))*cos(l + offset_angle*(pi/180)) + xo,
115 | y = (r0 + (b*l))*sin(l + offset_angle*(pi/180)) + yo)
116 | }
117 | ```
118 |
119 | * Define some parameters for the spiral
120 | * Create the spiral coordinates and convert it to an `{sf}` linestring
121 | ```{r}
122 | n_turns <- 50
123 | spiral_r1 <- 0.5
124 |
125 | spiral <-
126 | spiral_coords(
127 | xo = 0.5,
128 | yo = 0.5,
129 | n_points = 5000,
130 | n_turns = n_turns,
131 | r0 = 0,
132 | r1 = spiral_r1,
133 | offset_angle = 0) |>
134 | as.matrix() |>
135 | sf::st_linestring()
136 | ```
137 |
138 | * Visualise the `{sf}` spiral and image polygons as a sense check that they align
139 | ```{r}
140 | ggplot()+
141 | geom_sf(data = i_sf, aes(fill = col), col = NA) +
142 | geom_sf(data = spiral, col = "black") +
143 | scale_fill_viridis_c("", alpha = 0.75, direction = -1)+
144 | theme(legend.position = "")
145 | ```
146 |
147 | ## Compute spiral - image interscetions
148 | * Compute the minimum `thin` and maximum `thick` amount to buffer the intersections by
149 | * The `thick` value is set to be 95% of the gap between the spiral lines (so it will always scale appropriately)
150 | * Compute image spiral intersections
151 | * Rescale the greyscale value to be between `thick` and `thin` so that darker shades are closer to `thick` and lighter shades are closer to `thin`
152 | * Buffer the intersections by their scaled greyscale values
153 | ```{r}
154 | thin <- 0.00025
155 | thick <- ((spiral_r1/n_turns)/2)*0.95
156 |
157 | intersections <-
158 | st_intersection(i_sf, spiral) |>
159 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
160 | mutate(geometry = st_buffer(geometry, n, endCapStyle = "ROUND"))
161 | ```
162 |
163 | * Visualise the spiral image
164 | ```{r fig.width=8, fig.height=8, dpi=300}
165 | ggplot() + geom_sf(data = intersections, fill = "black", col = NA)
166 | ```
167 |
168 | * The colour can also be mapped to the colour shade - but this will ruin the illusion of a continuous line
169 | * I arrange the data by the thickness value descending prior to plotting so that the thinner lines are plotted on top of the thicker ones and don't get lost
170 | ```{r fig.width=8, fig.height=8, dpi=300, out.width="50%", fig.show='hold'}
171 | ggplot() +
172 | geom_sf(
173 | data = intersections |> arrange(desc(n)),
174 | aes (fill = n),
175 | col = NA) +
176 | scale_fill_gradient(low = "white", high = "black")+
177 | theme_void()+
178 | theme(legend.position = "")
179 |
180 | ggplot() +
181 | geom_sf(
182 | data = intersections |> arrange(desc(n)),
183 | aes (fill = n),
184 | col = NA) +
185 | scale_fill_viridis_c(direction = -1, option = "plasma")+
186 | theme_void()+
187 | theme(legend.position = "")
188 | ```
189 |
190 | * A single polygon of the spiral can be made with `st_union()`. The boundary linestring could also be returned afterwards by using `st_boundary()`
191 | ```{r fig.width=8, fig.height=8, dpi=300, out.width="50%", fig.show='hold'}
192 | ggplot() + geom_sf(data = intersections |> st_union(), col = 1, fill = "red")
193 | last_plot() + coord_sf(xlim = c(0.4, 0.6), ylim = c(0.4, 0.6))
194 | ```
195 |
196 | ## Create a function
197 | * Put together a function that runs the code above, with some small additions
198 | * The ability to invert the image
199 | * Control the outer spiral radius by a factor `spiral_r1_f` where a value of 1 takes it to the edge of the image
200 | * Parameterise the spiral max thickness with a factor `thick_f` where a value of 1 should make the spiral touch in the thickest areas
201 | * Note that this function also calls the `spiral_cords()` function defined above
202 |
203 | ```{r}
204 | spiral_image <-
205 | function(
206 | img,
207 | invert = FALSE,
208 | size = 300,
209 | n_shades = 16,
210 | spiral_points = 5000,
211 | spiral_turns = 50,
212 | spiral_r0 = 0,
213 | spiral_r1_f = 1,
214 | thin = 0.00025,
215 | thick_f = 0.95,
216 | spiral_offset_angle = 0,
217 | col_line = "black",
218 | col_bg = "white"){
219 |
220 | # Read image --------------------------------------------------------------
221 | if(class(img) == "magick-image"){i <- img} else {i <- magick::image_read(img)}
222 |
223 | # Process image to sf polygon
224 | i_sf <-
225 | i |>
226 | magick::image_resize(paste0(size,"x",size,"^")) |>
227 | magick::image_crop(geometry = paste0(size,"x",size), gravity = "center") |>
228 | magick::image_convert(type = "grayscale") |>
229 | magick::image_quantize(max = n_shades, dither=FALSE) |>
230 | magick::image_flip() |>
231 | magick::image_raster() |>
232 | dplyr::mutate(
233 | col2rgb(col) |> t() |> as_tibble(),
234 | col = scales::rescale(red, to = if(invert){c(0,1)}else{c(1, 0)})) |>
235 | dplyr::select(-green, -blue, -red) |>
236 | stars::st_as_stars() |>
237 | sf::st_as_sf(as_points = FALSE, merge = TRUE) |>
238 | sf::st_make_valid() |>
239 | sf::st_set_agr("constant") |>
240 | sf::st_normalize()
241 |
242 | # Generate spiral ----------------------------------------------------------
243 | spiral <-
244 | spiral_coords(
245 | xo = 0.5,
246 | yo = 0.5,
247 | n_points = spiral_points,
248 | n_turns = spiral_turns,
249 | r0 = spiral_r0,
250 | r1 = 0.5 * spiral_r1_f,
251 | offset_angle = spiral_offset_angle) |>
252 | as.matrix() |>
253 | sf::st_linestring()
254 |
255 | # Compute the thick value
256 | thick <- ((((0.5*spiral_r1_f) - spiral_r0)/spiral_turns)/2)*thick_f
257 |
258 | intersections <-
259 | sf::st_intersection(i_sf, spiral) |>
260 | dplyr::mutate(n = scales::rescale(col, to=c(thin, thick))) |>
261 | dplyr::mutate(geometry = sf::st_buffer(geometry, n, endCapStyle = "ROUND")) |>
262 | sf::st_union()
263 |
264 | ggplot2::ggplot() +
265 | ggplot2::geom_sf(data = intersections, fill = col_line, col = NA)+
266 | ggplot2::theme_void()+
267 | ggplot2::theme(panel.background = ggplot2::element_rect(fill = col_bg, colour = NA))+
268 | ggplot2::scale_x_continuous(limits = c(0,1))+
269 | ggplot2::scale_y_continuous(limits = c(0,1))
270 | }
271 | ```
272 |
273 | * Some examples
274 | ```{r fig.width=8, fig.height=8, dpi=300, fig.show='hold', out.width="50%"}
275 | spiral_image(i)
276 | spiral_image(i, invert = TRUE)
277 | spiral_image(i, col_bg = "grey20", col_line = "hotpink", invert = TRUE)
278 | spiral_image(i, spiral_r0 = 0.1)
279 | ```
280 |
281 | * Some interesting effects can be achieved by setting the `spiral_turns` to be specific fractions of `spiral_points`
282 | ```{r fig.width=8, fig.height=8, dpi=300, fig.show='hold', out.width="50%"}
283 | spiral_image(i, spiral_points = 150, spiral_turns = 50, thick_f = 0.5)
284 |
285 | spiral_image(i, spiral_points = 200, spiral_turns = 50, thick_f = 0.5)
286 | spiral_image(i, spiral_points = 200, spiral_turns = 50, thick_f = 0.5, spiral_offset_angle = 45)
287 |
288 | spiral_image(
289 | i,
290 | spiral_points = 250,
291 | spiral_turns = 50,
292 | invert = TRUE,
293 | col_bg = "grey10",
294 | col_line = "cyan",
295 | thick_f = 0.5)
296 | ```
297 |
298 |
299 | ## Double image on spiral
300 | * One last experiment
301 | * Using the ability in `st_buffer()` to buffer only one side of the spiral line, it's possible to render two images on the same spiral
302 | * Here I read a second image of audrey
303 |
304 | ```{r fig.width = 1}
305 | i2 <-
306 | magick::image_read('https://images.photowall.com/products/59144/audrey-hepburn-in-breakfast-at-tiffanys-1.jpg?h=699&q=85') |>
307 | magick::image_resize("300x")
308 |
309 | i2
310 | ```
311 |
312 | * Process the image as before
313 | * When it comes to the intersections, I buffer one with positive values and one with negative values and set `singleSide = TRUE`
314 | * It doesn't look like the `endCapStyle = "ROUND"` works for me when buffering on a single side...
315 | * Then visualise both sets of intersections with different colours
316 | * The fact that we normalised the `{sf}` polygon images to be between 0 and 1 makes this fairly trivial
317 | ```{r fig.width=8, fig.height=8, dpi=300}
318 | i2_sf <-
319 | i2 |>
320 | image_resize(paste0("300x300^")) |>
321 | image_crop(geometry = paste0("300x300"), gravity = "center") |>
322 | image_convert(type = "grayscale") |>
323 | image_quantize(max = 16, dither=FALSE) |>
324 | image_flip() |>
325 | image_raster() |>
326 | mutate(
327 | col2rgb(col) |> t() |> as_tibble(),
328 | col = scales::rescale(red, to = c(1, 0))) |>
329 | select(-green, -blue, -red) |>
330 | stars::st_as_stars() |>
331 | st_as_sf(as_points = FALSE, merge = TRUE) |>
332 | st_set_agr("constant") |>
333 | st_normalize() |>
334 | st_make_valid()
335 |
336 | intersections_1 <-
337 | st_intersection(i_sf, spiral) |>
338 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
339 | mutate(geometry = st_buffer(geometry, n, singleSide = TRUE))
340 |
341 | intersections_2 <-
342 | st_intersection(i2_sf, spiral) |>
343 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
344 | filter(st_geometry_type(geometry) != "POINT") |> # need to remove these for a negative buffer(?)
345 | mutate(geometry = st_buffer(geometry, -n, singleSide = TRUE))
346 |
347 | ggplot() +
348 | geom_sf(data = intersections_1, fill = 2, col = NA)+
349 | geom_sf(data = intersections_2, fill = 4, col = NA)
350 | last_plot() + coord_sf(xlim = c(0.3, 0.7), ylim = c(0.3, 0.7))
351 | ```
352 |
353 | ## Beyond spirals
354 | * Of course this method lends itself to any shapes being used (not just spirals)
355 |
356 | ### Grids
357 | * Here's a very quick look at using a grid of squares or hexagons instead using `st_make_grid()`
358 | * Just using the same values of `thin` and `thick` as before. This should be changed
359 |
360 | ```{r fig.width=8, fig.height=8, dpi=300}
361 | square_grid <- st_make_grid(i_sf, n = 50, square = TRUE) |> st_boundary() |> st_union()
362 |
363 | intersections <-
364 | st_intersection(i_sf, square_grid) |>
365 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
366 | mutate(geometry = st_buffer(geometry, n, endCapStyle = "ROUND"))
367 |
368 | ggplot() + geom_sf(data = intersections, fill = 1, col = NA)
369 | ```
370 |
371 |
372 | ```{r fig.width=8, fig.height=8, dpi=300}
373 | hex_grid <- st_make_grid(i_sf, n = 50, square = FALSE) |> st_boundary() |> st_union()
374 |
375 | intersections <-
376 | st_intersection(i_sf, hex_grid) |>
377 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
378 | mutate(geometry = st_buffer(geometry, n, endCapStyle = "ROUND"))
379 |
380 | ggplot() + geom_sf(data = intersections, fill = 1, col = NA)
381 | ```
382 |
383 | ### Flow field
384 | * I've modified some code from my [flow field art(?) repo](https://github.com/cj-holmes/flow-field-art) to create a set of LINESTRINGs across a flow field
385 | * This is just a quick proof of concept
386 | ```{r fig.width=8, fig.height=8, dpi=300}
387 | set.seed(4)
388 |
389 | # Set dimensions for noise generation
390 | x_side <- 400
391 | y_side <- 400
392 |
393 | # Create a noise field matrix with the {ambient} package
394 | m <-
395 | ambient::noise_simplex(
396 | c(y_side, x_side),
397 | frequency = 0.0003,
398 | octaves = 1,
399 | pertubation = "normal",
400 | pertubation_amplitude = 2,
401 | fractal = 'billow') |>
402 | scales::rescale(c(-90, 90)) # scale noise values to angles in degrees (-90 to 90)
403 |
404 | # Get the coords of flow line across the angle matrix
405 | ff_polys <- function(
406 | x_start,
407 | y_start,
408 | step_length,
409 | n_steps,
410 | angle_matrix){
411 |
412 | # Initialise vectors with the starting x and y values filled with NAs
413 | out_x <- c(x_start, rep(NA, n_steps))
414 | out_y <- c(y_start, rep(NA, n_steps))
415 |
416 | # If the starting point is outside the angle_matrix dimensions, return NULL
417 | if(x_start > ncol(angle_matrix) |
418 | x_start < 1 |
419 | y_start > nrow(angle_matrix) |
420 | y_start < 1){
421 | return(NULL)
422 | }
423 |
424 | # Loop through each step as we travel across the angle matrix
425 | for(i in 1:n_steps){
426 |
427 | # Get the angle of the nearest flow field point where we are for this iteration
428 | a <- angle_matrix[round(out_y[i]), round(out_x[i])]
429 |
430 | # Compute how far to move in x and y for the given angle and step_length
431 | step_x <- cos(a*(pi/180))*step_length
432 | step_y <- sin(a*(pi/180))*step_length
433 |
434 | # Add the distance in x and y to the current location
435 | next_x <- out_x[i] + step_x
436 | next_y <- out_y[i] + step_y
437 |
438 | # If the next point in the path sits outside the angle matrix, stop iterating along the path
439 | if(next_x > ncol(angle_matrix) |
440 | next_x < 1 |
441 | next_y > nrow(angle_matrix) |
442 | next_y < 1){
443 | break
444 | }
445 |
446 | # Append the new x and y location to the output
447 | # (ready to be used as the starting point for the next step iteration)
448 | out_x[i+1] <- next_x
449 | out_y[i+1] <- next_y
450 | }
451 |
452 | # Return tibble of the x, y, paths
453 | tibble(x = out_x, y = out_y) |> filter(!is.na(x), !is.na(y))
454 | }
455 |
456 | # Define number of points for flow lines to start at
457 | n <- 800
458 |
459 | # Re assign thick and thin
460 | thin <- 0.0001
461 | thick <- 0.0025
462 |
463 | # Compute the flow line LINESTRINGs
464 | ff <-
465 | tibble(
466 | x_start = runif(n, 1, ncol(m)),
467 | y_start = runif(n, 1, nrow(m))) |>
468 | mutate(
469 | id = row_number(),
470 | step_length = 1,
471 | n_steps = 400) |>
472 | mutate(
473 | paths = pmap(
474 | .l = list(x_start = x_start,
475 | y_start = y_start,
476 | step_length = step_length,
477 | n_steps = n_steps),
478 | .f = ff_polys,
479 | angle_matrix = m)) |>
480 | unnest(cols=paths) |>
481 | st_as_sf(coords = c("x", "y")) |>
482 | group_by(id) |>
483 | summarise() |>
484 | mutate(type = st_geometry_type(geometry)) |>
485 | filter(type == "MULTIPOINT") |>
486 | st_cast("LINESTRING") |>
487 | st_union() |>
488 | st_normalize()
489 |
490 | # Compute intersections and plot
491 | intersections <-
492 | st_intersection(i_sf, ff) |>
493 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
494 | mutate(geometry = st_buffer(geometry, n, endCapStyle = "ROUND"))
495 |
496 | ggplot() + geom_sf(data = intersections, fill = 1, col = NA)
497 | ```
498 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | # Photos on spirals
5 |
6 | ## Introduction
7 |
8 | - A while ago I saw an example of a cool effect where a photo was
9 | recreated on a single line spiral. The image was rendered by the
10 | spiral line getting thicker (to create darker areas) and thinner (to
11 | create lighter areas).
12 | - I can’t find the original example I saw, but I have since found this
13 | website - which seems to do the same
14 | thing in a really nice way!
15 | - So this is my attempt at a janky implementation of this effect in R
16 | using spatial intersections, driven by the `{sf}` package
17 | - This implementation doesn’t create a truly continuous single
18 | line image, but rather sections that are plotted with different
19 | thickness values
20 | - This code is inefficient and could contain errors - its just for
21 | fun to try and create these images!
22 |
23 | ``` r
24 | library(magick)
25 | library(sf)
26 | library(tidyverse)
27 | ```
28 |
29 | ## Read an image
30 |
31 | - Read a test image. I choose Audrey!
32 |
33 | ``` r
34 | i <-
35 | image_read('https://images.photowall.com/products/59143/audrey-hepburn-3.jpg?h=699&q=85') |>
36 | image_resize("300x")
37 |
38 | i
39 | ```
40 |
41 |
42 |
43 | ## Process image
44 |
45 | - Crop it square
46 | - Convert it to greyscale
47 | - Reduce the number of grey shades
48 | - Flip it to be upside down (more on this later!)
49 |
50 | ``` r
51 | # Define some variables for this step
52 | size <- 300
53 | n_shades <- 16
54 |
55 | i_processed <-
56 | i |>
57 | image_resize(paste0(size,"x",size,"^")) |>
58 | image_crop(geometry = paste0(size,"x",size), gravity = "center") |>
59 | image_convert(type = "grayscale") |>
60 | image_quantize(max = n_shades, dither=FALSE) |>
61 | image_flip()
62 |
63 | i_processed
64 | ```
65 |
66 |
67 |
68 | ## Convert image to polygons
69 |
70 | - Convert the image to a dataframe
71 | - Extract the red green and blue values for each pixel (they’re the
72 | same as I previously converted the image to greyscale)
73 | - Rescale the red channel (could be done with green or blue) to be
74 | between 1 and 0
75 | - Convert the dataframe to a `{stars}` raster object
76 | - Convert the `{stars}` raster to an `{sf}` polygon set merging cells
77 | with identical values
78 | - Make the polygons valid and normalise to be between 0 and 1 (this
79 | works because I cropped the image to be square originally)
80 | - This whole step feels clunky. I’d love to know if there is a more
81 | efficient/elegant way of converting an image raster to a set of
82 | polygons
83 |
84 | ``` r
85 | i_sf <-
86 | i_processed |>
87 | image_raster() |>
88 | mutate(
89 | col2rgb(col) |> t() |> as_tibble(),
90 | col = scales::rescale(red, to = c(1,0))) |>
91 | select(-green, -blue, -red) |>
92 | stars::st_as_stars() |>
93 | st_as_sf(as_points = FALSE, merge = TRUE) |>
94 | st_make_valid() |>
95 | st_set_agr("constant") |>
96 | st_normalize()
97 | ```
98 |
99 | - Visualise the `{sf}` polygons
100 | - Notice that the image is not plotting upside down (this is because
101 | we flipped it earlier)
102 | - It would of course be possible to reverse the y axis instead -
103 | but then this will cause problems when adding other (non
104 | inverted) layers to the plot
105 |
106 | ``` r
107 | ggplot() +
108 | geom_sf(data = i_sf, col = NA, aes(fill = col)) +
109 | scale_fill_viridis_c(direction = -1)
110 | ```
111 |
112 | 
113 |
114 | ## Generate spiral
115 |
116 | - A function for a parameterised spiral that I cobbled together from
117 | various online sources and some of my own ropey maths
118 | - This is probably not great but works well enough for this project
119 |
120 | ``` r
121 | #' Create spiral coordinates
122 | #'
123 | #' @param xo Spiral origin x coordinate
124 | #' @param yo Spiral origin y coordinate
125 | #' @param n_points Number of points on whole spiral (equally spaced in angle)
126 | #' @param n_turns Number of turns in spiral
127 | #' @param r0 Spiral inner radius
128 | #' @param r1 Spiral outer radius
129 | #' @param offset_angle Offset angle for start of spiral (in degrees)
130 | spiral_coords <- function(xo, yo, n_points, n_turns, r0, r1, offset_angle){
131 |
132 | b <- (r1 - r0)/(2*pi*n_turns)
133 | l <- seq(0, 2*pi*n_turns, l=n_points)
134 |
135 | tibble(
136 | x = (r0 + (b*l))*cos(l + offset_angle*(pi/180)) + xo,
137 | y = (r0 + (b*l))*sin(l + offset_angle*(pi/180)) + yo)
138 | }
139 | ```
140 |
141 | - Define some parameters for the spiral
142 | - Create the spiral coordinates and convert it to an `{sf}` linestring
143 |
144 | ``` r
145 | n_turns <- 50
146 | spiral_r1 <- 0.5
147 |
148 | spiral <-
149 | spiral_coords(
150 | xo = 0.5,
151 | yo = 0.5,
152 | n_points = 5000,
153 | n_turns = n_turns,
154 | r0 = 0,
155 | r1 = spiral_r1,
156 | offset_angle = 0) |>
157 | as.matrix() |>
158 | sf::st_linestring()
159 | ```
160 |
161 | - Visualise the `{sf}` spiral and image polygons as a sense check that
162 | they align
163 |
164 | ``` r
165 | ggplot()+
166 | geom_sf(data = i_sf, aes(fill = col), col = NA) +
167 | geom_sf(data = spiral, col = "black") +
168 | scale_fill_viridis_c("", alpha = 0.75, direction = -1)+
169 | theme(legend.position = "")
170 | ```
171 |
172 | 
173 |
174 | ## Compute spiral - image interscetions
175 |
176 | - Compute the minimum `thin` and maximum `thick` amount to buffer the
177 | intersections by
178 | - The `thick` value is set to be 95% of the gap between the spiral
179 | lines (so it will always scale appropriately)
180 | - Compute image spiral intersections
181 | - Rescale the greyscale value to be between `thick` and `thin` so that
182 | darker shades are closer to `thick` and lighter shades are closer to
183 | `thin`
184 | - Buffer the intersections by their scaled greyscale values
185 |
186 | ``` r
187 | thin <- 0.00025
188 | thick <- ((spiral_r1/n_turns)/2)*0.95
189 |
190 | intersections <-
191 | st_intersection(i_sf, spiral) |>
192 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
193 | mutate(geometry = st_buffer(geometry, n, endCapStyle = "ROUND"))
194 | ```
195 |
196 | - Visualise the spiral image
197 |
198 | ``` r
199 | ggplot() + geom_sf(data = intersections, fill = "black", col = NA)
200 | ```
201 |
202 | 
203 |
204 | - The colour can also be mapped to the colour shade - but this will
205 | ruin the illusion of a continuous line
206 | - I arrange the data by the thickness value descending prior to
207 | plotting so that the thinner lines are plotted on top of the thicker
208 | ones and don’t get lost
209 |
210 | ``` r
211 | ggplot() +
212 | geom_sf(
213 | data = intersections |> arrange(desc(n)),
214 | aes (fill = n),
215 | col = NA) +
216 | scale_fill_gradient(low = "white", high = "black")+
217 | theme_void()+
218 | theme(legend.position = "")
219 |
220 | ggplot() +
221 | geom_sf(
222 | data = intersections |> arrange(desc(n)),
223 | aes (fill = n),
224 | col = NA) +
225 | scale_fill_viridis_c(direction = -1, option = "plasma")+
226 | theme_void()+
227 | theme(legend.position = "")
228 | ```
229 |
230 | 
231 |
232 | - A single polygon of the spiral can be made with `st_union()`. The
233 | boundary linestring could also be returned afterwards by using
234 | `st_boundary()`
235 |
236 | ``` r
237 | ggplot() + geom_sf(data = intersections |> st_union(), col = 1, fill = "red")
238 | last_plot() + coord_sf(xlim = c(0.4, 0.6), ylim = c(0.4, 0.6))
239 | ```
240 |
241 | 
242 |
243 | ## Create a function
244 |
245 | - Put together a function that runs the code above, with some small
246 | additions
247 | - The ability to invert the image
248 | - Control the outer spiral radius by a factor `spiral_r1_f` where
249 | a value of 1 takes it to the edge of the image
250 | - Parameterise the spiral max thickness with a factor `thick_f`
251 | where a value of 1 should make the spiral touch in the thickest
252 | areas
253 | - Note that this function also calls the `spiral_cords()` function
254 | defined above
255 |
256 | ``` r
257 | spiral_image <-
258 | function(
259 | img,
260 | invert = FALSE,
261 | size = 300,
262 | n_shades = 16,
263 | spiral_points = 5000,
264 | spiral_turns = 50,
265 | spiral_r0 = 0,
266 | spiral_r1_f = 1,
267 | thin = 0.00025,
268 | thick_f = 0.95,
269 | spiral_offset_angle = 0,
270 | col_line = "black",
271 | col_bg = "white"){
272 |
273 | # Read image --------------------------------------------------------------
274 | if(class(img) == "magick-image"){i <- img} else {i <- magick::image_read(img)}
275 |
276 | # Process image to sf polygon
277 | i_sf <-
278 | i |>
279 | magick::image_resize(paste0(size,"x",size,"^")) |>
280 | magick::image_crop(geometry = paste0(size,"x",size), gravity = "center") |>
281 | magick::image_convert(type = "grayscale") |>
282 | magick::image_quantize(max = n_shades, dither=FALSE) |>
283 | magick::image_flip() |>
284 | magick::image_raster() |>
285 | dplyr::mutate(
286 | col2rgb(col) |> t() |> as_tibble(),
287 | col = scales::rescale(red, to = if(invert){c(0,1)}else{c(1, 0)})) |>
288 | dplyr::select(-green, -blue, -red) |>
289 | stars::st_as_stars() |>
290 | sf::st_as_sf(as_points = FALSE, merge = TRUE) |>
291 | sf::st_make_valid() |>
292 | sf::st_set_agr("constant") |>
293 | sf::st_normalize()
294 |
295 | # Generate spiral ----------------------------------------------------------
296 | spiral <-
297 | spiral_coords(
298 | xo = 0.5,
299 | yo = 0.5,
300 | n_points = spiral_points,
301 | n_turns = spiral_turns,
302 | r0 = spiral_r0,
303 | r1 = 0.5 * spiral_r1_f,
304 | offset_angle = spiral_offset_angle) |>
305 | as.matrix() |>
306 | sf::st_linestring()
307 |
308 | # Compute the thick value
309 | thick <- ((((0.5*spiral_r1_f) - spiral_r0)/spiral_turns)/2)*thick_f
310 |
311 | intersections <-
312 | sf::st_intersection(i_sf, spiral) |>
313 | dplyr::mutate(n = scales::rescale(col, to=c(thin, thick))) |>
314 | dplyr::mutate(geometry = sf::st_buffer(geometry, n, endCapStyle = "ROUND")) |>
315 | sf::st_union()
316 |
317 | ggplot2::ggplot() +
318 | ggplot2::geom_sf(data = intersections, fill = col_line, col = NA)+
319 | ggplot2::theme_void()+
320 | ggplot2::theme(panel.background = ggplot2::element_rect(fill = col_bg, colour = NA))+
321 | ggplot2::scale_x_continuous(limits = c(0,1))+
322 | ggplot2::scale_y_continuous(limits = c(0,1))
323 | }
324 | ```
325 |
326 | - Some examples
327 |
328 | ``` r
329 | spiral_image(i)
330 | spiral_image(i, invert = TRUE)
331 | spiral_image(i, col_bg = "grey20", col_line = "hotpink", invert = TRUE)
332 | spiral_image(i, spiral_r0 = 0.1)
333 | ```
334 |
335 | 


336 |
337 | - Some interesting effects can be achieved by setting the
338 | `spiral_turns` to be specific fractions of `spiral_points`
339 |
340 | ``` r
341 | spiral_image(i, spiral_points = 150, spiral_turns = 50, thick_f = 0.5)
342 |
343 | spiral_image(i, spiral_points = 200, spiral_turns = 50, thick_f = 0.5)
344 | spiral_image(i, spiral_points = 200, spiral_turns = 50, thick_f = 0.5, spiral_offset_angle = 45)
345 |
346 | spiral_image(
347 | i,
348 | spiral_points = 250,
349 | spiral_turns = 50,
350 | invert = TRUE,
351 | col_bg = "grey10",
352 | col_line = "cyan",
353 | thick_f = 0.5)
354 | ```
355 |
356 | 


357 |
358 | ## Double image on spiral
359 |
360 | - One last experiment
361 | - Using the ability in `st_buffer()` to buffer only one side of the
362 | spiral line, it’s possible to render two images on the same spiral
363 | - Here I read a second image of audrey
364 |
365 | ``` r
366 | i2 <-
367 | magick::image_read('https://images.photowall.com/products/59144/audrey-hepburn-in-breakfast-at-tiffanys-1.jpg?h=699&q=85') |>
368 | magick::image_resize("300x")
369 |
370 | i2
371 | ```
372 |
373 |
374 |
375 | - Process the image as before
376 | - When it comes to the intersections, I buffer one with positive
377 | values and one with negative values and set `singleSide = TRUE`
378 | - It doesn’t look like the `endCapStyle = "ROUND"` works for me
379 | when buffering on a single side…
380 | - Then visualise both sets of intersections with different colours
381 | - The fact that we normalised the `{sf}` polygon images to be between
382 | 0 and 1 makes this fairly trivial
383 |
384 | ``` r
385 | i2_sf <-
386 | i2 |>
387 | image_resize(paste0("300x300^")) |>
388 | image_crop(geometry = paste0("300x300"), gravity = "center") |>
389 | image_convert(type = "grayscale") |>
390 | image_quantize(max = 16, dither=FALSE) |>
391 | image_flip() |>
392 | image_raster() |>
393 | mutate(
394 | col2rgb(col) |> t() |> as_tibble(),
395 | col = scales::rescale(red, to = c(1, 0))) |>
396 | select(-green, -blue, -red) |>
397 | stars::st_as_stars() |>
398 | st_as_sf(as_points = FALSE, merge = TRUE) |>
399 | st_set_agr("constant") |>
400 | st_normalize() |>
401 | st_make_valid()
402 |
403 | intersections_1 <-
404 | st_intersection(i_sf, spiral) |>
405 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
406 | mutate(geometry = st_buffer(geometry, n, singleSide = TRUE))
407 |
408 | intersections_2 <-
409 | st_intersection(i2_sf, spiral) |>
410 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
411 | filter(st_geometry_type(geometry) != "POINT") |> # need to remove these for a negative buffer(?)
412 | mutate(geometry = st_buffer(geometry, -n, singleSide = TRUE))
413 |
414 | ggplot() +
415 | geom_sf(data = intersections_1, fill = 2, col = NA)+
416 | geom_sf(data = intersections_2, fill = 4, col = NA)
417 | ```
418 |
419 | 
420 |
421 | ``` r
422 | last_plot() + coord_sf(xlim = c(0.3, 0.7), ylim = c(0.3, 0.7))
423 | ```
424 |
425 | 
426 |
427 | ## Beyond spirals
428 |
429 | - Of course this method lends itself to any shapes being used (not
430 | just spirals)
431 |
432 | ### Grids
433 |
434 | - Here’s a very quick look at using a grid of squares or hexagons
435 | instead using `st_make_grid()`
436 | - Just using the same values of `thin` and `thick` as before. This
437 | should be changed
438 |
439 | ``` r
440 | square_grid <- st_make_grid(i_sf, n = 50, square = TRUE) |> st_boundary() |> st_union()
441 |
442 | intersections <-
443 | st_intersection(i_sf, square_grid) |>
444 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
445 | mutate(geometry = st_buffer(geometry, n, endCapStyle = "ROUND"))
446 |
447 | ggplot() + geom_sf(data = intersections, fill = 1, col = NA)
448 | ```
449 |
450 | 
451 |
452 | ``` r
453 | hex_grid <- st_make_grid(i_sf, n = 50, square = FALSE) |> st_boundary() |> st_union()
454 |
455 | intersections <-
456 | st_intersection(i_sf, hex_grid) |>
457 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
458 | mutate(geometry = st_buffer(geometry, n, endCapStyle = "ROUND"))
459 |
460 | ggplot() + geom_sf(data = intersections, fill = 1, col = NA)
461 | ```
462 |
463 | 
464 |
465 | ### Flow field
466 |
467 | - I’ve modified some code from my [flow field art(?)
468 | repo](https://github.com/cj-holmes/flow-field-art) to create a set
469 | of LINESTRINGs across a flow field
470 | - This is just a quick proof of concept
471 |
472 | ``` r
473 | set.seed(4)
474 |
475 | # Set dimensions for noise generation
476 | x_side <- 400
477 | y_side <- 400
478 |
479 | # Create a noise field matrix with the {ambient} package
480 | m <-
481 | ambient::noise_simplex(
482 | c(y_side, x_side),
483 | frequency = 0.0003,
484 | octaves = 1,
485 | pertubation = "normal",
486 | pertubation_amplitude = 2,
487 | fractal = 'billow') |>
488 | scales::rescale(c(-90, 90)) # scale noise values to angles in degrees (-90 to 90)
489 |
490 | # Get the coords of flow line across the angle matrix
491 | ff_polys <- function(
492 | x_start,
493 | y_start,
494 | step_length,
495 | n_steps,
496 | angle_matrix){
497 |
498 | # Initialise vectors with the starting x and y values filled with NAs
499 | out_x <- c(x_start, rep(NA, n_steps))
500 | out_y <- c(y_start, rep(NA, n_steps))
501 |
502 | # If the starting point is outside the angle_matrix dimensions, return NULL
503 | if(x_start > ncol(angle_matrix) |
504 | x_start < 1 |
505 | y_start > nrow(angle_matrix) |
506 | y_start < 1){
507 | return(NULL)
508 | }
509 |
510 | # Loop through each step as we travel across the angle matrix
511 | for(i in 1:n_steps){
512 |
513 | # Get the angle of the nearest flow field point where we are for this iteration
514 | a <- angle_matrix[round(out_y[i]), round(out_x[i])]
515 |
516 | # Compute how far to move in x and y for the given angle and step_length
517 | step_x <- cos(a*(pi/180))*step_length
518 | step_y <- sin(a*(pi/180))*step_length
519 |
520 | # Add the distance in x and y to the current location
521 | next_x <- out_x[i] + step_x
522 | next_y <- out_y[i] + step_y
523 |
524 | # If the next point in the path sits outside the angle matrix, stop iterating along the path
525 | if(next_x > ncol(angle_matrix) |
526 | next_x < 1 |
527 | next_y > nrow(angle_matrix) |
528 | next_y < 1){
529 | break
530 | }
531 |
532 | # Append the new x and y location to the output
533 | # (ready to be used as the starting point for the next step iteration)
534 | out_x[i+1] <- next_x
535 | out_y[i+1] <- next_y
536 | }
537 |
538 | # Return tibble of the x, y, paths
539 | tibble(x = out_x, y = out_y) |> filter(!is.na(x), !is.na(y))
540 | }
541 |
542 | # Define number of points for flow lines to start at
543 | n <- 800
544 |
545 | # Re assign thick and thin
546 | thin <- 0.0001
547 | thick <- 0.0025
548 |
549 | # Compute the flow line LINESTRINGs
550 | ff <-
551 | tibble(
552 | x_start = runif(n, 1, ncol(m)),
553 | y_start = runif(n, 1, nrow(m))) |>
554 | mutate(
555 | id = row_number(),
556 | step_length = 1,
557 | n_steps = 400) |>
558 | mutate(
559 | paths = pmap(
560 | .l = list(x_start = x_start,
561 | y_start = y_start,
562 | step_length = step_length,
563 | n_steps = n_steps),
564 | .f = ff_polys,
565 | angle_matrix = m)) |>
566 | unnest(cols=paths) |>
567 | st_as_sf(coords = c("x", "y")) |>
568 | group_by(id) |>
569 | summarise() |>
570 | mutate(type = st_geometry_type(geometry)) |>
571 | filter(type == "MULTIPOINT") |>
572 | st_cast("LINESTRING") |>
573 | st_union() |>
574 | st_normalize()
575 |
576 | # Compute intersections and plot
577 | intersections <-
578 | st_intersection(i_sf, ff) |>
579 | mutate(n = scales::rescale(col, to=c(thin, thick))) |>
580 | mutate(geometry = st_buffer(geometry, n, endCapStyle = "ROUND"))
581 |
582 | ggplot() + geom_sf(data = intersections, fill = 1, col = NA)
583 | ```
584 |
585 | 
586 |
--------------------------------------------------------------------------------