Compare commits

...

3 Commits

Author SHA1 Message Date
Michael Soukup
a71f1f1d50 Add Makefile 2021-07-16 11:56:24 +02:00
Michael Soukup
168d0c689f Add notebook 2021-07-16 11:56:08 +02:00
Michael Soukup
68d679d6ce Package and refactor photocat 2021-07-16 11:54:58 +02:00
9 changed files with 234 additions and 84 deletions

3
.gitignore vendored
View File

@ -1,2 +1,5 @@
__pycache__
venv
yolov3
data/photos
.*

13
Makefile Normal file
View File

@ -0,0 +1,13 @@
WEIGHTS = yolov3-tiny
#WEIGHTS = yolov3
photocat :
echo "Download YOLOv3 network $(WEIGHTS)"
wget "https://pjreddie.com/media/files/$(WEIGHTS).weights" --header "Referer: pjreddie.com" -P yolov3
wget "https://raw.githubusercontent.com/eriklindernoren/PyTorch-YOLOv3/master/config/$(WEIGHTS).cfg" -P yolov3
echo "Install with pip etc TODO"
clean :
rm -f yolov3/$(WEIGHTS).{weights,cfg}
.PHONY : photocat clean

133
photocat.ipynb Normal file
View File

@ -0,0 +1,133 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Automatic photo categorization\n",
"\n",
"\n",
"Goals:\n",
" - Categorize photos into semantically similar groups.\n",
" - Mark similar photos for removal.\n",
"\n",
"\n",
"## Table of contents\n",
" 1. [Features](#features)\n",
" 2. [Clustering](#clustering)\n",
" 3. [Deduplication](#deduplication)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"%load_ext autoreload\n",
"%autoreload 2\n",
"\n",
"import matplotlib.pyplot as plt\n",
"%matplotlib inline\n",
"\n",
"from tqdm import tqdm\n",
"#from tqdm.notebook import tqdm\n",
"\n",
"from toolz import compose\n",
"from toolz.curried import map, filter"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from photocat import fs, photo, group\n",
"\n",
"INPUT_DIR = 'data/photos'"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<a name=\"features\"></a>\n",
"## Features\n",
"\n",
"Extract features from EXIF data and YOLOv3 output."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def show_photos(photos, n_row, n_col, size=4):\n",
" _, axs = plt.subplots(n_row, n_col, figsize=(n_col*size, n_row*size))\n",
" axs = axs.flatten()\n",
" for p, ax in zip(photos, axs):\n",
" ax.imshow(p.thumbnail)\n",
" plt.show()\n",
"\n",
"photos = compose(\n",
" list,\n",
" tqdm,\n",
" map(lambda f: photo.Photo(f)),\n",
" fs.list_images\n",
")(INPUT_DIR)\n",
"\n",
"show_photos(photos[0:24], 6, 4)\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<a name=\"clustering\"></a>\n",
"## Clustering\n",
"\n",
"Normalize features and cluster with DBSCAN."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<a name=\"deduplication\"></a>\n",
"## Deduplication\n",
"\n",
"Use eucledian distance between outputs of topmost YOLOv3 layers as a metric for photo similarity."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.9.6"
}
},
"nbformat": 4,
"nbformat_minor": 2
}

0
photocat/__init__.py Normal file
View File

View File

@ -1,4 +1,5 @@
import os
import datetime
from toolz import compose
from toolz.curried import filter, map
@ -20,3 +21,9 @@ def list_images(folder):
map(lambda f: os.path.join(folder, f)),
filter(lambda f: os.path.splitext(f)[-1].lower() in IMG_EXT)
)(files)
def last_modified(filename):
epoch = os.path.getmtime(filename)
return datetime.datetime.fromtimestamp(epoch)

View File

@ -1,30 +1,33 @@
from collections import namedtuple
import datetime
from dataclasses import dataclass
from itertools import groupby
from toolz import curry, compose
from toolz.curried import map, filter
from photocat.photo import Photo
PhotoGroup = namedtuple(
'PhotoGroup',
['name', 'datetimes', 'photos']
)
@dataclass(init = False)
class PhotoGroup:
name: str
min_datetime: datetime.datetime
max_datetime: datetime.datetime
photos: list[Photo]
def __init__(self, photos: list[Photo]):
self.min_datetime = min(photos, key=lambda p: p.datetime).datetime
self.max_datetime = max(photos, key=lambda p: p.datetime).datetime
self.name = str(self.min_datetime) + '-' + str(self.max_datetime) # TODO
self.photos = 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)
PhotoGroup(list(v))
for _, v in groupby(sorted(photos, key=key), key=key)
]
photos_by_month = _group(lambda p: p.datetime.month)
photos_by_month = _group(lambda p: (p.datetime.year, p.datetime.month))

