Another Vue Example - Image Recognition Service Tester

This weekend, I did some work updating my little Node-based image recognition service tester testing tool. The back-end is built in Node with a front end using vanilla JavaScript and Handlebars. I thought it would be interesting to see what it would be like to re-write the code in Vue. To be clear, nothing was broken so this was a completely arbitrary decision, but as I wanted an excuse to write some more Vue, I figured it was a good idea. As I've said though - keep in mind I'm still learning how to use Vue so what follows will probably not be "ideal" code.

Old Version

Let's start by quickly discussing the "old" version. I've got old in quotes there as this project is only a few months old. As I said, it was vanilla JavaScript and Handlebars, nothing too terribly complex. The front end HTML looked like so - with a lot cut out for space:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title></title>
        <meta name="description" content="">
        <meta name="viewport" content="width=device-width">
        <link rel="stylesheet" href="css/app.css">
    </head>
    <body>

    <h1>Image Recognition Tester</h1>
    <form id="imageForm" method="post" enctype="multipart/form-data">
    <p>
    Select an image to use for testing: 
    <input type="file" name="testImage" id="testImage"><br/>
    <input type="submit" value="Upload">
    </p>
    </form>
    <img id="previewImage">

    <br clear="all"/>

    <div id="status"></div>

    <div id="amazonResults" class="results" style="display:none"></div>
    <div id="googleResults" class="results" style="display:none"></div>
    <div id="ibmResults" class="results" style="display:none"></div>
    <div id="msResults" class="results" style="display:none"></div>

    <script id="google-template" type="text/x-handlebars-template">
    <h1>Google Results</h1>
    <p>
        Google attempts to find results across the following types: crops (hints for where to crop), faces, labels, 
        landmarks, logos, properties, safeSearch, similar, and text.
    </p>

    <h3>Crops</h3>
    {{#if crops}}
        <ol>
        {{#each crops}}
        <li>
            <ul>
            {{#each this}}
            <li>{{this.x}},{{y}}</li>
            {{/each}}
            </ul>
        </li>
        {{/each}}
        </ol>
    {{else}}
    <p>No crops.</p>
    {{/if}}

    <h3>Faces</h3>
    {{#if faces}}
        <p>
            <i>Note, this report is not showing: angles, bounds, and features.</i>
        </p>
        {{#each faces}}
        <p>
            Basic Details:<br/>
            Anger: {{this.anger}} (likelihood: {{this.angerLikelihood}})<br/>
            Blurred: {{this.blurred}}<br/>
            Confidence: {{this.confidence}}<br/>
            Headware: {{this.headwear}} (likelihood: {{this.headwearLikelihood}})<br/>
            Joy: {{this.joy}} (likelihood: {{this.joyLikelihood}})<br/>
            Sorrow: {{this.sorrow}} (likelihood: {{this.sorrowLikelihood}})<br/>
            Surprise: {{this.surprise}} (likelihood: {{this.surpriseLikelihood}})<br/>
            Underexposed: {{this.underExposed}} (likelihood: {{this.underExposedLikelihood}})<br/>
        </p>
        {{/each}}
    {{else}}
    <p>No faces.</p>
    {{/if}}
    
    <h3>Labels</h3>
    {{#if labels}}
        <ul>
        {{#each labels}}
        <li>{{this}}</li>
        {{/each}}
        </ul>
    {{else}}
    <p>No labels.</p>
    {{/if}}

    <h3>Landmarks</h3>
    {{#if landmarks}}
        <p><i>This is the <b>non</b> verbose response. Verbose version will include location GPS values.</i></p>
        <ul>
        {{#each landmarks}}
            <li>{{this}}</li>
        {{/each}}
        </ul>
    {{else}}
    <p>No landmarks.</p>
    {{/if}}

    </script>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.10/handlebars.min.js"></script>
    <script src="/js/app.js"></script>
    </body>
</html>

In the above code sample, you can see a "main" body template followed by a script tag used for the Google rendering template. I cut out the three other script blocks to save space as well as some of the Google one as well. But the basic "form" of the page was:


html for the page with empty divs for each report

a hidden template for Google
a hidden template for IBM
a hidden template for Microsoft
a hidden template for Amazon

I could have done that better. Handlebars lets you build your templates in their own files and then use the CLI to compile them into functions. This saves space both on the HTML and execution time of JavaScript as well as it can skip "parsing" your template. (You can view the entire version of the template here).

On the JavaScript side - it was mainly DOM manipulation. I'd upload the image, call the Node code, and then pass the data to the compiled Handlebars templates. There was a lot of "hide this"/"show this" going on, but Handlebars pretty much took the data as is. I did one small bit of manipulation for something with Microsoft's service, but that was the exception.

Here's the source code for the original version:

let $, imageForm, imageField, imagePreview, statusDiv;
//hb related
let googleRenderer, googleResults, ibmRenderer, ibmResults, msRenderer, msResults, amazonRenderer, amazonResults;

window.addEventListener('DOMContentLoaded', () => {
    //alias jquery like a hipster
    $ = document.querySelector.bind(document);
    imageForm = $('#imageForm');
    imageField = $('#testImage');
    imagePreview = $('#previewImage');
    statusDiv = $('#status');

    imageForm.addEventListener('submit', doForm, false);
    imageField.addEventListener('change', doPreview, false);

    googleResults = $('#googleResults');
    googleRenderer = Handlebars.compile($('#google-template').innerHTML);

    ibmResults = $('#ibmResults');
    ibmRenderer = Handlebars.compile($('#ibm-template').innerHTML);

    msResults = $('#msResults');
    msRenderer = Handlebars.compile($('#ms-template').innerHTML);

    amazonResults = $('#amazonResults');
    amazonRenderer = Handlebars.compile($('#amazon-template').innerHTML);

}, false);

function doPreview() {
    if(!imageField.files || !imageField.files[0]) return;

    var reader = new FileReader();

    reader.onload = function (e) {
        imagePreview.src = e.target.result;
    }

    reader.readAsDataURL(imageField.files[0]);
    //todo - clear results. as soon as you pick a new image, even if you don't submit
    clearResults();
    statusDiv.innerHTML = 'Upload image for results.';
}

function doForm(e) {
    e.preventDefault();
    let currentImg = imageField.value;
    if(currentImg === '') return;
    console.log('Going to process '+currentImg);

    statusDiv.innerHTML = '<i>Uploading and processing - stand by...</i>';

    let fd = new FormData();
    fd.append('testImage', imageField.files[0]);

    fetch('/test', {
        method:'POST',
        body:fd
    }).then( 
        response => response.json()
    ).then( (result) => {
        console.log('file result', result.result);
        statusDiv.innerHTML = '';
        renderResults(result.result);
    }).catch( (e) => {
        console.error(e);
    });
}

function clearResults() {
    //https://stackoverflow.com/a/6243000/52160
    googleResults.style.display = 'none';
    ibmResults.style.display = 'none';
    msResults.style.display = 'none';
    amazonResults.style.display = 'none';
    googleResults.innerHTML = '';
    ibmResults.innerHTML = '';
    msResults.innerHTML = '';
    amazonResults.innerHTML = '';

}

function renderResults(data) {
    if(data.google) {
        googleResults.style.display = 'block';
        renderGoogle(data.google);
    }
    if(data.ibm) {
    ibmResults.style.display = 'block';
        renderIBM(data.ibm);
    }
    if(data.ms) {
        msResults.style.display = 'block';
        renderMS(data.ms);
    }
    if(data.amazon) {
    amazonResults.style.display = 'block';
        renderAmazon(data.amazon);
    }
}

function renderGoogle(data) {
    googleResults.innerHTML = googleRenderer(data);
}

function renderIBM(data) {

    ibmResults.innerHTML = ibmRenderer(data);

}

function renderMS(data) {
    console.log(data);

    /*
    Since handlebars is so anti-logic-in template...
    Non-clipart = 0,
ambiguous = 1,
normal-clipart = 2,
good-clipart = 3.
    */
    let ct = data.main.imageType.clipArtType;
    let ctType = 'Not Clipart';
    if(ct === 1) ctType = 'Ambiguous';
    if(ct === 2) ctType = 'Normal Clipart';
    if(ct === 3) ctType = 'Good Clipart'; 
    data.main.imageType.ctType = ctType;
    msResults.innerHTML = msRenderer(data);
}

function renderAmazon(data) {
    console.log(data.modlabels);
    console.log(data.celebs);
    amazonResults.innerHTML = amazonRenderer(data);

}

You can see just how much is handling grabbing DOM elements and changing them.

To be clear, I'm not saying this is bad per se, and it wasn't difficult without jQuery at all. But let's consider the Vue version.

The New Vue

(Can you guess how long I've been waiting to use that phrase?)

I began by creating a JSON export of my Node code's results so I could test more quicker. I then began working on JavaScript. One of the first things I ran into was handling the form and the image preview. As a reminder - the code lets you select an image with a regular input/file field and then render a preview. Only after you upload does processing begin. I quickly discovered the v-model doesn't work with input/file fields as they are read only. This is what I ended up with.

<form method="post" enctype="multipart/form-data" @submit.prevent="doForm">
    <p>
    Select an image to use for testing: 
    <input type="file" @change="doPreview"><br/>
    <input type="submit" value="Upload">
    </p>
</form>
<img :src="previewImage" class="previewImage">

I've got a change handler on the file field to do the 'auto preview' thing and I've attached a submit handler to the form. The addition of .prevent means that Vue will handle preventing the default form submission thing for me. (Yet another reason I'm falling in love with Vue.) Here is the beginning of the JavaScript as well as the two handlers involved with the form.


let myApp = new Vue({
    el:'#mainApp',
    data:{
        previewImage:'',
        status:'',
        file:null,
        google:null,
        ibm:null,
        ms:null,
        amazon:null
    },
    methods:{
        doPreview:function(e) {
            var that = this;
            if(!e.target.files || !e.target.files[0]) return;
                    
            let reader = new FileReader();
                    
            reader.onload = function (e) {
                that.previewImage = e.target.result;
            }
            
            reader.readAsDataURL(e.target.files[0]);
            this.file = e.target.files[0];
            this.google = null; this.ibm = null; this.ms = null; this.amazon = null;
            this.status = 'Upload image for results.';
        },
        doForm:function(e) {
            if(this.previewImage === '') return;
            console.log('Going to do form');
            this.status = '<i>Uploading and processing - stand by...</i>';

            let fd = new FormData();
            fd.append('testImage', this.file);

            fetch('/test', {
                method:'POST',
                body:fd
            }).then( 
                response => response.json()
            ).then( (result) => {
                console.log('file result', result.result);
                this.status=''
                this.renderResults(result.result);
            }).catch( (e) => {
                console.error(e);
            });
        },

For the most part, this is the same as before, except that I've attached the handlers to my Vue object. The actual rendering is done in another method, but as I don't call that method anywhere else, it should probably be folded into doForm, but I like the seperation a bit so I'm happy with it. Here's that method:

renderResults:function(data) {
    if(data.google) this.google = data.google;
    if(data.ibm) this.ibm = data.ibm;
    if(data.ms) {
        let ct = data.ms.main.imageType.clipArtType;
        let ctType = 'Not Clipart';
        if(ct === 1) ctType = 'Ambiguous';
        if(ct === 2) ctType = 'Normal Clipart';
        if(ct === 3) ctType = 'Good Clipart'; 
        data.ms.main.imageType.ctType = ctType;
        this.ms = data.ms;
    }
    if(data.amazon) this.amazon = data.amazon;
}

So in theory - I could have maybe just did something like - "for x in data, this[x] = data.x" - but the more specific checks felt... I don't know. Not better, but ok for now. If you remember, in my last update I made it easier to disable services so it's definitely possible .amazon or .ibm won't be there. And that's it for the JavaScript code. The entire file (found here) is now 69 lines, roughly half the lines of the previous version (136). Biggest thing missing are all (or most) the calls for DOM manipulation.

The front end HTML now has the templates "inside" the body as Vue is going to mark it up as is. Again I don't want to share the entire template as it is pretty long (although it did get smaller compared to the first version), so here is the same "cut" as before.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title></title>
        <meta name="description" content="">
        <meta name="viewport" content="width=device-width">
        <link rel="stylesheet" href="css/app.css">
        <style>
        [v-cloak] {
            display: none;
        }
        </style>
    </head>
    <body>

    <div id="mainApp" v-cloak>

        <h1>Image Recognition Tester</h1>
        <form method="post" enctype="multipart/form-data" @submit.prevent="doForm">
        <p>
        Select an image to use for testing: 
        <input type="file" @change="doPreview"><br/>
        <input type="submit" value="Upload">
        </p>
        </form>
        <img :src="previewImage" class="previewImage">

        <br clear="all"/>

        <div v-html="status"></div>

        <div v-if="google" class="results">
            <h1>Google Results</h1>
            <p>
                Google attempts to find results across the following types: crops (hints for where to crop), faces, labels, 
                landmarks, logos, properties, safeSearch, similar, and text.
            </p>
        
            <h3>Crops</h3>

            <div v-if="google.crops">
                <ol>
                <li v-for="cropset in google.crops">
                    <ul>
                    <li v-for="crop in cropset">{{crop.x}},{{crop.y}}</li>
                    </ul>
                </li>
                </ol>
            </div><div v-else>
                <p>No crops.</p>
            </div>

            <h3>Faces</h3>
            <div v-if="google.faces">
                <p>
                    <i>Note, this report is not showing: angles, bounds, and features.</i>
                </p>

                <p v-for="face in google.faces">
                    Basic Details:<br/>
                    Anger: {{face.anger}} (likelihood: {{face.angerLikelihood}})<br/>
                    Blurred: {{face.blurred}}<br/>
                    Confidence: {{face.confidence}}<br/>
                    Headware: {{face.headwear}} (likelihood: {{face.headwearLikelihood}})<br/>
                    Joy: {{face.joy}} (likelihood: {{face.joyLikelihood}})<br/>
                    Sorrow: {{face.sorrow}} (likelihood: {{face.sorrowLikelihood}})<br/>
                    Surprise: {{face.surprise}} (likelihood: {{face.surpriseLikelihood}})<br/>
                    Underexposed: {{face.underExposed}} (likelihood: {{face.underExposedLikelihood}})<br/>
                </p>
            </div><div v-else>
                <p>No faces.</p>
            </div>

            <h3>Labels</h3>
            <div v-if="google.labels.length">
                <ul>
                <li v-for="label in google.labels">{{label}}</li>
                </ul>
            </div><div v-else>
                <p>No labels.</p>
            </div>

            <h3>Landmarks</h3>
            <div v-if="google.landmarks.length">
                <p><i>This is the <b>non</b> verbose response. Verbose version will include location GPS values.</i></p>
                <ul>
                    <li v-for="landmark in google.landmarks">{{landmark}}</li>
                </ul>
            </div><div v-else>
                <p>No landmarks.</p>
            </div>
    
        </div>
    
    </div>
    
    <script src="https://unpkg.com/vue"></script>
    <script src="/js/app.js"></script>
    </body>
</html>

The full file may be found here.

For the most part, the conversion from Handlebars to Vue was straight forward. I screwed up a lot at first, but I was incredibly impressed by the error messages Vue spit out. In nearly every single case, it was obvious what I had done wrong and what I had to do to fix it. The only thing that threw me was an inline style:

<ul>
    <li v-for="color in google.properties.colors" 
    v-bind:style="{ backgroundColor:'#'+color }">{{color}}</li>
</ul>

It took me a bit to figure out that my CSS property had to go from background-color to backgroundColor. The error message told me I had invalid syntax, but I just couldn't figure out what was wrong with it.

All in all - I much prefer v-for over {{#each something}}. On the other hand, this form really wigs me out a bit:

<div v-if="newMoonOnSunday">
stuff
</div><div v-else>
the else block
</div>

It works - and I'll get used to it - but... yeah - wigs me out.

Anyway - I hope this sample is helpful to others. The entire code base is up on GitHub - https://github.com/cfjedimaster/recogtester. Feel free to suggest improvements to my use of Vue - I'm new so I won't mind. ;)

Like This?

If you like this article, please consider visiting my Amazon Wishlist or donating via PayPal to show your support. You can also subscribe to the email feed to get notified of new posts.

Want to read more like this?