(Images are somewhat out of focus as the camera I am using doesn't have auto focus and I didn't adjust its position for these pics, they were just test data!)
This is currently a very basic "viewer" type front end that shows the 9 most recently captureed images in the Redis database along with their metadata.
In future updates, I aim to add some sort of filtering and pagination or lazy load functionality.
The server is built using Python and the Flask framework. The front end web application is a single web page, styled with Bulma and using vanilla ES6 JavaScript with no JavaScript framework complexity / bloat.
First, let's take a look at how the project is organised.
- All of the code for the server is in a single file:
app.py
. This uses the Flask framework and maintains a connection to Redis. - The HTML for the front end is a Flask template, contained in
templates/index.html
. The application doesn't actually do any templating soindex.html
is essentially a static file, but I set it up as a template in case you want to build on this start point and do something dynamic on the home page. It also means that I could use a tiny bit of templating to create the<script>
tag that load the JavaScript for the front end... - The JavaScript that runs on the front end is a static file, contained in
static/app.js
. Flask knows how to serve this when asked for a URL/static/app.js
. You can see how this is resolved at the bottom of theindex.html
file:
<script type="text/javascript" src="{{ url_for('static', filename = 'app.js') }}" defer></script>
- There is no CSS file for this project, all CSS is provided by the Bulma framework. It's included in the
<head>
ofindex.html
and hosted on a CDN:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css">
The server-side Flask application is contained entirely in the app.py
file. This will be a high level walk through - if you'd like to lean more about Flask, check out the excellent documentation.
On startup, the code initialises Flask and connects to Redis:
app = Flask(__name__)
redis_client = redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379"))
I've chosen to configure the Redis client not to decode the bytes responses from Redis (by omitting decode_responses=True
). This is because we'll need the raw image data stored in Redis without decoding. So whenever we read String data from Redis, we will need to decode it back to a UTF-8 representation using .decode("utf-8")
and you will see this throughout the code.
The rest of the application is broken down into code for 4 different routes as follows:
This is very simple. Whenever /
is requested, simply return the contents of templates/index.html
processing any template directives in there. The only logic in the index file is one line that works out the relative path to the JavaScript file when including it in the HTML returned. This route returns a HTML page that links to the JavaScript file and the Bulma CSS file.
Bulma's CSS is hosted on an external CDN so there's no code here to serve it. Flask handles serving static files for us, so we don't need additional code for the JavaScript file either.
Here's the entire route:
@app.route("/")
def home():
return render_template("index.html")
We need an API type route that returns a JSON array containing the details of all of the images in Redis. Each entry in this array will be a JSON object containing all of the meta data about the image. We'll handle returning the actual bytes of the image data itself separately.
As we have the data indexed using the Search capability of Redis Stack (see main README for details), we can use the FT.SEARCH
command to get the details of the 9 most recent images.
FT.SEARCH
returns a count of matching documents, followed by a list of Document
objects. We'll iterate over that, creating a dictionary for each with the values that we want and add them to a List to return. Flask handles mapping this to JSON for us.
@app.route(f"/{API_ROUTE_PREFIX}/images")
def get_all_images():
all_images = []
# Run a search query to get the latest 9 images and return
# their data...
# ft.search idx:images "*" return 3 timestamp lux mime_type sortby timestamp desc limit 0 9
search_results = redis_client.ft(IMAGE_INDEX_NAME).search(Query("*").sort_by(IMAGE_TIMESTAMP_FIELD_NAME, False).paging(0, 9).return_fields(IMAGE_TIMESTAMP_FIELD_NAME, IMAGE_MIME_TYPE_FIELD_NAME, IMAGE_LUX_FIELD_NAME))
for doc in search_results.docs:
this_image = dict()
this_image[IMAGE_ID_FIELD_NAME] = doc.id.removeprefix(f"{IMAGE_KEY_PREFIX}:")
this_image[IMAGE_TIMESTAMP_FIELD_NAME] = doc.timestamp
this_image[IMAGE_MIME_TYPE_FIELD_NAME] = doc.mime_type
this_image[IMAGE_LUX_FIELD_NAME] = doc.lux
all_images.append(this_image)
return all_images
I'm removing the image:
prefix from the id
field sent to the front end, so that the front end (or other clients of this API) never see the full Redis key names. This is sort of a guard against API clients trying to manipulate the data via the API.
In future, I'll update this route to take some pagination parameters (replacing the hard coded .paging(0, 9)
).
This route is unlike the others in that it doesn't return a text-based response. Here, given the timestamp ID for an image, we want to return the data for that image, encoded in a way that the browser will recognise it as an image and render it correctly.
To achieve this, two fields need to be retrieved from the image's Hash in Redis. This can be done with the HMGET command. To get the Redis key for the desired image, we add the image:
prefix to the image ID passed into the route:
@app.route(f"/{API_ROUTE_PREFIX}/image/<image_id>")
def get_image(image_id):
# Look for the image data in Redis.
image_data = redis_client.hmget(f"{IMAGE_KEY_PREFIX}:{image_id}", [ IMAGE_DATA_FIELD_NAME, IMAGE_MIME_TYPE_FIELD_NAME ])
If the image isn't found, we'll get nothing back and should return a 404 to the front end:
if image_data[0] is None:
return f"Image {image_id} not found.", 404
If the image was found, we have a 2 element list in image_data
... with element 0 being the actual image data bytes, and element [1] the MIME type.
We need to load the raw image data into something that looks like an in memory file so that Flask can return it to the front end. We do that using Python's BytesIO
object (docs):
image_file = io.BytesIO(image_data[0])
# Rewind the image file to the start...
image_file.seek(0)
# Get the MIME type from the Redis response, and decode it from binary.
return send_file(image_file, mimetype=image_data[1].decode(STRING_ENCODING))
When the browser initially receives the HTML for the home page, there are no images contained in it. The image details get loaded into the div
whose ID is imageArea
dynamically when the JavaScript runs. Initially, it's just an empty div
:
<div class="columns is-flex-wrap-wrap" id="imageArea">
The columns
and is-flex-wrap-wrap
classes are defined in Bulma and give us a flexbox type grid.
The JavaScript file is loaded with the defer
option, so it only starts to execute after the browser has parsed the page's HTML. Once it starts to execute, it requests the /api/images
route from the Flask server.
If no images are returned, a notification is shown. This is already in the HTML for the page, but is initially hidden. It's shown as needed by removing the Bulma helper class is-hidden
:
const noImagesNotification = document.getElementById('noImagesNotification');
noImagesNotification.classList.remove('is-hidden');
If the Flask server returned a JSON array of objects, we loop over it. In each loop iteration, we get the ID and other properties of the image:
const imageResponse = await fetch(`/${API_PREFIX}/images`);
const imageList = await imageResponse.json();
for (const image of imageList) {
// Add markup for each image to the DOM...
}
Using the image ID (timestamp) we can also work out what URL we need to load the image from the Flask server:
const imageUrl = `/${API_PREFIX}/image/${image.id}`;
With the value in imageUrl
and metadata values in imageData
, we can then use a template string and a HTML fragment to create the HTML we need to display this item on the page:
const imageHTML = `
<div class="card m-4">
<div class="card-image">
<figure class="image is-16by9">
<img src="${imageUrl}" alt="Image ${image.id}">
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-content">
<p class="title is-4">${new Date(parseInt(image.timestamp * 1000, 10)).toUTCString()}</p>
</div>
</div>
<div class="content">
<ul>
${renderImageData(image)}
</ul>
</div>
</div>
</div>
</div>`;
The two complexities worth looking at in the above are:
- I wanted to display the timestamp that the picture was taken in a meaningful format. To do this, the timestamp gets multiplied by 1000 to make it a milliseconds timestamp. Javacript has a
Date
constructor that accepts these, and the resultingDate
object can be converted to a decent human readable date usingtoUTCString
. So the code to display the date is:new Date(parseInt(image.timestamp * 1000, 10)).toUTCString()
. - I also wanted to display all image metadata, without hard coding the names of the metadata fields. This is so that the front end will just display anything passed to it, and adding more metadata in the capture script won't require code changes in the front end. To do this, I created a utility function that takes an object containing name/value pairs and renders them out as a HTML list items:
function renderImageData(dataItems) {
let html = '';
for (const k in dataItems) {
html = `${html}<li><span class="has-text-weight-bold">${k}:</span> ${dataItems[k]}</li>`;
}
return html;
}
This is then called from the template string like so:
<div class="content">
<ul>
${renderImageData(imageData)}
</ul>
</div>
Now, all that remains is to add the HTML into the div
whose ID is imageArea
and move on to the next item in the loop until we're done:
// Before the loop so we aren't looking this up each iteration...
const imageArea = document.getElementById('imageArea');
// In the loop...
imageArea.innerHTML = `${imageArea.innerHTML}${imageHTML}`;
Before running the server, there are a couple of setup tasks to perform.
You need Python 3.7 or higher (I've tested this with Python 3.10). To check your Python version:
python3 --version
If you need to upgrade your Python version, use your operating system's package manager or refer to python.org.
Next, create and activate a Python virtual environment, then install the dependencies. These are the Flask framework and redis-py Redis client:
python3 -m venv venv
. ./venv/bin/activate
pip install -r requirements.txt
The code assumes by default that your Redis server is running on localhost
port 6379
. If this is not the case, you'll need to set the REDIS_URL
environment variable to a valid Redis URL describing where and how to connect to your Redis server.
For example, here's how to connect to a server on myhost
at port 9999
with password secret123
:
export REDIS_URL=redis://default:secret123@myhost:9999/
If you have a username and a password for your Redis server, use something like this:
export REDIS_URL=redis://myusername:secret123@myhost:9999/
If you don't need a username or a password:
export REDIS_URL=redis://myhost:9999/
Be sure to configure both the capture script and the separate server component to talk to the same Redis instance!
Alternatively, you can create a file in the server
folder called .env
and store your environment variable values there. See env.example
for an example. Don't commit .env
to source control, as your Redis credentials should be considered a secret and managed as such!
Having got everything set up, start the server like so:
flask run
You'll see output similar to the following:
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
The server should have connected to port 5000. When you point your browser at http://localhost:5000/
you'll see the front end showing any images that have been stored in Redis.
Whenever you are done using the server, press Ctrl-C to terminate it.