Selaa lähdekoodia

Resolve "features from original implementation"

Eric Tröbs 4 vuotta sitten
vanhempi
commit
027daf173c

+ 4 - 0
.gitignore

@@ -8,3 +8,7 @@
 env/
 venv/
 ENV/
+
+# projects
+projects/
+models/

+ 21 - 0
main.py

@@ -0,0 +1,21 @@
+#!/usr/bin/env python
+
+import sys
+
+from PyQt5 import QtWidgets
+
+from pycs.ui import MainWindow
+
+
+def main(args):
+    app = QtWidgets.QApplication(args)
+    window = MainWindow()
+    window.show()
+
+    retval = app.exec_()
+    return retval
+
+
+if __name__ == '__main__':
+    retval = main(sys.argv)
+    sys.exit(retval)

+ 5 - 0
pycs/pipeline/__init__.py

@@ -0,0 +1,5 @@
+#!/usr/bin/env python
+
+"""pipeline:  Detection and other model pipeline."""
+
+from .pipeline import Pipeline

+ 5 - 0
pycs/pipeline/detection/__init__.py

@@ -0,0 +1,5 @@
+#!/usr/bin/env python
+
+"""detection:  Face detection implementation."""
+
+from .detector import Detector

+ 64 - 0
pycs/pipeline/detection/detector.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env python
+
+"""Detector:  Face detection implementation."""
+
+import logging
+
+import numpy as np
+
+from ..utils import TFModel
+
+
+# import tensorflow.contrib.slim as slim
+
+
+class Detector(TFModel):
+    def __init__(self, config):
+        TFModel.__init__(self, config)
+
+        try:
+            # (1) Find feature tensor
+            self.tf_image_tensor = self.tf_graph.get_tensor_by_name("import/image_tensor:0")
+
+            self.tf_detection_boxes = self.tf_graph.get_tensor_by_name('import/detection_boxes:0')
+            self.tf_detection_scores = self.tf_graph.get_tensor_by_name('import/detection_scores:0')
+            self.tf_detection_classes = self.tf_graph.get_tensor_by_name('import/detection_classes:0')
+            self.tf_num_detections = self.tf_graph.get_tensor_by_name('import/num_detections:0')
+
+            self.input_shape = self.tf_image_tensor.shape[1:].as_list()
+            if "downscale-to" in config.keys():
+                for i in range(len(self.input_shape)):
+                    if self.input_shape[i] is None:
+                        self.input_shape[i] = config["downscale-to"]
+            logging.debug("Input shape: %s" % self.input_shape)
+
+        except:
+            self._report_error("Could not access tensors by name")
+
+    def detect_faces(self, image):
+        if None not in self.input_shape:
+            resized_image = image.resize(size=self.input_shape[0:2])
+        else:
+            resized_image = image
+
+        (boxes, scores, classes, num) = self.tf_session.run(
+            [self.tf_detection_boxes, self.tf_detection_scores, self.tf_detection_classes, self.tf_num_detections],
+            feed_dict={self.tf_image_tensor: np.expand_dims(resized_image, axis=0)})
+
+        sample_num = int(num[0])
+        sample_scores = scores[0][0:sample_num]
+        sample_boxes = boxes[0][0:sample_num]
+
+        filtered_boxes = sample_boxes[sample_scores > 0.5]
+        filtered_scores = sample_scores[sample_scores > 0.5]
+
+        ret_boxes = []
+        for index, box in enumerate(filtered_boxes):
+            score = sample_scores[index]
+            ymin, xmin, ymax, xmax = box
+            ret_box = {'x': float(xmin), 'y': float(ymin),
+                       'w': float(xmax - xmin), 'h': float(ymax - ymin),
+                       'score': float(score)}
+            ret_boxes += [ret_box]
+
+        return ret_boxes

+ 5 - 0
pycs/pipeline/features/__init__.py

@@ -0,0 +1,5 @@
+#!/usr/bin/env python
+
+"""__init__:  Feature extraction."""
+
+from .features import Features

+ 39 - 0
pycs/pipeline/features/features.py

@@ -0,0 +1,39 @@
+#!/usr/bin/env python
+
+"""features.py:  Feature extraction."""
+
+# import tensorflow.contrib.slim as slim
+import logging
+
+import numpy as np
+import scipy.misc
+import tensorflow as tf
+
+from ..utils import TFModel
+
+
+class Features(TFModel):
+    def __init__(self, config):
+        TFModel.__init__(self, config)
+
+        try:
+            # (1) Find feature tensor
+            feature_tensor = self.tf_graph.get_tensor_by_name(config["features-tensor"])
+            # self.tf_feature_out = slim.flatten(feature_tensor)
+            self.tf_feature_out = tf.keras.layers.Flatten()(feature_tensor)
+
+            # (2) Find input tensor
+            self.tf_input = self.tf_graph.get_tensor_by_name(config["input-tensor"])
+
+            self.input_shape = self.tf_input.shape[1:]
+            logging.debug("Input shape: %s" % self.input_shape)
+
+        except:
+            self._report_error("Could not access tensors by name")
+
+    def extract_features(self, image):
+        resized_image = scipy.misc.imresize(image, self.input_shape)
+        features = self.tf_session.run(self.tf_feature_out,
+                                       feed_dict={self.tf_input: np.expand_dims(resized_image, axis=0)})
+
+        return features

+ 116 - 0
pycs/pipeline/pipeline.py

@@ -0,0 +1,116 @@
+#!/usr/bin/env python
+
+"""pipeline:  Detection and other model pipeline."""
+
+import json
+import os.path
+
+from PIL import Image
+
+from .detection import Detector
+from .features import Features
+from ..utils import Errorable
+from ..utils import Video
+
+
+class Pipeline(Errorable):
+    def __init__(self, config):
+        Errorable.__init__(self)
+        self.config = config
+
+        self.detector, self.features = self._load_distribution()
+        self._err_children += [self.detector, self.features]
+
+    def _load_distribution(self):
+        try:
+            distribution_path = self.config['model-distribution']
+            with open(os.path.join(distribution_path, 'distribution.json'), 'r') as distribution_json_file:
+                distribution_json = json.load(distribution_json_file)
+
+            detector_config = distribution_json['detection']
+            features_config = distribution_json['features']
+
+        except:
+            self._report_error("Could not parse the distribution configuration")
+            return None, None
+
+        try:
+            detector = self._load_detector(detector_config)
+        except:
+            self._report_error("Could not load the detector")
+            return None, None
+
+        try:
+            features = self._load_features(features_config)
+        except:
+            detector.close()
+            self._report_error("Could not load the feature extraction mechanism")
+            return None, None
+
+        return detector, features
+
+    def _load_detector(self, config):
+        detector = Detector(config={**config, 'distribution-root': self.config['model-distribution']})
+
+        return detector
+
+    def _load_features(self, config):
+        features = Features(config={**config, 'distribution-root': self.config['model-distribution']})
+
+        return features
+
+    def execute(self, subjobs, callback):
+        callback(0)
+        subjob_count = float(len(subjobs))
+        for index, subjob in enumerate(subjobs):
+            subjob_name = subjob['subjob']
+            prediction = subjob['prediction']
+            jobinfo = subjob['jobinfo']
+            jobinfo[subjob_name] = {'done-by': 'pipeline'}
+
+            if subjob_name == 'detect-faces':
+                # Run face detection
+                if self.detector.last_error is not None:
+                    jobinfo[subjob_name]['error'] = self.detector.last_error
+                    jobinfo[subjob_name]['result'] = False
+
+                else:
+                    filename = subjob['filename']
+
+                    # Acquire image
+                    if subjob['filetype'] == 'image':
+                        img = Image.open(filename)
+                    elif subjob['filetype'] == 'video':
+                        if 'cap' in subjob.keys():
+                            cap = subjob['cap']
+                        else:
+                            cap = Video(filename)
+                        if cap.last_error is None:
+                            jobinfo[subjob_name]['error'] = cap.last_error
+                        else:
+                            jobinfo[subjob_name]['result'] = False
+                            continue
+
+                        img = cap.get_frame(subjob['frame'])
+                    else:
+                        jobinfo[subjob_name]['error'] = 'File format not supported!'
+                        jobinfo[subjob_name]['result'] = False
+                        continue
+
+                    faces = self.detector.detect_faces(img)
+
+                    if self.detector.last_error is not None:
+                        jobinfo[subjob_name]['error'] = self.detector.last_error
+                        jobinfo[subjob_name]['result'] = False
+                    else:
+                        prediction['faces'] = faces
+                        jobinfo[subjob_name]['result'] = True
+            else:
+                jobinfo[subjob_name]['result'] = False
+
+            callback(float(index) / subjob_count)
+        callback(1)
+
+    def close(self):
+        self.detector.close()
+        self.features.close()

+ 5 - 0
pycs/pipeline/utils/__init__.py

@@ -0,0 +1,5 @@
+#!/usr/bin/env python
+
+"""utils:  Various helper functions."""
+
+from .tfmodel import TFModel

+ 25 - 0
pycs/pipeline/utils/tfmodel.py

@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+
+"""tfmodel:  Base class for tensorflow based models."""
+
+from .utils import create_session_from_config
+from ...utils import Errorable
+
+
+class TFModelException(Exception):
+    def __init__(self, message):
+        Exception.__init__(self, message)
+
+
+class TFModel(Errorable):
+    def __init__(self, config):
+        Errorable.__init__(self)
+
+        self.tf_session = create_session_from_config(config)
+        if self.tf_session is None:
+            self.last_error = 'Session creation failed.'
+
+        self.tf_graph = self.tf_session.graph
+
+    def close(self):
+        self.tf_session.close()

+ 102 - 0
pycs/pipeline/utils/utils.py

