Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto Crop #36

Merged
merged 12 commits into from Sep 25, 2016
2 changes: 2 additions & 0 deletions Dockerfile
Expand Up @@ -11,5 +11,7 @@ RUN pip install -r requirements.txt
# Install app
COPY . /app

# HACK to get the test suite to not complain about imports
ENV PYTHONPATH /app/gallery
ENV PORT 8080
EXPOSE 8080
6 changes: 5 additions & 1 deletion Makefile
Expand Up @@ -10,7 +10,7 @@ requirements.txt: ## Regenerate requirements.txt
pip-compile > $@

clean: ## Delete transient files
find . -type d -name "__pycache__" -exec rm -rf {} \;
-find . -type d -name "__pycache__" -exec rm -rf {} \;

test: ## Run test suite
pytest --cov
Expand All @@ -20,3 +20,7 @@ docker/release: ## Build and push a new release to Docker Hub
docker-compose build
docker tag gallerycms_web crccheck/gallery-cms
docker push crccheck/gallery-cms

docker/test: clean
docker-compose build
docker-compose run web make test
57 changes: 57 additions & 0 deletions gallery/crop.py
@@ -0,0 +1,57 @@
from PIL.Image import BILINEAR


LENGTH = 200
BLEED_PX = LENGTH * 0.07


# WISHLIST persist this somewhere
CACHE = {}


def expand(bbox, amount):
"""
Expand a bounding box by `amount` pixels.
"""
return (bbox[0] - amount, bbox[1] - amount, bbox[2] + amount, bbox[3] + amount)


def crop_1(im):
"""
Crop buttons based on Pillow's built in .getbbox
"""
# Square off the image since the left and right edges are meaningless
trim_l = (im.width - im.height) // 2
square = im.crop(expand(
(trim_l, 0, trim_l + im.height, im.height),
-int(im.height * 0.10) # Trim around the edges
))

bbox = CACHE.get(im.filename)
if not bbox:
# TODO smarter downsampling
copy = square.copy()
copy.thumbnail((LENGTH, LENGTH), BILINEAR)

ratio = square.width / copy.width

gray = copy.convert('L')

# Pre-process mask
bw = gray.point(lambda x: 0 if 190 < x else 255, '1')
# treat highlights as features
# bw = gray.point(lambda x: 0 if 190 < x > 250 else 255, '1')
bbox = bw.getbbox()

# return bw.crop(box=bbox) # DEBUG: to evaluate quality of the crop
# TODO make sure box is square-ish
left, top, right, bottom = expand(bbox, BLEED_PX)
bbox = (
max(0, left * ratio),
max(0, top * ratio),
min(square.width, right * ratio),
min(square.height, bottom * ratio),
)
CACHE[im.filename] = bbox

return square.crop(box=bbox)
21 changes: 15 additions & 6 deletions gallery/gallery.py
Expand Up @@ -24,6 +24,8 @@
from pyexiv2 import ImageMetadata
from natsort import natsorted

from crop import crop_1


BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CIPHER_KEY = os.getenv('CIPHER_KEY', 'roflcopter')
Expand Down Expand Up @@ -96,7 +98,7 @@ def __str__(self):
def src(self):
"""Get the html 'src' attributes."""
return {
'thumb': quote('/thumbs/' + encode(CIPHER_KEY, '300x300:' + self.path)),
'thumb': quote('/thumbs/' + encode(CIPHER_KEY, 'v1:300x300:' + self.path)),
'original': quote('/images' + self.path),
}

Expand Down Expand Up @@ -159,11 +161,11 @@ async def homepage(request):
}


async def thumbs(request):
async def thumbs(request, crop=True):
encoded = request.match_info['encoded']
try:
w_x_h, path = decode(CIPHER_KEY, encoded).split(':', 2)
except (binascii.Error, UnicodeDecodeError):
__, w_x_h, path = decode(CIPHER_KEY, encoded).split(':', 3)
except (binascii.Error, UnicodeDecodeError, ValueError):
return web.HTTPNotFound()

abspath = args.STORAGE_DIR + path
Expand All @@ -173,9 +175,16 @@ async def thumbs(request):
return web.HTTPNotFound()

thumb_dimension = [int(x) for x in w_x_h.split('x')]
im.thumbnail(thumb_dimension)
bytes_file = BytesIO()
im.save(bytes_file, 'jpeg')

if crop:
cropped_im = crop_1(im)
cropped_im.thumbnail(thumb_dimension)
cropped_im.save(bytes_file, 'jpeg')
else:
im.thumbnail(thumb_dimension)
im.save(bytes_file, 'jpeg')

return web.Response(
status=200, body=bytes_file.getvalue(), content_type='image/jpeg',
headers={
Expand Down
2 changes: 1 addition & 1 deletion gallery/gallery_test.py
Expand Up @@ -90,7 +90,7 @@ async def test_handler_thumbs_404s_for_bad_requests():


async def test_handler_thumbs_delivers_jpeg():
req = MagicMock(match_info={'encoded': encode(CIPHER_KEY, '1x1:/Lenna.jpg')})
req = MagicMock(match_info={'encoded': encode(CIPHER_KEY, 'v1:1x1:/Lenna.jpg')})
with patch('gallery.gallery.args', STORAGE_DIR=FIXTURES_DIR, create=True):
resp = await thumbs(req)
assert resp.status == 200
Expand Down
9 changes: 5 additions & 4 deletions requirements.txt
Expand Up @@ -4,11 +4,12 @@
#
# pip-compile --output-file requirements.txt requirements.in
#
aioauth-client==0.8.2
aioauth-client==0.9.0
aiohttp-jinja2==0.8.0
aiohttp-session[aioredis]==0.5.0
aiohttp==0.22.5
aiohttp-session[aioredis]==0.7.0
aiohttp==1.0.2
aioredis==0.2.8 # via aiohttp-session
async-timeout==1.0.0 # via aiohttp
chardet==2.3.0 # via aiohttp
click==6.6 # via pip-tools
coverage==4.2
Expand All @@ -18,7 +19,7 @@ hiredis==0.2.0 # via aioredis
jinja2==2.8 # via aiohttp-jinja2
markupsafe==0.23 # via jinja2
mccabe==0.5.2 # via flake8
multidict==1.2.2 # via aiohttp
multidict==2.1.1 # via aiohttp
natsort==5.0.1
pillow==3.3.1
pip-tools==1.7.0
Expand Down
4 changes: 3 additions & 1 deletion src/styles/main.scss
Expand Up @@ -89,6 +89,7 @@ a {
background: lighten($background, 5%);
border-radius: 0.25em;
flex-basis: 200px;
margin-bottom: 0.5em;
overflow: hidden;

@media (max-width: 600px) {
Expand Down Expand Up @@ -135,8 +136,9 @@ a {
}

.keyword-container {
padding: 0;
list-style: none;
margin: 0;
padding: 0;
}

.keyword {
Expand Down