├── .gitignore ├── screenshot.png ├── requirements.in ├── static └── memphis-mini-dark.png ├── requirements.txt ├── LICENSE ├── README.md ├── templates └── index.html ├── tint_colors.py ├── imageviewer.py └── out.html /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | *.pyc 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/imageviewer/main/screenshot.png -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | Jinja2==2.11.3 2 | Pillow==9.2.0 3 | Unidecode==1.2.0 4 | tqdm==4.54.0 5 | -------------------------------------------------------------------------------- /static/memphis-mini-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/imageviewer/main/static/memphis-mini-dark.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile 6 | # 7 | jinja2==2.11.3 8 | # via -r requirements.in 9 | markupsafe==2.1.1 10 | # via jinja2 11 | pillow==9.2.0 12 | # via -r requirements.in 13 | tqdm==4.54.0 14 | # via -r requirements.in 15 | unidecode==1.2.0 16 | # via -r requirements.in 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Alex Chan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 17 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 18 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 19 | OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imageviewer 2 | 3 | I have a bunch of images on my hard drive, and some of them are stored in heavily nested folder structures. 4 | This is a script to help me look through them images. 5 | It scans a folder for image files (plus any subfolders), then arranges them in a grid. 6 | I can click any image to go to the original file: 7 | 8 | ![A collection of images arranged in a grid on a dark background.](screenshot.png) 9 | 10 | ## Installation 11 | 12 | 1. Install Python 3 13 | 2. Clone the repo 14 | 3. Install the requirements (`pip3 install -r requirements.txt`) 15 | 4. (Optional) install [dominant_colours](https://github.com/alexwlchan/dominant_colours) 16 | 17 | ## Usage 18 | 19 | Run the included imageviewer script, passing a path to the folder with images you want to browse, e.g. 20 | 21 | ```console 22 | $ python3 imageviewer.py ~/repos/alexwlchan.net 23 | ``` 24 | 25 | It will open an HTML file with the image grid in your browser. 26 | 27 | ## License 28 | 29 | Code: MIT. 30 | 31 | The background texture is [Memphis Mini Dark by Tomislava Babić](https://www.toptal.com/designers/subtlepatterns/memphis-mini-dark-pattern/), used under a Creative Commons licence. 32 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | images from {{ root }} 7 | 8 | 9 | 37 | 38 |

images from {{ root }}

