Making a Picture Heatmap

Milecia

Sometimes we want to focus on specific parts of an image to figure out what's going on. This might involve looking at different quadrants of an image to see what the special features are. That can be done with machine learning or user input.

In this post, we're going to look at different locations on multiple images to take a guess at where certain information or image details should be displayed. We'll save snapshots of the heatmap and upload them to Cloudinary and save a reference to them in a Postgres database with a Redwood app.

Some initial setup

A couple of things you'll need to have in place are:

  • a Cloudinary account
  • a local instance of Postgres

This covers all of the things we need outside of our Redwood app. Now we can open a terminal and create a new app.

Creating the Redwood app

In the terminal, run this command:

1$ yarn create redwood-app --typescript image-heatmap

Note: If you don't want to work with TypeScript, feel free to leave the --typescript flag out of the command.

This will generate a lot of code for you. The main two folders we'll be working in are web and api. The web folder is where we'll write all of the React code for the front-end. The api directory is where we'll define our database schema and write a back-end using GraphQL.

Let's start working in the back-end.

Building the database schema

Redwood uses Prisma as the ORM to handle database operations. In the api > db folder, you'll see a schema.prisma file. This is where we'll define the database connection and the models that make up our tables and relations.

The first thing we can do is change the provider value to postgresql from sqlite.

Then you'll notice that the url value is coming from the DATABASE_URL while is an environment variable. So you'll need to open the .env file and uncomment this line and update it with your local connection string. That may look similar to this:

1DATABASE_URL=postgres://postgres:admin@localhost:5432/heatmap

Now you can delete the example model that's in the schema.prisma and replace it with our real models below:

1model Image {
2 id Int @id @default(autoincrement())
3 name String
4 heatmap Heatmap[]
5}
6
7model Heatmap {
8 id String @id @default(uuid())
9 originalImage Image @relation(fields: [imageId], references: [id])
10 imageId Int
11 heatmapImage String
12}

This means that an image can have multiple heatmaps associated with it, but each heatmap can only be associated with one image. It'll help us keep track of which images we're working with when we look at reports using this data.

Now that we have the models ready, we can run a migration on our database with the following command:

1$ yarn redwood prisma migrate dev

This creates a new database and a couple of tables in our local Postgres instance. Now there's a really cool thing we can do to add images we want to work with.

Scaffolding with Redwood

Since we have our models in place, the Redwood CLI has a command that will generate all of the CRUD for us on both the front-end and back-end. In your terminal, run:

1$ yarn redwood generate scaffold image

This will create a lot of new files in different locations for us, but we'll have a fully functional front-end and back-end! This is a good time to start the app and take a look at what we have so far.

In your terminal, run this command:

1$ yarn redwood dev

This will open your browser to a page like this.

If you go to the /images/new page, you'll see where you can add the name of the image to our database.

Since we have everything set up for our images, let's get things ready to handle the heatmaps.

Adding more to the back-end

The front-end for the heatmap input will be pretty different from how we're handling the image data, so we'll build the back-end separately. We can still take advantage of the Redwood CLI to generate the back-end CRUD. Run the following command in your terminal:

1$ yarn redwood generate sdl --crud heatmap

Now if you take a look in api > src > graphql, you'll find all of the types for the heatmap queries and mutations. Then if you go over to api > src > services, you'll see the folder that has several files for the heatmap resolvers. There are a couple of test-related files and then the main file with the resolvers.

If you take a look inside heatmap.ts, you'll see all of the resolvers to handle getting data and updating data. We don't need to make any changes here. All of our work will be on the front-end now.

Adding a new package to the front-end

Before we write the code, let's install a package we'll need to take the heatmap image straight from the browser. In the terminal, go to the web directory and run the following command:

1$ yarn add html-to-image

This is a really nice package that converts HTML elements into images. Now let's make the page for our heatmaps.

Adding the page to make heatmaps

In the terminal, go to the root of the project again and run this command:

1$ yarn redwood generate page heatmap

This will create a new directory in web > src > pages called HeatmapPage. This also updates the Routes.tsx to include this new route in the app. Let's take a look in web > src > pages > HeatmapPage.

There are three files: one containing a Storybook story, one with a Jest test ready to run, and the page component itself. Open HeatmapPage.tsx. This is where we'll write all of the remaining code.

Updating the imports

You can delete all of the existing imports in this file and replace them with the following:

1import { useQuery, useMutation } from '@redwoodjs/web'
2import { useRef, useState } from 'react'
3import { toPng } from 'html-to-image'

These are the hooks and methods we'll work with. Now we have a couple of GraphQL queries to make.

Setting up the GraphQL queries

Let's add these queries below the imports. Add the following code:

1const GET_IMAGES = gql`
2 query {
3 images {
4 id
5 name
6 url
7 }
8 }
9`
10
11const CREATE_HEATMAP_MUTATION = gql`
12 mutation CreateHeatmapMutation($input: CreateHeatmapInput!) {
13 createHeatmap(input: $input) {
14 id
15 }
16 }
17`

The first query will return the id, name, and URL of all the images we have saved in the database. That way we'll be able to decide what to apply the heatmap to.

The second query is actually the mutation we need to call in order to make a new heatmap record in the database. It only returns the id because we don't need to do anything after the record is created.

Setting states in the component

Now we can start building the inner workings of this HeatmapPage component. We'll add some states and other things we need to make this component function like we want. Inside the HeatmapPage component, add the following code:

1const [createHeatmap] = useMutation(CREATE_HEATMAP_MUTATION)
2const { data, loading } = useQuery(GET_IMAGES)
3
4const heatmapRef = useRef(null)
5const [image, setImage] = useState({id: 1, url: `https://res.cloudinary.com/${cloudName}/image/upload/v1606580778/3dogs.jpg`})
6const [bottom, setBottom] = useState<string>('0')
7const [left, setLeft] = useState<string>('0')

The first two lines give us the method for creating new heatmap images and all of the images from our database, including the loading state. Then we set a ref to get the HTML elements that will make up the heatmap.

Lastly, we set a few state variables. The cloudName value the url should be the cloud name you see in your Cloudinary console.

With the initial variables in place, let's add a quick check to see if the data is still loading. Below the state variables, add the following code:

1if (loading) {
2 return <div>Loading...</div>
3}

If the data is still being loaded, we don't execute any more code. We just return this little loading element. The reason we do this is to prevent the app from crashing when there isn't data available when it expects.

The create/upload function

Now that we have a good data check in place, let's start writing the functions we'll need to upload the heatmap images to Cloudinary and create new records in the local Postgres database. Below the loading check, add the following code:

1const uploadHeatmap = async () => {
2 if (heatmapRef.current === null) {
3 return
4 }
5
6 const dataUrl = await toPng(heatmapRef.current, { cacheBust: true })
7
8 const uploadApi = `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`
9
10 const formData = new FormData()
11 formData.append('file', dataUrl)
12 formData.append('upload_preset', 'lqe6bakr')
13
14 const cloudinaryRes = await fetch(uploadApi, {
15 method: 'POST',
16 body: formData,
17 })
18
19 const input = {
20 heatmapImage: cloudinaryRes.url,
21 imageId: image.id,
22 }
23
24 createHeatmap({
25 variables: { input },
26 })
27}

There are a few things going on here. We do a quick check to make sure the heatmap ref isn't null so that we know we can capture the image. Then we convert that ref to an image using the toPng method of that library we added earlier.

Next, we set the URL for the Cloudinary API we need to upload any heatmap image. Remember that you can get the cloudName value from your Cloudinary console. Then we create a FormData object to hold the heatmap image and our Cloudinary upload preset.

Then we make the call to the API, upload the image, and get the response back. That's where we get the URL to the heatmap image that gets passed into the createHeatmap variables.

A little helper function

We'll be manually placing a heatmap over the images we select through button clicks. That's why we have the bottom and left states. These control the location of the heatmap on a given image. So we need a little switch statement to update those states correctly.

1const setPosition = (position) => {
2 switch(position) {
3 case 'top-left':
4 setBottom('0')
5 setLeft('0')
6 break
7 case 'top-right':
8 setBottom('0')
9 setLeft('80%')
10 break
11 case 'bottom-left':
12 setBottom('-150px')
13 setLeft('0')
14 break
15 case 'bottom-right':
16 setBottom('-150px')
17 setLeft('80%')
18 break
19 }
20}

These values are going to be used in CSS, that's why they might look a bit all over the place. All that's left now is writing the HTML elements for this component.

A simple user interface

There are going to be multiple buttons and a dropdown to help users target, along with an element to display the picture and update where the heatmap is shown.

Add the following code right below that helper function we made:

1return (
2 <>
3 <h1>HeatmapPage</h1>
4 <select onChange={(e) => {
5 const {id, url} = JSON.parse(e.target.value)
6 setImage({id: id, url: url})}
7 }
8 >
9 {data.images.map(image => (
10 <option
11 key={image.id}
12 value={`{"id": ${image.id}, "url": "${image.url}"}`}
13 >
14 {image.name}
15 </option>
16 ))
17 }
18 </select>
19 <button onClick={uploadHeatmap}>Upload Heatmap to Cloudinary</button>
20 <div style={{ display: 'flex', flexDirection: 'row'}}>
21 <button onClick={() => setPosition('top-left')}>Top-Left</button>
22 <button onClick={() => setPosition('top-right')}>Top-Right</button>
23 <button onClick={() => setPosition('bottom-left')}>Bottom-Left</button>
24 <button onClick={() => setPosition('bottom-right')}>Bottom-Right</button>
25 </div>
26 <div
27 ref={heatmapRef}
28 style={{
29 backgroundImage: `url(${image.url})`,
30 backgroundRepeat: 'no-repeat',
31 backgroundSize: 'cover',
32 height: 300,
33 position: 'absolute',
34 width: '100%'
35 }}
36 >
37 <div
38 style={{
39 background: 'radial-gradient(rgba(0, 255, 25, 0.5), rgba(255, 0, 25, 0.5))',
40 height: 150,
41 position: 'relative',
42 bottom: bottom,
43 left: left,
44 width: 250,
45 zIndex: 10
46 }}
47 ></div>
48 </div>
49 </>
50)

Let's walk through what's happening in this component. The first thing to note is the <select> element. This has an onChange function that gets the image id and URL from the selected element and updates the corresponding state.

Inside the <select> element, we're using the image data fetched from GraphQL to create the options we can choose from in the dropdown. Right after that, we have a button that triggers the upload to Cloudinary and creates the database record for the heatmap.

Then we have a set of buttons that update the location of the heatmap by updating that state. Finally, we have the image with the heatmap overlayed.

You'll see where the heatmapRef is being used so that we can capture the image. These two <div> elements just have some CSS properties that update based on the selected image option and heatmap location a user picks.

Your final code should look like this:

1import { useQuery, useMutation } from '@redwoodjs/web'
2import { useRef, useState } from 'react'
3import { toPng } from 'html-to-image'
4
5const GET_IMAGES = gql`
6 query {
7 images {
8 id
9 name
10 url
11 }
12 }
13`
14
15const CREATE_HEATMAP_MUTATION = gql`
16 mutation CreateHeatmapMutation($input: CreateHeatmapInput!) {
17 createHeatmap(input: $input) {
18 id
19 }
20 }
21`
22
23const HeatmapPage = () => {
24 const [createHeatmap] = useMutation(CREATE_HEATMAP_MUTATION)
25 const { data, loading } = useQuery(GET_IMAGES)
26
27 const heatmapRef = useRef(null)
28 const [image, setImage] = useState({id: 1, url: 'https://res.cloudinary.com/milecia/image/upload/v1606580778/3dogs.jpg'})
29 const [bottom, setBottom] = useState<string>('0')
30 const [left, setLeft] = useState<string>('0')
31
32 if (loading) {
33 return <div>Loading...</div>
34 }
35
36 const uploadHeatmap = async () => {
37 if (heatmapRef.current === null) {
38 return
39 }
40
41 const dataUrl = await toPng(heatmapRef.current, { cacheBust: true })
42
43 const uploadApi = `https://api.cloudinary.com/v1_1/milecia/image/upload`
44
45 const formData = new FormData()
46 formData.append('file', dataUrl)
47 formData.append('upload_preset', 'cwt1qiwn')
48
49 const cloudinaryRes = await fetch(uploadApi, {
50 method: 'POST',
51 body: formData,
52 })
53
54 const input = {
55 heatmapImage: cloudinaryRes.url,
56 imageId: image.id,
57 }
58
59 createHeatmap({
60 variables: { input },
61 })
62 }
63
64 const setPosition = (position) => {
65 switch(position) {
66 case 'top-left':
67 setBottom('0')
68 setLeft('0')
69 break
70 case 'top-right':
71 setBottom('0')
72 setLeft('80%')
73 break
74 case 'bottom-left':
75 setBottom('-150px')
76 setLeft('0')
77 break
78 case 'bottom-right':
79 setBottom('-150px')
80 setLeft('80%')
81 break
82 }
83 }
84
85 return (
86 <>
87 <h1>HeatmapPage</h1>
88 <select onChange={(e) => {
89 const {id, url} = JSON.parse(e.target.value)
90 setImage({id: id, url: url})}
91 }
92 >
93 {data.images.map(image => (
94 <option
95 key={image.id}
96 value={`{"id": ${image.id}, "url": "${image.url}"}`}
97 >
98 {image.name}
99 </option>
100 ))
101 }
102 </select>
103 <button onClick={uploadHeatmap}>Upload Heatmap to Cloudinary</button>
104 <div style={{ display: 'flex', flexDirection: 'row'}}>
105 <button onClick={() => setPosition('top-left')}>Top-Left</button>
106 <button onClick={() => setPosition('top-right')}>Top-Right</button>
107 <button onClick={() => setPosition('bottom-left')}>Bottom-Left</button>
108 <button onClick={() => setPosition('bottom-right')}>Bottom-Right</button>
109 </div>
110 <div
111 ref={heatmapRef}
112 style={{
113 backgroundImage: `url(${image.url})`,
114 backgroundRepeat: 'no-repeat',
115 backgroundSize: 'cover',
116 height: 300,
117 position: 'absolute',
118 width: '100%'
119 }}
120 >
121 <div
122 style={{
123 background: 'radial-gradient(rgba(0, 255, 25, 0.5), rgba(255, 0, 25, 0.5))',
124 height: 150,
125 position: 'relative',
126 bottom: bottom,
127 left: left,
128 width: 250,
129 zIndex: 10
130 }}
131 ></div>
132 </div>
133 </>
134 )
135}
136
137export default HeatmapPage

That's all of the code! Now open your terminal and run:

1$ yarn redwood dev

You should see something similar to this.

There's a chance you might want to add new images to select from. This is where you can go to the images/new page and add new URLs to other images.

Finished code

If you want to check out the complete project, you can find all of the code in the image-heatmap directory of this repo.

Or you can check out the front-end in this Code Sandbox.

Conclusion

There are several ways you could take advantage of a dynamically moving heatmap. Maybe you'll end up building some machine learning model to create heatmaps for you so you can take a look at where people interact with the page the most. Or maybe you'll make something that lets users see where they have the most activity in apps.s

Milecia

Software Team Lead

Milecia is a senior software engineer, international tech speaker, and mad scientist that works with hardware and software. She will try to make anything with JavaScript first.