Integrating everything into the GUI

For Luxocator's frontend, let's create a file called Luxocator.py. This module depends on OpenCV, wxPython, and some of Python's standard OS and threading functionality. It also depends on all the other modules that we have written in this chapter. Add the following import statements at the top of the file:

import numpy
import cv2
import os
import threading
import wx

from HistogramClassifier import HistogramClassifier
from ImageSearchSession import ImageSearchSession
import PyInstallerUtils
import ResizeUtils
import WxUtils

Now, let's implement the Luxocator class as a subclass of wx.Frame, which represents a GUI frame such as the contents of a window. Most of our GUI code is in the Luxocator class's __init__ method, which is therefore a big method but is not very complicated. Our GUI elements include a search control, previous and next buttons, a bitmap, and a label to show the classification result. All of these GUI elements are stored in the member variables. The bitmap is confined to a certain maximum size (by default, 768 pixels in the larger dimension) and the other elements are laid out below it.

Several methods are registered as callbacks to handle events such as the window closing, a search string being entered, or the next or previous button being clicked. Besides the GUI elements, other member variables include instances of our HistogramClassifier and ImageSearchSession classes. Here is the implementation of the initializer, interspersed with some remarks on the GUI elements that we are using:

class Luxocator(wx.Frame):

  def __init__(self, classifierPath, maxImageSize=768,
    verboseSearchSession=False,
    verboseClassifier=False):

    style = wx.CLOSE_BOX | wx.MINIMIZE_BOX | wx.CAPTION | \
      wx.SYSTEM_MENU | wx.CLIP_CHILDREN
    wx.Frame.__init__(self, None, title='Luxocator', \
      style=style)
    self.SetBackgroundColour(wx.Colour(232, 232, 232))

    self._maxImageSize = maxImageSize
    border = 12
    defaultQuery = 'luxury condo sales'

    self._index = 0
    self._session = ImageSearchSession()
    self._session.verbose = verboseSearchSession
    self._session.search(defaultQuery)

    self._classifier = HistogramClassifier()
    self._classifier.verbose = verboseClassifier
    self._classifier.deserialize(classifierPath)

    self.Bind(wx.EVT_CLOSE, self._onCloseWindow)

The search control (coming up next) deserves special attention because it contains multiple controls within it and its behavior differs slightly across operating systems. It can have up to three sub-controls: a text field, a search button, and a cancel button. There can be a callback for the Enter key being pressed while the text field is active. If the search and cancel buttons are present, they have callbacks on being clicked. We can set up the search control and its callbacks as follows:

    self._searchCtrl = wx.SearchCtrl(
      self, size=(self._maxImageSize / 3, -1),
      style=wx.TE_PROCESS_ENTER)
    self._searchCtrl.SetValue(defaultQuery)
    self._searchCtrl.Bind(wx.EVT_TEXT_ENTER,
      self._onSearchEntered)
    self._searchCtrl.Bind(wx.EVT_SEARCHCTRL_SEARCH_BTN,
      self._onSearchEntered)
    self._searchCtrl.Bind(wx.EVT_SEARCHCTRL_CANCEL_BTN,
      self._onSearchCanceled)

By contrast, the label, previous and next buttons, and bitmap do not have any sub-controls that need to concern us. We can set them up as follows:

    self._labelStaticText = wx.StaticText(self)

    self._prevButton = wx.Button(self, label='Prev')
    self._prevButton.Bind(wx.EVT_BUTTON,
      self._onPrevButtonClicked)

    self._nextButton = wx.Button(self, label='Next')
    self._nextButton.Bind(wx.EVT_BUTTON,
      self._onNextButtonClicked)

    self._staticBitmap = wx.StaticBitmap(self)

Our controls are lined up horizontally, with the search control on the left edge of the window, the previous and next buttons on the right edge, and a label halfway in between the search control and previous button. We will use an instance of wx.BoxSizer to define this horizontal layout:

  controlsSizer = wx.BoxSizer(wx.HORIZONTAL)
  controlsSizer.Add(self._searchCtrl, 0,
    wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, border)
  controlsSizer.Add((0, 0), 1) # Spacer
  controlsSizer.Add(
    self._labelStaticText, 0,
    wx.ALIGN_CENTER_VERTICAL)
  controlsSizer.Add((0, 0), 1) # Spacer
  controlsSizer.Add(
    self._prevButton, 0,
    wx.ALIGN_CENTER_VERTICAL | wx.LEFT | wx.RIGHT,
    border)
  controlsSizer.Add(
    self._nextButton, 0, wx.ALIGN_CENTER_VERTICAL)

The best thing about layouts (and Russian dolls) is that they can be nested, one inside another. Our horizontal layout of controls needs to appear below the bitmap. This relationship is a vertical layout, which we will define using another wx.BoxSizer instance:

    self._rootSizer = wx.BoxSizer(wx.VERTICAL)
    self._rootSizer.Add(self._staticBitmap, 0,
      wx.TOP | wx.LEFT | wx.RIGHT, border)
    self._rootSizer.Add(controlsSizer, 0, wx.EXPAND | wx.ALL,
      border)

    self.SetSizerAndFit(self._rootSizer)

    self._updateImageAndControls()

This is the end of the __init__ method.

As seen in the following code, we provide getters and setters for the verbose property of our ImageSearchSession instance and our HistogramClassifier instance:

  @property
  def verboseSearchSession(self):
    return self._session.verbose

  @verboseSearchSession.setter
  def verboseSearchSession(self, value):
    self._session.verbose = value

  @property
  def verboseClassifier(self):
    return self._classifier.verbose

  @verboseClassifier.setter
  def verboseClassifier(self, value):
    self._classifier.verbose = value

