first commit

This commit is contained in:
Nick Hilton 2016-02-20 11:52:43 -08:00
commit 5ab8f0e07f
3 changed files with 534 additions and 0 deletions

108
README.rst Normal file
View File

@ -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

426
rank_photos.py Executable file
View File

@ -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()

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB