10

TL;DR: What is the most efficient way to crop a rectangular image to a circle?

Explanation/Background:

I'm working on some code in R that will display Spotify artist images as circles instead of the default rectanges/squares. I couldn't find any packages or commands that crop images in R, especially to a circle, so I wrote my own function, circ, which reads 3-Dimensional (or 4-Dimensional) RGB(A) arrays and crops them to a circle using the parametric equation of a circle to determine the x values for every unique y. Here's my psuedocode:

Given an RGB(A) array:
    Find the center of the image, radius = min(x coord, y coord)
    Pre-crop the image to a square of dimensions 2r x 2r
    For every unique y value:
        Determine the x coordinates on the circle
        Make pixels outside of the circle transparent
    Return the cropped image as an RGBA array

This function is a tremendous improvement over my previous one, which checked the position of every pixel to see if it was inside or outside of the circle, but I still feel like it could be sped up further.

Is there a way I could check maybe half of the y-values instead of all of them, and mirror across the circle? Is there an actual cropping function I could use instead? Any and all help is much appreciated!

Edited to add some copy-paste-run code (thanks @lukeA):

My original cropping method:

circ = function(a){
  # First part of the function finds the radius of the circle and crops the image accordingly
  xc = floor(dim(a[,,1])[2]/2)  # X coordinate of the center
  yc = floor(dim(a[,,1])[1]/2)  # Y coordinate of the center
  r = min(xc, yc) - 1  # Radius is the smaller of the two -1 to avoid reading nonexistent data
  ma = array(data = c(a[,,1][(yc-r):(yc+r),(xc-r):(xc+r)],  # Read in the cropped image
                      a[,,2][(yc-r):(yc+r),(xc-r):(xc+r)],  # Of dimensions 2r x 2r, centered
                      a[,,3][(yc-r):(yc+r),(xc-r):(xc+r)],  # Around (xc, yc)
                      rep(1,length(a[,,1][(yc-r):(yc+r),(xc-r):(xc+r)]))),  # Add fourth alpha layer
             dim = c(length((yc-r):(yc+r)),length((xc-r):(xc+r)),4))

  if(yc > xc) yc = xc else if(xc > yc) xc = yc  # Re-evaluate your center for the cropped image
  xmax = dim(ma[,,1])[2]; ymax = dim(ma[,,1])[1]  # Find maximum x and y values

  # Second part of the function traces circle by the parametric eqn. and makes outside pixels transparent
  for(y in 1:ymax){  # For every y in the cropped image
    theta = asin((y - yc) / r)  # y = yc + r * sin(theta) by parametric equation for a circle
    x = xc + r * cos(theta)  # Then we can find the exact x coordinate using the same formula
    x = which.min(abs(1:xmax - x))  # Find which x in array is closest to exact coordinate
    if(!x - xc == 0 && !xmax - x == 0){  # If you're not at the "corners" of the circle
      ma[,,4][y,c(1:(xmax-x), (x+1):xmax)] = 0  # Make pixels on either side of the circle trans.
    } else if(!xmax - x == 0) ma[,,4][y,] = 0  # This line makes tops/bottoms transparent
  }
  return(ma)
} 

library(jpeg)
a = readJPEG("http://1.bp.blogspot.com/-KYvXCEvK9T4/Uyv8xyDQnTI/AAAAAAAAHFY/swaAHLS-ql0/s1600/pink-smiley-face-balls-laughing-HD-image-for-faacebook-sharing.jpg")
par(bg = "grey"); plot(1:2, type="n")  # Color background to check transparency
rasterImage(circ(a),1,1,2,2)

Modified version (thanks @dww):

dwwcirc = function(a){
  # First part of the function finds the radius of the circle and crops the image accordingly
  xc = floor(dim(a[,,1])[2]/2)  # X coordinate of the center
  yc = floor(dim(a[,,1])[1]/2)  # Y coordinate of the center
  r = min(xc, yc) - 1  # Radius is the smaller of the two -1 to avoid reading nonexistent data
  ma = array(data = c(a[,,1][(yc-r):(yc+r),(xc-r):(xc+r)],  # Read in the cropped image
                      a[,,2][(yc-r):(yc+r),(xc-r):(xc+r)],  # Of dimensions 2r x 2r, centered
                      a[,,3][(yc-r):(yc+r),(xc-r):(xc+r)],  # Around (xc, yc)
                      rep(1,length(a[,,1][(yc-r):(yc+r),(xc-r):(xc+r)]))),  # Add fourth alpha layer
             dim = c(length((yc-r):(yc+r)),length((xc-r):(xc+r)),4))

  if(yc > xc) yc = xc else if(xc > yc) xc = yc  # Re-evaluate your center for the cropped image
  xmax = dim(ma[,,1])[2]; ymax = dim(ma[,,1])[1]  # Find maximum x and y values

  x = rep(1:xmax, ymax)  # Vector containing all x values
  y = rep(1:ymax, each=xmax)  # Value containing all y values
  r2 = r^2
  ma[,,4][which(( (x-xc)^2 + (y-yc)^2 ) > r2)] = 0
  return(ma)
} 

library(jpeg)
a = readJPEG("http://1.bp.blogspot.com/-KYvXCEvK9T4/Uyv8xyDQnTI/AAAAAAAAHFY/swaAHLS-ql0/s1600/pink-smiley-face-balls-laughing-HD-image-for-faacebook-sharing.jpg")
par(bg = "grey"); plot(1:2, type="n")  # Color background to check transparency
rasterImage(dwwcirc(a),1,1,2,2)

Version using magick and plotrix (thanks @lukeA and @hrbrmstr):

library(plotrix)
jpeg(tf <- tempfile(fileext = "jpeg"), 1000, 1000)
par(mar = rep(0,4), yaxs="i", xaxs = "i")
plot(0, type = "n", ylim = c(0, 1), xlim = c(0,1), axes=F, xlab=NA, ylab=NA)
draw.circle(.5,.5,.5,col="black")
dev.off()

library(magick)
img = image_read("http://1.bp.blogspot.com/-KYvXCEvK9T4/Uyv8xyDQnTI/AAAAAAAAHFY/swaAHLS-ql0/s1600/pink-smiley-face-balls-laughing-HD-image-for-faacebook-sharing.jpg")
mask = image_read(tf)
radius = min(c(image_info(img)$width, image_info(img)$height))
mask = image_scale(mask, as.character(radius))

par(bg = "grey"); plot(1:2, type="n")
rasterImage(as.raster(image_composite(image = mask, composite_image = img, operator = "plus")),1,1,2,2)
Community
  • 1
  • 1
cass
  • 858
  • 1
  • 10
  • 17
  • You can crop using imager::imsub, but it only takes rectangular arguments. You are going to have to stick to what you are doing, but it does cut out one step of your heuristic. – shayaa Oct 16 '16 at 06:12
  • Shouldn't you use a real image processing tool, rather than trying to hack R like this? – Hong Ooi Oct 16 '16 at 07:21
  • [i only read the tl;dr] there are examples with grid graphics to make a raster mask – baptiste Oct 16 '16 at 08:49
  • You can use the new-ish `magick` package for pretty robust image transformations. – hrbrmstr Oct 16 '16 at 10:24
  • @HongOoi I thought about doing that, but since I'll be pulling upwards of 1,000 images that aren't guaranteed to be the same every time, I decided encoding it directly into my script would be easier. Do you know if there's a way to make calls to photoshop or gimp to crop images? – cass Oct 16 '16 at 16:10

2 Answers2

11

I dunno about "efficiency", but I would not reinvent the wheel here. Like suggested in the comments by @hrbrmstr, you may wanna try magick, which gives you all the flexibility you might need:

png(tf <- tempfile(fileext = ".png"), 1000, 1000)
par(mar = rep(0,4), yaxs="i", xaxs="i")
plot(0, type = "n", ylim = c(0,1), xlim=c(0,1), axes=F, xlab=NA, ylab=NA)
plotrix::draw.circle(.5,0.5,.5, col="black")
dev.off()

library(magick)
fn <- "https://www.gravatar.com/avatar/f57aba01c52e5c67696817eb87df84f2?s=328&d=identicon&r=PG&f=1"
img <- image_read(fn)
mask <- image_read(tf)
mask <- image_scale(mask, as.character(image_info(img)$width))