View File

@ -1,34 +0,0 @@
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()

29
photocat/main.py Normal file → Executable file
View File

@ -1,24 +1,22 @@
#!/usr/bin/env python3
#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
from photocat import fs, photo, group
MAX_ROWS = 100
MAX_COLS = 5
IMG_SIZE = (100, 100)
MAX_COLS = 4
NA_FILE = os.path.join(
NA_FILENAME = os.path.join(
os.path.dirname(__file__),
'na.jpg'
)
NA_IMG = image.to_bytes(image.read(NA_FILE, resize=IMG_SIZE))
NA_PHOTO = photo.Photo(NA_FILENAME)
def main():
@ -27,12 +25,12 @@ def main():
[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)]
[sg.Image(key='PHOTO %d' % (i*MAX_COLS+j), data=NA_PHOTO.to_bytes(), 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')]
[sg.Column(image_view, scrollable=True, size=(900, 700), element_justification='l')]
]
layout = [[
@ -52,7 +50,7 @@ def main():
# Process input photos into groups
groups = compose(
group.photos_by_month,
map(photo.read_photo),
map(lambda f: photo.Photo(f)),
fs.list_images
)(values['FOLDER'])
window['GROUP LIST'].update(values=[g.name for g in groups])
@ -64,16 +62,13 @@ def main():
# Assert number of photos
n_photos = len(current_group.photos)
assert n_photos <= MAX_ROWS*MAX_COLS
# Reset image view
# Update image view
for idx in range(MAX_ROWS*MAX_COLS):
if idx < n_photos:
window['PHOTO %d' % idx].update(data=NA_IMG, visible=True)
p = current_group.photos[idx]
window['PHOTO %d' % idx].update(data=p.to_bytes(), 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)

View File

@ -1,32 +1,62 @@
import os
import datetime
from collections import namedtuple
from dateutil import parser
import io
from dataclasses import dataclass
from PIL import Image, ExifTags
from image import read_exif
from photocat import fs
Photo = namedtuple(
'Photo',
['filename', 'datetime', 'exif', 'features', 'selected']
)
IMG_SIZE = (200, 200)
EXIF_DATETIME_KEY = 'DateTime'
EXIF_DATETIME_FORMAT = '%Y:%m:%d %H:%M:%S'
def _exif_dt(exif):
try:
return parser.parse(exif['DateTimeOriginal'])
return datetime.datetime.strptime(
exif[EXIF_DATETIME_KEY],
EXIF_DATETIME_FORMAT
)
except Exception:
return None
def _last_modified_dt(filename):
epoch = os.path.getmtime(filename)
return datetime.datetime.fromtimestamp(epoch)
@dataclass(init = False)
class Photo:
filename: str
exif: dict
datetime: datetime.datetime
thumbnail: Image
features: list
selected: bool = True
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)
def __init__(self, filename: str):
self.filename = filename
img = Image.open(filename)
exif = img.getexif()
if exif is None:
raise Exception("No EXIF data for image %s" % filename)
self.exif = {
ExifTags.TAGS[k]: v
for k, v in exif.items()
if k in ExifTags.TAGS
}
self.datetime = _exif_dt(self.exif) or fs.last_modified(filename)
cur_width, cur_height = img.size
new_width, new_height = IMG_SIZE
scale = min(new_height/cur_height, new_width/cur_width)
self.thumbnail = img.resize((int(cur_width*scale), int(cur_height*scale)), Image.ANTIALIAS)
self.features = [] # TODO
print("Loaded", filename, "at", self.datetime, "with exif", self.exif)
def to_bytes(self) -> bytes:
"""Convert image to PNG format and return as byte-string object."""
bio = io.BytesIO()
self.thumbnail.save(bio, format="PNG")
return bio.getvalue()