@@ -0,0 +1,102 @@
+#!/usr/bin/env python
+
+"""utils:  Various helper functions."""
+
+import logging
+import os.path
+import sys
+
+import google.protobuf.message
+import tensorflow as tf
+from google.protobuf import text_format
+
+
+def create_session_from_config(config):
+    try:
+        # (1) Frozen graph
+        if "model-frozengraph" in config.keys():
+            full_path = os.path.join(config["distribution-root"], config["model-frozengraph"])
+            logging.debug("Loading frozen graph: %s" % full_path)
+            graph = tf.Graph()
+            with graph.as_default():
+                graph_definition = tf.compat.v1.GraphDef()
+                with tf.io.gfile.GFile(full_path, 'rb') as graph_file:
+                    logging.debug("Opened file, deserializing...")
+                    serialized_graph = graph_file.read()
+                    graph_definition.ParseFromString(serialized_graph)
+                    tf.import_graph_def(graph_definition)
+
+            logging.debug("Done, creating session.")
+            return _create_tf_session(graph=graph)
+
+        # (2) GraphDef + Checkpoint
+        elif "model-graphdef" in config.keys():
+            full_path_def = os.path.join(config["distribution-root"], config["model-graphdef"])
+            full_path_ckpt = os.path.join(config["distribution-root"], config["model-checkpoint"])
+            logging.debug("Loading graphdef: %s" % full_path_def)
+            graph = tf.Graph()
+            with graph.as_default():
+                graph_definition = tf.compat.v1.GraphDef()
+                if full_path_def.endswith('pb'):
+                    with tf.io.gfile.GFile(full_path_def, 'rb') as graph_file:
+                        logging.debug("Opened file, deserializing...")
+                        serialized_graph = graph_file.read()
+                        graph_definition.ParseFromString(serialized_graph)
+                elif full_path_def.endswith('graph') or full_path_def.endswith('pbtxt'):
+                    with tf.io.gfile.GFile(full_path_def, 'r') as graph_file:
+                        logging.debug("Opened file, deserializing...")
+                        text_format.Merge(graph_file.read(), graph_definition)
+                else:
+                    raise Exception("Unknown file type: %s" % full_path_def)
+
+                tf.import_graph_def(graph_definition)
+
+            logging.debug("Done, creating session.")
+            session = _create_tf_session(graph=graph)
+
+            logging.debug('Restoring checkpoint %s' % full_path_ckpt)
+            saver = tf.compat.v1.train.Saver()
+            saver.restore(sess=session, save_path=full_path_ckpt)
+
+            return session
+
+        # (3) Metagraph
+        elif "model-metagraph" in config.keys():
+            full_path_meta = os.path.join(config["distribution-root"], config["model-metagraph"])
+            full_path_ckpt = os.path.join(config["distribution-root"], config["model-checkpoint"])
+
+            logging.debug('Importing metagraph, creating session...')
+            session = _create_tf_session()
+
+            logging.debug('Loading %s' % full_path_meta)
+            saver = tf.compat.v1.train.import_meta_graph(full_path_meta, clear_devices=True, import_scope="import")
+
+            logging.debug('Restoring checkpoint %s' % full_path_ckpt)
+            saver.restore(sess=session, save_path=full_path_ckpt)
+
+            return session
+    except OSError as os_error:
+        logging.error("Error while attempting to load: %s" % os_error)
+        logging.error("Config: %s" % config)
+    except google.protobuf.message.DecodeError as decode_error:
+        logging.error("Error while attempting to load: %s" % decode_error)
+        logging.error("Config: %s" % config)
+    except:
+        t = sys.exc_info()[0]
+        v = sys.exc_info()[1]
+        logging.error("Error while attempting to load: %s, %s" % (t, v))
+        logging.error("Config: %s" % config)
+
+    return None
+
+
+def _create_tf_session(graph=None):
+    tf_config = tf.compat.v1.ConfigProto()
+    tf_config.gpu_options.allow_growth = True
+
+    if graph is not None:
+        session = tf.compat.v1.Session(graph=graph)
+    else:
+        session = tf.compat.v1.Session()
+
+    return session

+ 5 - 0
pycs/project/__init__.py

@@ -0,0 +1,5 @@
+#!/usr/bin/env python
+
+"""project:  Encapsulates a project with all its properties."""
+
+from .project import Project

+ 94 - 0
pycs/project/project.py

@@ -0,0 +1,94 @@
+#!/usr/bin/env python
+
+"""project:  Encapsulates a project with all its properties."""
+
+import json
+import os
+
+from ..pipeline import Pipeline
+from ..utils import Errorable
+from ..utils import Video
+
+
+class Project(Errorable):
+    def __init__(self, project_root, config=None):
+        Errorable.__init__(self)
+        self._update_ui_fn = lambda: None
+
+        new = config is not None
+
+        # (1) Check if directory exists
+        self.root = project_root
+
+        if not os.path.exists(self.root) and not new:
+            self._report_error("Cannot open directory: %s" % self.root)
+            return
+        elif not os.path.exists(self.root) and new:
+            try:
+                os.mkdir(self.root)
+            except:
+                self._report_error("Cannot create directory: %s" % self.root)
+                return
+
+        # (2) Load project json
+        if new:
+            self._config = config
+        else:
+            try:
+                with open(os.path.join(self.root, 'project.json'), 'r') as c:
+                    self._config = json.load(c)
+            except:
+                self._report_error("Cannot open configuration for project: %s" % self.root)
+                return
+
+        # (3) Load detection/extraction pipeline
+        pipeline_config = self._config["pipeline"]
+
+        self._pipeline = Pipeline(pipeline_config)
+        self._err_children += [self._pipeline]
+
+    def save(self):
+        try:
+            with open(os.path.join(self.root, 'project.json'), 'w') as c:
+                json.dump(self._config, c, indent=2)
+        except:
+            self._report_error("Cannot save configuration for project: %s" % self.root)
+
+    def execute(self, jobs, callback=lambda progress: True):
+        all_subjobs = []
+        for job in jobs:
+            job['prediction'] = {}
+            job['jobinfo'] = {}
+            for subjob in job['jobs']:
+                if job['filetype'] == 'image':
+                    pipeline_subjob = {'filetype': 'image',
+                                       'filename': job['filename'],
+                                       'prediction': job['prediction'],
+                                       'jobinfo': job['jobinfo'],
+                                       'subjob': subjob}
+                    all_subjobs += [pipeline_subjob]
+                # TODO split video into frames
+                elif job['filetype'] == 'video':
+                    cap = Video(job['filename'])
+                    if cap.framecount > 0:
+                        job['prediction-by-frame'] = [{} for frame in range(cap.framecount)]
+                        job['jobinfo-by-frame'] = [{} for frame in range(cap.framecount)]
+                        for frame in range(cap.framecount):
+                            pipeline_subjob = {'filetype': 'video',
+                                               'cap': cap,
+                                               'frame': frame,
+                                               'filename': job['filename'],
+                                               'prediction': job['prediction-by-frame'][frame],
+                                               'jobinfo': job['jobinfo-by-frame'][frame],
+                                               'subjob': subjob}
+                            all_subjobs += [pipeline_subjob]
+
+        self._pipeline.execute(all_subjobs, callback)
+        callback(1)
+
+    def close(self):
+        self.detector.close()
+        self.features.close()
+
+    def close(self):
+        self._pipeline.close()

+ 13 - 0
pycs/ui/AboutDialog.py

@@ -0,0 +1,13 @@
+#!/usr/bin/env python
+
+import os
+
+from PyQt5 import uic, QtWidgets
+
+
+class AboutDialog(QtWidgets.QDialog):
+    def __init__(self, **kwargs):
+        super(AboutDialog, self).__init__(**kwargs)
+
+        spath = os.path.dirname(__file__)
+        uic.loadUi(os.path.join(spath, 'AboutDialog.ui'), self)

+ 125 - 0
pycs/ui/AboutDialog.ui

@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+    <class>AboutDialog</class>
+    <widget class="QDialog" name="AboutDialog">
+        <property name="geometry">
+            <rect>
+                <x>0</x>
+                <y>0</y>
+                <width>582</width>
+                <height>696</height>
+            </rect>
+        </property>
+        <property name="windowTitle">
+            <string>About Carpe Simiam</string>
+        </property>
+        <layout class="QVBoxLayout" name="verticalLayout">
+            <item>
+                <layout class="QHBoxLayout" name="horizontalLayout">
+                    <item>
+                        <layout class="QVBoxLayout" name="verticalLayout_2">
+                            <item>
+                                <widget class="QGraphicsView" name="graphicsView">
+                                    <property name="minimumSize">
+                                        <size>
+                                            <width>500</width>
+                                            <height>500</height>
+                                        </size>
+                                    </property>
+                                    <property name="maximumSize">
+                                        <size>
+                                            <width>500</width>
+                                            <height>500</height>
+                                        </size>
+                                    </property>
+                                    <property name="styleSheet">
+                                        <string notr="true">background:transparent</string>
+                                    </property>
+                                    <property name="verticalScrollBarPolicy">
+                                        <enum>Qt::ScrollBarAlwaysOff</enum>
+                                    </property>
+                                    <property name="horizontalScrollBarPolicy">
+                                        <enum>Qt::ScrollBarAlwaysOff</enum>
+                                    </property>
+                                </widget>
+                            </item>
+                            <item>
+                                <widget class="QLabel" name="label">
+                                    <property name="text">
+                                        <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;div style=&quot;&quot;&gt;&lt;p&gt;Copyright (C) 2017&lt;br/&gt;Clemens-Alexander
+                                            Brust&lt;br/&gt;Computer Vision Group&lt;br/&gt;Friedrich Schiller University Jena&lt;/p&gt;&lt;p&gt;Carpe Simiam
+                                            uses the following projects:&lt;/p&gt;&lt;ul style=&quot;margin-top: 0px; margin-bottom: 0px; margin-left: 0px;
+                                            margin-right: 0px; -qt-list-indent: 1;&quot;&gt;&lt;li style=&quot; margin-top:0px; margin-bottom:0px;
+                                            margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;&quot;&gt;&lt;a href=&quot;https://www.csie.ntu.edu.tw/~cjlin/liblinear/&quot;&gt;&lt;span
+                                            style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;liblinear&lt;/span&gt;&lt;/a&gt; by Chih-Jena Lin
+                                            at National Taiwan University (&lt;a href=&quot;https://www.csie.ntu.edu.tw/~cjlin/liblinear/COPYRIGHT&quot;&gt;&lt;span
+                                            style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;BSD License&lt;/span&gt;&lt;/a&gt;)&lt;br/&gt;&lt;/li&gt;&lt;li
+                                            style=&quot; margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0;
+                                            text-indent:0px;&quot;&gt;&lt;a href=&quot;https://github.com/cvjena/cn24&quot;&gt;&lt;span style=&quot;
+                                            text-decoration: underline; color:#0000ff;&quot;&gt;CN24&lt;/span&gt;&lt;/a&gt; by Clemens-Alexander Brust at
+                                            Friedrich Schiller University Jena (&lt;a href=&quot;https://github.com/cvjena/cn24/blob/master/LICENSE&quot;&gt;&lt;span
+                                            style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;BSD License&lt;/span&gt;&lt;/a&gt;)&lt;/li&gt;&lt;/ul&gt;
+                                            &lt;/div&gt;
+                                            &lt;/body&gt;&lt;/html&gt;
+                                        </string>
+                                    </property>
+                                    <property name="alignment">
+                                        <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
+                                    </property>
+                                    <property name="wordWrap">
+                                        <bool>true</bool>
+                                    </property>
+                                </widget>
+                            </item>
+                        </layout>
+                    </item>
+                </layout>
+            </item>
+            <item>
+                <widget class="QDialogButtonBox" name="buttonBox">
+                    <property name="orientation">
+                        <enum>Qt::Horizontal</enum>
+                    </property>
+                    <property name="standardButtons">
+                        <set>QDialogButtonBox::Close</set>
+                    </property>
+                </widget>
+            </item>
+        </layout>
+    </widget>
+    <resources/>
+    <connections>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>accepted()</signal>
+            <receiver>AboutDialog</receiver>
+            <slot>accept()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>248</x>
+                    <y>254</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>157</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>rejected()</signal>
+            <receiver>AboutDialog</receiver>
+            <slot>reject()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>316</x>
+                    <y>260</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>286</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+    </connections>
+</ui>

