mirror of
https://github.com/MarcZierle/photo-log-backend.git
synced 2024-12-29 10:57:58 +00:00
decouple work from api requests and move files to object storage
This commit is contained in:
parent
d160bf3e7b
commit
8ae78c5b91
@ -1 +0,0 @@
|
||||
Subproject commit 70828ba4e14e67a1db819de5c6371713145f868c
|
@ -1,5 +1,11 @@
|
||||
from rest_framework import serializers
|
||||
from photo_log.models import PhotoGroup, Photo, PhotoLog, PhotoTag
|
||||
from photo_log.models import (
|
||||
PhotoGroup,
|
||||
Photo,
|
||||
PhotoLog,
|
||||
PhotoTag,
|
||||
PhotoLogTemplate,
|
||||
)
|
||||
|
||||
|
||||
class PhotoTagSerializer(serializers.ModelSerializer):
|
||||
@ -8,6 +14,12 @@ class PhotoTagSerializer(serializers.ModelSerializer):
|
||||
fields = ('id', 'name', 'color')
|
||||
|
||||
|
||||
class PhotoLogTemplateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PhotoLogTemplate
|
||||
fields = ('id', 'title', 'date', 'render_date', 'start_slide_image', 'slides',)
|
||||
|
||||
|
||||
class PhotoGroupSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PhotoGroup
|
||||
@ -28,6 +40,12 @@ class PhotoSerializer(serializers.ModelSerializer):
|
||||
fields = ('id', 'legacy_id', 'group', 'bbox_coords', 'rotate', 'intersections', 'original_image', 'cropped_image', 'ocr_text', 'tag')
|
||||
|
||||
|
||||
class AddPhotoSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Photo
|
||||
fields = ('id', 'legacy_id', 'group', 'bbox_coords', 'rotate', 'intersections', 'original_image', 'cropped_image', 'ocr_text', 'tag')
|
||||
|
||||
|
||||
class PhotoUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Photo
|
||||
|
@ -17,6 +17,9 @@ from .views import (
|
||||
PhotoTagsAPIView,
|
||||
RetrieveUpdateDestroyPhotoTagAPIView,
|
||||
CreatePhotoTagAPIView,
|
||||
PhotoLogTemplatesAPIView,
|
||||
CreatePhotoLogTemplateAPIView,
|
||||
RetrieveUpdateDestroyPhotoLogTemplateAPIView,
|
||||
)
|
||||
|
||||
|
||||
@ -36,7 +39,12 @@ urlpatterns = [
|
||||
path('updatephotolog/<int:pk>/', UpdatePhotoLogAPIView.as_view()),
|
||||
path('updatephoto/<int:pk>/', UpdatePhotoAPIView.as_view()),
|
||||
path('generatephotolog/<int:pk>/', GeneratePhotoLogAPIView.as_view()),
|
||||
|
||||
path('phototag/', CreatePhotoTagAPIView.as_view()),
|
||||
path('phototags/', PhotoTagsAPIView.as_view()),
|
||||
path('phototag/<int:pk>/', RetrieveUpdateDestroyPhotoTagAPIView.as_view()),
|
||||
|
||||
path('photolog/template/', CreatePhotoLogTemplateAPIView.as_view()),
|
||||
path('photolog/templates/', PhotoLogTemplatesAPIView.as_view()),
|
||||
path('photolog/template/<int:pk>/', RetrieveUpdateDestroyPhotoLogTemplateAPIView.as_view()),
|
||||
]
|
||||
|
158
api/views.py
158
api/views.py
@ -1,20 +1,30 @@
|
||||
from rest_framework import generics, views, status
|
||||
from rest_framework.response import Response
|
||||
from django.shortcuts import get_list_or_404
|
||||
from photo_log.models import PhotoGroup, Photo, PhotoLog, PhotoTag
|
||||
from photo_log.models import (
|
||||
PhotoGroup,
|
||||
Photo,
|
||||
PhotoLog,
|
||||
PhotoTag,
|
||||
PhotoLogTemplate,
|
||||
)
|
||||
from .serializers import (
|
||||
PhotoGroupSerializer,
|
||||
PhotosSerializer,
|
||||
PhotoSerializer,
|
||||
AddPhotoSerializer,
|
||||
PhotoUpdateSerializer,
|
||||
PhotoLogSerializer,
|
||||
PhotoLogsSerializer,
|
||||
PhotoTagSerializer,
|
||||
)
|
||||
PhotoLogTemplateSerializer,
|
||||
)
|
||||
|
||||
from .photolog_generator import generate_photolog_from_latex
|
||||
from .autocrop.autocrop import autocrop
|
||||
|
||||
from photo_log import tasks
|
||||
|
||||
from django.db.models import FileField
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
from django.core.files.base import ContentFile
|
||||
@ -38,6 +48,21 @@ class RetrieveUpdateDestroyPhotoTagAPIView(generics.RetrieveUpdateDestroyAPIView
|
||||
serializer_class = PhotoTagSerializer
|
||||
|
||||
|
||||
class PhotoLogTemplatesAPIView(generics.ListAPIView):
|
||||
queryset = PhotoLogTemplate.objects.all()
|
||||
serializer_class = PhotoLogTemplateSerializer
|
||||
|
||||
|
||||
class CreatePhotoLogTemplateAPIView(generics.CreateAPIView):
|
||||
queryset = PhotoLogTemplate.objects.all()
|
||||
serializer_class = PhotoLogTemplateSerializer
|
||||
|
||||
|
||||
class RetrieveUpdateDestroyPhotoLogTemplateAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = PhotoLogTemplate.objects.all()
|
||||
serializer_class = PhotoLogTemplateSerializer
|
||||
|
||||
|
||||
class PhotoGroupAPIView(generics.ListAPIView):
|
||||
queryset = PhotoGroup.objects.all()
|
||||
serializer_class = PhotoGroupSerializer
|
||||
@ -66,7 +91,7 @@ class PhotoAPIView(generics.RetrieveAPIView):
|
||||
|
||||
class AddPhotoAPIView(generics.CreateAPIView):
|
||||
queryset = Photo.objects.all()
|
||||
serializer_class = PhotoSerializer
|
||||
serializer_class = AddPhotoSerializer
|
||||
|
||||
|
||||
class AddPhotoGroupAPIView(generics.CreateAPIView):
|
||||
@ -141,88 +166,15 @@ class GeneratePhotoLogAPIView(generics.RetrieveAPIView):
|
||||
photolog_data = self.get_serializer(photolog).data
|
||||
|
||||
if photolog_data:
|
||||
title = photolog_data['title']
|
||||
date = photolog_data['date']
|
||||
render_date = photolog_data['render_date']
|
||||
start_slide_image = photolog_data['start_slide_image']
|
||||
slides = photolog_data['slides']
|
||||
tasks.generate_photo_log_chain(
|
||||
photolog_data['id'],
|
||||
)
|
||||
|
||||
log_bytes = generate_photolog_from_latex(request, title, date, render_date, start_slide_image, slides)
|
||||
|
||||
if log_bytes:
|
||||
photolog.pdf.save('log.pdf', ContentFile(log_bytes))
|
||||
return Response({'pdf': 'https://zierle-training.riezel.com%s' % str(photolog.pdf.url)})
|
||||
return Response({'status': 'task created'})
|
||||
|
||||
return Response({"error": "Not Found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
def crop_photo_and_save(id, save=False):
|
||||
return "code run with id " + str(id)
|
||||
|
||||
def save_cropped_pillow_image(photo, pil_img):
|
||||
cropped_img_field = photo.cropped_image
|
||||
|
||||
buffer = BytesIO()
|
||||
pil_img.save(fp=buffer, format="PNG")
|
||||
pil_img = ContentFile(buffer.getvalue())
|
||||
|
||||
img_name = "cropped_image.png"
|
||||
cropped_img_field.save(img_name, InMemoryUploadedFile(
|
||||
pil_img,
|
||||
None, # field_name
|
||||
img_name, # image name
|
||||
'image/png',
|
||||
pil_img.tell, # image size
|
||||
None
|
||||
))
|
||||
|
||||
def simple_crop_to_bbox(photo):
|
||||
img = Image.open(photo.original_image)
|
||||
img = rotateByExif(img)
|
||||
bbox = photo.bbox_coords
|
||||
rotate_angle = photo.rotate
|
||||
|
||||
if not img:
|
||||
return None
|
||||
|
||||
#if rotate_angle:
|
||||
# img = img.rotate(rotate_angle, expand=1)
|
||||
|
||||
if bbox:
|
||||
img = img.crop((
|
||||
bbox[0][0],
|
||||
bbox[0][1],
|
||||
bbox[2][0],
|
||||
bbox[2][1]
|
||||
))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def rotateByExif(img):
|
||||
try:
|
||||
for orientation in ExifTags.TAGS.keys():
|
||||
if ExifTags.TAGS[orientation]=='Orientation':
|
||||
break
|
||||
|
||||
exif = img._getexif()
|
||||
|
||||
if not exif:
|
||||
return img
|
||||
|
||||
if exif[orientation] == 3:
|
||||
img=img.rotate(180, expand=True)
|
||||
elif exif[orientation] == 6:
|
||||
img=img.rotate(270, expand=True)
|
||||
elif exif[orientation] == 8:
|
||||
img=img.rotate(90, expand=True)
|
||||
|
||||
except (AttributeError, KeyError, IndexError):
|
||||
# cases: image don't have getexif
|
||||
pass
|
||||
return img
|
||||
|
||||
|
||||
class AutoCropPhotoAPIView(generics.RetrieveAPIView):
|
||||
|
||||
queryset = Photo.objects.all()
|
||||
@ -241,33 +193,29 @@ class AutoCropPhotoAPIView(generics.RetrieveAPIView):
|
||||
return Response({'error':'cropping mode not specified (auto, bbox or inters)'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
mode = request.query_params.get('mode')
|
||||
|
||||
photo = Photo.objects.get(id=photo_id)
|
||||
|
||||
cropped_img = photo.cropped_image
|
||||
if cropped_img:
|
||||
cropped_img = cropped_img.name
|
||||
else:
|
||||
cropped_img = None
|
||||
|
||||
if mode == 'bbox':
|
||||
photo = Photo.objects.get(id=photo_id)
|
||||
cropped_img = simple_crop_to_bbox(photo)
|
||||
save_cropped_pillow_image(photo, cropped_img)
|
||||
tasks.crop_image_bbox_chain(
|
||||
photo.id,
|
||||
photo.original_image.name,
|
||||
photo.bbox_coords,
|
||||
photo.rotate,
|
||||
cropped_img
|
||||
)
|
||||
elif mode == 'auto' or mode == 'inters':
|
||||
photo = Photo.objects.get(id=photo_id)
|
||||
img = Image.open(photo.original_image)
|
||||
|
||||
img = rotateByExif(img)
|
||||
|
||||
try:
|
||||
cropped_img, _, bbox, intersections = autocrop(img)
|
||||
except Exception:
|
||||
cropped_img = img
|
||||
bbox = None
|
||||
intersections = None
|
||||
|
||||
if mode == 'auto':
|
||||
save_cropped_pillow_image(photo, cropped_img)
|
||||
|
||||
if bbox:
|
||||
photo.bbox_coords = bbox
|
||||
photo.save()
|
||||
|
||||
if intersections:
|
||||
photo.intersections = intersections
|
||||
photo.save()
|
||||
# TODO: mode 'inters': just calculate intersections w/o saving the cropped image
|
||||
tasks.crop_image_auto_chain(
|
||||
photo.id,
|
||||
photo.original_image.name,
|
||||
cropped_img
|
||||
)
|
||||
else:
|
||||
return Response({'error':'invalid cropping mode (auto, bbox or inters)'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
@ -0,0 +1,5 @@
|
||||
# This will make sure the app is always imported when
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celeryapp import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
24
config/celery_supervisor.conf
Normal file
24
config/celery_supervisor.conf
Normal file
@ -0,0 +1,24 @@
|
||||
[program:celery_zierle_training_staging]
|
||||
|
||||
directory=/home/marc/www-staging/backend
|
||||
|
||||
user=www-data
|
||||
numprocs=1
|
||||
stdout_logfile=/var/log/celery/worker.log
|
||||
stderr_logfile=/var/log/celery/worker.log
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=10
|
||||
|
||||
command=/home/marc/www-staging/backend/env/bin/python3 -m celery -A config worker --loglevel=INFO
|
||||
|
||||
; Need to wait for currently executing tasks to finish at shutdown.
|
||||
; Increase this if you have very long running tasks.
|
||||
stopwaitsecs = 60
|
||||
|
||||
; Causes supervisor to send the termination signal (SIGTERM) to the whole process group.
|
||||
stopasgroup=true
|
||||
|
||||
; Set Celery priority higher than default (999)
|
||||
; so, if rabbitmq is supervised, it will start first.
|
||||
priority=1000
|
22
config/celeryapp.py
Normal file
22
config/celeryapp.py
Normal file
@ -0,0 +1,22 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
app = Celery('config')
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def debug_task(self):
|
||||
print(f'Request: {self.request!r}')
|
@ -26,7 +26,7 @@ SECRET_KEY = 'django-insecure-z465dl_(vk55hxbm0bj*mp-ok3!*=ssw#!$5s2nrxa!9j+67z+
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['zierle-training.riezel.com', 'localhost', '127.0.0.1', '192.168.1.244']
|
||||
ALLOWED_HOSTS = ['zierle-training-staging.riezel.com', 'localhost', '127.0.0.1', '192.168.1.244']
|
||||
|
||||
|
||||
# Application definition
|
||||
@ -44,8 +44,11 @@ INSTALLED_APPS = [
|
||||
'rest_framework',
|
||||
'corsheaders',
|
||||
'drf_yasg',
|
||||
'storages',
|
||||
'django_extensions',
|
||||
'django_tex',
|
||||
'colorfield',
|
||||
'django_celery_results',
|
||||
|
||||
# local
|
||||
'accounts',
|
||||
@ -146,7 +149,7 @@ AUTH_USER_MODEL = 'accounts.CustomUser'
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
TIME_ZONE = 'CET'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@ -166,29 +169,60 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
|
||||
|
||||
MINIO = False
|
||||
MINIO = True
|
||||
if MINIO:
|
||||
# MinIO S3 Object-Storage
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage'
|
||||
#STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage'
|
||||
|
||||
AWS_ACCESS_KEY_ID = 'zierle-trainig-minio'
|
||||
AWS_SECRET_ACCESS_KEY = 'iGWJt7IFlKzufMtVTnl4W4kGmHz0TTBG'
|
||||
AWS_ACCESS_KEY_ID = 'zierle-training'
|
||||
AWS_SECRET_ACCESS_KEY = 'IMienQKx6B5foJRegqZnSTk9MsUjDvd4'
|
||||
AWS_STORAGE_BUCKET_NAME = 'zierle-training'
|
||||
AWS_S3_ENDPOINT_URL = 'https://minio.riezel.com'
|
||||
AWS_DEFAULT_ACL = 'public'
|
||||
|
||||
MEDIA_URL = 'https://minio.riezel.com/zierle-training/'
|
||||
STATIC_URL = 'https://minio.riezel.com/zierle-training/'
|
||||
#STATIC_URL = 'https://minio.riezel.com/zierle-training/'
|
||||
|
||||
AWS_S3_OBJECT_PARAMETERS = {
|
||||
'CacheControl': 'public, max-age=86400',
|
||||
}
|
||||
else:
|
||||
#MEDIA_URL = 'media/'
|
||||
#MEDIA_ROOT = BASE_DIR / 'media'
|
||||
MEDIA_URL = 'media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static/")
|
||||
#STATICFILES_DIRS = [os.path.join(BASE_DIR, "static/")]
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static/")
|
||||
#STATICFILES_DIRS = [os.path.join(BASE_DIR, "static/")]
|
||||
|
||||
|
||||
|
||||
# Celery
|
||||
# See https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html
|
||||
CELERY_CACHE_BACKEND = 'default'
|
||||
|
||||
CELERY_WORK_DIR = '/home/marc/www-staging/celery/'
|
||||
|
||||
CELERY_BROKER_URL = 'redis://localhost:6378/1'
|
||||
CELERY_RESULT_BACKEND= 'redis://localhost:6378/1'
|
||||
|
||||
CELERY_TIMEZONE = 'CET'
|
||||
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = {
|
||||
'visibility_timeout': 300,
|
||||
}
|
||||
|
||||
|
||||
# Redis Cache
|
||||
# See
|
||||
|
||||
CACHES = {
|
||||
'default': {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": "redis://127.0.0.1:6378/1",
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
"KEY_PREFIX": "zierletraining",
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,16 @@
|
||||
from django.contrib import admin
|
||||
from .models import PhotoGroup, Photo, PhotoLog, PhotoTag
|
||||
from .models import (
|
||||
PhotoGroup,
|
||||
Photo,
|
||||
PhotoLog,
|
||||
PhotoTag,
|
||||
PhotoLogTemplate,
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(PhotoGroup)
|
||||
admin.site.register(Photo)
|
||||
admin.site.register(PhotoLog)
|
||||
admin.site.register(PhotoTag)
|
||||
admin.site.register(PhotoLogTemplate)
|
||||
|
||||
|
425
photo_log/autocrop/autocrop.py
Executable file
425
photo_log/autocrop/autocrop.py
Executable file
@ -0,0 +1,425 @@
|
||||
import cv2
|
||||
import imutils
|
||||
import random
|
||||
import numpy as np
|
||||
import math
|
||||
from PIL import Image, ImageDraw
|
||||
import itertools
|
||||
import pytesseract
|
||||
|
||||
|
||||
def preprocess_image(image, blur_params=[20,20,25], canny_params=[25,250]):
|
||||
"""
|
||||
Turns the image into grayscale, applies a bilateral filter
|
||||
to smooth out unimportant parts of the image, and returns
|
||||
the Canny filtered result.
|
||||
image: cv2.imread image
|
||||
"""
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
gray = cv2.bilateralFilter(gray, blur_params[0], blur_params[1], blur_params[2],)
|
||||
edged = cv2.Canny(gray, canny_params[0], canny_params[1])
|
||||
return edged
|
||||
|
||||
|
||||
def hough_transform(edged):
|
||||
"""
|
||||
Returns and image representing the hough space of the edge image.
|
||||
edged: Canny filtered cv2 image
|
||||
"""
|
||||
max_d = np.sqrt(edged.shape[0]**2 + edged.shape[1]**2)
|
||||
hough_space = [[0] * (2 * math.ceil(max_d)) for i in range(157*2)]
|
||||
for y in range(edged.shape[0]):
|
||||
for x in range(edged.shape[1]):
|
||||
pixel = edged[y][x]
|
||||
if not pixel > 0:
|
||||
continue
|
||||
for alpha in range(157*2):
|
||||
d = x * math.cos(alpha/2.0/100.0) + y * math.sin(alpha/2.0/100.0)
|
||||
hough_space[alpha][round(d)+math.ceil(max_d)] += 1
|
||||
return hough_space
|
||||
|
||||
|
||||
#TODO fix removal on "the other site" of the hough space => negative indexes in try / except
|
||||
def get_max_params(hough_space, num_params, rm_radius=7):
|
||||
"""
|
||||
Iterates over the maxima of hough space image.
|
||||
After each maximum found a circle of the radius rm_radius is beeing "cut out"
|
||||
of the hough space image.
|
||||
Returns an array of tuples containing the maximum parameters (alpha, d).
|
||||
hough_space: hough space image
|
||||
num_params: number of the maxima to be found
|
||||
rm_radius: optional = 7; the radius to be cut out around the maximum found
|
||||
"""
|
||||
all_max_params = []
|
||||
for i in range(num_params):
|
||||
hough_array = np.array(hough_space)
|
||||
max_params = np.unravel_index(hough_array.argmax(), hough_array.shape)
|
||||
|
||||
if -math.inf in max_params:
|
||||
break
|
||||
|
||||
alpha = max_params[0]/2.0/100.0
|
||||
d = max_params[1] - hough_array.shape[1]/2.0
|
||||
all_max_params.append((alpha, d))
|
||||
|
||||
for yi in range(rm_radius*2+1):
|
||||
for xi in range(rm_radius*2+1):
|
||||
if math.sqrt((-rm_radius+yi)**2 + (-rm_radius+xi)**2) <= rm_radius:
|
||||
try:
|
||||
hough_space[abs(max_params[0]-rm_radius+yi)][abs(max_params[1]-rm_radius+xi)] = -math.inf
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return all_max_params, hough_space
|
||||
|
||||
|
||||
def _draw_hough_space(hough_space):
|
||||
max_val = np.amax(hough_space)
|
||||
img = Image.new(mode="RGB", size=(len(hough_space[0]), len(hough_space)))
|
||||
pixels = img.load()
|
||||
for y in range(len(hough_space)):
|
||||
for x in range(len(hough_space[0])):
|
||||
if hough_space[y][x] == -math.inf:
|
||||
hough_space[y][x] = 0
|
||||
val = int(hough_space[y][x] / float(max_val) * 255)
|
||||
pixels[x,y] = (val,val,val)
|
||||
return img
|
||||
|
||||
|
||||
def _draw_lines(image, all_max_params, resize_height):
|
||||
img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
img = Image.fromarray(img)
|
||||
new_height = resize_height
|
||||
new_width = int(new_height * img.size[0] / img.size[1])
|
||||
img = img.resize((new_width, new_height), Image.ANTIALIAS)
|
||||
pixels = img.load()
|
||||
|
||||
for (alpha,d) in all_max_params:
|
||||
dist = 0.5 # line thickness (distance image pixels to line coordinates)
|
||||
for y in range(img.size[1]):
|
||||
for x in range(img.size[0]):
|
||||
val = (x * math.cos(alpha) + y * math.sin(alpha)) - d
|
||||
if val <= dist and val >= -dist:
|
||||
pixels[x,y] = (0,200,255)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def get_intersect(a1, a2, b1, b2):
|
||||
"""
|
||||
Returns the point of intersection of the lines passing through a2,a1 and b2,b1.
|
||||
a1: [x, y] a point on the first line
|
||||
a2: [x, y] another point on the first line
|
||||
b1: [x, y] a point on the second line
|
||||
b2: [x, y] another point on the second line
|
||||
"""
|
||||
s = np.vstack([a1,a2,b1,b2]) # s for stacked
|
||||
h = np.hstack((s, np.ones((4, 1)))) # h for homogeneous
|
||||
l1 = np.cross(h[0], h[1]) # get first line
|
||||
l2 = np.cross(h[2], h[3]) # get second line
|
||||
x, y, z = np.cross(l1, l2) # point of intersection
|
||||
if z == 0: # lines are parallel
|
||||
return (float('inf'), float('inf'))
|
||||
return (x/z, y/z)
|
||||
|
||||
|
||||
def insert_intersection(intersections, params1, params2, inters_pt):
|
||||
if not params1 in intersections:
|
||||
intersections[params1] = []
|
||||
intersections[params1].append((params2, inters_pt))
|
||||
|
||||
if not params2 in intersections:
|
||||
intersections[params2] = []
|
||||
intersections[params2].append((params1, inters_pt))
|
||||
|
||||
|
||||
# TODO fix wrong random intersection points
|
||||
def find_intersections(all_max_params, img_size, allowed_angle_diff=3):
|
||||
"""
|
||||
Takes hough space parameters of found lines and returns a list of tuples of
|
||||
intersection points.
|
||||
all_max_params: (alpha, d) tuple list of hough space parameters
|
||||
img_size: the size of the in which the intersections are to be found in
|
||||
"""
|
||||
intersections_by_lines = {}
|
||||
all_intersections = []
|
||||
|
||||
other_max_params = all_max_params.copy()
|
||||
for (alpha1,d1) in all_max_params:
|
||||
for (alpha2,d2) in other_max_params:
|
||||
if alpha1 == alpha2 and d1 == d2:
|
||||
continue
|
||||
|
||||
y = random.randint(0, img_size[0])
|
||||
x = (d1 - (y * math.sin(alpha1))) / math.cos(alpha1)
|
||||
a1 = [x,y]
|
||||
|
||||
y = random.randint(0, img_size[0])
|
||||
x = (d1 - (y * math.sin(alpha1))) / math.cos(alpha1)
|
||||
a2 = [x,y]
|
||||
|
||||
y = random.randint(0, img_size[0])
|
||||
x = (d2 - (y * math.sin(alpha2))) / math.cos(alpha2)
|
||||
b1 = [x,y]
|
||||
|
||||
y = random.randint(0, img_size[0])
|
||||
x = (d2 - (y * math.sin(alpha2))) / math.cos(alpha2)
|
||||
b2 = [x,y]
|
||||
|
||||
# get intersection point of two lines, where each line
|
||||
# is given by two random points of each line
|
||||
# a1, a1: two [x,y] points on line a
|
||||
# b1, b1: two [x,y] points on line b
|
||||
inters = get_intersect(a1,a2, b1,b2)
|
||||
|
||||
# are lines parallel or is intersection outside of the image?
|
||||
if math.inf in inters:
|
||||
continue
|
||||
if inters[0] < 0 or inters[0] >= img_size[1]:
|
||||
continue
|
||||
if inters[1] < 0 or inters[1] >= img_size[0]:
|
||||
continue
|
||||
|
||||
# calculate vectors of each line 1 and 2
|
||||
p1 = [a1[0]-a2[0], a1[1]-a2[1]]
|
||||
p2 = [b1[0]-b2[0], b1[1]-b2[1]]
|
||||
|
||||
inters = (round(inters[0]),round(inters[1]))
|
||||
|
||||
try:
|
||||
dot_product = p1[0]*p2[0] + p1[1]*p2[1]
|
||||
abs_p1 = math.sqrt(p1[0]**2 + p1[1]**2)
|
||||
abs_p2 = math.sqrt(p2[0]**2 + p2[1]**2)
|
||||
|
||||
angle = math.degrees(math.acos(dot_product / (abs_p1 * abs_p2)))
|
||||
angle_diff = abs(abs(angle) - 90)
|
||||
except ValueError as e:
|
||||
print(e)
|
||||
continue
|
||||
|
||||
if not(angle_diff > allowed_angle_diff):
|
||||
all_intersections.append(inters)
|
||||
params1 = (alpha1, d1)
|
||||
params2 = (alpha2, d2)
|
||||
insert_intersection(intersections_by_lines, params1, params2, inters)
|
||||
|
||||
other_max_params.remove((alpha1, d1))
|
||||
|
||||
return intersections_by_lines, all_intersections
|
||||
|
||||
|
||||
def _draw_intersections(image, intersections):
|
||||
image = image.copy()
|
||||
for inters in intersections:
|
||||
color = (0,255,0)
|
||||
pt_radius = 1
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.ellipse((inters[0]-pt_radius,inters[1]-pt_radius,inters[0]+pt_radius,inters[1]+pt_radius), fill=color, outline=color)
|
||||
return image
|
||||
|
||||
|
||||
def get_n_connections(start, intersections_by_lines, n):
|
||||
if n <= 0:
|
||||
return [[start]]
|
||||
|
||||
connections = []
|
||||
for line in intersections_by_lines[start]:
|
||||
neighbors_connections = get_n_connections(line[0], intersections_by_lines, n-1)
|
||||
for con in neighbors_connections:
|
||||
if not start in con:
|
||||
if len(con) == 1:
|
||||
connections.append([start, con[0]])
|
||||
else:
|
||||
connections.append([start] + con)
|
||||
|
||||
return connections
|
||||
|
||||
|
||||
def get_cycles(intersections_by_lines, n=4):
|
||||
start = list(intersections_by_lines.keys())[0]
|
||||
connections = get_n_connections(start, intersections_by_lines, n=n-1)
|
||||
|
||||
cycles = []
|
||||
for connection in connections:
|
||||
last_vertex = connection[-1]
|
||||
# can we get back to the beginning?
|
||||
if start in [con[0] for con in intersections_by_lines[last_vertex]]:
|
||||
cycles.append(connection + [start])
|
||||
|
||||
return cycles
|
||||
|
||||
|
||||
def get_intersection_points_from_lines(rects_lines, intersections_by_lines):
|
||||
rects_points = []
|
||||
for rect in rects_lines:
|
||||
points = []
|
||||
for i in range(len(rect)-1):
|
||||
line1 = rect[i]
|
||||
line2 = rect[i+1]
|
||||
points.append(list(filter(lambda con: con[0]==line2, intersections_by_lines[line1]))[0][1])
|
||||
points.append(points[0])
|
||||
rects_points.append(points)
|
||||
return rects_points
|
||||
|
||||
|
||||
def remove_small_rects(rects_points, image_shape, min_image_coverage=0.3):
|
||||
image_area = image_shape[0] * image_shape[1]
|
||||
possible_rects = []
|
||||
for rect in rects_points:
|
||||
pt1 = rect[0]
|
||||
pt2 = rect[1]
|
||||
pt3 = rect[2]
|
||||
len_side1 = math.sqrt((pt1[0]-pt2[0])**2 + (pt1[1]-pt2[1])**2)
|
||||
len_side2 = math.sqrt((pt2[0]-pt3[0])**2 + (pt2[1]-pt3[1])**2)
|
||||
|
||||
area = len_side1 * len_side2
|
||||
|
||||
if area >= min_image_coverage * image_area:
|
||||
possible_rects.append(rect)
|
||||
return possible_rects
|
||||
|
||||
|
||||
def _draw_rects(rects_points, image):
|
||||
image_lines = image.copy()
|
||||
draw = ImageDraw.Draw(image_lines)
|
||||
for rect in rects_points:
|
||||
rect = [(pt[0],pt[1]) for pt in rect]
|
||||
draw.line(rect, width=2, fill='yellow')
|
||||
return image_lines
|
||||
|
||||
|
||||
def crop_warp_image(image, rect):
|
||||
#rect = np.asarray(rect[:4])
|
||||
|
||||
rect = np.array(rect,dtype = "float32").reshape(4,2)
|
||||
|
||||
ordered_points = np.zeros((4, 2), dtype = "float32")
|
||||
s = rect.sum(axis = 1)
|
||||
ordered_points[0] = rect[np.argmin(s)]
|
||||
ordered_points[2] = rect[np.argmax(s)]
|
||||
|
||||
diff = np.diff(rect, axis = 1)
|
||||
ordered_points[1] = rect[np.argmin(diff)]
|
||||
ordered_points[3] = rect[np.argmax(diff)]
|
||||
|
||||
(tl, tr, br, bl) = ordered_points
|
||||
widthA = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))
|
||||
widthB = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))
|
||||
heightA = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))
|
||||
heightB = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))
|
||||
|
||||
maxWidth = max(int(widthA), int(widthB))
|
||||
maxHeight = max(int(heightA), int(heightB))
|
||||
|
||||
dst_rect = np.array([
|
||||
[0, 0],
|
||||
[maxWidth - 1, 0],
|
||||
[maxWidth - 1, maxHeight - 1],
|
||||
[0, maxHeight - 1]], dtype = "float32")
|
||||
|
||||
M = cv2.getPerspectiveTransform(ordered_points, dst_rect)
|
||||
warped_img = cv2.warpPerspective(image, M, (maxWidth, maxHeight))
|
||||
|
||||
return warped_img
|
||||
|
||||
|
||||
def get_rect_bounding_box(rect):
|
||||
min_x = min([pt[0] for pt in rect])
|
||||
max_x = max([pt[0] for pt in rect])
|
||||
min_y = min([pt[1] for pt in rect])
|
||||
max_y = max([pt[1] for pt in rect])
|
||||
|
||||
bbox = [[min_x, min_y], [max_x, min_y], [max_x, max_y], [min_x, max_y]]
|
||||
|
||||
return bbox
|
||||
|
||||
|
||||
def _cv2ImageToPIL(cv2_image):
|
||||
#cv2_image = cv2.cvtColor(cv2_image, cv2.COLOR_BGR2RGB)
|
||||
pil_image = Image.fromarray(cv2_image)
|
||||
return pil_image
|
||||
|
||||
|
||||
def _PILImageToCv2(pil_image):
|
||||
cv2_image = np.asarray(pil_image)
|
||||
return cv2_image
|
||||
|
||||
|
||||
def autocrop(original_image, DEBUG=False):
|
||||
"""
|
||||
Automatically crops an image to size of the flip chart in the given image,
|
||||
and returns a cropped PIL image.
|
||||
image: the PIL image to crop
|
||||
"""
|
||||
|
||||
resize_height = 300
|
||||
blur_params = [35,15,40] # size, color, space
|
||||
canny_params = [80,250]
|
||||
num_hough_params = 50
|
||||
hough_rm_radius = 20
|
||||
line_angle_diff = 3
|
||||
min_image_coverage = 0.3
|
||||
|
||||
original_image = _PILImageToCv2(original_image)
|
||||
|
||||
# shrink image to a smaller size to speed up autocropping
|
||||
ratio = original_image.shape[0] / float(resize_height)
|
||||
image_small = imutils.resize(original_image, height=resize_height)
|
||||
|
||||
edge_image = preprocess_image(image_small, blur_params=blur_params, canny_params=canny_params)
|
||||
|
||||
if DEBUG:
|
||||
cv2.imwrite('debug_out/edges.png', edge_image)
|
||||
|
||||
hough_space = hough_transform(edge_image)
|
||||
all_max_params, hough_space = get_max_params(hough_space, num_params=num_hough_params, rm_radius=hough_rm_radius)
|
||||
|
||||
if DEBUG:
|
||||
hough_space_image = _draw_hough_space(hough_space)
|
||||
hough_space_image.save("debug_out/hough.png")
|
||||
image_lines = _draw_lines(original_image, all_max_params, resize_height)
|
||||
|
||||
intersections_by_lines, all_intersections = find_intersections(all_max_params, image_small.shape, allowed_angle_diff=line_angle_diff)
|
||||
|
||||
if DEBUG:
|
||||
image_lines = _draw_intersections(image_lines, all_intersections)
|
||||
|
||||
rects_lines = get_cycles(intersections_by_lines)
|
||||
rects_points = get_intersection_points_from_lines(rects_lines, intersections_by_lines)
|
||||
rects_points = remove_small_rects(rects_points, image_small.shape, min_image_coverage=min_image_coverage)
|
||||
|
||||
if DEBUG:
|
||||
image_lines = _draw_rects(rects_points, image_lines)
|
||||
image_lines.save("debug_out/line.png")
|
||||
|
||||
# TODO: find best rectangle
|
||||
try:
|
||||
best_rect = rects_points[0]
|
||||
best_rect = [(round(pt[0]*ratio), round(pt[1]*ratio)) for pt in best_rect]
|
||||
best_rect = best_rect[:4]
|
||||
|
||||
cropped_image = crop_warp_image(original_image, best_rect)
|
||||
|
||||
cropped_image = _cv2ImageToPIL(cropped_image)
|
||||
|
||||
bbox = get_rect_bounding_box(best_rect)
|
||||
except Exception as e:
|
||||
cropped_image = _cv2ImageToPIL(original_image)
|
||||
best_rect = None
|
||||
bbox = None
|
||||
|
||||
intersections_as_list = [[round(i[0]*ratio), round(i[1]*ratio)] for i in all_intersections]
|
||||
|
||||
return cropped_image, best_rect, bbox, intersections_as_list
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
#img = Image.open('example_imgs/bill.png')
|
||||
img = Image.open('example_imgs/image4.jpg')
|
||||
|
||||
cropped_image, rect, bbox, intersections = autocrop(img, DEBUG=True)
|
||||
|
||||
cropped_image.save('debug_out/cropped.png')
|
||||
print(rect)
|
||||
print(bbox)
|
||||
print(intersections)
|
16
photo_log/autocrop/ocr.py
Executable file
16
photo_log/autocrop/ocr.py
Executable file
@ -0,0 +1,16 @@
|
||||
################################################################
|
||||
##### OCR
|
||||
################################################################
|
||||
|
||||
|
||||
gray = cv2.cvtColor(cropped_image, cv2.COLOR_BGR2GRAY)
|
||||
gray = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
|
||||
gray = cv2.medianBlur(gray, 3)
|
||||
|
||||
cv2.imwrite("debug_out/bin.png", gray)
|
||||
|
||||
img = Image.fromarray(cropped_image)
|
||||
|
||||
tessdata_dir_config = r'--tessdata-dir "./ocr_langs"'
|
||||
text = pytesseract.image_to_string(gray, lang='deu-best', config=tessdata_dir_config)
|
||||
print(text)
|
BIN
photo_log/autocrop/ocr_langs/deu-best.traineddata
Executable file
BIN
photo_log/autocrop/ocr_langs/deu-best.traineddata
Executable file
Binary file not shown.
13
photo_log/autocrop/requirements.txt
Executable file
13
photo_log/autocrop/requirements.txt
Executable file
@ -0,0 +1,13 @@
|
||||
imageio==2.13.5
|
||||
imutils==0.5.4
|
||||
networkx==2.6.3
|
||||
numpy==1.22.0
|
||||
opencv-python==4.5.5.62
|
||||
packaging==21.3
|
||||
Pillow==9.0.0
|
||||
pyparsing==3.0.6
|
||||
pytesseract==0.3.8
|
||||
PyWavelets==1.2.0
|
||||
scikit-image==0.19.1
|
||||
scipy==1.7.3
|
||||
tifffile==2021.11.2
|
@ -2,6 +2,10 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
|
||||
from model_utils import FieldTracker
|
||||
|
||||
from . import tasks
|
||||
|
||||
from colorfield.fields import ColorField
|
||||
|
||||
from datetime import date
|
||||
@ -34,11 +38,11 @@ def get_default_photogroup():
|
||||
|
||||
def get_original_photo_path(instance, filename):
|
||||
_, ext = os.path.splitext(filename)
|
||||
return 'static/original_images/%s%s' % (str(uuid.uuid4()), ext)
|
||||
return 'original_images/%s%s' % (str(uuid.uuid4()), ext)
|
||||
|
||||
def get_cropped_photo_path(instance, filename):
|
||||
_, ext = os.path.splitext(filename)
|
||||
return 'static/cropped_images/%s%s' % (str(uuid.uuid4()), ext)
|
||||
return 'cropped_images/%s%s' % (str(uuid.uuid4()), ext)
|
||||
|
||||
|
||||
class Photo(models.Model):
|
||||
@ -81,62 +85,64 @@ class Photo(models.Model):
|
||||
default=None,
|
||||
)
|
||||
|
||||
tracker = FieldTracker()
|
||||
|
||||
def __str__(self):
|
||||
return "Photo #" + str(self.id)
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Photo)
|
||||
def auto_delete_file_on_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Deletes file from filesystem
|
||||
when corresponding `MediaFile` object is deleted.
|
||||
"""
|
||||
if instance.original_image:
|
||||
if os.path.isfile(instance.original_image.path):
|
||||
os.remove(instance.original_image.path)
|
||||
instance.original_image.delete(save=False)
|
||||
|
||||
if instance.cropped_image:
|
||||
if os.path.isfile(instance.cropped_image.path):
|
||||
os.remove(instance.cropped_image.path)
|
||||
instance.cropped_image.delete(save=False)
|
||||
|
||||
|
||||
@receiver(models.signals.pre_save, sender=Photo)
|
||||
def auto_delete_file_on_change(sender, instance, **kwargs):
|
||||
"""
|
||||
Deletes old file from filesystem
|
||||
when corresponding `MediaFile` object is updated
|
||||
with new file.
|
||||
"""
|
||||
if not instance.pk:
|
||||
return False
|
||||
|
||||
try:
|
||||
old_file = Photo.objects.get(pk=instance.pk).original_image
|
||||
photo = Photo.objects.get(pk=instance.pk)
|
||||
except Photo.DoesNotExist:
|
||||
return False
|
||||
|
||||
# check original image for change
|
||||
new_file = instance.original_image
|
||||
old_file = photo.original_image
|
||||
if not old_file == new_file:
|
||||
if os.path.isfile(old_file.path):
|
||||
os.remove(old_file.path)
|
||||
photo.original_image.delete(save=False)
|
||||
|
||||
try:
|
||||
old_file = Photo.objects.get(pk=instance.pk).cropped_image
|
||||
except Photo.DoesNotExist:
|
||||
return False
|
||||
|
||||
if old_file.name:
|
||||
# check cropped image for change
|
||||
new_file = instance.cropped_image
|
||||
old_file = photo.cropped_image
|
||||
if not old_file == new_file:
|
||||
if os.path.isfile(old_file.path):
|
||||
os.remove(old_file.path)
|
||||
photo.cropped_image.delete(save=False)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Photo)
|
||||
def max_resize_images(sender, instance, **kwargs):
|
||||
MAX_IMAGE_WIDTH = 800
|
||||
|
||||
tracker = instance.tracker
|
||||
|
||||
original_image_s3_path = instance.original_image.name
|
||||
if original_image_s3_path and tracker.has_changed('original_image'):
|
||||
tasks.max_resize_image_chain(original_image_s3_path, MAX_IMAGE_WIDTH)
|
||||
|
||||
cropped_image_s3_path = instance.cropped_image.name
|
||||
if cropped_image_s3_path and tracker.has_changed('cropped_image'):
|
||||
tasks.max_resize_image_chain(cropped_image_s3_path, MAX_IMAGE_WIDTH)
|
||||
|
||||
|
||||
def get_empty_photolog_default():
|
||||
return []
|
||||
|
||||
def get_photolog_pdf_path(instance, filename):
|
||||
return "static/photolog_pdf/%s.pdf" % str(uuid.uuid4())
|
||||
return "photolog_pdf/%s.pdf" % str(uuid.uuid4())
|
||||
|
||||
class PhotoLog(models.Model):
|
||||
title = models.CharField(null=False, blank=False, max_length=5000)
|
||||
@ -159,35 +165,23 @@ class PhotoLog(models.Model):
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class PhotoLogTemplate(models.Model):
|
||||
title = models.CharField(null=False, blank=False, max_length=5000)
|
||||
date = models.DateField(auto_now=False, default=date.today)
|
||||
render_date = models.BooleanField(null=False, blank=True, default=True)
|
||||
start_slide_image = models.IntegerField(null=True, blank=True, default=3)
|
||||
slides = models.JSONField(
|
||||
null=False,
|
||||
blank=False,
|
||||
default=list,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=PhotoLog)
|
||||
def auto_delete_file_on_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Deletes file from filesystem
|
||||
when corresponding `MediaFile` object is deleted.
|
||||
"""
|
||||
if instance.pdf:
|
||||
if os.path.isfile(instance.pdf.path):
|
||||
os.remove(instance.pdf.path)
|
||||
|
||||
@receiver(models.signals.pre_save, sender=PhotoLog)
|
||||
def auto_delete_file_on_change(sender, instance, **kwargs):
|
||||
"""
|
||||
Deletes old file from filesystem
|
||||
when corresponding `MediaFile` object is updated
|
||||
with new file.
|
||||
"""
|
||||
if not instance.pk:
|
||||
return False
|
||||
|
||||
try:
|
||||
old_file = PhotoLog.objects.get(pk=instance.pk).pdf
|
||||
except PhotoLog.DoesNotExist:
|
||||
return False
|
||||
|
||||
if not old_file.name:
|
||||
return False
|
||||
|
||||
new_file = instance.pdf
|
||||
if not old_file == new_file:
|
||||
if os.path.isfile(old_file.path):
|
||||
os.remove(old_file.path)
|
||||
instance.pdf.delete(save=False)
|
||||
|
55
photo_log/photolog_layout.py
Executable file
55
photo_log/photolog_layout.py
Executable file
@ -0,0 +1,55 @@
|
||||
from datetime import datetime
|
||||
from django.conf import settings
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def generate_tex(title, date, render_date, start_slide_image, slides, id_to_name, work_dir):
|
||||
log_start = "% !TEX program = xelatex\n\\documentclass[aspectratio=169]{beamer}\n\\usepackage{graphicx}\n\\usepackage{tikz}\n\\usepackage[absolute,overlay]{textpos}\n\\definecolor{gray1}{rgb}{0.85,0.85,0.85}\n\\definecolor{gray2}{rgb}{0.95,0.95,0.95}\n\\setbeamertemplate{background canvas}{%\n\\begin{tikzpicture}[remember picture,overlay]\n\\begin{scope}[xshift=0.55\\textwidth, yshift=-4.5cm]\n\\fill[even odd rule,inner color=gray2,outer color=gray1] (0,0) circle (10);\n\\node[inner sep=0pt] at (0.26,-4.02) {\\includegraphics[width=1.16\\textwidth]{wood_floor}};\n\\end{scope}\n\\end{tikzpicture}%\n}\n\\setbeamertemplate{navigation symbols}{}\n\\vfuzz=1000pt\n\\hfuzz=1000pt\n\\usepackage{fontspec}\n\\newfontfamily{\\gill} [ Path = "+str(settings.BASE_DIR)+"/static/photolog_assets/,UprightFont = * Medium,BoldFont = * Bold] {Gill Sans MT}\n\\definecolor{title_line_red}{rgb}{0.8,0.2,0.2}\n\\makeatletter\n\\let\\old@rule\\@rule\n\\def\\@rule[#1]#2#3{\\textcolor{title_line_red}{\\old@rule[#1]{#2}{#3}}}\n\\makeatother\n\\title{Fotoprotokoll}\n\\author{Antje Zierle-Kohlmorgen}\n\\institute{Zierle-Training}\n\\date{2022}\n\\begin{document}\n\\gill"
|
||||
log_end = "\\begin{frame}\n\\begin{columns}\n\\begin{column}{0.6\\textwidth}\n\\begin{center}\n\\includegraphics[width=0.8\\textwidth]{smile}\n\\end{center}\n\\end{column}\n\\begin{column}{0.4\\textwidth}\n\\Huge{Viel Erfolg!}\n\\end{column}\n\\end{columns}\n\\end{frame}\n\\end{document}"
|
||||
|
||||
title_slide_start = "\\begin{frame}\n\\begin{textblock*}{13.0cm}(3cm,1.75cm)\n\\huge{"
|
||||
title_slide_end_w_photo = "}\\\\[-0.25cm]\n\\rule{14cm}{0.05cm}\n\\begin{tikzpicture}[remember picture, overlay]\n\\node[xshift=0.8\\textwidth,yshift=-0.5cm]{\\includegraphics[height=4.5cm]{%s}};\n\\end{tikzpicture}\n\\end{textblock*}\n\\end{frame}"
|
||||
title_slide_end_wo_photo = "}\\\\[-0.25cm]\n\\rule{14cm}{0.05cm}\n\n\\end{textblock*}\n\\end{frame}"
|
||||
|
||||
slide_start = "\\begin{frame}\n\\begin{columns}"
|
||||
slide_end = "\\end{columns}\n\\hfill\\\\[1.5cm]\n\\end{frame}"
|
||||
|
||||
photo_start = "\\begin{column}{%.3f\\textwidth}\n\\begin{center}\n\\includegraphics[height=6.5cm]{"
|
||||
photo_end = "}\\end{center}\n\\end{column}"
|
||||
|
||||
assets_path = "{%s}" % (os.path.abspath(os.path.join(work_dir, '..', 'photolog_assets')))
|
||||
photos_path = "{%s}" % (str(work_dir))
|
||||
include_paths = "\\graphicspath{"+assets_path+","+photos_path+"}\n"
|
||||
|
||||
tex_file_content = log_start
|
||||
tex_file_content += include_paths
|
||||
|
||||
tex_file_content += title_slide_start + title
|
||||
if date and render_date:
|
||||
date = datetime.strptime(date, '%Y-%m-%dT%H:%M:%S')
|
||||
tex_file_content += "\\\\" + date.strftime("%d.%m.%Y")
|
||||
|
||||
if start_slide_image:
|
||||
tex_file_content += title_slide_end_w_photo % (id_to_name[str(start_slide_image)])
|
||||
else:
|
||||
tex_file_content += title_slide_end_wo_photo
|
||||
|
||||
for slide in slides:
|
||||
tex_file_content += slide_start
|
||||
num_none_photos = sum([1 for p in slide if p==None])
|
||||
|
||||
for photo_id in slide:
|
||||
if not photo_id:
|
||||
continue
|
||||
if num_none_photos == 2: # slides are saved as [2, None, None] for only 1 image
|
||||
tex_file_content += photo_start % (3.0/len(slide))
|
||||
else:
|
||||
tex_file_content += photo_start % (1.0/len(slide))
|
||||
tex_file_content += id_to_name[str(photo_id)]
|
||||
tex_file_content += photo_end
|
||||
tex_file_content += slide_end
|
||||
|
||||
tex_file_content += log_end
|
||||
|
||||
return tex_file_content
|
389
photo_log/tasks.py
Normal file
389
photo_log/tasks.py
Normal file
@ -0,0 +1,389 @@
|
||||
from celery import shared_task, chain, group, chord
|
||||
import boto3
|
||||
import imghdr
|
||||
from PIL import Image, ExifTags
|
||||
|
||||
import os, shutil
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.apps import apps
|
||||
|
||||
from django_tex.shortcuts import compile_template_to_pdf
|
||||
|
||||
from .photolog_layout import generate_tex
|
||||
from .autocrop.autocrop import autocrop
|
||||
|
||||
|
||||
@shared_task
|
||||
def chordfinisher(*args, **kwargs):
|
||||
"""
|
||||
Used at the end of chord( group( ... ), chordfinisher.s())
|
||||
to chain multiple groups together.
|
||||
"""
|
||||
return 'OK'
|
||||
|
||||
|
||||
@shared_task
|
||||
def download_s3_file(folder_path, s3_file_path, bucket):
|
||||
"""
|
||||
Downloads a file stored in a S3 object storages.
|
||||
:param folder_path Path on local disk where the file should be saved.
|
||||
:param s3_file_path Full file path in the S3 storage bucket (without the buckets name).
|
||||
:param bucket The name of the bucket where the file is stored in.
|
||||
"""
|
||||
|
||||
# create local folder
|
||||
if not os.path.exists(folder_path):
|
||||
os.makedirs(folder_path, exist_ok=True) # mkdir -p
|
||||
|
||||
s3_resource = boto3.resource(
|
||||
's3',
|
||||
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
client = s3_resource.meta.client
|
||||
|
||||
# retrieve the file name if the file is stored in a sub dir on the S3
|
||||
file_name = s3_file_path.split('/')[-1]
|
||||
|
||||
client.download_file(bucket, s3_file_path, os.path.join(folder_path, file_name))
|
||||
|
||||
|
||||
@shared_task
|
||||
def upload_s3_file(file_path, s3_file_path, bucket):
|
||||
"""
|
||||
Uploads a file to a S3 object storages.
|
||||
:param file_path Path on local disk where the is saved.
|
||||
:param s3_file_path Full file path in the S3 storage bucket (without the buckets name).
|
||||
:param bucket The name of the bucket where the file will be stored in.
|
||||
"""
|
||||
|
||||
s3_resource = boto3.resource(
|
||||
's3',
|
||||
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
client = s3_resource.meta.client
|
||||
|
||||
client.upload_file(file_path, bucket, s3_file_path)
|
||||
|
||||
|
||||
@shared_task
|
||||
def update_model_field(app_name, model_name, pk, field_name, new_value):
|
||||
model = apps.get_model(app_name, model_name)
|
||||
instance = model.objects.get(pk=pk)
|
||||
setattr(instance, field_name, new_value)
|
||||
instance.save()
|
||||
|
||||
|
||||
def rotateByExif(img):
|
||||
try:
|
||||
for orientation in ExifTags.TAGS.keys():
|
||||
if ExifTags.TAGS[orientation]=='Orientation':
|
||||
break
|
||||
|
||||
exif = img._getexif()
|
||||
|
||||
if not exif:
|
||||
return img
|
||||
|
||||
if exif[orientation] == 3:
|
||||
img=img.rotate(180, expand=True)
|
||||
elif exif[orientation] == 6:
|
||||
img=img.rotate(270, expand=True)
|
||||
elif exif[orientation] == 8:
|
||||
img=img.rotate(90, expand=True)
|
||||
|
||||
except (AttributeError, KeyError, IndexError):
|
||||
# cases: image don't have getexif
|
||||
pass
|
||||
return img
|
||||
|
||||
|
||||
@shared_task
|
||||
def max_resize_image(image_path, max_width):
|
||||
if not imghdr.what(image_path):
|
||||
return
|
||||
|
||||
img = Image.open(image_path)
|
||||
img = rotateByExif(img)
|
||||
|
||||
if img.size[0] <= max_width:
|
||||
return
|
||||
|
||||
wpercent = (max_width/float(img.size[0]))
|
||||
hsize = int((float(img.size[1])*float(wpercent)))
|
||||
|
||||
img = img.resize((max_width,hsize), Image.ANTIALIAS)
|
||||
img.save(image_path)
|
||||
|
||||
|
||||
@shared_task
|
||||
def crop_image_bbox(image_path, bbox, rotate_angle):
|
||||
if not imghdr.what(image_path):
|
||||
return
|
||||
|
||||
img = Image.open(image_path)
|
||||
img = rotateByExif(img)
|
||||
|
||||
if rotate_angle:
|
||||
img = img.rotate(rotate_angle, expand=1)
|
||||
|
||||
if bbox:
|
||||
img = img.crop((
|
||||
bbox[0][0],
|
||||
bbox[0][1],
|
||||
bbox[2][0],
|
||||
bbox[2][1]
|
||||
))
|
||||
|
||||
img.save(image_path)
|
||||
|
||||
|
||||
@shared_task
|
||||
def crop_image_auto(image_path):
|
||||
if not imghdr.what(image_path):
|
||||
return
|
||||
|
||||
img = Image.open(image_path)
|
||||
img = rotateByExif(img)
|
||||
|
||||
try:
|
||||
cropped_img, _, bbox, intersections = autocrop(img)
|
||||
except Exception:
|
||||
cropped_img = img
|
||||
bbox = None
|
||||
intersections = None
|
||||
|
||||
# TODO: save bbox an intersections in photo.bbox_cords and photo.intersections
|
||||
|
||||
cropped_img.save(image_path)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_folder(folder_path):
|
||||
shutil.rmtree(folder_path, ignore_errors=True)
|
||||
|
||||
|
||||
@shared_task
|
||||
def generate_photolog_from_latex(title, date, render_date, start_slide_image, slides, id_to_name, work_dir, out_file):
|
||||
template_name = 'photolog.tex'
|
||||
context = {
|
||||
'content': generate_tex(title, date, render_date, start_slide_image, slides, id_to_name, work_dir)
|
||||
}
|
||||
|
||||
pdf_bytes = compile_template_to_pdf(template_name, context)
|
||||
|
||||
with open(out_file, 'wb+') as file:
|
||||
file.write(pdf_bytes)
|
||||
|
||||
|
||||
def max_resize_image_chain(full_file_name, max_width, work_folder_name=uuid4()):
|
||||
BASE_DIR = settings.CELERY_WORK_DIR
|
||||
|
||||
folder = os.path.join(BASE_DIR, str(work_folder_name))
|
||||
local_file = os.path.join(folder, full_file_name.split('/')[-1])
|
||||
|
||||
bucket = settings.AWS_STORAGE_BUCKET_NAME
|
||||
|
||||
chain(
|
||||
download_s3_file.si(
|
||||
folder,
|
||||
full_file_name,
|
||||
bucket
|
||||
),
|
||||
max_resize_image.si(
|
||||
local_file,
|
||||
max_width=max_width
|
||||
),
|
||||
upload_s3_file.si(
|
||||
local_file,
|
||||
full_file_name,
|
||||
bucket
|
||||
),
|
||||
delete_folder.si(
|
||||
folder
|
||||
),
|
||||
)()
|
||||
|
||||
|
||||
def crop_image_bbox_chain(photo_pk, full_file_name, bbox, rotate_angle, cropped_img_name=None, work_folder_name=uuid4()):
|
||||
BASE_DIR = settings.CELERY_WORK_DIR
|
||||
|
||||
if not cropped_img_name:
|
||||
cropped_img_name = os.path.join('cropped_images', str(uuid4()) + '.png')
|
||||
|
||||
folder = os.path.join(BASE_DIR, str(work_folder_name))
|
||||
local_file = os.path.join(folder, os.path.basename(full_file_name))
|
||||
|
||||
bucket = settings.AWS_STORAGE_BUCKET_NAME
|
||||
s3_upload_name = 'cropped_images/' + os.path.basename(cropped_img_name)
|
||||
|
||||
chain(
|
||||
download_s3_file.si(
|
||||
folder,
|
||||
full_file_name,
|
||||
bucket
|
||||
),
|
||||
crop_image_bbox.si(
|
||||
local_file,
|
||||
bbox,
|
||||
rotate_angle
|
||||
),
|
||||
upload_s3_file.si(
|
||||
local_file,
|
||||
s3_upload_name,
|
||||
bucket
|
||||
),
|
||||
update_model_field.si(# photo_log.Photo(pk=photo_pk).cropped_image = s3_upload_name
|
||||
'photo_log', 'Photo', photo_pk,
|
||||
'cropped_image', s3_upload_name
|
||||
),
|
||||
delete_folder.si(
|
||||
folder
|
||||
),
|
||||
)()
|
||||
|
||||
|
||||
def crop_image_auto_chain(photo_pk, full_file_name, cropped_img_name=None, work_folder_name=uuid4()):
|
||||
BASE_DIR = settings.CELERY_WORK_DIR
|
||||
|
||||
if not cropped_img_name:
|
||||
cropped_img_name = os.path.join('cropped_images', str(uuid4()) + '.png')
|
||||
|
||||
folder = os.path.join(BASE_DIR, str(work_folder_name))
|
||||
local_file = os.path.join(folder, os.path.basename(full_file_name))
|
||||
|
||||
bucket = settings.AWS_STORAGE_BUCKET_NAME
|
||||
s3_upload_name = 'cropped_images/' + os.path.basename(cropped_img_name)
|
||||
|
||||
chain(
|
||||
download_s3_file.si(
|
||||
folder,
|
||||
full_file_name,
|
||||
bucket
|
||||
),
|
||||
crop_image_auto.si(
|
||||
local_file
|
||||
),
|
||||
upload_s3_file.si(
|
||||
local_file,
|
||||
s3_upload_name,
|
||||
bucket
|
||||
),
|
||||
update_model_field.si(# photo_log.Photo(pk=photo_pk).cropped_image = s3_upload_name
|
||||
'photo_log', 'Photo', photo_pk,
|
||||
'cropped_image', s3_upload_name
|
||||
),
|
||||
delete_folder.si(
|
||||
folder
|
||||
),
|
||||
)()
|
||||
|
||||
|
||||
def get_photo_log_assets_tasks():
|
||||
BASE_DIR = settings.CELERY_WORK_DIR
|
||||
folder = os.path.join(BASE_DIR, 'photolog_assets')
|
||||
|
||||
bucket = settings.AWS_STORAGE_BUCKET_NAME
|
||||
|
||||
download_tasks = []
|
||||
|
||||
asset_files = [
|
||||
'Gill Sans MT Bold.ttf',
|
||||
'Gill Sans MT Medium.ttf',
|
||||
'smile.png',
|
||||
'wood_floor.jpg'
|
||||
]
|
||||
|
||||
for file in asset_files:
|
||||
path = os.path.join(folder, file)
|
||||
if not os.path.exists(path):
|
||||
download_tasks.append(
|
||||
download_s3_file.si(
|
||||
folder,
|
||||
'photolog_assets/' + file,
|
||||
bucket
|
||||
)
|
||||
)
|
||||
|
||||
return download_tasks
|
||||
|
||||
|
||||
def generate_photo_log_chain(photo_log_id, work_folder_name=uuid4()):
|
||||
from photo_log.models import Photo, PhotoLog
|
||||
|
||||
BASE_DIR = settings.CELERY_WORK_DIR
|
||||
folder = os.path.join(BASE_DIR, str(work_folder_name))
|
||||
|
||||
bucket = settings.AWS_STORAGE_BUCKET_NAME
|
||||
|
||||
photo_log = PhotoLog.objects.get(pk=photo_log_id)
|
||||
|
||||
slides = photo_log.slides
|
||||
slides = [photo for slide in slides for photo in slide] # flatten slides lists
|
||||
|
||||
if photo_log.start_slide_image:
|
||||
slides.append(photo_log.start_slide_image)
|
||||
|
||||
photos = Photo.objects.filter(pk__in=slides).values('id', 'original_image', 'cropped_image')
|
||||
|
||||
photo_id_to_file_name = {}
|
||||
download_files_tasks = []
|
||||
for photo in photos:
|
||||
image = photo['cropped_image']
|
||||
if not image:
|
||||
image = photo['original_image']
|
||||
|
||||
download_files_tasks.append(
|
||||
download_s3_file.si(
|
||||
folder,
|
||||
image,
|
||||
bucket
|
||||
)
|
||||
)
|
||||
|
||||
photo_id_to_file_name[photo['id']], _ = os.path.splitext(os.path.basename(image))
|
||||
|
||||
download_files_tasks.extend(get_photo_log_assets_tasks())
|
||||
|
||||
download_files_tasks = chord( group(download_files_tasks), chordfinisher.si() )
|
||||
|
||||
pdf_file = photo_log.pdf.name
|
||||
if pdf_file:
|
||||
pdf_file = os.path.basename(pdf_file)
|
||||
else:
|
||||
pdf_file = os.path.join(str(uuid4()) + '.pdf')
|
||||
|
||||
pdf_file = os.path.join(folder, pdf_file)
|
||||
pdf_s3_path = 'photolog_pdf/' + os.path.basename(pdf_file)
|
||||
|
||||
chain(
|
||||
download_files_tasks,
|
||||
generate_photolog_from_latex.si(
|
||||
photo_log.title,
|
||||
photo_log.date,
|
||||
photo_log.render_date,
|
||||
photo_log.start_slide_image,
|
||||
photo_log.slides,
|
||||
photo_id_to_file_name,
|
||||
folder,
|
||||
pdf_file
|
||||
),
|
||||
upload_s3_file.si(
|
||||
pdf_file,
|
||||
pdf_s3_path,
|
||||
bucket
|
||||
),
|
||||
update_model_field.si(
|
||||
'photo_log', 'PhotoLog', photo_log.id,
|
||||
'pdf', pdf_s3_path
|
||||
),
|
||||
delete_folder.si(
|
||||
folder
|
||||
)
|
||||
)()
|
@ -1,20 +1,37 @@
|
||||
amqp==5.1.1
|
||||
asgiref==3.4.1
|
||||
async-timeout==4.0.2
|
||||
attrs==21.4.0
|
||||
autobahn==22.4.2
|
||||
Automat==20.2.0
|
||||
beautifulsoup4==4.10.0
|
||||
billiard==3.6.4.0
|
||||
boto3==1.24.11
|
||||
botocore==1.27.11
|
||||
celery==5.2.7
|
||||
certifi==2021.10.8
|
||||
cffi==1.15.0
|
||||
channels==3.0.4
|
||||
charset-normalizer==2.0.7
|
||||
click==8.1.3
|
||||
click-didyoumean==0.3.0
|
||||
click-plugins==1.1.1
|
||||
click-repl==0.2.0
|
||||
constantly==15.1.0
|
||||
coreapi==2.3.3
|
||||
coreschema==0.0.4
|
||||
cryptography==37.0.2
|
||||
daphne==3.0.2
|
||||
Deprecated==1.2.13
|
||||
Django==3.2.8
|
||||
django-celery-results==2.3.1
|
||||
django-colorfield==0.7.1
|
||||
django-cors-headers==3.12.0
|
||||
django-extensions==3.1.5
|
||||
django-filter==21.1
|
||||
django-model-utils==4.2.0
|
||||
django-redis==5.2.0
|
||||
django-storages==1.12.3
|
||||
django-tex==1.1.10
|
||||
djangorestframework==3.13.1
|
||||
drf-yasg==1.20.0
|
||||
@ -27,6 +44,8 @@ incremental==21.3.0
|
||||
inflection==0.5.1
|
||||
itypes==1.2.0
|
||||
Jinja2==3.1.2
|
||||
jmespath==1.0.0
|
||||
kombu==5.2.4
|
||||
lxml==4.6.3
|
||||
Markdown==3.3.7
|
||||
MarkupSafe==2.1.1
|
||||
@ -35,6 +54,7 @@ numpy==1.22.0
|
||||
opencv-python==4.5.5.62
|
||||
packaging==21.3
|
||||
Pillow==9.0.0
|
||||
prompt-toolkit==3.0.29
|
||||
psycopg2==2.9.1
|
||||
pyasn1==0.4.8
|
||||
pyasn1-modules==0.2.8
|
||||
@ -45,9 +65,11 @@ pytesseract==0.3.8
|
||||
python-dateutil==2.8.2
|
||||
pytz==2021.3
|
||||
PyWavelets==1.2.0
|
||||
redis==4.3.3
|
||||
requests==2.26.0
|
||||
ruamel.yaml==0.17.21
|
||||
ruamel.yaml.clib==0.2.6
|
||||
s3transfer==0.6.0
|
||||
scikit-image==0.19.1
|
||||
scipy==1.7.3
|
||||
service-identity==21.1.0
|
||||
@ -61,4 +83,7 @@ txaio==22.2.1
|
||||
typing_extensions==4.2.0
|
||||
uritemplate==4.1.1
|
||||
urllib3==1.26.7
|
||||
vine==5.0.0
|
||||
wcwidth==0.2.5
|
||||
wrapt==1.14.1
|
||||
zope.interface==5.4.0
|
||||
|
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
37
scripts/test_celery_cropping.py
Normal file
37
scripts/test_celery_cropping.py
Normal file
@ -0,0 +1,37 @@
|
||||
from photo_log.models import Photo
|
||||
from photo_log import tasks
|
||||
|
||||
from celery import chain
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def run():
|
||||
BASE_DIR = '/home/marc/www-staging/celery/'
|
||||
|
||||
photo_pk = 126
|
||||
full_file_name = Photo.objects.get(pk=photo_pk).original_image.name
|
||||
|
||||
folder = os.path.join(BASE_DIR, str(photo_pk))
|
||||
local_file = os.path.join(folder, full_file_name.split('/')[-1])
|
||||
|
||||
bucket = 'zierle-training'
|
||||
|
||||
res = chain(
|
||||
tasks.download_s3_file.si(
|
||||
folder,
|
||||
full_file_name,
|
||||
bucket
|
||||
),
|
||||
tasks.max_resize_image.si(
|
||||
local_file,
|
||||
max_width=75
|
||||
),
|
||||
tasks.upload_s3_file.si(
|
||||
local_file,
|
||||
full_file_name,
|
||||
bucket
|
||||
)
|
||||
)()
|
||||
|
||||
print(res.get())
|
5
scripts/test_celery_photolog.py
Normal file
5
scripts/test_celery_photolog.py
Normal file
@ -0,0 +1,5 @@
|
||||
from photo_log import tasks
|
||||
|
||||
|
||||
def run():
|
||||
tasks.generate_photo_log_chain(45)
|
Loading…
Reference in New Issue
Block a user