first commit
This commit is contained in:
		
						commit
						5ab8f0e07f
					
				
							
								
								
									
										108
									
								
								README.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								README.rst
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										426
									
								
								rank_photos.py
									
									
									
									
									
										Executable 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
									
								
							
							
						
						
									
										
											BIN
										
									
								
								screenshot.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.1 MiB | 
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user