+ 93 - 0
pycs/ui/AnnotatedImageView.py

@@ -0,0 +1,93 @@
+#!/usr/bin/env python
+
+"""AnnotatedImageView:  Displays images with bounding boxes etc."""
+
+from PyQt5 import QtWidgets, QtGui, QtCore
+
+from ..utils import Video
+
+
+class AnnotatedImageView(QtWidgets.QGraphicsView):
+    def __init__(self, parent):
+        super(AnnotatedImageView, self).__init__(parent=parent)
+
+        self._scene = QtWidgets.QGraphicsScene()
+        self.setScene(self._scene)
+
+        self._rect = QtCore.QRectF(0, 0, 0, 0)
+
+        self.setRenderHint(QtGui.QPainter.Antialiasing)
+
+        self._image = None
+        self._current_video = None
+
+    def display(self, prediction):
+        self._scene.clear()
+        if prediction['filetype'] == 'image':
+            self._image = QtGui.QImage(prediction['filename'])
+            item = QtWidgets.QGraphicsPixmapItem(QtGui.QPixmap.fromImage(self._image))
+            self._scene.addItem(item)
+            self._scene.setSceneRect(item.boundingRect())
+            self._rect = item.boundingRect()
+
+            if 'prediction' in prediction.keys():
+                self._overlay(prediction['prediction'])
+
+        elif prediction['filetype'] == 'video':
+            if 'cap' in prediction.keys():
+                cap = prediction['cap']
+            else:
+                cap = Video(prediction['filename'])
+
+            if 'frame' in prediction.keys():
+                frame = prediction['frame']
+            else:
+                frame = 0
+
+            video_frame = cap.get_frame(frame)
+            if video_frame is not None:
+                self._image = QtGui.QImage(video_frame,
+                                           video_frame.shape[1], video_frame.shape[0],
+                                           video_frame.shape[2] * video_frame.shape[1], QtGui.QImage.Format_RGB888)
+
+                item = QtWidgets.QGraphicsPixmapItem(QtGui.QPixmap.fromImage(self._image))
+                self._scene.addItem(item)
+                self._scene.setSceneRect(item.boundingRect())
+                self._rect = item.boundingRect()
+
+                if 'prediction-by-frame' in prediction.keys():
+                    self._overlay(prediction['prediction-by-frame'][frame])
+
+        self.refit_display()
+
+    def _overlay(self, prediction):
+        if 'faces' in prediction.keys():
+            self._overlay_faces(prediction['faces'])
+
+    def _overlay_faces(self, faces):
+        box_background = QtGui.QColor(255, 255, 255, 51)
+        box_border = QtGui.QColor(0, 0, 0, 1)
+        width = float(self._image.width())
+        height = float(self._image.height())
+
+        for face in faces:
+            self._scene.addRect(face['x'] * width, face['y'] * height, face['w'] * width, face['h'] * height,
+                                QtGui.QPen(QtGui.QBrush(box_border), 3.0),
+                                QtGui.QBrush(box_background))
+
+    def refit_display(self):
+        if self._rect is None:
+            self.resetTransform()
+        else:
+            self.fitInView(self._rect, QtCore.Qt.KeepAspectRatio)
+
+    def resizeEvent(self, event: QtGui.QResizeEvent):
+        self.refit_display()
+        QtWidgets.QGraphicsView.resizeEvent(self, event)
+
+    def reset(self):
+        self._scene.clear()
+        self._image = None
+        self._current_video = None
+        self._rect = None
+        # TODO display context action

+ 117 - 0
pycs/ui/ChimpManagementDialog.ui

@@ -0,0 +1,117 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+    <class>ChimpManagementDialog</class>
+    <widget class="QDialog" name="ChimpManagementDialog">
+        <property name="geometry">
+            <rect>
+                <x>0</x>
+                <y>0</y>
+                <width>512</width>
+                <height>443</height>
+            </rect>
+        </property>
+        <property name="windowTitle">
+            <string>Manage Individuals</string>
+        </property>
+        <layout class="QVBoxLayout" name="verticalLayout">
+            <item>
+                <widget class="QTableWidget" name="individualTable">
+                    <property name="cornerButtonEnabled">
+                        <bool>false</bool>
+                    </property>
+                    <attribute name="verticalHeaderVisible">
+                        <bool>false</bool>
+                    </attribute>
+                    <column>
+                        <property name="text">
+                            <string>Name</string>
+                        </property>
+                    </column>
+                    <column>
+                        <property name="text">
+                            <string>Age</string>
+                        </property>
+                    </column>
+                    <column>
+                        <property name="text">
+                            <string>Age Group</string>
+                        </property>
+                    </column>
+                    <column>
+                        <property name="text">
+                            <string>Gender</string>
+                        </property>
+                    </column>
+                    <column>
+                        <property name="text">
+                            <string># Ex.</string>
+                        </property>
+                    </column>
+                </widget>
+            </item>
+            <item>
+                <layout class="QHBoxLayout" name="horizontalLayout">
+                    <item>
+                        <widget class="QPushButton" name="addButton">
+                            <property name="text">
+                                <string>Add Individual</string>
+                            </property>
+                        </widget>
+                    </item>
+                    <item>
+                        <widget class="QPushButton" name="deleteButton">
+                            <property name="text">
+                                <string>Delete Individual</string>
+                            </property>
+                        </widget>
+                    </item>
+                    <item>
+                        <widget class="QDialogButtonBox" name="buttonBox">
+                            <property name="orientation">
+                                <enum>Qt::Horizontal</enum>
+                            </property>
+                            <property name="standardButtons">
+                                <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+                            </property>
+                        </widget>
+                    </item>
+                </layout>
+            </item>
+        </layout>
+    </widget>
+    <resources/>
+    <connections>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>accepted()</signal>
+            <receiver>ChimpManagementDialog</receiver>
+            <slot>accept()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>248</x>
+                    <y>254</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>157</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>rejected()</signal>
+            <receiver>ChimpManagementDialog</receiver>
+            <slot>reject()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>316</x>
+                    <y>260</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>286</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+    </connections>
+</ui>

+ 259 - 0
pycs/ui/LabelSessionDialog.ui