Our _onCloseWindow callback just cleans up the application by calling the Destroy method of the superclass. Here is the implementation:

  def _onCloseWindow(self, event):
    self.Destroy()

Our _onSearchEntered callback submits the query string via the search method ImageSearchSession. Then, it calls a helper method, _updateImageAndControls, which asynchronously fetches images and updates the GUI, as we will see later. Here is the implementation of _onSearchEntered:

  def _onSearchEntered(self, event):
    query = event.GetString()
    if len(query) < 1:
      return
    self._session.search(query)
    self._index = 0
    self._updateImageAndControls()

Our _onSearchCanceled callback simply clears the search control's text field, as seen in the following code:

  def _onSearchCanceled(self, event):
    self._searchCtrl.Clear()

Our remaining GUI event callbacks _onNextButtonClicked and _onPrevButtonClicked check whether more results are available and if so, use the searchNext or searchPrev method of ImageSearchSession. Then, using the _updateImageAndControls helper method, images are fetched asynchronously and the GUI is updated. Here are the implementations of the callbacks:

  def _onNextButtonClicked(self, event):
    self._index += 1
    if self._index >= self._session.offset + \
      self._session.numResultsReceived - 1:
      self._session.searchNext()
    self._updateImageAndControls()

  def _onPrevButtonClicked(self, event):
    self._index -= 1
    if self._index < self._session.offset:
      self._session.searchPrev()
    self._updateImageAndControls()

The _disableControls method disables the search control and the previous and next buttons, as follows:

  def _disableControls(self):
    self._searchCtrl.Disable()
    self._prevButton.Disable()
    self._nextButton.Disable()

Conversely, the _enableControls method enables the search control, the previous button (if we are not already at the first available search result), and the next button (if we are not already at the last available search result). Here is the implementation:

  def _enableControls(self):
    self._searchCtrl.Enable()
    if self._index > 0:
      self._prevButton.Enable()
    if self._index < self._session.numResultsAvailable - 1:
      self._nextButton.Enable()

The _updateImageAndControls method first disables the controls because we do not want to handle any new queries until the current query is handled. Then, a busy cursor is shown and another helper method, _updateImageAndControlsAsync, is started on a background thread. Here is the implementation:

  def _updateImageAndControls(self):
    # Disable the controls.
    self._disableControls()
    # Show the busy cursor.
    wx.BeginBusyCursor()
    # Get the image in a background thread.
    threading.Thread(
      target=self._updateImageAndControlsAsync).start()

The background method, _updateImageAndControlsAsync, starts by fetching an image and converting it to the OpenCV format. If the image cannot be fetched and converted, an error message is used as the label. Otherwise, the image is classified and resized to an appropriate size for display. Then, the resized image and the classification label are passed to a third and final helper method, _updateImageAndControlsResync, which updates the GUI on the main thread. Here is the implementation of _updateImageAndControlsAsync:

  def _updateImageAndControlsAsync(self):
    # Get the current image.
    image, url = self._session.getCvImageAndUrl(
      self._index % self._session.numResultsRequested)
    if image is None:
      # Provide an error message.
      label = 'Failed to decode image'
    else:
      # Classify the image.
      label = self._classifier.classify(image, url)
      # Resize the image while maintaining its aspect ratio.
      image = ResizeUtils.cvResizeAspectFill(
        image, self._maxImageSize)
    # Update the GUI on the main thread.
    wx.CallAfter(self._updateImageAndControlsResync, image,
      label)

The synchronous callback, _updateImageAndControlsResync, hides the busy cursor, creates a wxPython bitmap from the fetched image (or just a black bitmap if no image was successfully fetched and converted), shows the image and its classification label, resizes GUI elements, re-enables controls, and refreshes the window. Here is its implementation:

  def _updateImageAndControlsResync(self, image, label):
    # Hide the busy cursor.
    wx.EndBusyCursor()
    if image is None:
      # Provide a black bitmap.
      bitmap = wx.EmptyBitmap(self._maxImageSize,
        self._maxImageSize / 2)
    else:
      # Convert the image to bitmap format.
      bitmap = WxUtils.wxBitmapFromCvImage(image)
    # Show the bitmap.
    self._staticBitmap.SetBitmap(bitmap)
    # Show the label.
    self._labelStaticText.SetLabel(label)
    # Resize the sizer and frame.
    self._rootSizer.Fit(self)
    # Re-enable the controls.
    self._enableControls()
    # Refresh.
    self.Refresh()

When the image cannot be successfully fetched and converted, the user sees something like the following screenshot:

Integrating everything into the GUI

Conversely, when an image is successfully fetched and converted, the user sees the classification result, as shown in the following screenshot:

Integrating everything into the GUI

That completes the implementation of the Luxocator class. Now, let's write a main method to set resource paths and launch an instance of Luxocator:

def main():
  os.environ['REQUESTS_CA_BUNDLE'] = \
    PyInstallerUtils.resourcePath('cacert.pem')
  app = wx.App()
  luxocator = Luxocator(
    PyInstallerUtils.resourcePath('classifier.mat'),
    verboseSearchSession=False, verboseClassifier=False)
  luxocator.Show()
  app.MainLoop()

if __name__ == '__main__':
  main()

Note that one of the resources is a certificate bundle called cacert.pem. It is required by Requests in order to make an SSL connection, which is in turn required by Bing. You can find a copy of it inside this chapter's code bundle, which is downloadable from my website at http://nummist.com/opencv/7376_02.zip. Place cacert.pem in the same folder as Luxocator.py. Note that our code sets an environment variable, REQUESTS_CA_BUNDLE, which is used by Requests to locate the certificate bundle.