From 3223d2ac00067a30f4af21ad4b6bf6d1900915ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Fri, 27 Oct 2023 22:30:59 +0200 Subject: [PATCH] Pridani editace fotek --- ...23304_add_photo_adjustment_2acb6fa61028.py | 39 ++++ ...31020-235321_add_crop_info_c3aef4ae2669.py | 34 +++ ...-163210_add_size_to_photos_4996ad8bc231.py | 30 +++ src/background_jobs/elevation.py | 26 +-- src/background_jobs/photo.py | 26 ++- src/database/models.py | 24 +++ src/endpoints/photo_editor_preview.py | 53 +++++ .../dataloaders/multi_models.py | 2 + .../dataloaders/single_model.py | 4 +- src/graphql_schema/entities/event.py | 2 - src/graphql_schema/entities/photo.py | 124 +++++++++-- .../entities/types/mutation_input.py | 19 ++ src/graphql_schema/entities/types/types.py | 9 +- src/main.py | 33 +++ src/scripts/add_photo_sizes.py | 34 +++ src/scripts/elevation.py | 4 +- src/utils/image.py | 193 +++++++++++++++++- 17 files changed, 602 insertions(+), 54 deletions(-) create mode 100644 alembic/versions/20231015-223304_add_photo_adjustment_2acb6fa61028.py create mode 100644 alembic/versions/20231020-235321_add_crop_info_c3aef4ae2669.py create mode 100644 alembic/versions/20231024-163210_add_size_to_photos_4996ad8bc231.py create mode 100644 src/endpoints/photo_editor_preview.py create mode 100644 src/scripts/add_photo_sizes.py diff --git a/alembic/versions/20231015-223304_add_photo_adjustment_2acb6fa61028.py b/alembic/versions/20231015-223304_add_photo_adjustment_2acb6fa61028.py new file mode 100644 index 0000000..518611b --- /dev/null +++ b/alembic/versions/20231015-223304_add_photo_adjustment_2acb6fa61028.py @@ -0,0 +1,39 @@ +"""Add photo adjustment + +Revision ID: 2acb6fa61028 +Revises: 956d295689bf +Create Date: 2023-10-15 22:33:04.911765 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2acb6fa61028' +down_revision = '956d295689bf' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('photo_adjustment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('photo_id', sa.Integer(), nullable=False), + sa.Column('rotate', sa.Float(), nullable=True), + sa.Column('contrast', sa.Float(), nullable=True), + sa.Column('brightness', sa.Float(), nullable=True), + sa.Column('saturation', sa.Float(), nullable=True), + sa.Column('sharpness', sa.Float(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['photo_id'], ['photo.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('photo_adjustment') + # ### end Alembic commands ### diff --git a/alembic/versions/20231020-235321_add_crop_info_c3aef4ae2669.py b/alembic/versions/20231020-235321_add_crop_info_c3aef4ae2669.py new file mode 100644 index 0000000..0e8a752 --- /dev/null +++ b/alembic/versions/20231020-235321_add_crop_info_c3aef4ae2669.py @@ -0,0 +1,34 @@ +"""add crop info + +Revision ID: c3aef4ae2669 +Revises: 2acb6fa61028 +Create Date: 2023-10-20 23:53:21.772545 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c3aef4ae2669' +down_revision = '2acb6fa61028' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('photo_adjustment', sa.Column('crop_left', sa.Float(), nullable=True)) + op.add_column('photo_adjustment', sa.Column('crop_top', sa.Float(), nullable=True)) + op.add_column('photo_adjustment', sa.Column('crop_width', sa.Float(), nullable=True)) + op.add_column('photo_adjustment', sa.Column('crop_height', sa.Float(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('photo_adjustment', 'crop_height') + op.drop_column('photo_adjustment', 'crop_width') + op.drop_column('photo_adjustment', 'crop_top') + op.drop_column('photo_adjustment', 'crop_left') + # ### end Alembic commands ### diff --git a/alembic/versions/20231024-163210_add_size_to_photos_4996ad8bc231.py b/alembic/versions/20231024-163210_add_size_to_photos_4996ad8bc231.py new file mode 100644 index 0000000..251362b --- /dev/null +++ b/alembic/versions/20231024-163210_add_size_to_photos_4996ad8bc231.py @@ -0,0 +1,30 @@ +"""add size to photos + +Revision ID: 4996ad8bc231 +Revises: c3aef4ae2669 +Create Date: 2023-10-24 16:32:10.826763 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4996ad8bc231' +down_revision = 'c3aef4ae2669' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('photo', sa.Column('width', sa.Integer(), nullable=False)) + op.add_column('photo', sa.Column('height', sa.Integer(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('photo', 'height') + op.drop_column('photo', 'width') + # ### end Alembic commands ### diff --git a/src/background_jobs/elevation.py b/src/background_jobs/elevation.py index 520525a..0867cc4 100644 --- a/src/background_jobs/elevation.py +++ b/src/background_jobs/elevation.py @@ -19,23 +19,23 @@ async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str): async with get_session() as db: await models.Flight.update( db, {"gpx_track_filename": output_name, "has_terrain_elevation": True}, - id=flight_id) - + id=flight_id + ) except ClientResponseError as e: print(e) async def add_terrain_elevation_to_photo(photo): - async with get_session() as db: - try: - elevation = await elevation_api.get_elevation_for_points([ - {"lat": photo.gps_latitude, "lng": photo.gps_longitude} - ]) - if not elevation: - print("Cannot get elevation") - return + try: + elevation = await elevation_api.get_elevation_for_points([ + {"lat": photo.gps_latitude, "lng": photo.gps_longitude} + ]) + if not elevation: + print("Cannot get elevation") + return - terrain_elevation = elevation[0]['elevation'] + terrain_elevation = elevation[0]['elevation'] + async with get_session() as db: await models.Photo.update(db_session=db, obj=photo, data={"terrain_elevation": terrain_elevation}) - except Exception as e: - print(f"Cannot get elevation: {e}") + except Exception as e: + print(f"Cannot get elevation: {e}") diff --git a/src/background_jobs/photo.py b/src/background_jobs/photo.py index 97da3c3..08f9c93 100644 --- a/src/background_jobs/photo.py +++ b/src/background_jobs/photo.py @@ -1,15 +1,19 @@ -from utils.image import resize_image +from database import models +from database.transaction import get_session +from utils.image import PhotoEditor -async def resize_photo(path: str, filename: str): - return await resize_image(path, filename, new_width=2500, quality=85) +async def resize_photo(path: str, filename: str, photo_id: int): + editor = PhotoEditor(path, filename) + editor.resize(new_width=2500) + editor.write_to_file(quality=85) + width, height = editor.img_size + + async with get_session() as db: + await models.Photo.update(db, {"width": width, "height": height}, id=photo_id) -async def generate_thumbnail(path: str, filename: str, quality: int = 90): - return await resize_image( - path, filename, - new_width=300, - dest_path=f"{path}/thumbs/", - dest_filename=filename, - quality=quality - ) +async def generate_thumbnail(path: str, filename: str): + editor = PhotoEditor(path, filename) + editor.resize(new_width=300) + editor.write_to_file(quality=85, dest_path=f"{path}/thumbs/") \ No newline at end of file diff --git a/src/database/models.py b/src/database/models.py index 093aacf..8391a7d 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -118,6 +118,8 @@ class Photo(BaseModel): filename: Mapped[str] = mapped_column(String(128), nullable=False) is_flight_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="0") description: Mapped[str] = mapped_column(Text, nullable=False) + width: Mapped[int] = mapped_column(Integer, nullable=False) + height: Mapped[int] = mapped_column(Integer, nullable=False) exposed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) gps_latitude: Mapped[float] = mapped_column(Float, nullable=True) gps_longitude: Mapped[float] = mapped_column(Float, nullable=True) @@ -130,9 +132,31 @@ class Photo(BaseModel): flight: Mapped['Flight'] = relationship(foreign_keys=[flight_id]) point_of_interest: Mapped['PointOfInterest'] = relationship() + adjustment: Mapped['PhotoAdjustment'] = relationship() created_by: Mapped['User'] = relationship() +class PhotoAdjustment(BaseModel): + __tablename__ = "photo_adjustment" + + id: Mapped[int] = mapped_column(primary_key=True) + photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id'), nullable=False) + rotate: Mapped[float] = mapped_column(Float, nullable=True) + contrast: Mapped[float] = mapped_column(Float, nullable=True) + brightness: Mapped[float] = mapped_column(Float, nullable=True) + saturation: Mapped[float] = mapped_column(Float, nullable=True) + sharpness: Mapped[float] = mapped_column(Float, nullable=True) + + crop_left: Mapped[float] = mapped_column(Float, nullable=True) + crop_top: Mapped[float] = mapped_column(Float, nullable=True) + crop_width: Mapped[float] = mapped_column(Float, nullable=True) + crop_height: Mapped[float] = mapped_column(Float, nullable=True) + + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + photo: Mapped['Photo'] = relationship() + + class Aircraft(BaseModel): __tablename__ = "aircraft" diff --git a/src/endpoints/photo_editor_preview.py b/src/endpoints/photo_editor_preview.py new file mode 100644 index 0000000..2564719 --- /dev/null +++ b/src/endpoints/photo_editor_preview.py @@ -0,0 +1,53 @@ +import os.path +from sqlalchemy import select +from starlette.responses import StreamingResponse +from database import models +from database.transaction import get_session +from endpoints.base import AuthEndpoint +from paths import get_photo_basepath +from utils.image import PhotoEditor + + +class PhotoEditorEndpoint(AuthEndpoint): + async def show_preview(self, photo_id: int, **kwargs): + async with get_session() as db: + photo = (await db.scalars( + select(models.Photo) + .filter(models.Photo.id == photo_id) + )).one() + + basepath = get_photo_basepath(photo.flight_id) + filename = photo.filename + + original_filename = '_original_'+filename + if os.path.exists(f"{basepath}/{original_filename}"): + filename = original_filename + + editor = PhotoEditor(basepath, filename) + editor.resize(new_height=900) + # TODO: idealni je udelat co nejdriv resize + # velikost muze ovlivnit: orez, otoceni, coz jsou dve nejnarocnejsi operace... + # ^^ tohle by bylo fajn cachovat + + if kwargs.get("rotate"): + editor.rotate(kwargs['rotate'], True) + + crop = { + key.replace("crop_", ""): value + for key, value in kwargs.items() + if key in ('crop_left', 'crop_top', 'crop_width', 'crop_height') and value is not None + } + if crop and len(crop.keys()) == 4: + editor.crop(**crop) + + adjustments = { + key: value for key, value in kwargs.items() + if key in ('saturation', 'brightness', 'contrast', 'sharpness') and value is not None + } + if adjustments: + editor.adjust(**adjustments) + + + + return StreamingResponse(content=editor.get_as_stream(), media_type="image/jpeg") + diff --git a/src/graphql_schema/dataloaders/multi_models.py b/src/graphql_schema/dataloaders/multi_models.py index bd290b8..10ccaca 100644 --- a/src/graphql_schema/dataloaders/multi_models.py +++ b/src/graphql_schema/dataloaders/multi_models.py @@ -27,11 +27,13 @@ flights_by_copilot_dataloader = DataLoader( extra_join=[models.Flight.copilots]).load, cache=False ) + flights_by_aircraft_dataloader = DataLoader( load_fn=MultiModelsDataloader( models.Flight, relationship_column=models.Flight.aircraft_id ).load, cache=False) + flight_by_poi_dataloader = DataLoader( load_fn=MultiModelsDataloader( models.Flight, diff --git a/src/graphql_schema/dataloaders/single_model.py b/src/graphql_schema/dataloaders/single_model.py index c54cf49..43c733f 100644 --- a/src/graphql_schema/dataloaders/single_model.py +++ b/src/graphql_schema/dataloaders/single_model.py @@ -16,7 +16,9 @@ organizations_dataloader = create_dataloader(models.Organization) airport_weather_info_loader = create_dataloader(models.WeatherInfo) poi_dataloader = create_dataloader(models.PointOfInterest) poi_type_dataloader = create_dataloader(models.PointOfInterestType) - +flight_dataloader = create_dataloader(models.Flight) +photo_adjustment_dataloader = create_dataloader(models.PhotoAdjustment, relationship_column=models.PhotoAdjustment.photo_id) +photo_dataloader = create_dataloader(models.Photo) cover_photo_loader = create_dataloader( models.Photo, relationship_column=models.Photo.flight_id, diff --git a/src/graphql_schema/entities/event.py b/src/graphql_schema/entities/event.py index 8929275..44af430 100644 --- a/src/graphql_schema/entities/event.py +++ b/src/graphql_schema/entities/event.py @@ -2,10 +2,8 @@ from typing import List, Optional import strawberry from fastapi import HTTPException from starlette.status import HTTP_401_UNAUTHORIZED - from database import models from decorators.endpoints import authenticated_user_only -from database.transaction import get_session from graphql_schema.entities.resolvers.base import BaseMutationResolver from graphql_schema.entities.resolvers.event import EventQueryResolver from graphql_schema.entities.types.mutation_input import CreateEventInput, EditEventInput diff --git a/src/graphql_schema/entities/photo.py b/src/graphql_schema/entities/photo.py index b3ca3d5..e6c8021 100644 --- a/src/graphql_schema/entities/photo.py +++ b/src/graphql_schema/entities/photo.py @@ -1,19 +1,25 @@ import asyncio -from typing import List +import os +import shutil +from typing import List, Optional import strawberry +from PIL import Image +from sqlalchemy import delete, select + from background_jobs.elevation import add_terrain_elevation_to_photo from background_jobs.photo import generate_thumbnail, resize_photo from database import models from decorators.endpoints import authenticated_user_only from database.transaction import get_session -from graphql_schema.entities.resolvers.base import BaseQueryResolver +from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver from graphql_schema.entities.resolvers.photo import PhotoMutationResolver -from graphql_schema.entities.types.types import Photo +from graphql_schema.entities.types.types import Photo, PhotoAdjustment +from logger import log from paths import get_photo_basepath from utils.file import delete_file -from utils.image import parse_exif_info, rotate_image +from utils.image import parse_exif_info, rotate_image_no_crop, adjust_image, resize_image, PhotoEditor from utils.upload import handle_file_upload -from graphql_schema.entities.types.mutation_input import EditPhotoInput, UploadPhotoInput +from graphql_schema.entities.types.mutation_input import EditPhotoInput, UploadPhotoInput, AdjustmentInput @strawberry.type @@ -22,6 +28,10 @@ class PhotoQueries: async def photos(root, info) -> List[Photo]: return await BaseQueryResolver(Photo, models.Photo).get_list(user_id=info.context.user_id) + @strawberry.field() + async def photo(root, info, id: int) -> Photo: + return await BaseQueryResolver(Photo, models.Photo).get_one(id, user_id=info.context.user_id) + @strawberry.type class PhotoMutation: @@ -32,12 +42,16 @@ class PhotoMutation: filename = await handle_file_upload(input.photo, path) exif_info = await parse_exif_info(path, filename) + img = Image.open(f"{path}/{filename}") + photo = await PhotoMutationResolver().create( user_id=info.context.user_id, data={ "flight_id": input.flight_id, "name": input.name, "filename": filename, + "width": img.width, + "height": img.height, "description": input.description, "exposed_at": exif_info.get("datetime_original"), "gps_latitude": exif_info.get("gps_latitude"), @@ -47,7 +61,7 @@ class PhotoMutation: }, ) - info.context.background_tasks.add_task(resize_photo, path=path, filename=filename) + info.context.background_tasks.add_task(resize_photo, path=path, filename=filename, photo_id=photo.id) info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=filename) if exif_info.get("gps_latitude") and exif_info.get("gps_longitude"): @@ -62,28 +76,100 @@ class PhotoMutation: @strawberry.mutation() @authenticated_user_only() - async def rotate_photo(self, info, id: int, angle: int) -> Photo: + async def change_orientation(self, info, id: int, direction: str) -> Photo: async with get_session() as db: photo = (await db.scalars( BaseQueryResolver(Photo, models.Photo).get_query(user_id=info.context.user_id, object_id=id) )).one() - photo_filename = photo.filename flight_id = photo.flight_id + photo_id = photo.id + photo_filename = photo.filename + + photo_path = get_photo_basepath(flight_id) + original_filename = "_original_" + photo_filename + if not os.path.isfile(photo_path + "/" + original_filename): + shutil.copyfile(photo_path + "/" + photo_filename, photo_path + "/" + original_filename) + + degrees_map = { + "clockwise": -90, + "counterClockwise": 90 + } + + # rotate original + editor = PhotoEditor(photo_path, original_filename) + editor.rotate(degrees=degrees_map[direction], crop_after_rotate=False) + editor.write_to_file(quality=100) + + # rotate possibly adjusted image + editor = PhotoEditor(photo_path, photo_filename) + editor.rotate(degrees=degrees_map[direction], crop_after_rotate=False) + editor.write_to_file(quality=100) + + info.context.background_tasks.add_task(generate_thumbnail, path=photo_path, filename=photo_filename) + + async with get_session() as db: + photo = await models.Photo.update(db, { + "width": editor.img.width, + "height": editor.img.height + }, id=photo_id) + return Photo(**photo.as_dict()) + + @strawberry.mutation() + @authenticated_user_only() + async def adjust_photo(self, info, id: int, adjustment: AdjustmentInput) -> Photo: + async with get_session() as db: + photo = (await db.scalars( + BaseQueryResolver(Photo, models.Photo).get_query(user_id=info.context.user_id, object_id=id) + )).one() + flight_id = photo.flight_id + photo_filename = photo.filename photo_as_dict = photo.as_dict() - await asyncio.gather( - rotate_image( - path=get_photo_basepath(flight_id), - filename=photo_filename, - angle=angle, - ), - rotate_image( - path=get_photo_basepath(flight_id) + "/thumbs", - filename=photo_filename, - angle=angle, - ), + photo_path = get_photo_basepath(flight_id) + + # TOOD: presunout do samostatne metody + original_filename = "_original_" + photo_filename + if not os.path.isfile(photo_path + "/" + original_filename): + shutil.copyfile(photo_path + "/" + photo_filename, photo_path + "/" + original_filename) + + editor = ( + PhotoEditor(photo_path, original_filename) + .adjust( + brightness=adjustment.brightness, + contrast=adjustment.contrast, + sharpness=adjustment.sharpness, + saturation=adjustment.saturation + ) ) + if adjustment.rotate: + rotate_angle = adjustment.rotate + editor.rotate(rotate_angle, adjustment.crop_after_rotate) + + if adjustment.crop: + editor.crop(**adjustment.crop.to_dict()) + + editor.write_to_file(dest_filename=photo_filename) + + info.context.background_tasks.add_task(generate_thumbnail, path=photo_path, filename=photo_filename) + + async with get_session() as db: + await db.execute(delete(models.PhotoAdjustment).filter(models.PhotoAdjustment.photo_id == id)) + + crop_info = {"crop_" + key: value for key, value in adjustment.crop.to_dict().items()} if adjustment.crop else {} + await models.PhotoAdjustment.create(db, { + "photo_id": id, + "contrast": adjustment.contrast, + "saturation": adjustment.saturation, + "brightness": adjustment.brightness, + "rotate": adjustment.rotate, + "sharpness": adjustment.sharpness, + **crop_info + }) + + width, height = editor.img_size + await models.Photo.update(db, {"width": width, "height": height}, id=id) + return Photo(**photo_as_dict) @strawberry.mutation() diff --git a/src/graphql_schema/entities/types/mutation_input.py b/src/graphql_schema/entities/types/mutation_input.py index 75ba01b..9cec9a9 100644 --- a/src/graphql_schema/entities/types/mutation_input.py +++ b/src/graphql_schema/entities/types/mutation_input.py @@ -77,6 +77,25 @@ class EditPhotoInput: } +@strawberry.input +class CropInput(BaseGraphqlInputType): + left: float + top: float + width: float + height: float + + +@strawberry.input +class AdjustmentInput: + rotate: Optional[float] = 0 + crop_after_rotate: Optional[bool] = True, + brightness: Optional[float] = 1 + contrast: Optional[float] = 1 + saturation: Optional[float] = 1 + sharpness: Optional[float] = 1 + crop: Optional[CropInput] = None + + @strawberry_sqlalchemy_input(models.Flight, exclude_fields=[ "id", "aircraft_id", "deleted", "landing_airport_id", "takeoff_airport_id", "takeoff_weather_info_id", "landing_weather_info_id", "gpx_track_filename", "event_id" diff --git a/src/graphql_schema/entities/types/types.py b/src/graphql_schema/entities/types/types.py index 8b44e6c..b440936 100644 --- a/src/graphql_schema/entities/types/types.py +++ b/src/graphql_schema/entities/types/types.py @@ -14,7 +14,7 @@ from graphql_schema.dataloaders.multi_models import ( ) from graphql_schema.dataloaders.single_model import ( poi_dataloader, poi_type_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader, cover_photo_loader, - airport_weather_info_loader, organizations_dataloader + airport_weather_info_loader, organizations_dataloader, flight_dataloader, photo_adjustment_dataloader, photo_dataloader ) from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type from paths import ( @@ -65,6 +65,11 @@ class WeatherInfo: pass +@strawberry_sqlalchemy_type(models.PhotoAdjustment) +class PhotoAdjustment: + photo: Photo = strawberry.field(resolver=lambda root: photo_dataloader.load(root.photo_id)) + + @strawberry_sqlalchemy_type(models.PointOfInterest) class PointOfInterest: type: Optional[PointOfInterestType] = strawberry.field(resolver=lambda root: poi_type_dataloader.load(root.type_id)) @@ -79,6 +84,8 @@ class Photo: point_of_interest: Optional[PointOfInterest] = strawberry.field( resolver=lambda root: poi_dataloader.load(root.point_of_interest_id) ) + flight: Flight = strawberry.field(resolver=lambda root: flight_dataloader.load(root.flight_id)) + adjustment: Optional[PhotoAdjustment] = strawberry.field(resolver=lambda root: photo_adjustment_dataloader.load(root.id)) @strawberry_sqlalchemy_type(models.Flight) diff --git a/src/main.py b/src/main.py index aa33410..c449e60 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,6 @@ from datetime import timedelta +from typing import Optional + from fastapi import FastAPI, APIRouter, Depends, Security from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearerCookie, JwtRefreshBearerCookie from sqlalchemy import select @@ -10,6 +12,7 @@ from strawberry.fastapi import GraphQLRouter from config import APP_SECRET_KEY, GRAPHIQL, APP_DEBUG, ALLOW_CORS_ORIGINS from database import models, async_session from endpoints.login import LoginEndpoint, LoginInput, RefreshEndpoint, LogoutEndpoint +from endpoints.photo_editor_preview import PhotoEditorEndpoint from endpoints.registration import RegistrationInput, RegistrationEndpoint from graphql_schema.schema import schema, GraphQLContext @@ -121,6 +124,36 @@ class App: refresh_token=self.refresh_security ).on_post(resp) + @self.api_router.get("/photo/editor-preview/{photo_id}") + async def photo_editor_preview( + photo_id: int, + brightness: Optional[float] = None, + contrast: Optional[float] = None, + saturation: Optional[float] = None, + sharpness: Optional[float] = None, + rotate: Optional[float] = None, + crop_left: Optional[float] = None, + crop_top: Optional[float] = None, + crop_width: Optional[float] = None, + crop_height: Optional[float] = None, + ): + return await PhotoEditorEndpoint( + access_token=self.access_security, + refresh_token=self.refresh_security + ).show_preview( + photo_id=photo_id, + logged_user_id=0, + saturation=saturation, + brightness=brightness, + contrast=contrast, + sharpness=sharpness, + crop_top=crop_top, + crop_left=crop_left, + crop_height=crop_height, + crop_width=crop_width, + rotate=rotate, + ) + @self.api_router.post("/registration", status_code=201) async def registration(user: RegistrationInput): return await RegistrationEndpoint().on_post(user) diff --git a/src/scripts/add_photo_sizes.py b/src/scripts/add_photo_sizes.py new file mode 100644 index 0000000..2fae4b7 --- /dev/null +++ b/src/scripts/add_photo_sizes.py @@ -0,0 +1,34 @@ +import asyncio +import sys +from PIL import Image +from PIL.Image import DecompressionBombWarning +from sqlalchemy import select + +sys.path.insert(0, "/app/src") +from paths import get_photo_basepath # noqa +from database import async_session, models # noqa + + +async def add_sizes_to_photos(): + async with async_session() as session: + photos = (await session.scalars( + select(models.Photo) + )).all() + + for photo in photos: + path = get_photo_basepath(photo.flight_id) + + try: + img = Image.open(f"{path}/{photo.filename}") + await models.Photo.update(session, {"width": img.width, "height": img.height}, obj=photo) + print(path, photo.filename, img.width, img.height, "OK") + except (Exception, DecompressionBombWarning) as e: + print(path, photo.filename, e) + + await session.flush() + await session.commit() + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(add_sizes_to_photos()) diff --git a/src/scripts/elevation.py b/src/scripts/elevation.py index b68ec63..075ecdf 100644 --- a/src/scripts/elevation.py +++ b/src/scripts/elevation.py @@ -2,10 +2,8 @@ import asyncio import sys from sqlalchemy import select -from paths import FLIGHT_GPX_TRACK_PATH - sys.path.insert(0, "/app/src") - +from paths import FLIGHT_GPX_TRACK_PATH # noqa from database import async_session, models # noqa from external.elevation import elevation_api # noqa from external.gpx_parser import GPXParser # noqa diff --git a/src/utils/image.py b/src/utils/image.py index b60f638..10ba998 100644 --- a/src/utils/image.py +++ b/src/utils/image.py @@ -1,7 +1,11 @@ +import io + +import math from datetime import datetime -from typing import Optional +from typing import Optional, Tuple import exif from PIL import Image, UnidentifiedImageError +from PIL.ImageEnhance import Brightness, Contrast, Color, Sharpness from utils.file import check_directories from utils.gps import gps_to_decimal @@ -27,6 +31,114 @@ async def parse_exif_info(path: str, filename: str) -> dict: return exif_info +class PhotoEditor: + def __init__(self, path: str, filename: str): + self.path = path + self.filename = filename + + self.img = Image.open(f"{path}/{filename}") + self.img_size = self.img.size + + def resize(self, new_width: Optional[int] = None, new_height: Optional[int] = None): + if not new_width and not new_height: + raise ValueError("Set either new_width or new_height") + + w, h = self.img.size + aspect_ratio = w / h + + if not new_width: + # TODO: tohle bude asi blbe, tu se bude muset asi delit? + new_width = int(new_height * aspect_ratio) + + if not new_height: + new_height = int(new_width / aspect_ratio) + + self.img = self.img.resize((new_width, new_height), Image.BICUBIC) + self.img_size = self.img.size + + return self + + def _get_cropbox_after_rotate(self, degrees: float, rotated_width: int, rotated_height: int) -> Tuple[int, int]: + original_width, original_height = self.img_size + aspect_ratio = float(original_width) / original_height + rotated_aspect_ratio = float(rotated_width) / rotated_height + angle = math.fabs(degrees) * math.pi / 180 + + if aspect_ratio < 1: + total_height = float(original_width) / rotated_aspect_ratio + else: + total_height = float(original_height) + + h = total_height / (aspect_ratio * math.sin(angle) + math.cos(angle)) + w = h * aspect_ratio + + return round(w), round(h) + + def rotate(self, degrees: float, crop_after_rotate: bool = False): + self.img = self.img.rotate(degrees, resample=Image.Resampling.BICUBIC, expand=True) + + if crop_after_rotate: + rotated_width, rotated_height = self.img.size + new_width, new_height = self._get_cropbox_after_rotate(degrees, rotated_width, rotated_height) + + left = round((rotated_width - new_width) / 2) + top = round((rotated_height - new_height) / 2) + + self.img = self.img.crop((left, top, new_width, new_height)) + + self.img_size = self.img.size + return self + + def crop(self, left: float, top: float, width: float, height: float): + w, h = self.img_size + left_px = int(left * w) + top_px = int(top * h) + width_px = int(width * w) + height_px = int(height * h) + + self.img = self.img.crop((left_px, top_px, left_px + width_px, top_px + height_px)) + self.img_size = self.img.size + return self + + def adjust( + self, + brightness: Optional[float] = None, + contrast: Optional[float] = None, + saturation: Optional[float] = None, + sharpness: Optional[float] = None + ): + adjustments = [ + (Brightness, brightness), + (Contrast, contrast), + (Color, saturation), + (Sharpness, sharpness) + ] + for adjustment, value in adjustments: + if value is not None: + self.img = adjustment(self.img).enhance(value) + + return self + + def get_as_stream(self): + img_io = io.BytesIO() + self.img.save(img_io, 'JPEG') + img_io.seek(0) + + return img_io + + def write_to_file( + self, quality: int = 90, dest_path: Optional[str] = None, dest_filename: Optional[str] = None + ) -> str: + dest = f"{dest_path or self.path}/{dest_filename or self.filename}" + self.img.save(dest, 'JPEG', quality=quality) + + return dest + + + +# ----------- +# odsud niz to bude vse asi na smazani, vse by mela umet trida PhotoEditor + async def resize_image( path: str, filename: str, new_width: int, quality: int = 90, dest_path: Optional[str] = None, @@ -47,14 +159,34 @@ async def resize_image( image = image.resize((new_width, new_height), Image.LANCZOS) check_directories(dest_path) image.save(f"{dest_path}/{dest_filename}", 'JPEG', quality=quality) + + return new_width, new_height except UnidentifiedImageError: pass -async def rotate_image( +def crop_image_after_rotate( + original_width: int, original_height: int, rotated_width: int, rotated_height: int, degrees: float +) -> Tuple[int, int]: + aspect_ratio = float(original_width) / original_height + rotated_aspect_ratio = float(rotated_width) / rotated_height + angle = math.fabs(degrees) * math.pi / 180 + + if aspect_ratio < 1: + total_height = float(original_width) / rotated_aspect_ratio + else: + total_height = float(original_height) + + h = total_height / (aspect_ratio * math.sin(angle) + math.cos(angle)) + w = h * aspect_ratio + + return round(w), round(h) + + +async def rotate_image_no_crop( path: str, filename: str, - angle: int, + angle: float, dest_path: Optional[str] = None, dest_filename: Optional[str] = None ): @@ -65,6 +197,59 @@ async def rotate_image( dest_filename = filename img = Image.open(f"{path}/{filename}") - img = img.rotate(angle, Image.LANCZOS, expand=True) + rotated_img = img.rotate(angle, Image.BICUBIC, expand=True) + check_directories(dest_path) + rotated_img.save(f"{dest_path}/{dest_filename}", 'JPEG', quality=100) + + +async def adjust_image( + path: str, + filename: str, + rotate: float, + brightness: float, + contrast: float, + saturation: float, + sharpness: float, + crop: dict, + dest_path: Optional[str] = None, + dest_filename: Optional[str] = None +): + if not dest_path: + dest_path = path + + if not dest_filename: + dest_filename = filename + + img = Image.open(f"{path}/{filename}") + + if rotate: + rotated_img = img.rotate(rotate, Image.BICUBIC, expand=True) + + width, height = img.size + rotated_width, rotated_height = rotated_img.size + new_width, new_height = crop_image_after_rotate(width, height, rotated_width, rotated_height, rotate) + + left = round((rotated_width - new_width) / 2) + top = round((rotated_height - new_height) / 2) + + img = rotated_img.crop((left, top, left + new_width, top + new_height)) + + adjustments = [ + (Brightness, brightness), + (Contrast, contrast), + (Color, saturation), + (Sharpness, sharpness) + ] + for adj, value in adjustments: + img = adj(img).enhance(value) + + if crop: + w, h = img.size + left = crop['left'] * w + top = crop['top'] * h + width = crop['width'] * w + height = crop['height'] * h + img = img.crop((left, top, left + width, top + height)) + img.save(f"{dest_path}/{dest_filename}", 'JPEG', quality=100)