@@ -0,0 +1,259 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+    <class>LabelSessionDialog</class>
+    <widget class="QDialog" name="LabelSessionDialog">
+        <property name="geometry">
+            <rect>
+                <x>0</x>
+                <y>0</y>
+                <width>587</width>
+                <height>412</height>
+            </rect>
+        </property>
+        <property name="windowTitle">
+            <string>Label Images</string>
+        </property>
+        <layout class="QVBoxLayout" name="verticalLayout">
+            <item>
+                <widget class="QGroupBox" name="filesBox">
+                    <property name="title">
+                        <string>Images</string>
+                    </property>
+                    <layout class="QVBoxLayout" name="verticalLayout_2">
+                        <item>
+                            <widget class="QListWidget" name="filesList"/>
+                        </item>
+                        <item>
+                            <layout class="QHBoxLayout" name="horizontalLayout">
+                                <item>
+                                    <widget class="QPushButton" name="removeButton">
+                                        <property name="text">
+                                            <string>Remove Image</string>
+                                        </property>
+                                        <property name="icon">
+                                            <iconset theme="list-remove">
+                                                <normaloff>.</normaloff>.
+                                            </iconset>
+                                        </property>
+                                    </widget>
+                                </item>
+                                <item>
+                                    <widget class="QPushButton" name="addVideoButton">
+                                        <property name="enabled">
+                                            <bool>true</bool>
+                                        </property>
+                                        <property name="text">
+                                            <string>Add Video...</string>
+                                        </property>
+                                        <property name="icon">
+                                            <iconset theme="video-x-generic">
+                                                <normaloff>.</normaloff>.
+                                            </iconset>
+                                        </property>
+                                    </widget>
+                                </item>
+                                <item>
+                                    <widget class="QPushButton" name="addImagesButton">
+                                        <property name="text">
+                                            <string>Add Images...</string>
+                                        </property>
+                                        <property name="icon">
+                                            <iconset theme="list-add">
+                                                <normaloff>.</normaloff>.
+                                            </iconset>
+                                        </property>
+                                    </widget>
+                                </item>
+                            </layout>
+                        </item>
+                    </layout>
+                </widget>
+            </item>
+            <item>
+                <widget class="QGroupBox" name="paramsBox">
+                    <property name="title">
+                        <string>Settings</string>
+                    </property>
+                    <layout class="QFormLayout" name="formLayout">
+                        <item row="0" column="0">
+                            <widget class="QLabel" name="detectionThresholdLabel">
+                                <property name="text">
+                                    <string>Detection Threshold</string>
+                                </property>
+                            </widget>
+                        </item>
+                        <item row="2" column="0">
+                            <widget class="QLabel" name="unknownLabel">
+                                <property name="text">
+                                    <string>Unknown Individual Threshold</string>
+                                </property>
+                            </widget>
+                        </item>
+                        <item row="0" column="1">
+                            <layout class="QVBoxLayout" name="verticalLayout_3">
+                                <item>
+                                    <widget class="QSlider" name="detectionThresholdSlider">
+                                        <property name="minimum">
+                                            <number>0</number>
+                                        </property>
+                                        <property name="maximum">
+                                            <number>100</number>
+                                        </property>
+                                        <property name="orientation">
+                                            <enum>Qt::Horizontal</enum>
+                                        </property>
+                                        <property name="invertedAppearance">
+                                            <bool>false</bool>
+                                        </property>
+                                        <property name="tickPosition">
+                                            <enum>QSlider::TicksBelow</enum>
+                                        </property>
+                                        <property name="tickInterval">
+                                            <number>5</number>
+                                        </property>
+                                    </widget>
+                                </item>
+                                <item>
+                                    <layout class="QHBoxLayout" name="horizontalLayout_2">
+                                        <item>
+                                            <widget class="QLabel" name="label">
+                                                <property name="text">
+                                                    <string>Low</string>
+                                                </property>
+                                            </widget>
+                                        </item>
+                                        <item>
+                                            <spacer name="horizontalSpacer">
+                                                <property name="orientation">
+                                                    <enum>Qt::Horizontal</enum>
+                                                </property>
+                                                <property name="sizeHint" stdset="0">
+                                                    <size>
+                                                        <width>40</width>
+                                                        <height>20</height>
+                                                    </size>
+                                                </property>
+                                            </spacer>
+                                        </item>
+                                        <item>
+                                            <widget class="QLabel" name="label_3">
+                                                <property name="text">
+                                                    <string>High</string>
+                                                </property>
+                                            </widget>
+                                        </item>
+                                    </layout>
+                                </item>
+                            </layout>
+                        </item>
+                        <item row="2" column="1">
+                            <layout class="QVBoxLayout" name="verticalLayout_4">
+                                <item>
+                                    <widget class="QSlider" name="unknownSlider">
+                                        <property name="minimum">
+                                            <number>0</number>
+                                        </property>
+                                        <property name="maximum">
+                                            <number>100</number>
+                                        </property>
+                                        <property name="singleStep">
+                                            <number>1</number>
+                                        </property>
+                                        <property name="pageStep">
+                                            <number>10</number>
+                                        </property>
+                                        <property name="orientation">
+                                            <enum>Qt::Horizontal</enum>
+                                        </property>
+                                        <property name="tickPosition">
+                                            <enum>QSlider::TicksBelow</enum>
+                                        </property>
+                                        <property name="tickInterval">
+                                            <number>5</number>
+                                        </property>
+                                    </widget>
+                                </item>
+                                <item>
+                                    <layout class="QHBoxLayout" name="horizontalLayout_3">
+                                        <item>
+                                            <widget class="QLabel" name="label_2">
+                                                <property name="text">
+                                                    <string>Low</string>
+                                                </property>
+                                            </widget>
+                                        </item>
+                                        <item>
+                                            <spacer name="horizontalSpacer_2">
+                                                <property name="orientation">
+                                                    <enum>Qt::Horizontal</enum>
+                                                </property>
+                                                <property name="sizeHint" stdset="0">
+                                                    <size>
+                                                        <width>40</width>
+                                                        <height>20</height>
+                                                    </size>
+                                                </property>
+                                            </spacer>
+                                        </item>
+                                        <item>
+                                            <widget class="QLabel" name="label_4">
+                                                <property name="text">
+                                                    <string>High</string>
+                                                </property>
+                                            </widget>
+                                        </item>
+                                    </layout>
+                                </item>
+                            </layout>
+                        </item>
+                    </layout>
+                </widget>
+            </item>
+            <item>
+                <widget class="QDialogButtonBox" name="buttonBox">
+                    <property name="orientation">
+                        <enum>Qt::Horizontal</enum>
+                    </property>
+                    <property name="standardButtons">
+                        <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+                    </property>
+                </widget>
+            </item>
+        </layout>
+    </widget>
+    <resources/>
+    <connections>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>accepted()</signal>
+            <receiver>LabelSessionDialog</receiver>
+            <slot>accept()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>248</x>
+                    <y>254</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>157</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>rejected()</signal>
+            <receiver>LabelSessionDialog</receiver>
+            <slot>reject()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>316</x>
+                    <y>260</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>286</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+    </connections>
+</ui>

+ 117 - 0
pycs/ui/LabeledImagesDialog.ui

@@ -0,0 +1,117 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+    <class>LabeledImagesDialog</class>
+    <widget class="QDialog" name="LabeledImagesDialog">
+        <property name="geometry">
+            <rect>
+                <x>0</x>
+                <y>0</y>
+                <width>855</width>
+                <height>590</height>
+            </rect>
+        </property>
+        <property name="sizePolicy">
+            <sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
+                <horstretch>0</horstretch>
+                <verstretch>0</verstretch>
+            </sizepolicy>
+        </property>
+        <property name="windowTitle">
+            <string>Dialog</string>
+        </property>
+        <layout class="QVBoxLayout" name="verticalLayout_2">
+            <item>
+                <layout class="QHBoxLayout" name="horizontalLayout_2">
+                    <item>
+                        <widget class="QGroupBox" name="groupBox">
+                            <property name="title">
+                                <string>Status</string>
+                            </property>
+                            <layout class="QHBoxLayout" name="horizontalLayout">
+                                <item>
+                                    <widget class="QTextBrowser" name="logText"/>
+                                </item>
+                            </layout>
+                        </widget>
+                    </item>
+                    <item>
+                        <layout class="QVBoxLayout" name="verticalLayout">
+                            <item>
+                                <widget class="QPushButton" name="importIndividualsButton">
+                                    <property name="text">
+                                        <string>Import Individuals</string>
+                                    </property>
+                                </widget>
+                            </item>
+                            <item>
+                                <widget class="QPushButton" name="processSamplesButton">
+                                    <property name="text">
+                                        <string>Process Samples</string>
+                                    </property>
+                                </widget>
+                            </item>
+                            <item>
+                                <spacer name="verticalSpacer">
+                                    <property name="orientation">
+                                        <enum>Qt::Vertical</enum>
+                                    </property>
+                                    <property name="sizeHint" stdset="0">
+                                        <size>
+                                            <width>20</width>
+                                            <height>40</height>
+                                        </size>
+                                    </property>
+                                </spacer>
+                            </item>
+                        </layout>
+                    </item>
+                </layout>
+            </item>
+            <item>
+                <widget class="QDialogButtonBox" name="buttonBox">
+                    <property name="orientation">
+                        <enum>Qt::Horizontal</enum>
+                    </property>
+                    <property name="standardButtons">
+                        <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+                    </property>
+                </widget>
+            </item>
+        </layout>
+    </widget>
+    <resources/>
+    <connections>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>accepted()</signal>
+            <receiver>LabeledImagesDialog</receiver>
+            <slot>accept()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>248</x>
+                    <y>254</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>157</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>rejected()</signal>
+            <receiver>LabeledImagesDialog</receiver>
+            <slot>reject()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>316</x>
+                    <y>260</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>286</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+    </connections>
+</ui>

+ 280 - 0
pycs/ui/MainWindow.py

