commit 58cfd41f438a593f7f3ef4a85e5e76b1d0cb6e9b Author: Michael Soukup Date: Sat Jul 10 08:48:00 2021 +0200 Working image grid view. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82adb58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +venv diff --git a/README.md b/README.md new file mode 100644 index 0000000..f78832b --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# photocat + +Time to unfuck my photo library. diff --git a/photocat/fs.py b/photocat/fs.py new file mode 100644 index 0000000..4eed20a --- /dev/null +++ b/photocat/fs.py @@ -0,0 +1,22 @@ +import os +from toolz import compose +from toolz.curried import filter, map + + +IMG_EXT = ( + '.png', + '.jpeg', + '.jpg', + '.svg', + '.apng', + '.gif', + '.webp', + '.avif' +) + +def list_images(folder): + _, _, files = next(os.walk(folder)) + return compose( + map(lambda f: os.path.join(folder, f)), + filter(lambda f: os.path.splitext(f)[-1].lower() in IMG_EXT) + )(files) diff --git a/photocat/group.py b/photocat/group.py new file mode 100644 index 0000000..c2b75ff --- /dev/null +++ b/photocat/group.py @@ -0,0 +1,30 @@ +from collections import namedtuple +from itertools import groupby +from toolz import curry, compose +from toolz.curried import map, filter + + + +PhotoGroup = namedtuple( + 'PhotoGroup', + ['name', 'datetimes', 'photos'] +) + + +@curry +def _group(key, photos): + def create_group(k, photos): + min_dt = min(photos, key=lambda p: p.datetime).datetime + max_dt = max(photos, key=lambda p: p.datetime).datetime + name = str(min_dt) + '-' + str(max_dt) # TODO + return PhotoGroup(name, (min_dt, max_dt), photos) + + return [ + create_group(k, list(v)) + for k, v in groupby(sorted(photos, key=key), key=key) + ] + + +photos_by_month = _group(lambda p: p.datetime.month) + + diff --git a/photocat/image.py b/photocat/image.py new file mode 100644 index 0000000..39456f7 --- /dev/null +++ b/photocat/image.py @@ -0,0 +1,34 @@ +import io +from PIL import Image, ExifTags + + +def read(filename, resize=None): + """Read and optionally resize an image.""" + img = Image.open(filename) + cur_width, cur_height = img.size + if resize: + new_width, new_height = resize + scale = min(new_height/cur_height, new_width/cur_width) + img = img.resize((int(cur_width*scale), int(cur_height*scale)), Image.ANTIALIAS) + return img + + +def read_exif(filename): + """Read EXIF data.""" + img = Image.open(filename) + exif = img.getexif() + if exif is None: + raise Exception("No EXIF data for image %s" % filename) + return { + ExifTags.TAGS[k]: v + for k, v in exif.items() + if k in ExifTags.TAGS + } + + +def to_bytes(img): + """Convert image to PNG format and return as byte-string object.""" + bio = io.BytesIO() + img.save(bio, format="PNG") + return bio.getvalue() + diff --git a/photocat/main.py b/photocat/main.py new file mode 100644 index 0000000..a09e6e0 --- /dev/null +++ b/photocat/main.py @@ -0,0 +1,85 @@ +#import PySimpleGUI as sg +import PySimpleGUIQt as sg +import os +from toolz import compose +from toolz.curried import map + +import fs +import image +import photo +import group + + +MAX_ROWS = 100 +MAX_COLS = 5 +IMG_SIZE = (100, 100) + +NA_FILE = os.path.join( + os.path.dirname(__file__), + 'na.jpg' +) +NA_IMG = image.to_bytes(image.read(NA_FILE, resize=IMG_SIZE)) + + +def main(): + group_select = [ + [sg.Text('Input folder'), sg.In(size=(25,1), enable_events=True, key='FOLDER'), sg.FolderBrowse()], + [sg.Listbox(values=[], enable_events=True, size=(40,20),key='GROUP LIST')] + ] + image_view = [ + [sg.Image(key='PHOTO %d' % (i*MAX_COLS+j), data=NA_IMG, visible=False, enable_events=True) for j in range(MAX_COLS)] + for i in range(MAX_ROWS) + ] + group_view = [ + [sg.Text('Group: ')], + [sg.Column(image_view, scrollable=True, size=(650, 700), element_justification='l')] + ] + + layout = [[ + sg.Column(group_select, element_justification='c'), + sg.VSeperator(), + sg.Column(group_view, element_justification='c') + ]] + window = sg.Window('photocat', layout, resizable=True) + + groups = [] + current_group = None + while True: + event, values = window.read() + if event in (sg.WIN_CLOSED, 'Exit'): + break + if event == 'FOLDER': + # Process input photos into groups + groups = compose( + group.photos_by_month, + map(photo.read_photo), + fs.list_images + )(values['FOLDER']) + window['GROUP LIST'].update(values=[g.name for g in groups]) + elif event == 'GROUP LIST': + # Find photo group + group_name = values['GROUP LIST'][0] + print("Selected group: ", group_name) + current_group = next(g for g in groups if g.name == group_name) + # Assert number of photos + n_photos = len(current_group.photos) + assert n_photos <= MAX_ROWS*MAX_COLS + # Reset image view + for idx in range(MAX_ROWS*MAX_COLS): + if idx < n_photos: + window['PHOTO %d' % idx].update(data=NA_IMG, visible=True) + else: + window['PHOTO %d' % idx].update(visible=False) + # Load and display images + for idx, p in enumerate(current_group.photos): + img_data = image.to_bytes(image.read(p.filename, resize=IMG_SIZE)) + window['PHOTO %d' % idx].update(data=img_data) + elif event.startswith('PHOTO'): + idx = int(event.split(' ')[-1]) + print("Selected photo %d" % idx) + + window.close() + + +if __name__ == '__main__': + main() diff --git a/photocat/na.jpg b/photocat/na.jpg new file mode 100644 index 0000000..06c2d3f Binary files /dev/null and b/photocat/na.jpg differ diff --git a/photocat/photo.py b/photocat/photo.py new file mode 100644 index 0000000..89bc8bd --- /dev/null +++ b/photocat/photo.py @@ -0,0 +1,32 @@ +import os +import datetime +from collections import namedtuple +from dateutil import parser + +from image import read_exif + + +Photo = namedtuple( + 'Photo', + ['filename', 'datetime', 'exif', 'features', 'selected'] +) + + +def _exif_dt(exif): + try: + return parser.parse(exif['DateTimeOriginal']) + except Exception: + return None + +def _last_modified_dt(filename): + epoch = os.path.getmtime(filename) + return datetime.datetime.fromtimestamp(epoch) + + +def read_photo(filename): + exif = read_exif(filename) + print(filename, exif) + dt = _exif_dt(exif) or _last_modified_dt(filename) + features = [] # TODO + return Photo(filename, dt, exif, features, True) +