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).
I can’t find the original example I saw, but I have since found this
website https://spiralbetty.com/ - which seems to do the same
thing in a really nice way!
So this is my attempt at a janky implementation of this effect in R
using spatial intersections, driven by the {sf} package
This implementation doesn’t create a truly continuous single
line image, but rather sections that are plotted with different
thickness values
This code is inefficient and could contain errors - its just for
fun to try and create these images!
library(magick)
library(sf)
library(tidyverse)
Read an image
Read a test image. I choose Audrey!
i<-
image_read('https://images.photowall.com/products/59143/audrey-hepburn-3.jpg?h=699&q=85') |>
image_resize("300x")
i
Process image
Crop it square
Convert it to greyscale
Reduce the number of grey shades
Flip it to be upside down (more on this later!)
# Define some variables for this stepsize<-300n_shades<-16i_processed<-i|>
image_resize(paste0(size,"x",size,"^")) |>
image_crop(geometry= paste0(size,"x",size), gravity="center") |>
image_convert(type="grayscale") |>
image_quantize(max=n_shades, dither=FALSE) |>
image_flip()
i_processed
Convert image to polygons
Convert the image to a dataframe
Extract the red green and blue values for each pixel (they’re the
same as I previously converted the image to greyscale)
Rescale the red channel (could be done with green or blue) to be
between 1 and 0
Convert the dataframe to a {stars} raster object
Convert the {stars} raster to an {sf} polygon set merging cells
with identical values
Make the polygons valid and normalise to be between 0 and 1 (this
works because I cropped the image to be square originally)
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
The colour can also be mapped to the colour shade - but this will
ruin the illusion of a continuous line
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
I’ve modified some code from my flow field art(?)
repo to create a set
of LINESTRINGs across a flow field
This is just a quick proof of concept
set.seed(4)
# Set dimensions for noise generationx_side<-400y_side<-400# Create a noise field matrix with the {ambient} packagem<-ambient::noise_simplex(
c(y_side, x_side),
frequency=0.0003,
octaves=1,
pertubation="normal",
pertubation_amplitude=2,
fractal='billow') |>scales::rescale(c(-90, 90)) # scale noise values to angles in degrees (-90 to 90)# Get the coords of flow line across the angle matrixff_polys<-function(
x_start,
y_start,
step_length,
n_steps,
angle_matrix){
# Initialise vectors with the starting x and y values filled with NAsout_x<- c(x_start, rep(NA, n_steps))
out_y<- c(y_start, rep(NA, n_steps))
# If the starting point is outside the angle_matrix dimensions, return NULLif(x_start> ncol(angle_matrix) |x_start<1|y_start> nrow(angle_matrix) |y_start<1){
return(NULL)
}
# Loop through each step as we travel across the angle matrixfor(iin1:n_steps){
# Get the angle of the nearest flow field point where we are for this iterationa<-angle_matrix[round(out_y[i]), round(out_x[i])]
# Compute how far to move in x and y for the given angle and step_lengthstep_x<- cos(a*(pi/180))*step_lengthstep_y<- sin(a*(pi/180))*step_length# Add the distance in x and y to the current locationnext_x<-out_x[i] +step_xnext_y<-out_y[i] +step_y# If the next point in the path sits outside the angle matrix, stop iterating along the pathif(next_x> ncol(angle_matrix) |next_x<1|next_y> nrow(angle_matrix) |next_y<1){
break
}
# Append the new x and y location to the output # (ready to be used as the starting point for the next step iteration)out_x[i+1] <-next_xout_y[i+1] <-next_y
}
# Return tibble of the x, y, paths
tibble(x=out_x, y=out_y) |> filter(!is.na(x), !is.na(y))
}
# Define number of points for flow lines to start atn<-800# Re assign thick and thinthin<-0.0001thick<-0.0025# Compute the flow line LINESTRINGsff<-
tibble(
x_start= runif(n, 1, ncol(m)),
y_start= runif(n, 1, nrow(m))) |>
mutate(
id= row_number(),
step_length=1,
n_steps=400) |>
mutate(
paths= pmap(
.l=list(x_start=x_start,
y_start=y_start,
step_length=step_length,
n_steps=n_steps),
.f=ff_polys,
angle_matrix=m)) |>
unnest(cols=paths) |>
st_as_sf(coords= c("x", "y")) |>
group_by(id) |>
summarise() |>
mutate(type= st_geometry_type(geometry)) |>
filter(type=="MULTIPOINT") |>
st_cast("LINESTRING") |>
st_union() |>
st_normalize()
# Compute intersections and plotintersections<-
st_intersection(i_sf, ff) |>
mutate(n=scales::rescale(col, to=c(thin, thick))) |>
mutate(geometry= st_buffer(geometry, n, endCapStyle="ROUND"))
ggplot() + geom_sf(data=intersections, fill=1, col=NA)