@@ -0,0 +1,280 @@
+#!/usr/bin/env python
+
+import copy
+import json
+import os
+
+from PyQt5 import uic, QtWidgets, QtCore, QtGui
+
+from . import AboutDialog
+from .NewProjectWizard import NewProjectWizard
+from .PredictionDialog import PredictionDialog
+from ..project import Project
+from ..utils import Video
+
+
+class MainWindow:
+    # Properties
+    def _set_project(self, project):
+        self.__project = project
+        if project is not None:
+            project._update_ui_fn = lambda: self._update_ui()
+        self._update_ui()
+
+    def _get_project(self):
+        return self.__project
+
+    _project = property(fget=_get_project, fset=_set_project)
+
+    def _has_project(self):
+        if self.__project is not None:
+            return True
+        else:
+            return False
+
+    def _set_predictions(self, predictions):
+        self.__predictions = predictions
+        self._update_prediction_ui()
+
+    def _get_predictions(self):
+        return self.__predictions
+
+    _predictions = property(fget=_get_predictions, fset=_set_predictions)
+
+    def _project_needs_saving(self):
+        return True
+
+    # Constructor
+    def __init__(self, **kwargs):
+        self.ui = QtWidgets.QMainWindow(**kwargs)
+
+        # Properties
+        self.__project = None
+        self.__predictions = []
+
+        # UI
+        spath = os.path.dirname(__file__)
+        uic.loadUi(os.path.join(spath, 'MainWindow.ui'), self.ui)
+
+        self.ui.statusLabel = QtWidgets.QLabel(self.ui)
+        self.ui.statusBar.addPermanentWidget(self.ui.statusLabel)
+
+        self.ui.timerThread = QtCore.QThread(self.ui)
+
+        self.ui.timer = QtCore.QTimer()
+        self.ui.timer.setInterval(2000)
+        self.ui.timer.moveToThread(self.ui.timerThread)
+
+        # Actions
+        # File
+        self.ui.actionNew.triggered.connect(self._project_new)
+        self.ui.actionOpen.triggered.connect(self._project_open)
+        self.ui.actionClose.triggered.connect(self._project_try_closing)
+        self.ui.actionSave.triggered.connect(self._project_save)
+        self.ui.actionQuit.triggered.connect(self._file_quit)
+
+        # Help
+        self.ui.actionAbout.triggered.connect(self._help_about)
+
+        self.ui.actionPredict_Images.triggered.connect(self._predict_via_dialog)
+        self.ui.predictButton.clicked.connect(self._predict_via_dialog)
+
+        self.ui.imageThumbnailGallery.currentRowChanged.connect(self._prediction_row_changed)
+
+        self._update_ui()
+        self._update_prediction_ui()
+
+        # TODO delete me
+        # self._project = Project('test-project')
+
+    def _update_ui(self):
+        # Status text
+        if self._has_project():
+            status_text = "OK"
+            if self._project.last_error is not None:
+                status_text = self._project.last_error
+        else:
+            status_text = "No project loaded."
+
+        self.ui.pipelineStatusLabel.setText(status_text)
+
+        # Action availability
+        project_only_actions = [self.ui.actionSave,
+                                self.ui.actionSave_As,
+                                self.ui.actionClose,
+                                self.ui.actionImport_Labeled_Faces,
+                                self.ui.actionImport_Labeled_Images,
+                                self.ui.startLabelingSessionButton,
+                                self.ui.actionStart_Labeling_Session,
+                                self.ui.actionManage_Individuals,
+                                self.ui.actionPredict_Images,
+                                self.ui.predictButton,
+                                self.ui.actionUpdate_Model,
+                                self.ui.updateButton,
+                                self.ui.actionValidate_Model,
+                                self.ui.clearPredictionsButton,
+                                self.ui.exportCSVButton]
+
+        for action in project_only_actions:
+            action.setEnabled(self._has_project())
+
+    def show(self):
+        return self.ui.show()
+
+    #####################
+    # Project lifecycle #
+    #####################
+    def _project_try_closing(self):
+        if self._has_project():
+            if self._project_needs_saving():
+                # Ask user
+                box = QtWidgets.QMessageBox()
+                box.setText('The current project has unsaved changes. Would you like to save them?')
+                box.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Cancel)
+                retval = box.exec_()
+                if retval == QtWidgets.QMessageBox.Yes:
+                    self._project.save()
+                    if self._project.last_error is not None:
+                        error_string = self._project.last_error
+                        QtWidgets.QMessageBox.warning(self.ui, 'Error', 'Error while saving (see log for details): %s'
+                                                      % error_string)
+                        self._project.reset_error()
+                        return False
+                elif retval == QtWidgets.QMessageBox.No:
+                    pass
+                elif retval == QtWidgets.QMessageBox.Cancel:
+                    return False
+
+            self._project.close()
+            self._project = None
+            return True
+        else:
+            return True
+
+    def _project_new(self):
+        retval = self._project_try_closing()
+        if retval:
+            w = NewProjectWizard(self.ui)
+            w_retval = w.exec_()
+            if w_retval:
+                self._project = Project(w.project_root, w.project_config)
+
+    def _project_open(self):
+        retval = self._project_try_closing()
+        if retval:
+            selection = QtWidgets.QFileDialog.getExistingDirectory(self.ui, 'Select Project Folder')
+            if len(selection) > 0 and os.path.exists(os.path.join(selection, 'project.json')):
+                self._project = Project(selection)
+                if self._project.last_error is not None:
+                    error_string = self._project.last_error
+                    try:
+                        QtWidgets.QMessageBox.warning(self.ui, 'Error', 'Error while opening (see log for details): %s'
+                                                      % error_string)
+                        self._project.close()
+                    except:
+                        pass
+                    self._project = None
+            elif len(selection) > 0:  # Project file does not exist
+                QtWidgets.QMessageBox.warning(self.ui, 'Error', 'Could not find project file: %s' % os.path.join(selection, 'project.json'))
+
+    def _project_save(self):
+        self._project.save()
+        if self._project.last_error is not None:
+            error_string = self._project.last_error
+            QtWidgets.QMessageBox.warning(self.ui, 'Error', 'Error while saving (see log for details): %s'
+                                          % error_string)
+            self._project.reset_error()
+
+    def _file_quit(self):
+        # TODO: warning if there are any unsaved changes
+        self.close()
+
+    def _help_about(self):
+        about = AboutDialog()
+        about.exec_()
+
+    #####################################
+    # Prediction actions and management #
+    #####################################
+    def _predict_via_dialog(self):
+        prediction_dialog = PredictionDialog(parent=self.ui)
+        if prediction_dialog.exec_():
+
+            # TODO extract callback and progress dialog to make it reusable
+            jobs = copy.deepcopy(prediction_dialog.jobs)
+            progress_dialog = QtWidgets.QProgressDialog('Executing prediction jobs...', 'Abort', 0, 1000, self.ui)
+            progress_dialog.setWindowModality(QtCore.Qt.WindowModal)
+
+            def callback(progress):
+                progress_dialog.setValue(int(progress * 1000))
+                for i in range(10):
+                    QtWidgets.QApplication.processEvents()
+
+            self._project.execute(jobs, callback=callback)
+
+            progress_dialog.close()
+            self._predictions += jobs
+
+    def _update_prediction_ui(self):
+        # TODO implement better sync
+        self.ui.imageThumbnailGallery.clear()
+        for prediction in self._predictions:
+            filename = prediction['filename']
+            if prediction['filetype'] == 'image':
+                icon = QtGui.QIcon(filename)
+                item = QtWidgets.QListWidgetItem(icon, os.path.basename(filename))
+            else:
+                item = QtWidgets.QListWidgetItem(os.path.basename(filename))
+            self.ui.imageThumbnailGallery.addItem(item)
+            self._prediction_row_changed(self.ui.imageThumbnailGallery.currentRow())
+
+        if len(self._predictions) == 0:
+            self._prediction_row_changed(-1)
+
+    def _prediction_row_changed(self, row):
+        if row in range(len(self._predictions)):
+            prediction = self._predictions[row]
+            self._setup_video_slider(prediction)
+            self._display_prediction(prediction)
+        else:
+            self._setup_video_slider({'filetype': 'empty'})
+            self._display_prediction({'filetype': 'empty'})
+
+    def _setup_video_slider(self, prediction):
+        if prediction['filetype'] == 'video':
+            video_cap = Video(prediction['filename'])
+            if video_cap.last_error is None:
+                self.ui.frameNumber.setDecMode()
+                self.ui.frameNumber.display(0)
+                self.ui.frameNumber.setDigitCount(5)
+                self.ui.frameNumber.show()
+
+                self.ui.frameSlider.show()
+                self.ui.frameSlider.setValue(0)
+                self.ui.frameSlider.setMinimum(0)
+                self.ui.frameSlider.setMaximum(video_cap.framecount - 1)
+
+                self.ui.frameSlider.valueChanged.connect(
+                    lambda value, _prediction=prediction:
+                    [self._display_prediction(dict(frame=value, cap=video_cap, **_prediction)),
+                     self.ui.frameNumber.display(value)])
+            else:
+                self._disable_video_slider()
+        else:
+            self._disable_video_slider()
+
+    def _disable_video_slider(self):
+        self.ui.frameSlider.setValue(0)
+        try:
+            self.ui.frameSlider.valueChanged.disconnect()
+        except:
+            pass
+        self.ui.frameSlider.hide()
+        self.ui.frameNumber.hide()
+
+    def _display_prediction(self, prediction):
+        self.ui.annotatedImageView.display(prediction)
+        filtered_dict = {i: prediction[i] for i in prediction if i != 'cap'}
+        if 'cap' in prediction.keys():
+            filtered_dict['cap'] = '[VideoCapture]'
+        self.ui.predictionDebugText.setPlainText(json.dumps(filtered_dict, indent=2, skipkeys=True))

+ 444 - 0
pycs/ui/MainWindow.ui

