diff --git a/api/autocrop b/api/autocrop deleted file mode 160000 index 70828ba..0000000 --- a/api/autocrop +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 70828ba4e14e67a1db819de5c6371713145f868c diff --git a/api/serializers.py b/api/serializers.py index 0c25176..f1d75ae 100755 --- a/api/serializers.py +++ b/api/serializers.py @@ -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 diff --git a/api/urls.py b/api/urls.py index ada7b9d..0a94a8e 100755 --- a/api/urls.py +++ b/api/urls.py @@ -17,6 +17,9 @@ from .views import ( PhotoTagsAPIView, RetrieveUpdateDestroyPhotoTagAPIView, CreatePhotoTagAPIView, + PhotoLogTemplatesAPIView, + CreatePhotoLogTemplateAPIView, + RetrieveUpdateDestroyPhotoLogTemplateAPIView, ) @@ -36,7 +39,12 @@ urlpatterns = [ path('updatephotolog//', UpdatePhotoLogAPIView.as_view()), path('updatephoto//', UpdatePhotoAPIView.as_view()), path('generatephotolog//', GeneratePhotoLogAPIView.as_view()), + path('phototag/', CreatePhotoTagAPIView.as_view()), path('phototags/', PhotoTagsAPIView.as_view()), path('phototag//', RetrieveUpdateDestroyPhotoTagAPIView.as_view()), + + path('photolog/template/', CreatePhotoLogTemplateAPIView.as_view()), + path('photolog/templates/', PhotoLogTemplatesAPIView.as_view()), + path('photolog/template//', RetrieveUpdateDestroyPhotoLogTemplateAPIView.as_view()), ] diff --git a/api/views.py b/api/views.py index 70cc96a..34eb866 100755 --- a/api/views.py +++ b/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'] - - log_bytes = generate_photolog_from_latex(request, title, date, render_date, start_slide_image, slides) + tasks.generate_photo_log_chain( + photolog_data['id'], + ) - 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() @@ -240,34 +192,30 @@ class AutoCropPhotoAPIView(generics.RetrieveAPIView): if not 'mode' in request.query_params: 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) diff --git a/config/__init__.py b/config/__init__.py index e69de29..0666028 100755 --- a/config/__init__.py +++ b/config/__init__.py @@ -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',) diff --git a/config/celery_supervisor.conf b/config/celery_supervisor.conf new file mode 100644 index 0000000..1153a54 --- /dev/null +++ b/config/celery_supervisor.conf @@ -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 diff --git a/config/celeryapp.py b/config/celeryapp.py new file mode 100644 index 0000000..b40b2cb --- /dev/null +++ b/config/celeryapp.py @@ -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}') diff --git a/config/settings.py b/config/settings.py index d214bc5..15c9a92 100755 --- a/config/settings.py +++ b/config/settings.py @@ -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", + } +} diff --git a/photo_log/admin.py b/photo_log/admin.py index d01451e..b9750bb 100755 --- a/photo_log/admin.py +++ b/photo_log/admin.py @@ -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) diff --git a/photo_log/autocrop/autocrop.py b/photo_log/autocrop/autocrop.py new file mode 100755 index 0000000..d0a4ec1 --- /dev/null +++ b/photo_log/autocrop/autocrop.py @@ -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) diff --git a/photo_log/autocrop/ocr.py b/photo_log/autocrop/ocr.py new file mode 100755 index 0000000..7bb7e70 --- /dev/null +++ b/photo_log/autocrop/ocr.py @@ -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) \ No newline at end of file diff --git a/photo_log/autocrop/ocr_langs/deu-best.traineddata b/photo_log/autocrop/ocr_langs/deu-best.traineddata new file mode 100755 index 0000000..6dd6548 Binary files /dev/null and b/photo_log/autocrop/ocr_langs/deu-best.traineddata differ diff --git a/photo_log/autocrop/requirements.txt b/photo_log/autocrop/requirements.txt new file mode 100755 index 0000000..15c513e --- /dev/null +++ b/photo_log/autocrop/requirements.txt @@ -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 diff --git a/photo_log/models.py b/photo_log/models.py index bb06e04..159e9a2 100755 --- a/photo_log/models.py +++ b/photo_log/models.py @@ -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 + # check cropped image for change + new_file = instance.cropped_image + old_file = photo.cropped_image + if not old_file == new_file: + photo.cropped_image.delete(save=False) - if old_file.name: - new_file = instance.cropped_image - if not old_file == new_file: - if os.path.isfile(old_file.path): - os.remove(old_file.path) + +@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) diff --git a/photo_log/photolog_layout.py b/photo_log/photolog_layout.py new file mode 100755 index 0000000..2cdda54 --- /dev/null +++ b/photo_log/photolog_layout.py @@ -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 diff --git a/photo_log/tasks.py b/photo_log/tasks.py new file mode 100644 index 0000000..3051da5 --- /dev/null +++ b/photo_log/tasks.py @@ -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 + ) + )() diff --git a/requirements.txt b/requirements.txt index c0c9bb4..59a3bd8 100755 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/test_celery_cropping.py b/scripts/test_celery_cropping.py new file mode 100644 index 0000000..a0a6836 --- /dev/null +++ b/scripts/test_celery_cropping.py @@ -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()) diff --git a/scripts/test_celery_photolog.py b/scripts/test_celery_photolog.py new file mode 100644 index 0000000..45bce3c --- /dev/null +++ b/scripts/test_celery_photolog.py @@ -0,0 +1,5 @@ +from photo_log import tasks + + +def run(): + tasks.generate_photo_log_chain(45)