39 | 40 | {% for im in images %} 41 | {% set path, metadata = im %} 42 |
43 | 44 | 45 | 46 | 47 |
48 | {% endfor %} 49 | -------------------------------------------------------------------------------- /tint_colors.py: -------------------------------------------------------------------------------- 1 | # from docstore 2 | # https://github.com/alexwlchan/docstore/blob/main/src/docstore/tint_colors.py 3 | 4 | import collections 5 | import colorsys 6 | import math 7 | import os 8 | import subprocess 9 | 10 | import wcag_contrast_ratio as contrast 11 | 12 | 13 | def choose_tint_color_from_dominant_colors(dominant_colors, background_color): 14 | """ 15 | Given a set of dominant colors (say, from a k-means algorithm) and the 16 | background against which they'll be displayed, choose a tint color. 17 | 18 | Both ``dominant_colors`` and ``background_color`` should be tuples in [0,1]. 19 | """ 20 | # The minimum contrast ratio for text and background to meet WCAG AA 21 | # is 4.5:1, so discard any dominant colours with a lower contrast. 22 | sufficient_contrast_colors = [ 23 | col for col in dominant_colors if contrast.rgb(col, background_color) >= 4.5 24 | ] 25 | 26 | # If none of the dominant colours meet WCAG AA with the background, 27 | # try again with black and white -- every colour in the RGB space 28 | # has a contrast ratio of 4.5:1 with at least one of these, so we'll 29 | # get a tint colour, even if it's not a good one. 30 | # 31 | # Note: you could modify the dominant colours until one of them 32 | # has sufficient contrast, but that's omitted here because it adds 33 | # a lot of complexity for a relatively unusual case. 34 | if not sufficient_contrast_colors: 35 | return choose_tint_color_from_dominant_colors( 36 | dominant_colors=dominant_colors + [(0, 0, 0), (1, 1, 1)], 37 | background_color=background_color, 38 | ) 39 | 40 | # Of the colors with sufficient contrast, pick the one with the 41 | # highest saturation. This is meant to optimise for colors that are 42 | # more colourful/interesting than simple greys and browns. 43 | hsv_candidates = { 44 | tuple(rgb_col): colorsys.rgb_to_hsv(*rgb_col) 45 | for rgb_col in sufficient_contrast_colors 46 | } 47 | 48 | return max(hsv_candidates, key=lambda rgb_col: hsv_candidates[rgb_col][2]) 49 | 50 | 51 | def from_hex(hs): 52 | """ 53 | Returns an RGB tuple from a hex string, e.g. #ff0102 -> (255, 1, 2) 54 | """ 55 | return int(hs[1:3], 16), int(hs[3:5], 16), int(hs[5:7], 16) 56 | 57 | 58 | def choose_tint_color_for_file(path): 59 | """ 60 | Returns the tint colour for a file. 61 | """ 62 | background_color = (0, 0, 0) 63 | 64 | cmd = ["dominant_colours", "--no-palette", "--max-colours=12", path] 65 | 66 | dominant_colors = [ 67 | from_hex(line) 68 | for line in subprocess.check_output(cmd).splitlines() 69 | ] 70 | 71 | colors = [ 72 | (r / 255, g / 255, b / 255) for r, g, b in dominant_colors 73 | ] 74 | 75 | return choose_tint_color_from_dominant_colors( 76 | dominant_colors=colors, background_color=background_color 77 | ) 78 | 79 | 80 | def choose_tint_color(*, thumbnail_path, file_path, **kwargs): 81 | # In general, we use the thumbnail to choose the tint color. The thumbnail 82 | # is what the tint color will usually appear next to. However, thumbnails 83 | # for animated GIFs are MP4 videos rather than images, so we need to go to 84 | # the original image to get the tint color. 85 | if file_path.endswith((".jpg", ".jpeg", ".gif", ".png")): 86 | return choose_tint_color_for_file(file_path, **kwargs) 87 | else: 88 | return choose_tint_color_for_file(thumbnail_path, **kwargs) 89 | -------------------------------------------------------------------------------- /imageviewer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import imghdr 4 | import json 5 | import os 6 | import re 7 | import subprocess 8 | import sys 9 | import tempfile 10 | import webbrowser 11 | 12 | from jinja2 import Environment, FileSystemLoader 13 | from PIL import Image 14 | import tqdm 15 | from unidecode import unidecode 16 | 17 | from tint_colors import choose_tint_color_for_file 18 | 19 | 20 | CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".cache") 21 | 22 | os.makedirs(CACHE_DIR, exist_ok=True) 23 | 24 | 25 | def get_file_paths_under(root): 26 | """Generates the paths to every file under ``root``.""" 27 | if not os.path.isdir(root): 28 | raise ValueError(f"Cannot find files under non-existent directory: {root!r}") 29 | 30 | for dirpath, _, filenames in os.walk(root): 31 | for f in filenames: 32 | yield os.path.join(dirpath, f) 33 | 34 | 35 | def get_image_paths_under(root): 36 | for path in get_file_paths_under(root): 37 | if path.endswith(".md"): 38 | continue 39 | 40 | if imghdr.what(path) is not None: 41 | yield path 42 | 43 | 44 | def slugify(u): 45 | """Convert Unicode string into blog slug.""" 46 | u = re.sub("[–—/:;,.]", "-", u) # replace separating punctuation 47 | a = unidecode(u).lower() # best ASCII substitutions, lowercased 48 | a = re.sub(r"[^a-z0-9 -]", "", a) # delete any other characters 49 | a = a.replace(" ", "-") # spaces to hyphens 50 | a = re.sub(r"-+", "-", a) # condense repeated hyphens 51 | return a 52 | 53 | 54 | def as_hex(color): 55 | r, g, b = color 56 | return "#%02x%02x%02x" % (int(r * 255), int(g * 255), int(b * 255)) 57 | 58 | 59 | class ImageViewerCache: 60 | def __init__(self, root): 61 | self.root = root 62 | self._cache_entry_path = os.path.join(CACHE_DIR, slugify(root)) 63 | 64 | def __enter__(self): 65 | try: 66 | with open(self._cache_entry_path) as infile: 67 | self._data = json.load(infile) 68 | except FileNotFoundError: 69 | self._data = {"root": self.root, "images": {}} 70 | 71 | assert self._data["root"] == self.root 72 | 73 | self._old_images = self._data["images"] 74 | self._data["images"] = {} 75 | 76 | return self 77 | 78 | def __exit__(self, *exc_details): 79 | with open(self._cache_entry_path, "w") as outfile: 80 | outfile.write(json.dumps(self._data, indent=2, sort_keys=True)) 81 | 82 | def add_image(self, path): 83 | rel_path = os.path.relpath(path, root) 84 | 85 | if ( 86 | rel_path in self._old_images 87 | and self._old_images[rel_path]["mtime"] == os.stat(path).st_mtime 88 | ): 89 | self._data["images"][rel_path] = self._old_images[rel_path] 90 | else: 91 | im = Image.open(path) 92 | 93 | try: 94 | tint_color = as_hex(choose_tint_color_for_file(path)) 95 | except subprocess.CalledProcessError as e: 96 | tint_color = '#999999' 97 | 98 | self._data["images"][rel_path] = { 99 | "mtime": os.stat(path).st_mtime, 100 | "dimensions": { 101 | "width": im.width, 102 | "height": im.height, 103 | }, 104 | "tint_color": tint_color, 105 | } 106 | 107 | def get_images(self): 108 | return sorted( 109 | self._data["images"].items(), key=lambda im: im[1]["mtime"], reverse=True 110 | ) 111 | 112 | 113 | if __name__ == "__main__": 114 | try: 115 | root = sys.argv[1] 116 | except IndexError: 117 | sys.exit(f"Usage: {__file__}") 118 | 119 | with ImageViewerCache(root) as cache: 120 | for path in tqdm.tqdm(list(get_image_paths_under(root))): 121 | cache.add_image(path) 122 | 123 | environment = Environment(loader=FileSystemLoader("templates/")) 124 | template = environment.get_template("index.html") 125 | 126 | _, tmp_path = tempfile.mkstemp(suffix=".html") 127 | 128 | with open(tmp_path, "w") as outfile: 129 | outfile.write( 130 | template.render( 131 | root=os.path.abspath(root), 132 | images=cache.get_images(), 133 | static_dir=os.path.join( 134 | os.path.dirname(os.path.abspath(__file__)), "static" 135 | ), 136 | ) 137 | ) 138 | 139 | webbrowser.open('file://' + tmp_path) 140 | -------------------------------------------------------------------------------- /out.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 35 | 36 |

images from file:///Users/alexwlchan/Documents/wellcome/wellcome-textfiles

37 | 38 | 39 | 40 |
41 | 42 | 43 | 44 | 45 |
46 | 47 | 48 |
49 | 50 | 51 | 52 | 53 |
54 | 55 | 56 |
57 | 58 | 59 | 60 | 61 |
62 | 63 | 64 |
65 | 66 | 67 | 68 | 69 |
70 | 71 | 72 |
73 | 74 | 75 | 76 | 77 |
78 | 79 | 80 |
81 | 82 | 83 | 84 | 85 |
86 | 87 | 88 |
89 | 90 | 91 | 92 | 93 |
94 | 95 | 96 |
97 | 98 | 99 | 100 | 101 |
102 | 103 | 104 |
105 | 106 | 107 | 108 | 109 |
110 | 111 | 112 |
113 | 114 | 115 | 116 | 117 |
118 | 119 | 120 |
121 | 122 | 123 | 124 | 125 |
126 | 127 | 128 |
129 | 130 | 131 | 132 | 133 |
134 | 135 | 136 |
137 | 138 | 139 | 140 | 141 |
142 | 143 | 144 |
145 | 146 | 147 | 148 | 149 |
150 | 151 | --------------------------------------------------------------------------------