Conf42 JavaScript 2020 - Online

- premiere 5PM GMT

Canvas pixels transformation: beauty comes slowly

Video size:

Abstract

How to turn maps or any image into beautiful mosaics with the Canvas api? I have a passion for maps and their aesthetics. With my project, I transform the canvas of a Mapbox map into a color mosaic.

However, my algorithm runs through all the pixels of the canvas, which takes a long time: more than 2 seconds to render a map on a fullHD screen. I started exploring the WebGL API to speed up rendering.

Summary

  • Victor is a lead developer at Theodo company based in Paris. Canvas pixel manipulation beauty comes slowly. This Conf 42 talk is basically a coding session. It will deal with the Javascript canvas API and in the second part with the usage of web workers to ease user interactions.
  • In order to copy mapbox pixels we need to get Mapbox pixels. We are going to apply our detection algorithm. We iterate over all pixels of the image. And we have a threshold to work very well with full colored images.
  • Web workers are a simple means for web content to run scripts in background threads. The worker thread can perform tasks without interfering with the user interface. How to ease user interaction with a web worker.

Transcript

This transcript was autogenerated. To make changes, submit a PR.
Welcome to this Javascript session. Canvas pixel manipulation beauty comes slowly. This Conf 42 talk is basically a coding session, which I call pseudo live coding session because, well, it's pre recorded and it will deal with the Javascript canvas API and in the second part with the usage of web workers to ease user interactions when there's a lot of computations to do. So these are learnings I had on a personal project called Mosaics, which transforms a map into a color map with kind of mosaic delimited by roads. I'm Victor. I'm a lead developer at Theodo company based in Paris. Well, as for the Paris office. So where exactly in Paris is Theodo based? So you can have a look at this beautiful map on maposeic.com. Maybe you can recognize Paris map if I zoom a little bit. Well, Theodo is kind of here, so it takes a few seconds to compute it. We'll discuss it later, but after a few seconds we have a beautiful mosaic. Towns are beautiful, but such is the countryside, such as this town in Borgoin. This is actually the place where I had the id during the containment to draw automatically maps delimited by roads. So I was doing it by hand and that's how I looked at the canvas. So our goal today is to build this mosaic from scratch. In order to do so, I have a small project running on localhost, a small react app that displays a map on the left and on the right. We are going to progressively build our mosaic. So let's have a look at the code. I have a react app with typescript, and here is a component called canvas demo that renders on the left the mapbox container, and on the right the mosaic container that we're going to fill. So what exactly is Mapbox? Mapbox is a great library that allows you to build a maps. Just calling Mapbox map a new object that is initialized with the container. I pass the reference of the container a style. So here the style is a satellite maps, a zoom level between zero and 22, I think, and a center to initialize the map with. So here I put a random longitude latitude so that we are able to travel a little bit. And when the map is rendered, I call this unrender function that console log renders and stop the loading the loading indicator. So if I refresh, you can see that there are a couple of, couple of renders. Okay, so our first steps for pixels manipulation, we are going to first paint a rectangle, then access the rectangle pixels. We're going to paint the pixels randomly we're going to set the size to look like the map box size, copy its pixels, apply a transformation, and then we'll apply the area detection algorithm to paint the mosaics. So let's first add this rectangle. So I initialize a constant with the canvas element that I get by id. So this is a reference to this id, a canvas HTML element. Then I need to access its context. Okay, so there are a couple of different contexts available and I'm using the 2d context. And then the mosaic context is possibly null. So if it is null, let's just return. Okay. Then I say, okay, I want you to fill with the style color magenta and fill a rectangle at the initial position. So top left corner with the canvas dimensions. So how does it look? Well, we have a beautiful, very beautiful magenta rectangle with the canvas default size actually. So 300 by 150 pixels. Okay, so that's a good first step. Then we're going to look at the pixels because this is an image. So we should be able to see what this is composed of. So the mosaidata is actually the context on the context. I call this method guest image data with the top left corner and the size of the data I want. Okay, so if we console log it, what do we get? Okay, image data. So you see there is three properties and we're console logging the data directly. Okay, so we have an array of numbers, actually eight clamped numbers. So clamped means that they're between zero and 255. And actually each pixel is composed of four numbers, the RGB and a values. So that's what I put here. So our pixels are indexed from top left to bottom right. The first pixel is this one. And each pixel is composed of four numbers. Okay, so magenta is a mix between blue and red. Okay, so having that, let's paint the pixels. So we're going to paint them randomly. So this size is not defined canvas width and height canvas. And let's iterate over the data of the data. And at each pixel index I, we assign random pixel value, a random number between zero and 255. And then once we have this, we need to apply to the context, the new image data with the put image data method. Okay, so now you can see that we have kind of mosaic, actually a lot of pixels with random columns. And each time the map box renders, map renders, we have a new computation. So maybe I could just display the console here. Okay, now we want to set the same size as for the mapbox shape. So we need here to access the mapbox canvas. Okay, so the same way we're going to use a mapbox context, which this time will not be the 2d context, but instead the Webgl context because mapbox uses webgl API and we'll set our mosaics canvas width and height with the drawing buffer width and height of the mapbox context. Okay, so we'll need to do this before. And the map is actually the object we just constructed here. So we need to pass a maps to our function map gl dot map object. Okay, so the same way this context may not be it presence. Okay, so what do we got here? We got actually a huge canvas that is twice the desired size. This is because on retina screens mapbox renders twice as much as pixels per directions. So actually I need to fix the CSS size to access the CSS size here. So if I put a width of let's say 644, it's not going to make it. Okay, now we're good. So we need to do it in the code. So that's why I use this. So I use the style property of our HTML elements and I set it to a string necessarily with the size divided by two. So you see that the width of the canvas must not be confended with the style width property. Okay, so it started looking nice. What do we have to do now? So we are going to copy mapbox pixels. So in order to copy mapbox pixels we need to get Mapbox pixels. So actually we're going to use the Webgl API before or after this? Yeah, actually I don't need to console like this. Okay, so I construct a new int number array of the correct size. So width times height times four because each pixel has four coordinates. And to write the pixels in this array I need to call the read pixels method on the mapbox context with initial positions, the size and some other constants and also the reference to my constant. Okay, so now I have the mapbox pixels so I'm able to copy them. Okay, nice. So it's not actually done yet in Shemando Taranel because you see Shemanda Taranell here is written on the opposite side. This is because the mapbox, the Webgl convention started in the bottom left. So we need to apply some transformation to our pixels. So here I wrote some utils to be able to do that. So it considers that we have an x and y axis and each index position can allow us to find the x and y point position. So basically when we start to find a transformation from one canvas to the other, I have the I position in the index position in the mosaic reference, I convert it into an x and y point. Then this point in the mapbox canvas is just the same, but with a transformation on the y axis. And then with this calculus I get the map box index, pixel index. So I just need to call this little functional it. Okay, then I get the mapbox pixel index. So now we recreate, so that we could completely paint access the pixels of a map. So we are going to apply our detection algorithm. So let's describe a bit this detection algorithm. So the source is basically binary color image and the target is going to be a colorful image. So I have to detect three areas here in this image. So how do we do? We iterate over all pixels of the image and we make sure we delimit the contour of the image. So this is a zoom. Well, not actually a zoom, but yeah, with very big pixels our source. And we'll iterate from top left to bottom right with this eye index. And we are going to detect first this black area. So the source color is black and we want a target color of Xiong. And we call this routine paint area which push the index in a stack, in a two visit stack. And while this stack is not empty, it pops an element and paints this element. Then it looks at the neighbors of this element. So there is one at east, south, southwest and north, and the west and north pixel are not considered here. And if the neighbor color is of the same color, which is black, it pushes it in the two visit stack. So it will push here the south pixels. And once the stack is empty, Webgl have this first area painted. Then it takes the next pixel, well that we haven't visited. So each time we visit a pixel, we also mark it as visited and it paints the white area the same way. Then these two pixels have already been visited. So the next pixel will be this one and the target color this time will be burgundy, which is Bordeaux. And here we go. So I just need to call this transformer. Instead of doing this, I have a class canvas data transformer, which is actually the implementation of the previously described algorithm, which has, well, is contrasted with a source pixel array, a target pixel array, a size, and it generalizes a visited pixel sex. The paint target data is the main method and it will do what I previously described. Okay, for each area that we have not visited, it calls the pen current area method, which initialize this tag, et cetera. Okay, let's look for example at the method adjacent points. This is the method that gives the neighbor of a point. So for each point, each point has four neighbors, southeast, west and north. Okay, so having this, we'll call the transformer method, paint target data. And now what we have from our transformer, we can get the target pixel array. So actually put image data, receives an image data. So actually it's the image data that we need to pass here and we need to set. Actually we cannot set it like this. We cannot assign data to a value because it's a read only property. So we use the set method. Okay, let's look at it. It's working even with this shape, which is quite nice. So we have a threshold, although it doesn't work very well with full colored images because we just have a threshold to detect white areas. So that's why I'm using now another style for our Mapbox mile maps, which is a road style that I customized. So that's what is really nice with Mapbox is that there is an application tile studio or something. Yeah. Where you can customize your map styles. And this is the one I created to be able to render the map. So actually we're facing here, I'm facing some user interaction problems because all the computation is taken by my algorithm and it blocks the UI. So with difficulties here, I want to drag, but I can't get it. So even if I want to see the Kamarg, I'm going to have a lot of troubles to do this. So that leads me to the second part of this talk, which is how to ease user interaction with a web worker. So we want to run the process of the algorithm in a web worker. So why do we want this? Web workers are a simple means for web content to run scripts in background threads. That's perfect. The worker thread can perform tasks without interfering with the user interface. Okay, so basically, how does it work? Basically we initialize a worker with a file and we send on the worker side, we have an on message function that listens to messages, do the jobs, and then pass the result to the main thread. Okay, so the problem is for us is that we cannot use this for me, actually, I cannot use this directly because webpack is going to build the application and the URL is not going to be available anymore. So that's why I need to use a worker loader. So worker loader is a webpack module that I already installed in my node modules. Okay. So I just need to import the worker with this syntax. And actually this syntax is to make webpack understand to load the file with the worker loader. So what I also had to do is to eject my create react app to be able to override webpack configuration. So that's what I did and this is what my config looks like. So I'm using customize. Correct. But it's basically the same syntax as the one we just saw. Okay, so I'll import. Okay, we also have in this documentation a typescript hint that indicates you to declare a new module so that web worker is understood by typescript. Okay, so this is where my custom typing is. I have here a paintworker that initialize. Well, there's a constant that we call the on message method on. And when we receive a message, I will console log the message. So new worker. And the URL is it. So our worker is paint worker. Okay. And const worker, new worker. That's what we're supposed to do. Yeah, you'll need this because there's a rule added by create react app that doesn't want you to use this syntax because they don't want to be bound to webpacks actually. So that's why to ignore next line. Okay, is there any log? No, because we haven't passed any message to our walker. So before we do all this transformation, we'll pass a message. Work on this please. And Webgl listen to the response. The worker received the message, which is a message event with the data. Work on this piece and in the other side we receive the response. Got it? Okay, so now we're going to be able to put this in our walker so it's not the maps box pixels, so work on our data. We're going to initialize it with the data we want. Okay. And this is actually an object with the type I created and. Yeah, sorry, worker payload. Yeah, worker payload is an object with what I need here. And we're going to use it in our class, in our constructor, so there is no event anymore. Okay. And then when I call the transformer paint target data, I will pass the data that was just painted. Okay. And here I need, instead of doing this, I'm going to call the worker with the payload and target pixel array will be the mosaic data, the data. The source pixel array will be the walker, the mapbox pixels and the size of the canvas. And we'll post this payload and on the reception so it's the same data which is a worker response type UIT array I can console receives. And then we'll set the Masai data to this new data we just received and then put image data, the new image data and set is loading inside the on message function. Okay, reorder to type our magenta pixel and you see that the UI is not blocked anymore. However, there is a lot of render that are triggered. The worker previously is doing jobs that are outdated. So to prevent this I can terminate the worker job each time I'm about to do a new render. So this aborts the worker and it's also killed it. So I need to recreate it. That's why I want it to be a variable. Okay, and now you see that the worker is not doing any unnecessary renders. This is the only message response I received. Okay, so now we are done with our beautiful mosaic. As for a conclusion, as you can see, my solution is quite slow, so the algorithm is quite long to compute. So for example, for the 5 million pixels I have on my full screen, it takes up to 5 seconds, so it's very long indeed. So how could I improve this? So first I asked myself, why does mapbox map render so quickly? So first they apply only on layers, so they have a background, for example. Then they apply a layer of road, then a layer of forest, so they don't need to fill the whole image pixel by pixel. And then they use the Webgl API which allows you to draw shapes very efficiently thanks to vertex arrays. So maybe I could inspire with this with a new algorithm. Algorithm that would follow the road with the GPS coordinates and find the intersections to delimit the area's vertexes and then apply a layer with these vertexes. Another, another option. I asked myself why a software like Photoshops is so fast to detect areas. And one hypothesis is that it uses all the capabilities of the operating system. So I would like to try to run my process with binary instructions which can be compiled with webassembly and maybe it could improve. Well, it would be interesting to see the gain with this, so if you have any suggestion, don't hesitate to contact me. So I put my email address here. Thank, thanks a lot for your attention and I hope you'll do some great drawing with the canvas API.
...

Victor Lebrun

Web Developer @ Theodo

Victor Lebrun's LinkedIn account



Awesome tech events for

Priority access to all content

Video hallway track

Community chat

Exclusive promotions and giveaways