rstudio / plumber

Turn your R code into a web API.
https://www.rplumber.io
Other
1.39k stars 256 forks source link

`png` serializer with dynamic image size #837

Open logstar opened 2 years ago

logstar commented 2 years ago

If you wish to dynamically size images, you will need render and capture the graphical output yourself and return the contents with the appropriate Content-Type header. See the existing image renderers as a model of how to do this.

-- https://www.rplumber.io/articles/rendering-output.html#customizing-image-serializers viewed on 10/18/2021

According to the above documentation, a png serializer with dynamic image sizes can be implemented with the current plumber framework. This issue is a feature request for creating a friendly and stable interface for users to directly use png serializer with dynamic image sizes.

I attempted to implement a png serializer with dynamic image sizes, dyn_param_png_serializer, which is listed below. However, is dyn_param_png_serializer properly implemented? As plumber::serializer_device apparently also supports async execution, will dyn_param_png_serializer cause async errors? Will dyn_param_png_serializer likely be supported by future plumber versions?

Additionally, expressions inside dyn_param_png_serializer cannot directly access variables in /plot endpoint function, e.g. image_width and myData, but why the passed my_plot_func can be called directly within dyn_param_png_serializer? Is there any pointers on the scope of variables in the context of plumber endpoint function definition, registration, runtime, etc?

dyn_param_png_serializer <- function(plot_func, width, height, res) {
  # print(image_width) # <simpleError in print(image_width): object 'image_width' not found>
  tmpfn <- base::tempfile()

  grDevices::png(tmpfn, width = width, height = height, res = res)
  base::on.exit({base::unlink(tmpfn)}, add = TRUE)

  device_id <- grDevices::dev.cur()

  plot_func()

  grDevices::dev.off(device_id)

  fconn <- base::file(tmpfn, "rb")
  base::on.exit({base::close(fconn)}, add = TRUE)

  img <- base::readBin(fconn, "raw", n = base::file.info(tmpfn)$size)

  return(img)
}

#* Plot out data from the iris dataset
#* @param spec If provided, filter the data to only this species (e.g. 'setosa')
#* @get /plot
#* @serializer contentType list(type="image/png")
function(spec){
  myData <- iris
  title <- "All Species"

  # Filter if the species was specified
  if (!missing(spec)){
    title <- paste0("Only the '", spec, "' Species")
    myData <- subset(iris, Species == spec)
    image_width <- 1000
  } else {
    image_width <- 2000
  }

  my_plot_func <- function() {
    plot(myData$Sepal.Length, myData$Petal.Length,
         main=title, xlab="Sepal Length", ylab="Petal Length")
  }

  img <- dyn_param_png_serializer(my_plot_func, image_width, 1000, 300)

  return(img)
}

This example is adapted from https://www.rplumber.io/articles/introduction.html and https://github.com/rstudio/plumber/blob/06e46f3ff5119e5f1cb8af29ef49aecb3cbb932a/R/serializer.R#L473-L535.

meztez commented 2 years ago

I did an example over stackoverflow

https://stackoverflow.com/questions/65098103/plumber-r-render-a-svg-file/65101289#65101289

library(plumber)

device_size <- function() {
  h_ <- 7
  w_ <- 7
  list(
    h = function() h_,
    w = function() w_,
    set_h = function(h) if (!is.null(h)) {h_ <<- as.numeric(h)},
    set_w = function(w) if (!is.null(w)) {w_ <<- as.numeric(w)}
  )
}

output_size <- device_size()

serializer_dynamic_svg <- function(..., type = "image/svg+xml") {
  serializer_device(
    type = type,
    dev_on = function(filename) {
      grDevices::svg(filename,
                     width = output_size$w(),
                     height = output_size$h())
    }
  )
}
register_serializer("svg", serializer_dynamic_svg)

#* @filter dynamic_size
function(req) {
  if (req$PATH_INFO == "/plot") {
    output_size$set_w(req$args$width)
    output_size$set_h(req$args$height)
  }
  plumber::forward()
}

Will revisit

schloerke commented 2 years ago

This is a great solution given the tools available, @meztez !

schloerke commented 2 years ago

@logstar Dynamic sizes is a difficult thing to implement as plumber has to assume that once the endpoint has started running, images are being created. @meztez has skirted around not being able to use the route definition by using a filter to set size information that is later used in the image serialization. This solution still allows for the device to be opened before the route execution begins and for the device to be closed once the route function ends.

However, is dyn_param_png_serializer properly implemented?

I believe so!

Minor adjustments:

As plumber::serializer_device apparently also supports async execution, will dyn_param_png_serializer cause async errors? No. (yay!) Once dyn_param_png_serializer() begins its execution, it will not stop. So async code is not allowed to run as the main R session is not free.

Will dyn_param_png_serializer likely be supported by future plumber versions?

I would like to make this experience better. I don't know if it will be this function directly or through something similar to @meztez 's filter solution.

why the passed my_plot_func can be called directly within dyn_param_png_serializer? Is there any pointers on the scope of variables in the context of plumber endpoint function definition, registration, runtime, etc?

Check out https://adv-r.hadley.nz/functions.html?q=lexi#lexical-scoping . The whole book is a great resource for learning the nitty gritty details about R!

Here is a good example from https://prl.ccs.neu.edu/blog/2019/09/10/scoping-in-r/:

x <- 1
f <- function() {
  x
}
g <- function() {
  x <- 2
  f()
}
g() # What does this return?
zq2323 commented 2 years ago

I did an example over stackoverflow

https://stackoverflow.com/questions/65098103/plumber-r-render-a-svg-file/65101289#65101289

library(plumber)

device_size <- function() {
  h_ <- 7
  w_ <- 7
  list(
    h = function() h_,
    w = function() w_,
    set_h = function(h) if (!is.null(h)) {h_ <<- as.numeric(h)},
    set_w = function(w) if (!is.null(w)) {w_ <<- as.numeric(w)}
  )
}

output_size <- device_size()

serializer_dynamic_svg <- function(..., type = "image/svg+xml") {
  serializer_device(
    type = type,
    dev_on = function(filename) {
      grDevices::svg(filename,
                     width = output_size$w(),
                     height = output_size$h())
    }
  )
}
register_serializer("svg", serializer_dynamic_svg)

#* @filter dynamic_size
function(req) {
  if (req$PATH_INFO == "/plot") {
    output_size$set_w(req$args$width)
    output_size$set_h(req$args$height)
  }
  plumber::forward()
}

Will revisit

Hi @meztez ,

Thanks for the tips to overwrite the 'png'. I just have one question for the req$args. how can I set values of the widthand heightin the request. I'm new to the plumber. I try to add them in the endpoint but failed.

here is my code:

#* @parser json
#* @parser multi
#* @serializer png list(width = 600, height = 800)
#* @param width
#* @param height
#* @post /plot
function(req, res,  width, height){
  df <- data.frame(
  gp = factor(rep(letters[1:3], each = 10)),
  y = rnorm(30)
)
ds <- do.call(rbind, lapply(split(df, df$gp), function(d) {
  data.frame(mean = mean(d$y), sd = sd(d$y), gp = d$gp)
}))
p <- ggplot2::ggplot(df, ggplot2::aes(gp, y)) +
  ggplot2::geom_point() +
  ggplot2::geom_point(data = ds, ggplot2::aes(y = mean), colour = 'red', size = 3)

print(p)
}
r <- httr::POST(
  "host://0.0.0.0/plot",
  httr::accept_json(),
  body = list(
    width = jsonlite::toJSON(list(width = 600)) ,
    height = jsonlite::toJSON(list(height = 800))
  ),
  httr::write_disk("test.png"), overwrite = TRUE)

)
meztez commented 2 years ago

http://0.0.0.0/plot?width=100&height=100

slodge commented 1 year ago

Will dyn_param_png_serializer likely be supported by future plumber versions?

I'd love to see this (or the filter version) inside the library. Anything I (or others) can assist with?


In addition to the ideas above, I'm wondering about whether to make the content-type itself dynamic - switching between jpeg, png and svg on client demand

schloerke commented 1 year ago

@slodge

This provides a way forward without adding new serializer methods...

Thank you!

slodge commented 1 year ago

Start of a PR is in.... there is lots of testing and documentation needed if anyone else wants to contribute 👍

Going to need to get the other PRs #889 #891 #892 out of the way before this one can be merged - as there will be merge conflicts around the endpoint, parameter and open api code...

Small nudge: could do with RStudio making decisions on these PRs. Delays will cause extra work ... my memory is fading... making changes gets harder every day :)

slodge commented 1 year ago

PR Redone with personal email... and might have to do it again yet... I have too many email addresses ...