├── .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 | 
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 |
39 |
40 | {% for im in images %}
41 | {% set path, metadata = im %}
42 |
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 |
37 |
38 |
39 |
40 |
46 |
47 |
48 |
54 |
55 |
56 |
62 |
63 |
64 |
70 |
71 |
72 |
78 |
79 |
80 |
86 |
87 |
88 |
94 |
95 |
96 |
102 |
103 |
104 |
110 |
111 |
112 |
118 |
119 |
120 |
126 |
127 |
128 |
134 |
135 |
136 |
142 |
143 |
144 |
150 |
151 |
--------------------------------------------------------------------------------