Now

img

enter image description here

mask

enter image description here

image_composite(mask, img, "plus") 

enter image description here

image_composite(mask, img, "minus") 

enter image description here

Some other composite operators:

# https://www.imagemagick.org/Magick++/Enumerations.html#CompositeOperator
ops <- c("over", "in", "out", "atop", "xor", "plus", "minus", "add",  "difference", "multiply")
for (op in ops) {
  print(image_composite(img, mask, op))
  print(op)
  readline()
}
lukeA
  • 53,097
  • 5
  • 97
  • 100
  • I'm giving this a try now. I do have a couple questions for you while I try to figure out for myself. – cass Oct 16 '16 at 16:52
  • Oops, I'm new to commenting - I didn't realize hitting enter would post. The first is that spotify keeps their artist pictures as jpegs, not PNGs, but this should work about the same, I think. The other question is does "minus" remove (or render invisible) the area you mask? Or does it keep it, just as the inverse of the color? Thanks – cass Oct 16 '16 at 16:55
  • #1 works the same, yep. #2 "minus" is _"composite image - image, with overflow cropped to zero. The matte chanel is ignored (set to 255, full coverage)."_. So, black mask means it's fully visible. (Gray darkens it. White is black.) – lukeA Oct 16 '16 at 17:16
  • Ok, so I've gotten a little more familiar with composite_image and the various operators (thanks for the link), and while it seems like this is what I should be doing instead of messing around with arrays, the area around the circle is definitely not transparent. running `par(bg = "grey"); plot(1:2)` followed by `rasterImage(as.raster(image_composite(mask, img, "plus")),1,1,2,2)` shows that the background is obscured. As it'll wind up being fairly essential that the areas around the circle are transparent and not white/black, is there a way to fix this? Maybe using "in" or "copyopacity"? – cass Oct 16 '16 at 17:47
  • I can't follow. Please edit your post and add a full minimal & reproducible example, read to copy-paste-run, which illustrated the problem (what _is_, what _should be_?). – lukeA Oct 16 '16 at 17:59
  • 2
    Ok, I put in the three variants I have. After running all three, @dww 's suggestion had the lowest runtime (1.420 seconds), then your variant (7.027 seconds) and my original (16.749 seconds). Additionally, the `image_composite` version cuts out the grey background that's visible using the other two versions, which I think means that the image isn't really cropped to a circle so much as a circle is cut out in white. Maybe I implemented it incorrectly? – cass Oct 16 '16 at 18:38
3

You can improve the performance of your circ function if you do a vectorised subset-assign operation on your array (instead of looping) using the the fact that (x-xc)^2 +(y-yc)^2 > r^2 for points outside a circle.

To do this, replace the 2nd part of your function with

  # Second part of the function traces circle by...
  x = rep(1:xmax, ymax)
  y = rep(1:ymax, each=xmax)
  r2 = r^2
  ma[,,4][which(( (x-xc)^2 + (y-yc)^2 ) > r2)] <- 0
  return(ma)
dww
  • 30,425
  • 5
  • 68
  • 111
  • 1
    Wow! I tried this and checked the runtime on a [decent-sized jpeg](http://1.bp.blogspot.com/-KYvXCEvK9T4/Uyv8xyDQnTI/AAAAAAAAHFY/swaAHLS-ql0/s1600/pink-smiley-face-balls-laughing-HD-image-for-faacebook-sharing.jpg). It takes less than one-tenth the time (down to 1.232 seconds from 16.887, according to `proc.time()`) which is insane. I'll be implementing this immediately, thanks! – cass Oct 16 '16 at 18:06
  • Great, glad it helped. Just one heads-up. the definitions of x & y *might* need to be exchanged, depending if your array is defined in column or row order. If they are the wrong way round it is an easy fix - (you will know if this needs tweaking if the circle gets offset from the centre for non-square images). Let me know if this happens an I'll fix the answer accordingly – dww Oct 16 '16 at 18:15
  • I haven't noticed anything more off-center than it originally was (thanks to the -1 when defining radius), but it seems like you call specific cells in array a, dimension d `a[,,d][y,x]`, so I think you're golden. – cass Oct 16 '16 at 18:45