How I built an image proxy server to anonymise images in twenty minutes
Let’s say you want to anonymise images by hiding any detected face, just by tweaking the src
attribute of the <img>
tag in HTML.
If we build a proxy server that does the work for us, we can prepend that server’s endpoint to any image and have them anonymised on their way to the browser, without touching (or owning) the source image. We could even write a jQuery plugin that auto-anonymised all images by tweaking the src
attribute on page load.
Design
Imagine this diagram looks all professional and that:
Our proxy will download any image from the internet, anonymise it, and serve the new image to the browser.
We are going to use Facebox by Machine Box to detect the faces, and while it’s easy to make HTTP requests in Go, I am going to use the Machine Box Go SDKto make things even easier to read.
The nice thing about using Facebox is that, with very little extra work, we could actually teach it the faces that we want to leave in the picture.
I timed myself writing this solution and it came to just under twenty minutes, but I must admit that I start by stealing this code from my past self:
Given an image, and a list of detected faces, it creates a new image and puts black squares wherever a face appears. This is the essence of the service, and it’s only 22 lines of code.
Get Facebox running (1 minute)
I wasted a whole minute getting Facebox running by typing the following into my terminal:
docker run -p 8080:8080 -e "MB_KEY=$MB_KEY" machinebox/facebox
Frustrated by this abhorrent waste of time, I was keen to get started on the tool.
If you haven’t setup your MB_KEY environment variable, this might take you another three or four minutes if you can bear it. You can get a free key from the Account page on the Machine Box website.
The server code (5 minutes)
I created a new folder called anonproxy
, and added a main.go
file containing this setup stuff:
func main() { var ( addr = flag.String("addr", "localhost:8000", "Listen address") faceboxAddr = flag.String("facebox", "https://localhost:8080", "Facebox address") ) flag.Parse() client := &http.Client{Timeout: 10 * time.Second} fb := facebox.New(*faceboxAddr)
This just sets some flags for my program, including the address on which my proxy will run (localhost:8000
) and where Facebox is running (https://localhost:8080
).
I create an http.Client
and set a ten second timeout — if we can’t get the image within ten seconds, we’ll abandon ship and return an error. And I create a Facebox client called fb
after importing github.com/machinebox/sdk-go/facebox
.
Next I added a placeholder handler:
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// the proxy will go here
})
Before finally printing some helpful info, and running the server using ListenAndServe
:
fmt.Println("Facebox at", *faceboxAddr) fmt.Println("listening on", *addr) fmt.Println("usage:", "https://"+*addr+"/?src=https://...") if err := http.ListenAndServe(*addr, nil); err != nil { log.Fatalln(err) }
The proxy handler (10 minutes)
The last thing I had to do is to write the code that will run when an HTTP request comes in.
This code goes inside the
http.HandleFunc
block that we wrote earlier.
I got the URL of the source image from the src
parameter. So you can use the proxy like this:
https://myproxy.com/?src=https://veritone.com/wp-content/uploads/2019/06/09105753/redact_wp_image.png
Here’s that code:
urlStr := r.URL.Query().Get(“src”) u, err := url.Parse(urlStr) if err != nil { http.Error(w, “url: “+err.Error(), http.StatusBadRequest) return } if !u.IsAbs() { http.Error(w, “url: absolute url required”, http.StatusBadRequest) return }
url.Parse
allows us to make sure the URL makes sense, and the IsAbs
method can even help us make sure it’s an absolute path.
All being well with the URL, it was time to download the image:
resp, err := client.Get(urlStr) if err != nil { http.Error(w, “download failed: “+err.Error(), http.StatusBadRequest) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { http.Error(w, “download failed: “+resp.Status, resp.StatusCode) return }
I use the client
I created earlier to Get
the image, and respond to any errors what might occur, including a non-2xx status code.
Never forget to close the body (usually by deferring it) otherwise your app will be leaking more than a thing with holes in it that isn’t supposed to have holes in it, like a bowl or a bucket. Not a sponge, which — while it does have holes in — is actually quite good at holding water.
I then download the image using iotuil.ReadAll
into a []byte
called b
:
b, err := ioutil.ReadAll(resp.Body) if err != nil { http.Error(w, “download failed: “+err.Error(), http.StatusInternalServerError) return }
I need to use the image data in two places, one to send to Facebox for analysis, and the other to decode into an image.Image
that my anonymise function needs.
TIP: If I only needed the data in one place, I would just use the resp.Body directly which wouldn’t necessarily mean buffering the whole image in memory.
Now I can use b
to decode the image by creating a new io.Reader
and passing it into image.Decode
. This will fail if the file isn’t an image, or if the format isn’t supported.
img, format, err := image.Decode(bytes.NewReader(b)) if err != nil { http.Error(w, “image: “+err.Error(), http.StatusInternalServerError) return }
img
will hold the image itself, while the format
string will tell me if it’s a gif
, jpeg
or png
.
Then I used the Facebox SDK to look for faces:
faces, err := fb.Check(bytes.NewReader(b)) if err != nil { http.Error(w, “facebox: “+err.Error(), http.StatusInternalServerError) return }
The faces slice that gets returned is the same structure as my anonymise function needs, so that was the next thing to do:
anonImg := anonymise(img, faces)
The anonImg
image is the source image, with all the faces redacted, and all that is left is to reply to the request with this data.
We could select a format to encode it with, but since we have the format string, let’s encode it using the same format as the original image. This is a nice touch and won’t interfere with alpha channels in PNGs or other features of the images.
Finally I added a switch block with code that set the appropriate Content-Type header on the response, and encoded the image using the appropriate package.
switch format { case “jpeg”: w.Header().Set(“Content-Type”, “image/jpg”) if err := jpeg.Encode(w, anonImg, &jpeg.Options{Quality: 100}); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } case “gif”: w.Header().Set(“Content-Type”, “image/gif”) if err := gif.Encode(w, anonImg, nil); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } case “png”: w.Header().Set(“Content-Type”, “image/png”) if err := png.Encode(w, anonImg); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } default: http.Error(w, “unsupported format: “+format, http.StatusInternalServerError) return }
Let’s take a closer look at the JPEG switch case:
w.Header().Set(“Content-Type”, “image/jpg”) err := jpeg.Encode(w, anonImg, &jpeg.Options{Quality: 100}) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return }
As you can see, we set the Content-Type
header to image/jpg
, and the Encode
function from the jpeg
package to encode the anonImg
to the http.ResponseWriter
w
. We use the best quality we can, because we want our proxy to be awesome.
And that’s it.
In a terminal, make sure Facebox is running, and run our proxy with go run main.go
.
Try it out (4 minutes)
Hit up some of these URLs:
It works no matter how many faces are detected:
And PNGs work — with transparent backgrounds: