first commit
This commit is contained in:
commit
5ab8f0e07f
|
@ -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
|
|
@ -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()
|
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
Loading…
Reference in New Issue