@@ -0,0 +1,444 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+    <class>MainWindow</class>
+    <widget class="QMainWindow" name="MainWindow">
+        <property name="geometry">
+            <rect>
+                <x>0</x>
+                <y>0</y>
+                <width>970</width>
+                <height>624</height>
+            </rect>
+        </property>
+        <property name="windowTitle">
+            <string>Carpe Simiam</string>
+        </property>
+        <widget class="QWidget" name="centralWidget">
+            <layout class="QHBoxLayout" name="horizontalLayout">
+                <item>
+                    <layout class="QVBoxLayout" name="verticalLayout_2">
+                        <item>
+                            <widget class="AnnotatedImageView" name="annotatedImageView">
+                                <property name="sizePolicy">
+                                    <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+                                        <horstretch>0</horstretch>
+                                        <verstretch>0</verstretch>
+                                    </sizepolicy>
+                                </property>
+                            </widget>
+                        </item>
+                        <item>
+                            <layout class="QHBoxLayout" name="frameCtrlLayout">
+                                <item>
+                                    <widget class="QLCDNumber" name="frameNumber"/>
+                                </item>
+                                <item>
+                                    <widget class="QSlider" name="frameSlider">
+                                        <property name="orientation">
+                                            <enum>Qt::Horizontal</enum>
+                                        </property>
+                                    </widget>
+                                </item>
+                            </layout>
+                        </item>
+                    </layout>
+                </item>
+                <item>
+                    <layout class="QVBoxLayout" name="verticalLayout">
+                        <item>
+                            <widget class="QListWidget" name="imageThumbnailGallery">
+                                <property name="sizePolicy">
+                                    <sizepolicy hsizetype="Preferred" vsizetype="Expanding">
+                                        <horstretch>0</horstretch>
+                                        <verstretch>0</verstretch>
+                                    </sizepolicy>
+                                </property>
+                                <property name="minimumSize">
+                                    <size>
+                                        <width>0</width>
+                                        <height>0</height>
+                                    </size>
+                                </property>
+                                <property name="maximumSize">
+                                    <size>
+                                        <width>16777215</width>
+                                        <height>16777215</height>
+                                    </size>
+                                </property>
+                                <property name="iconSize">
+                                    <size>
+                                        <width>96</width>
+                                        <height>54</height>
+                                    </size>
+                                </property>
+                                <property name="viewMode">
+                                    <enum>QListView::ListMode</enum>
+                                </property>
+                                <property name="uniformItemSizes">
+                                    <bool>true</bool>
+                                </property>
+                            </widget>
+                        </item>
+                        <item>
+                            <layout class="QHBoxLayout" name="horizontalLayout_2">
+                                <item>
+                                    <widget class="QPushButton" name="clearPredictionsButton">
+                                        <property name="text">
+                                            <string>Clear Predictions</string>
+                                        </property>
+                                    </widget>
+                                </item>
+                                <item>
+                                    <widget class="QPushButton" name="exportCSVButton">
+                                        <property name="text">
+                                            <string>Export as CSV...</string>
+                                        </property>
+                                    </widget>
+                                </item>
+                            </layout>
+                        </item>
+                    </layout>
+                </item>
+            </layout>
+        </widget>
+        <widget class="QMenuBar" name="menuBar">
+            <property name="geometry">
+                <rect>
+                    <x>0</x>
+                    <y>0</y>
+                    <width>970</width>
+                    <height>28</height>
+                </rect>
+            </property>
+            <widget class="QMenu" name="menuFile">
+                <property name="title">
+                    <string>Pro&amp;ject</string>
+                </property>
+                <addaction name="actionNew"/>
+                <addaction name="actionOpen"/>
+                <addaction name="separator"/>
+                <addaction name="actionSave"/>
+                <addaction name="actionSave_As"/>
+                <addaction name="separator"/>
+                <addaction name="actionClose"/>
+                <addaction name="separator"/>
+                <addaction name="actionQuit"/>
+            </widget>
+            <widget class="QMenu" name="menuHelp">
+                <property name="title">
+                    <string>He&amp;lp</string>
+                </property>
+                <addaction name="actionAbout"/>
+            </widget>
+            <widget class="QMenu" name="menuData">
+                <property name="title">
+                    <string>&amp;Data</string>
+                </property>
+                <addaction name="actionPredict_Images"/>
+                <addaction name="separator"/>
+                <addaction name="actionStart_Labeling_Session"/>
+                <addaction name="actionManage_Individuals"/>
+                <addaction name="separator"/>
+                <addaction name="actionImport_Labeled_Faces"/>
+                <addaction name="actionImport_Labeled_Images"/>
+                <addaction name="separator"/>
+                <addaction name="actionUpdate_Model"/>
+                <addaction name="actionValidate_Model"/>
+            </widget>
+            <addaction name="menuFile"/>
+            <addaction name="menuData"/>
+            <addaction name="menuHelp"/>
+        </widget>
+        <widget class="QDockWidget" name="statusWidget">
+            <attribute name="dockWidgetArea">
+                <number>1</number>
+            </attribute>
+            <widget class="QWidget" name="dockWidgetContents_2">
+                <layout class="QVBoxLayout" name="verticalLayout_3">
+                    <item>
+                        <layout class="QVBoxLayout" name="verticalLayout_5">
+                            <item>
+                                <widget class="QLabel" name="pipelineStatusLabel">
+                                    <property name="text">
+                                        <string/>
+                                    </property>
+                                </widget>
+                            </item>
+                        </layout>
+                    </item>
+                    <item>
+                        <spacer name="verticalSpacer_2">
+                            <property name="orientation">
+                                <enum>Qt::Vertical</enum>
+                            </property>
+                            <property name="sizeHint" stdset="0">
+                                <size>
+                                    <width>20</width>
+                                    <height>20</height>
+                                </size>
+                            </property>
+                        </spacer>
+                    </item>
+                    <item>
+                        <widget class="QGroupBox" name="quickAccessBox">
+                            <property name="title">
+                                <string>Quick Tasks</string>
+                            </property>
+                            <layout class="QVBoxLayout" name="verticalLayout_4">
+                                <item>
+                                    <widget class="QPushButton" name="predictButton">
+                                        <property name="text">
+                                            <string>Predict...</string>
+                                        </property>
+                                        <property name="icon">
+                                            <iconset theme="media-playback-start">
+                                                <normaloff>.</normaloff>.
+                                            </iconset>
+                                        </property>
+                                    </widget>
+                                </item>
+                                <item>
+                                    <widget class="QPushButton" name="startLabelingSessionButton">
+                                        <property name="text">
+                                            <string>Label...</string>
+                                        </property>
+                                        <property name="icon">
+                                            <iconset theme="list-add">
+                                                <normaloff>.</normaloff>.
+                                            </iconset>
+                                        </property>
+                                    </widget>
+                                </item>
+                                <item>
+                                    <widget class="QPushButton" name="updateButton">
+                                        <property name="text">
+                                            <string>Update Model</string>
+                                        </property>
+                                        <property name="icon">
+                                            <iconset theme="view-refresh">
+                                                <normaloff>.</normaloff>.
+                                            </iconset>
+                                        </property>
+                                    </widget>
+                                </item>
+                            </layout>
+                        </widget>
+                    </item>
+                </layout>
+            </widget>
+        </widget>
+        <widget class="QStatusBar" name="statusBar"/>
+        <widget class="QToolBar" name="toolBar">
+            <property name="windowTitle">
+                <string>toolBar</string>
+            </property>
+            <attribute name="toolBarArea">
+                <enum>TopToolBarArea</enum>
+            </attribute>
+            <attribute name="toolBarBreak">
+                <bool>false</bool>
+            </attribute>
+            <addaction name="actionNew"/>
+            <addaction name="actionOpen"/>
+            <addaction name="actionSave"/>
+            <addaction name="separator"/>
+            <addaction name="actionPredict_Images"/>
+            <addaction name="actionManage_Individuals"/>
+            <addaction name="separator"/>
+            <addaction name="actionUpdate_Model"/>
+            <addaction name="separator"/>
+            <addaction name="actionAbout"/>
+        </widget>
+        <widget class="QDockWidget" name="dockWidget">
+            <property name="windowTitle">
+                <string>Deb&amp;ug Output</string>
+            </property>
+            <attribute name="dockWidgetArea">
+                <number>1</number>
+            </attribute>
+            <widget class="QWidget" name="dockWidgetContents">
+                <layout class="QHBoxLayout" name="horizontalLayout_3">
+                    <item>
+                        <widget class="QPlainTextEdit" name="predictionDebugText">
+                            <property name="font">
+                                <font>
+                                    <family>Courier</family>
+                                    <pointsize>8</pointsize>
+                                </font>
+                            </property>
+                            <property name="lineWrapMode">
+                                <enum>QPlainTextEdit::NoWrap</enum>
+                            </property>
+                            <property name="readOnly">
+                                <bool>true</bool>
+                            </property>
+                        </widget>
+                    </item>
+                </layout>
+            </widget>
+        </widget>
+        <action name="actionAbout">
+            <property name="icon">
+                <iconset theme="help-browser">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;About</string>
+            </property>
+        </action>
+        <action name="actionOpen">
+            <property name="icon">
+                <iconset theme="document-open">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;Open...</string>
+            </property>
+            <property name="shortcut">
+                <string>Ctrl+O</string>
+            </property>
+        </action>
+        <action name="actionSave">
+            <property name="icon">
+                <iconset theme="document-save">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;Save</string>
+            </property>
+            <property name="shortcut">
+                <string>Ctrl+S</string>
+            </property>
+        </action>
+        <action name="actionSave_As">
+            <property name="icon">
+                <iconset theme="document-save-as">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>Sa&amp;ve As...</string>
+            </property>
+            <property name="shortcut">
+                <string>Ctrl+Shift+S</string>
+            </property>
+        </action>
+        <action name="actionClose">
+            <property name="icon">
+                <iconset theme="document-close">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;Close</string>
+            </property>
+            <property name="shortcut">
+                <string>Ctrl+W</string>
+            </property>
+        </action>
+        <action name="actionQuit">
+            <property name="icon">
+                <iconset theme="system-log-out">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;Quit</string>
+            </property>
+            <property name="shortcut">
+                <string>Ctrl+Q</string>
+            </property>
+        </action>
+        <action name="actionNew">
+            <property name="checkable">
+                <bool>false</bool>
+            </property>
+            <property name="icon">
+                <iconset theme="document-new">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;New</string>
+            </property>
+            <property name="iconText">
+                <string>New</string>
+            </property>
+            <property name="shortcut">
+                <string>Ctrl+N</string>
+            </property>
+        </action>
+        <action name="actionImport_Labeled_Faces">
+            <property name="icon">
+                <iconset>
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;Import Labeled Faces...</string>
+            </property>
+        </action>
+        <action name="actionManage_Individuals">
+            <property name="icon">
+                <iconset theme="document-properties">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;Manage Individuals...</string>
+            </property>
+        </action>
+        <action name="actionPredict_Images">
+            <property name="icon">
+                <iconset theme="media-playback-start">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;Predict Images...</string>
+            </property>
+        </action>
+        <action name="actionUpdate_Model">
+            <property name="icon">
+                <iconset theme="view-refresh">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;Update Model</string>
+            </property>
+        </action>
+        <action name="actionValidate_Model">
+            <property name="text">
+                <string>&amp;Validate Model...</string>
+            </property>
+        </action>
+        <action name="actionImport_Labeled_Images">
+            <property name="text">
+                <string>Import &amp;Labeled Images...</string>
+            </property>
+        </action>
+        <action name="actionStart_Labeling_Session">
+            <property name="icon">
+                <iconset theme="list-add">
+                    <normaloff>.</normaloff>.
+                </iconset>
+            </property>
+            <property name="text">
+                <string>&amp;Start Labeling Session...</string>
+            </property>
+        </action>
+    </widget>
+    <customwidgets>
+        <customwidget>
+            <class>AnnotatedImageView</class>
+            <extends>QGraphicsView</extends>
+            <header>pycs.ui.AnnotatedImageView</header>
+        </customwidget>
+    </customwidgets>
+    <resources/>
+    <connections/>
+</ui>

+ 224 - 0
pycs/ui/NewProjectWizard.py

