commit 5ab8f0e07f0d3d56fa78f430ca6d4a112c7ad78a Author: Nick Hilton Date: Sat Feb 20 11:52:43 2016 -0800 first commit diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..cf01771 --- /dev/null +++ b/README.rst @@ -0,0 +1,108 @@ +Photo Ranking With Elo +====================== + +.. hyper link references + +.. _`Elo Ranking System`: http://en.wikipedia.org/wiki/Elo_rating_system +.. _`exifread`: https://pypi.python.org/pypi/ExifRead + + +What is this? +------------- + +This is a tool that uses the `Elo Ranking System`_ written in Python using: +- Matplotlib +- Numpy +- exifread + +Features: +- Persistent state from execution to execution so you can pickup where you left off +- Auto image rotation that the camera recored in the EXIF meta data + + +Install dependencies +-------------------- + +Use your system's package manager to install Numpy & Matplotlib if you don't +already have them installed. + +Next, you can use pip to install the EXIF image reader package `exifread`_: + +.. code-block:: bash + + pip install exifread --user + + +How to rank photos +------------------ + +Once you have to dependencies installed, run ``rank_photos.py`` on the command +line passing it the directory of photos. + +.. code-block:: bash + + ./rank_photos.py --help +usage: rank_photos.py [-h] [-r N_ROUNDS] [-f FIGSIZE FIGSIZE] photo_dir + +Uses the Elo ranking algorithm to sort your images by rank. The program reads +the comand line for images to present to you in random order, then you select +the better photo. After N iteration the resulting rankings are displayed. + +positional arguments: + photo_dir The photo directory to scan for .jpg images + +optional arguments: + -h, --help show this help message and exit + -r N_ROUNDS, --n-rounds N_ROUNDS + Specifies the number of rounds to pass through the + photo set + -f FIGSIZE FIGSIZE, --figsize FIGSIZE FIGSIZE + Specifies width and height of the Matplotlib figsize + (20, 12) + +For example, iterate over all photos three times: + +.. code-block:: bash + + ./rank_photos.py -r 3 some/path/to/photos + +After the number of rounds complete, `ranked.txt` is written into the photo dir. + + +Ranking work is cached +---------------------- + +After completing N rounds of ranking, a file called ``ranking_table.json`` is +written into the photo dir. The next time ``rank_photos.py`` is executed with +the photo dir, this table is read in and ranking can continue where you left +off. + +You can also add new photos the the directory and they will get added to the +ranked list even though they weren't included previously. + + +Example +------- + +Suppose there is a dir containing some photos: + +.. code-block:: bash + + ls -1 ~/Desktop/example/ + 20160102_164732.jpg + 20160109_151557.jpg + 20160109_151607.jpg + 20160109_152318.jpg + 20160109_152400.jpg + 20160109_152414.jpg + 20160109_153443.jpg + +These photos haven't been ranked yet, so lets ranking, 1 round: + +.. code-block:: bash + + ./rank_photos.py -r 1 ~/Desktop/example/ + +Example display: + +.. image:: screenshot.png diff --git a/rank_photos.py b/rank_photos.py new file mode 100755 index 0000000..7e2eb6a --- /dev/null +++ b/rank_photos.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python +""" +Matplotlib based photo ranking system using the Elo rating system. + +Reference: http://en.wikipedia.org/wiki/Elo_rating_system + +by Nick Hilton + +This file is in the public domain. + +""" + +# Python +import argparse +import glob +import json +import os +import sys + + +# 3rd party +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.image as mpimg + +import exifread + + +class Photo: + + LEFT = 0 + RIGHT = 1 + + def __init__(self, filename, score = 1400.0, wins = 0, matches = 0): + + if not os.path.isfile(filename): + raise ValueError("Could not find the file: %s" % filename) + + self._filename = filename + self._score = score + self._wins = wins + self._matches = matches + + # read orientation with exifread + + with open(filename, 'rb') as fd: + tags = exifread.process_file(fd) + + self._rotation = str(tags['Image Orientation']) + + + def filename(self): + return self._filename + + + def matches(self): + return self._matches + + + def rotation(self): + return self._rotation + + + def score(self, s = None, is_winner = None): + + if s is None: + return self._score + + assert is_winner is not None + + self._score = s + + self._matches += 1 + + if is_winner: + self._wins += 1 + + + def win_percentage(self): + return 100.0 * float(self._wins) / float(self._matches) + + + def __eq__(self, rhs): + return self._filename == rhs._filename + + + def to_dict(self): + + return { + 'filename' : self._filename, + 'score' : self._score, + 'matches' : self._matches, + 'wins' : self._wins, + } + + +class Display(object): + """ + Given two photos, displays them with Matplotlib and provides a graphical + means of choosing the better photo. + """ + + + def __init__(self, f1, f2, title = None, figsize = None): + + self._choice = None + + assert isinstance(f1, Photo) + assert isinstance(f2, Photo) + + if figsize is None: + figsize = [20,12] + + fig = plt.figure(figsize=figsize) + + h = 10 + + ax11 = plt.subplot2grid((h,2), (0,0), rowspan = h - 1) + ax12 = plt.subplot2grid((h,2), (0,1), rowspan = h - 1) + + ax21 = plt.subplot2grid((h,6), (h - 1, 1)) + ax22 = plt.subplot2grid((h,6), (h - 1, 4)) + + kwargs = dict(s = 'Select', ha = 'center', va = 'center', fontsize=20) + + ax21.text(0.5, 0.5, **kwargs) + ax22.text(0.5, 0.5, **kwargs) + + self._fig = fig + self._ax_select_left = ax21 + self._ax_select_right = ax22 + + fig.subplots_adjust( + left = 0.02, + bottom = 0.02, + right = 0.98, + top = 0.98, + wspace = 0.05, + hspace = 0, + ) + + left = self._read(f1) + right = self._read(f2) + + ax11.imshow(left) + ax12.imshow(right) + + for ax in [ax11, ax12, ax21, ax22]: + ax.set_xticklabels([]) + ax.set_yticklabels([]) + + ax.set_xticks([]) + ax.set_yticks([]) + + self._attach_callbacks() + + if title: + fig.suptitle(title, fontsize=20) + + plt.show() + + + def _read(self, photo): + + data = mpimg.imread(photo.filename()) + + r = photo.rotation() + +#~ print "Rotation: ", r +#~ print " data.shape = ", data.shape + + # rotate as necessary + + if r == 'Horizontal (normal)': + pass + + elif r == 'Rotated 90 CW': + + data = np.rot90(data, 3) + + elif r == 'Rotated 90 CCW': + + data = np.rot90(data, 1) + + else: + raise RuntimeError('Unhandled rotation "%s"' % r) + +#~ print " data.shape = ", data.shape + + return data + + + def _on_click(self, event): + + if event.inaxes == self._ax_select_left: + self._choice = Photo.LEFT + plt.close(self._fig) + + elif event.inaxes == self._ax_select_right: + self._choice = Photo.RIGHT + plt.close(self._fig) + + + def _attach_callbacks(self): + self._fig.canvas.mpl_connect('button_press_event', self._on_click) + + +class EloTable: + + + def __init__(self, max_increase = 32.0): + self._K = max_increase + self._photos = {} + self._shuffled_keys = [] + + + def add_photo(self, photo): + + filename = photo.filename() + + if filename not in self._photos: + self._photos[filename] = photo + + + def get_ranked_list(self): + + # Convert the dictionary into a list and then sort by score. + + ranked_list = self._photos.values() + + ranked_list = sorted( + ranked_list, + key = lambda record : record.score(), + reverse = True) + + return ranked_list + + + def rank_photos(self, n_iterations, figsize): + """ + Displays two photos using the command "gnome-open". Then asks which + photo is better. + """ + + n_photos = len(self._photos) + + keys = self._photos.keys() + + for i in xrange(n_iterations): + + np.random.shuffle(keys) + + n_matchups = n_photos / 2 + + for j in xrange(0, n_photos, 2): + + match_up = j / 2 + + title = 'Round %d / %d, Match Up %d / %d' % ( + i + 1, n_iterations, + match_up + 1, + n_matchups) + + photo_a = self._photos[keys[j]] + photo_b = self._photos[keys[j+1]] + + d = Display(photo_a, photo_b, title, figsize) + + if d == Photo.LEFT: + self.__score_result(photo_a, photo_b) + else: + self.__score_result(photo_b, photo_a) + + + def __score_result(self, winning_photo, loosing_photo): + + # Current ratings + R_a = winning_photo.score() + R_b = loosing_photo.score() + + # Expectation + + E_a = 1.0 / (1.0 + 10.0 ** ((R_a - R_b) / 400.0)) + + E_b = 1.0 / (1.0 + 10.0 ** ((R_b - R_a) / 400.0)) + + # New ratings + R_a = R_a + self._K * (1.0 - E_a) + R_b = R_b + self._K * (0.0 - E_b) + + winning_photo.score(R_a, True) + loosing_photo.score(R_b, False) + + + def to_dict(self): + + rl = self.get_ranked_list() + + rl = [x.to_dict() for x in rl] + + return {'photos' : rl} + + + +def main(): + + description = """\ +Uses the Elo ranking algorithm to sort your images by rank. The program reads +the comand line for images to present to you in random order, then you select +the better photo. After N iteration the resulting rankings are displayed. +""" + parser = argparse.ArgumentParser(description = description) + + parser.add_argument( + "-r", + "--n-rounds", + type = int, + default = 3, + help = "Specifies the number of rounds to pass through the photo set" + ) + + parser.add_argument( + "-f", + "--figsize", + nargs = 2, + type = int, + default = [20, 12], + help = "Specifies width and height of the Matplotlib figsize (20, 12)" + ) + + parser.add_argument( + "photo_dir", + help = "The photo directory to scan for .jpg images" + ) + + args = parser.parse_args() + + assert os.path.isdir(args.photo_dir) + + os.chdir(args.photo_dir) + + ranking_table_json = 'ranking_table.json' + ranked_txt = 'ranked.txt' + + # Create the ranking table and add photos to it. + + table = EloTable() + + #-------------------------------------------------------------------------- + # Read in table .json if present + + if os.path.isfile(ranking_table_json): + with open(ranking_table_json, 'r') as fd: + d = json.load(fd) + + # create photos and add to table + + for p in d['photos']: + + photo = Photo(**p) + + table.add_photo(photo) + + #-------------------------------------------------------------------------- + # glob for files, to include newly added files + + filelist = glob.glob('*.jpg') + + photos = [Photo(x) for x in filelist] + + for p in photos: + table.add_photo(p) + + #-------------------------------------------------------------------------- + # Rank the photos! + + table.rank_photos(args.n_rounds, args.figsize) + + #-------------------------------------------------------------------------- + # save the table + + with open(ranking_table_json, 'w') as fd: + + d = table.to_dict() + + jstr = json.dumps(d, indent = 4, separators=(',', ' : ')) + + fd.write(jstr) + + #-------------------------------------------------------------------------- + # dump ranked list to disk + + with open(ranked_txt, 'w') as fd: + + ranked_list = table.get_ranked_list() + + heading_fmt = "%4d %4.0f %7d %7.2f %s\n" + + heading = "Rank Score Matches Win % Filename\n" + + fd.write(heading) + + for i, photo in enumerate(ranked_list): + + line = heading_fmt %( + i + 1, + photo.score(), + photo.matches(), + photo.win_percentage(), + photo.filename()) + + fd.write(line) + + #-------------------------------------------------------------------------- + # dump ranked list to screen + + print "Final Ranking:" + + with open(ranked_txt, 'r') as fd: + text = fd.read() + + print text + + + +if __name__ == "__main__": main() diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..ad76824 Binary files /dev/null and b/screenshot.png differ