@@ -0,0 +1,224 @@
+#!/usr/bin/env python
+
+"""NewProjectWizard:  Wizard to create new projects."""
+
+import os
+
+from PyQt5 import QtWidgets, QtGui
+
+
+class NewProjectWizard(QtWidgets.QWizard):
+    def _get_project_config(self):
+        return self._project_config
+
+    project_config = property(_get_project_config)
+
+    def _get_project_root(self):
+        return self._project_root
+
+    project_root = property(_get_project_root)
+
+    def __init__(self, parent=None):
+        self._project_config = None
+        self._project_root = None
+        QtWidgets.QWizard.__init__(self, parent=parent)
+
+        self.addPage(IntroductionPage(self))
+        self.addPage(ProjectFolderPage(self))
+        self.addPage(ModelPage(self))
+        self.addPage(DonePage(self))
+
+        self.setPixmap(QtWidgets.QWizard.LogoPixmap, QtGui.QPixmap(':/icons/banner.jpg'))
+        self.setWindowTitle('New Project')
+
+
+# (0) Introduction
+class IntroductionPage(QtWidgets.QWizardPage):
+    def __init__(self, parent=None):
+        QtWidgets.QWizardPage.__init__(self, parent=parent)
+
+        self.setTitle('Introduction')
+        self.setPixmap(QtWidgets.QWizard.WatermarkPixmap, QtGui.QPixmap(':/icons/banner.jpg'))
+
+        label = QtWidgets.QLabel('This wizard will create a new project. Please verify that the '
+                                 'following prerequisites are available to you: <ul>'
+                                 '<li>Carpe Simiam Model Distribution v1.0</li></ul>')
+        label.setWordWrap(True)
+
+        layout = QtWidgets.QVBoxLayout()
+        layout.addWidget(label)
+
+        self.setLayout(layout)
+
+
+# (1) Project Folder
+class ProjectFolderPage(QtWidgets.QWizardPage):
+    def __init__(self, parent=None):
+        QtWidgets.QWizardPage.__init__(self, parent=parent)
+
+        self._valid = False
+
+        self.setTitle('Project Details')
+        self.setSubTitle('')
+
+        projectNameLabel = QtWidgets.QLabel("Project name:")
+        projectNameLineEdit = QtWidgets.QLineEdit()
+        projectNameLabel.setBuddy(projectNameLineEdit)
+
+        projectDescriptionLabel = QtWidgets.QLabel("Project description:")
+        projectDescriptionLineEdit = QtWidgets.QLineEdit()
+        projectDescriptionLabel.setBuddy(projectDescriptionLineEdit)
+
+        projectFolderLabel = QtWidgets.QLabel("Project folder:")
+        self.projectFolderLineEdit = QtWidgets.QLineEdit()
+        projectFolderLabel.setBuddy(self.projectFolderLineEdit)
+        self.validationLabel = QtWidgets.QLabel()
+        self.validationLabel.setStyleSheet("QLabel{ color: red }")
+
+        projectFolderButton = QtWidgets.QPushButton("Browse...")
+        projectFolderButton.clicked.connect(self.onProjectFolderBrowse)
+        self.projectFolderLineEdit.textChanged.connect(self.onProjectFolderChanged)
+
+        layout = QtWidgets.QVBoxLayout()
+        folderLine = QtWidgets.QHBoxLayout()
+
+        layout.addWidget(projectNameLabel)
+        layout.addWidget(projectNameLineEdit)
+        layout.addWidget(projectDescriptionLabel)
+        layout.addWidget(projectDescriptionLineEdit)
+
+        layout.addWidget(projectFolderLabel)
+        layout.addLayout(folderLine)
+        layout.addWidget(self.validationLabel)
+        folderLine.addWidget(self.projectFolderLineEdit)
+        folderLine.addWidget(projectFolderButton)
+
+        self.setLayout(layout)
+        self.registerField("projectFolder*", self.projectFolderLineEdit)
+        self.registerField("projectName*", projectNameLineEdit)
+        self.registerField("projectDescription", projectDescriptionLineEdit)
+
+    def onProjectFolderBrowse(self):
+        selection = QtWidgets.QFileDialog.getExistingDirectory(self, 'Select Project Folder')
+        if len(selection) > 0:
+            self.projectFolderLineEdit.setText(selection)
+
+    def onProjectFolderChanged(self):
+        def validate(text):
+            if len(text) == 0:
+                return 'Please enter a valid destination for your project.'
+            elif not os.path.exists(text):
+                return 'The specified folder does not exist.'
+            elif len(os.listdir(text)) > 0:
+                return 'The specified folder is not empty.'
+            return None
+
+        result = validate(self.projectFolderLineEdit.text())
+        self._valid = result is None
+        if result is not None:
+            self.validationLabel.setText(str(result))
+        else:
+            self.validationLabel.setText('')
+
+    def validatePage(self):
+        self.onProjectFolderChanged()
+        return self._valid and QtWidgets.QWizardPage.validatePage(self)
+
+
+# (2) Model Distribution
+class ModelPage(QtWidgets.QWizardPage):
+    def __init__(self, parent=None):
+        QtWidgets.QWizardPage.__init__(self, parent=parent)
+        self._valid = False
+
+        self.setTitle("Model Distribution")
+        self.setSubTitle("Specify the folder where the Carpe Simiam Model Distribution v1.0 is located.")
+
+        # self.setCommitPage(True)
+
+        modelFolderLabel = QtWidgets.QLabel("Model distribution folder:")
+        modelFolderLineEdit = QtWidgets.QLineEdit()
+        modelFolderLabel.setBuddy(modelFolderLineEdit)
+        validationLabel = QtWidgets.QLabel("")
+        validationLabel.setStyleSheet("QLabel{ color: red }")
+
+        modelFolderButton = QtWidgets.QPushButton("Browse...")
+        modelFolderButton.clicked.connect(self.onModelFolderBrowse)
+        modelFolderLineEdit.textChanged.connect(self.onModelFolderChanged)
+
+        layout = QtWidgets.QVBoxLayout()
+        folderLine = QtWidgets.QHBoxLayout()
+        layout.addWidget(modelFolderLabel)
+        layout.addLayout(folderLine)
+        layout.addWidget(validationLabel)
+        folderLine.addWidget(modelFolderLineEdit)
+        folderLine.addWidget(modelFolderButton)
+
+        self.setLayout(layout)
+        self.registerField("modelFolder*", modelFolderLineEdit)
+
+        self.modelFolderLineEdit = modelFolderLineEdit
+        self.validationLabel = validationLabel
+
+    def onModelFolderBrowse(self):
+        selection = QtWidgets.QFileDialog.getExistingDirectory(self, 'Select Model Distribution Folder')
+        if len(selection) > 0:
+            self.modelFolderLineEdit.setText(selection)
+
+    def onModelFolderChanged(self):
+        def validate(text):
+            if len(text) == 0:
+                return 'Please enter a valid model distribution location.'
+            if not os.path.exists(text):
+                return 'The specified folder does not exist.'
+            if "distribution.json" not in os.listdir(text):
+                return 'The specified folder does not contain a model distribution'
+            return None
+
+        result = validate(self.modelFolderLineEdit.text())
+        self._valid = result is None
+        if result is not None:
+            self.validationLabel.setText(str(result))
+        else:
+            self.validationLabel.setText('')
+
+    def validatePage(self):
+        return self._valid and QtWidgets.QWizardPage.validatePage(self)
+
+
+# (4) Done
+class DonePage(QtWidgets.QWizardPage):
+    def initializePage(self):
+        # Collect fields
+        w = self.wizard()
+        w._project_config = {
+            'description': self.field('projectDescription'),
+            'name': self.field('projectName'),
+            'pipeline': {
+                'model-distribution': self.field('modelFolder')
+            }
+        }
+        w._project_root = self.field('projectFolder')
+
+        # TODO insert summary of project here
+        done_message = "" \
+                       "<p>You can now take the following steps to get started:" \
+                       "<ul><li>Add individuals to the population. To do this, " \
+                       "select <em>Data</em> &gt; <em>Manage Individuals...</em></li>" \
+                       "<li>Import labeled data for training. Choose the appropriate " \
+                       "entry from the <em>Data</em> menu.</li>" \
+                       "<li>Run prediction on images using the <em>Predict Images...</em> button.</li>" \
+                       "</ul></p><p>Configuration:<br>" + str(w._project_config) + "</p>"
+
+        self.doneLabel.setText(done_message)
+
+    def __init__(self, parent=None):
+        QtWidgets.QWizardPage.__init__(self, parent=parent)
+
+        self.doneLabel = QtWidgets.QLabel('Done.')
+        self.doneLabel.setWordWrap(True)
+
+        layout = QtWidgets.QVBoxLayout()
+        layout.addWidget(self.doneLabel)
+
+        self.setLayout(layout)

+ 71 - 0
pycs/ui/PredictionDialog.py

@@ -0,0 +1,71 @@
+#!/usr/bin/env python
+
+"""PredictionDialog:  Dialog to select images and videos to predict."""
+
+import os
+
+from PyQt5 import uic, QtWidgets, QtGui, QtCore
+
+
+class PredictionDialog:
+    FileTypeRole = QtCore.Qt.UserRole + 1
+    FileNameRole = QtCore.Qt.UserRole + 2
+
+    image_extensions = ['.jpg', '.png', '.jpeg']
+    video_extensions = ['.mp4', '.avi', '.mkv', '.mpg', '.mpeg']
+
+    def _get_jobs(self):
+        def to_job(item: QtGui.QStandardItem):
+            filename = item.data(self.FileNameRole)
+            filetype = item.data(self.FileTypeRole)
+            return {'filename': filename, 'filetype': filetype, 'jobs': ['detect-faces']}
+
+        return list(map(to_job, [self.model.item(index) for index in range(self.model.rowCount())]))
+
+    jobs = property(fget=_get_jobs)
+
+    def __init__(self, **kwargs):
+        self.ui = QtWidgets.QDialog(**kwargs)
+
+        spath = os.path.dirname(__file__)
+        uic.loadUi(os.path.join(spath, 'PredictionDialog.ui'), self.ui)
+
+        self.model = QtGui.QStandardItemModel()
+        self.ui.filesList.setModel(self.model)
+
+        # self.add_image('/home/brust/demonstrations/carpe-simiam/images/Alexandra_V110_5_09-08-10.png')
+        # self.add_video('/home/brust/demonstrations/carpe-simiam/images/cut.avi')
+
+        self.ui.addFileButton.clicked.connect(self._add_files)
+
+    def _add_files(self):
+        extensions = 'All supported files (' + \
+                     (' '.join(['*' + extension for extension in self.image_extensions + self.video_extensions])) + \
+                     ')'
+
+        selection = QtWidgets.QFileDialog.getOpenFileNames(self.ui, 'Select Files To Add', filter=extensions)
+        for filename in selection[0]:
+            _, extension = os.path.splitext(filename)
+            extension = extension.lower()
+
+            if extension in self.image_extensions:
+                self.add_image(filename)
+            elif extension in self.video_extensions:
+                self.add_video(filename)
+
+    def exec_(self):
+        return self.ui.exec_()
+
+    def add_image(self, image):
+        # TODO Check for existence
+        item = QtGui.QStandardItem(QtGui.QIcon(image), os.path.basename(image))
+        item.setData('image', self.FileTypeRole)
+        item.setData(image, self.FileNameRole)
+        self.model.appendRow(item)
+
+    def add_video(self, video):
+        # TODO Check for existence
+        item = QtGui.QStandardItem(QtGui.QIcon(video), os.path.basename(video))
+        item.setData('video', self.FileTypeRole)
+        item.setData(video, self.FileNameRole)
+        self.model.appendRow(item)

+ 114 - 0
pycs/ui/PredictionDialog.ui

@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+    <class>PredictionDialog</class>
+    <widget class="QDialog" name="PredictionDialog">
+        <property name="geometry">
+            <rect>
+                <x>0</x>
+                <y>0</y>
+                <width>587</width>
+                <height>412</height>
+            </rect>
+        </property>
+        <property name="windowTitle">
+            <string>Predict Images</string>
+        </property>
+        <layout class="QVBoxLayout" name="verticalLayout">
+            <item>
+                <widget class="QGroupBox" name="filesBox">
+                    <property name="title">
+                        <string>Images</string>
+                    </property>
+                    <layout class="QVBoxLayout" name="verticalLayout_2">
+                        <item>
+                            <widget class="QListView" name="filesList">
+                                <property name="iconSize">
+                                    <size>
+                                        <width>32</width>
+                                        <height>32</height>
+                                    </size>
+                                </property>
+                                <property name="uniformItemSizes">
+                                    <bool>true</bool>
+                                </property>
+                            </widget>
+                        </item>
+                        <item>
+                            <layout class="QHBoxLayout" name="horizontalLayout">
+                                <item>
+                                    <widget class="QPushButton" name="removeButton">
+                                        <property name="text">
+                                            <string>Remove File</string>
+                                        </property>
+                                        <property name="icon">
+                                            <iconset theme="list-remove">
+                                                <normaloff>.</normaloff>.
+                                            </iconset>
+                                        </property>
+                                    </widget>
+                                </item>
+                                <item>
+                                    <widget class="QPushButton" name="addFileButton">
+                                        <property name="text">
+                                            <string>Add File...</string>
+                                        </property>
+                                        <property name="icon">
+                                            <iconset theme="list-add">
+                                                <normaloff>.</normaloff>.
+                                            </iconset>
+                                        </property>
+                                    </widget>
+                                </item>
+                            </layout>
+                        </item>
+                    </layout>
+                </widget>
+            </item>
+            <item>
+                <widget class="QDialogButtonBox" name="buttonBox">
+                    <property name="orientation">
+                        <enum>Qt::Horizontal</enum>
+                    </property>
+                    <property name="standardButtons">
+                        <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+                    </property>
+                </widget>
+            </item>
+        </layout>
+    </widget>
+    <resources/>
+    <connections>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>accepted()</signal>
+            <receiver>PredictionDialog</receiver>
+            <slot>accept()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>248</x>
+                    <y>254</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>157</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+        <connection>
+            <sender>buttonBox</sender>
+            <signal>rejected()</signal>
+            <receiver>PredictionDialog</receiver>
+            <slot>reject()</slot>
+            <hints>
+                <hint type="sourcelabel">
+                    <x>316</x>
+                    <y>260</y>
+                </hint>
+                <hint type="destinationlabel">
+                    <x>286</x>
+                    <y>274</y>
+                </hint>
+            </hints>
+        </connection>
+    </connections>
+</ui>

+ 99 - 0
pycs/ui/ProgressDialog.ui

@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+    <class>ProgressDialog</class>
+    <widget class="QDialog" name="ProgressDialog">
+        <property name="geometry">
+            <rect>
+                <x>0</x>
+                <y>0</y>
+                <width>692</width>
+                <height>85</height>
+            </rect>
+        </property>
+        <property name="sizePolicy">
+            <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+                <horstretch>0</horstretch>
+                <verstretch>0</verstretch>
+            </sizepolicy>
+        </property>
+        <property name="minimumSize">
+            <size>
+                <width>692</width>
+                <height>85</height>
+            </size>
+        </property>
+        <property name="maximumSize">
+            <size>
+                <width>692</width>
+                <height>85</height>
+            </size>
+        </property>
+        <property name="cursor">
+            <cursorShape>WaitCursor</cursorShape>
+        </property>
+        <property name="contextMenuPolicy">
+            <enum>Qt::NoContextMenu</enum>
+        </property>
+        <property name="windowTitle">
+            <string>Carpe Simiam</string>
+        </property>
+        <property name="modal">
+            <bool>true</bool>
+        </property>
+        <layout class="QVBoxLayout" name="verticalLayout">
+            <item>
+                <widget class="QLabel" name="statusText">
+                    <property name="text">
+                        <string>TextLabel</string>
+                    </property>
+                </widget>
+            </item>
+            <item>
+                <widget class="QProgressBar" name="progressBar">
+                    <property name="value">
+                        <number>24</number>
+                    </property>
+                </widget>
+            </item>
+            <item>
+                <layout class="QHBoxLayout" name="horizontalLayout">
+                    <item>
+                        <widget class="QLabel" name="timeRemainingLabel">
+                            <property name="text">
+                                <string/>
+                            </property>
+                        </widget>
+                    </item>
+                    <item>
+                        <spacer name="horizontalSpacer">
+                            <property name="orientation">
+                                <enum>Qt::Horizontal</enum>
+                            </property>
+                            <property name="sizeHint" stdset="0">
+                                <size>
+                                    <width>40</width>
+                                    <height>20</height>
+                                </size>
+                            </property>
+                        </spacer>
+                    </item>
+                    <item>
+                        <widget class="QPushButton" name="cancelButton">
+                            <property name="text">
+                                <string>Cancel</string>
+                            </property>
+                            <property name="default">
+                                <bool>true</bool>
+                            </property>
+                            <property name="flat">
+                                <bool>false</bool>
+                            </property>
+                        </widget>
+                    </item>
+                </layout>
+            </item>
+        </layout>
+    </widget>
+    <resources/>
+    <connections/>
+</ui>

+ 6 - 0
pycs/ui/__init__.py

@@ -0,0 +1,6 @@
+#!/usr/bin/env python
+
+"""ui:  All dialogs and windows in Carpe Simiam."""
+
+from .AboutDialog import AboutDialog
+from .MainWindow import MainWindow

+ 6 - 0
pycs/utils/__init__.py

@@ -0,0 +1,6 @@
+#!/usr/bin/env python
+
+"""__init__.py:  General utilities."""
+
+from .errorable import Errorable
+from .video import Video

+ 68 - 0
pycs/utils/errorable.py

@@ -0,0 +1,68 @@
+#!/usr/bin/env python
+
+"""errorable:  Base class for classes with error state."""
+
+import logging
+import sys
+
+
+class Errorable:
+    def __init__(self):
+        self._update_ui_fn = lambda: None
+        self._last_error = None
+        self._err_children = []
+
+    def reset_error(self):
+        self.last_error = None
+
+    def get_last_error(self):
+        errors = ""
+        for child in self._err_children:
+            if child.last_error is not None:
+                if len(errors) > 0:
+                    errors += ", %s" % child.last_error
+                else:
+                    errors += "%s" % child.last_error
+        if self._last_error is not None:
+            if len(errors) > 0:
+                errors += ", %s" % self._last_error
+            else:
+                errors += "%s" % self._last_error
+        if len(errors) == 0:
+            return None
+        else:
+            return errors
+
+    def set_last_error(self, error):
+        self._last_error = error
+        if error is None:
+            for child in self._err_children:
+                child.last_error = None
+
+    last_error = property(get_last_error, set_last_error)
+
+    def _report_error(self, error=None):
+        if error is None:
+            error_str = 'General error'
+        else:
+            error_str = str(error)
+
+        self.last_error = error_str
+
+        # (1) Get exception info (if any)
+        t = sys.exc_info()[0]
+        v = sys.exc_info()[1]
+
+        # (2) Get calling stack frame
+        try:
+            caller = str(sys._getframe(1).f_code.co_name)
+
+        except:
+            caller = "Unknown"
+
+        logging.error("Error in %s" % caller)
+        logging.error(str(error))
+        if t is not None:
+            logging.error("Underlying: %s, %s" % (t, v))
+
+        self._update_ui_fn()

+ 43 - 0
pycs/utils/video.py

@@ -0,0 +1,43 @@
+#!/usr/bin/env python
+
+"""video:  Description."""
+
+from json import JSONEncoder
+
+import cv2
+
+from .errorable import Errorable
+
+
+class Video(Errorable, JSONEncoder):
+    framecount = property(fget=lambda self: self._framecount)
+
+    def get_frame(self, frame):
+        self._cap.set(cv2.CAP_PROP_POS_FRAMES, frame)
+        retval, image = self._cap.read()
+        if not retval:
+            self._report_error('OpenCV error prevents reading of frame!')
+            return None
+        else:
+            rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
+
+            return rgb
+
+    def __init__(self, filename):
+        Errorable.__init__(self)
+
+        self._filename = filename
+        self._framecount = -1
+        try:
+            self._cap = cv2.VideoCapture(self._filename)
+            if not self._cap.isOpened():
+                raise Exception
+            self._framecount = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
+        except:
+            self._report_error('Error opening %s' % filename)
+
+    def close(self):
+        self._cap.release()
+
+    def default(self, o):
+        return 'CAPTURE'