Pridani editace fotek

This commit is contained in:
Michal Kváček
2023-10-27 22:30:59 +02:00
parent 322e61bce3
commit 3223d2ac00
17 changed files with 602 additions and 54 deletions
@@ -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 ###
@@ -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 ###
@@ -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 ###
+3 -3
View File
@@ -19,14 +19,13 @@ async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str):
async with get_session() as db: async with get_session() as db:
await models.Flight.update( await models.Flight.update(
db, {"gpx_track_filename": output_name, "has_terrain_elevation": True}, db, {"gpx_track_filename": output_name, "has_terrain_elevation": True},
id=flight_id) id=flight_id
)
except ClientResponseError as e: except ClientResponseError as e:
print(e) print(e)
async def add_terrain_elevation_to_photo(photo): async def add_terrain_elevation_to_photo(photo):
async with get_session() as db:
try: try:
elevation = await elevation_api.get_elevation_for_points([ elevation = await elevation_api.get_elevation_for_points([
{"lat": photo.gps_latitude, "lng": photo.gps_longitude} {"lat": photo.gps_latitude, "lng": photo.gps_longitude}
@@ -36,6 +35,7 @@ async def add_terrain_elevation_to_photo(photo):
return 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}) await models.Photo.update(db_session=db, obj=photo, data={"terrain_elevation": terrain_elevation})
except Exception as e: except Exception as e:
print(f"Cannot get elevation: {e}") print(f"Cannot get elevation: {e}")
+15 -11
View File
@@ -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): async def resize_photo(path: str, filename: str, photo_id: int):
return await resize_image(path, filename, new_width=2500, quality=85) 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): async def generate_thumbnail(path: str, filename: str):
return await resize_image( editor = PhotoEditor(path, filename)
path, filename, editor.resize(new_width=300)
new_width=300, editor.write_to_file(quality=85, dest_path=f"{path}/thumbs/")
dest_path=f"{path}/thumbs/",
dest_filename=filename,
quality=quality
)
+24
View File
@@ -118,6 +118,8 @@ class Photo(BaseModel):
filename: Mapped[str] = mapped_column(String(128), nullable=False) filename: Mapped[str] = mapped_column(String(128), nullable=False)
is_flight_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="0") is_flight_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="0")
description: Mapped[str] = mapped_column(Text, nullable=False) 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) exposed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
gps_latitude: Mapped[float] = mapped_column(Float, nullable=True) gps_latitude: Mapped[float] = mapped_column(Float, nullable=True)
gps_longitude: 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]) flight: Mapped['Flight'] = relationship(foreign_keys=[flight_id])
point_of_interest: Mapped['PointOfInterest'] = relationship() point_of_interest: Mapped['PointOfInterest'] = relationship()
adjustment: Mapped['PhotoAdjustment'] = relationship()
created_by: Mapped['User'] = 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): class Aircraft(BaseModel):
__tablename__ = "aircraft" __tablename__ = "aircraft"
+53
View File
@@ -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")
@@ -27,11 +27,13 @@ flights_by_copilot_dataloader = DataLoader(
extra_join=[models.Flight.copilots]).load, extra_join=[models.Flight.copilots]).load,
cache=False cache=False
) )
flights_by_aircraft_dataloader = DataLoader( flights_by_aircraft_dataloader = DataLoader(
load_fn=MultiModelsDataloader( load_fn=MultiModelsDataloader(
models.Flight, models.Flight,
relationship_column=models.Flight.aircraft_id relationship_column=models.Flight.aircraft_id
).load, cache=False) ).load, cache=False)
flight_by_poi_dataloader = DataLoader( flight_by_poi_dataloader = DataLoader(
load_fn=MultiModelsDataloader( load_fn=MultiModelsDataloader(
models.Flight, models.Flight,
@@ -16,7 +16,9 @@ organizations_dataloader = create_dataloader(models.Organization)
airport_weather_info_loader = create_dataloader(models.WeatherInfo) airport_weather_info_loader = create_dataloader(models.WeatherInfo)
poi_dataloader = create_dataloader(models.PointOfInterest) poi_dataloader = create_dataloader(models.PointOfInterest)
poi_type_dataloader = create_dataloader(models.PointOfInterestType) 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( cover_photo_loader = create_dataloader(
models.Photo, models.Photo,
relationship_column=models.Photo.flight_id, relationship_column=models.Photo.flight_id,
-2
View File
@@ -2,10 +2,8 @@ from typing import List, Optional
import strawberry import strawberry
from fastapi import HTTPException from fastapi import HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED from starlette.status import HTTP_401_UNAUTHORIZED
from database import models from database import models
from decorators.endpoints import authenticated_user_only 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.base import BaseMutationResolver
from graphql_schema.entities.resolvers.event import EventQueryResolver from graphql_schema.entities.resolvers.event import EventQueryResolver
from graphql_schema.entities.types.mutation_input import CreateEventInput, EditEventInput from graphql_schema.entities.types.mutation_input import CreateEventInput, EditEventInput
+105 -19
View File
@@ -1,19 +1,25 @@
import asyncio import asyncio
from typing import List import os
import shutil
from typing import List, Optional
import strawberry import strawberry
from PIL import Image
from sqlalchemy import delete, select
from background_jobs.elevation import add_terrain_elevation_to_photo from background_jobs.elevation import add_terrain_elevation_to_photo
from background_jobs.photo import generate_thumbnail, resize_photo from background_jobs.photo import generate_thumbnail, resize_photo
from database import models from database import models
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only
from database.transaction import get_session 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.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 paths import get_photo_basepath
from utils.file import delete_file 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 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 @strawberry.type
@@ -22,6 +28,10 @@ class PhotoQueries:
async def photos(root, info) -> List[Photo]: async def photos(root, info) -> List[Photo]:
return await BaseQueryResolver(Photo, models.Photo).get_list(user_id=info.context.user_id) 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 @strawberry.type
class PhotoMutation: class PhotoMutation:
@@ -32,12 +42,16 @@ class PhotoMutation:
filename = await handle_file_upload(input.photo, path) filename = await handle_file_upload(input.photo, path)
exif_info = await parse_exif_info(path, filename) exif_info = await parse_exif_info(path, filename)
img = Image.open(f"{path}/{filename}")
photo = await PhotoMutationResolver().create( photo = await PhotoMutationResolver().create(
user_id=info.context.user_id, user_id=info.context.user_id,
data={ data={
"flight_id": input.flight_id, "flight_id": input.flight_id,
"name": input.name, "name": input.name,
"filename": filename, "filename": filename,
"width": img.width,
"height": img.height,
"description": input.description, "description": input.description,
"exposed_at": exif_info.get("datetime_original"), "exposed_at": exif_info.get("datetime_original"),
"gps_latitude": exif_info.get("gps_latitude"), "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) info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=filename)
if exif_info.get("gps_latitude") and exif_info.get("gps_longitude"): if exif_info.get("gps_latitude") and exif_info.get("gps_longitude"):
@@ -62,27 +76,99 @@ class PhotoMutation:
@strawberry.mutation() @strawberry.mutation()
@authenticated_user_only() @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: async with get_session() as db:
photo = (await db.scalars( photo = (await db.scalars(
BaseQueryResolver(Photo, models.Photo).get_query(user_id=info.context.user_id, object_id=id) BaseQueryResolver(Photo, models.Photo).get_query(user_id=info.context.user_id, object_id=id)
)).one() )).one()
photo_filename = photo.filename
flight_id = photo.flight_id 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() photo_as_dict = photo.as_dict()
await asyncio.gather( photo_path = get_photo_basepath(flight_id)
rotate_image(
path=get_photo_basepath(flight_id), # TOOD: presunout do samostatne metody
filename=photo_filename, original_filename = "_original_" + photo_filename
angle=angle, if not os.path.isfile(photo_path + "/" + original_filename):
), shutil.copyfile(photo_path + "/" + photo_filename, photo_path + "/" + original_filename)
rotate_image(
path=get_photo_basepath(flight_id) + "/thumbs", editor = (
filename=photo_filename, PhotoEditor(photo_path, original_filename)
angle=angle, .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) return Photo(**photo_as_dict)
@@ -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=[ @strawberry_sqlalchemy_input(models.Flight, exclude_fields=[
"id", "aircraft_id", "deleted", "landing_airport_id", "takeoff_airport_id", "id", "aircraft_id", "deleted", "landing_airport_id", "takeoff_airport_id",
"takeoff_weather_info_id", "landing_weather_info_id", "gpx_track_filename", "event_id" "takeoff_weather_info_id", "landing_weather_info_id", "gpx_track_filename", "event_id"
+8 -1
View File
@@ -14,7 +14,7 @@ from graphql_schema.dataloaders.multi_models import (
) )
from graphql_schema.dataloaders.single_model import ( from graphql_schema.dataloaders.single_model import (
poi_dataloader, poi_type_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader, cover_photo_loader, 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 graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
from paths import ( from paths import (
@@ -65,6 +65,11 @@ class WeatherInfo:
pass 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) @strawberry_sqlalchemy_type(models.PointOfInterest)
class PointOfInterest: class PointOfInterest:
type: Optional[PointOfInterestType] = strawberry.field(resolver=lambda root: poi_type_dataloader.load(root.type_id)) 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( point_of_interest: Optional[PointOfInterest] = strawberry.field(
resolver=lambda root: poi_dataloader.load(root.point_of_interest_id) 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) @strawberry_sqlalchemy_type(models.Flight)
+33
View File
@@ -1,4 +1,6 @@
from datetime import timedelta from datetime import timedelta
from typing import Optional
from fastapi import FastAPI, APIRouter, Depends, Security from fastapi import FastAPI, APIRouter, Depends, Security
from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearerCookie, JwtRefreshBearerCookie from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearerCookie, JwtRefreshBearerCookie
from sqlalchemy import select 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 config import APP_SECRET_KEY, GRAPHIQL, APP_DEBUG, ALLOW_CORS_ORIGINS
from database import models, async_session from database import models, async_session
from endpoints.login import LoginEndpoint, LoginInput, RefreshEndpoint, LogoutEndpoint from endpoints.login import LoginEndpoint, LoginInput, RefreshEndpoint, LogoutEndpoint
from endpoints.photo_editor_preview import PhotoEditorEndpoint
from endpoints.registration import RegistrationInput, RegistrationEndpoint from endpoints.registration import RegistrationInput, RegistrationEndpoint
from graphql_schema.schema import schema, GraphQLContext from graphql_schema.schema import schema, GraphQLContext
@@ -121,6 +124,36 @@ class App:
refresh_token=self.refresh_security refresh_token=self.refresh_security
).on_post(resp) ).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) @self.api_router.post("/registration", status_code=201)
async def registration(user: RegistrationInput): async def registration(user: RegistrationInput):
return await RegistrationEndpoint().on_post(user) return await RegistrationEndpoint().on_post(user)
+34
View File
@@ -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())
+1 -3
View File
@@ -2,10 +2,8 @@ import asyncio
import sys import sys
from sqlalchemy import select from sqlalchemy import select
from paths import FLIGHT_GPX_TRACK_PATH
sys.path.insert(0, "/app/src") sys.path.insert(0, "/app/src")
from paths import FLIGHT_GPX_TRACK_PATH # noqa
from database import async_session, models # noqa from database import async_session, models # noqa
from external.elevation import elevation_api # noqa from external.elevation import elevation_api # noqa
from external.gpx_parser import GPXParser # noqa from external.gpx_parser import GPXParser # noqa
+189 -4
View File
@@ -1,7 +1,11 @@
import io
import math
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, Tuple
import exif import exif
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from PIL.ImageEnhance import Brightness, Contrast, Color, Sharpness
from utils.file import check_directories from utils.file import check_directories
from utils.gps import gps_to_decimal from utils.gps import gps_to_decimal
@@ -27,6 +31,114 @@ async def parse_exif_info(path: str, filename: str) -> dict:
return exif_info 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( async def resize_image(
path: str, filename: str, new_width: int, quality: int = 90, path: str, filename: str, new_width: int, quality: int = 90,
dest_path: Optional[str] = None, dest_path: Optional[str] = None,
@@ -47,14 +159,34 @@ async def resize_image(
image = image.resize((new_width, new_height), Image.LANCZOS) image = image.resize((new_width, new_height), Image.LANCZOS)
check_directories(dest_path) check_directories(dest_path)
image.save(f"{dest_path}/{dest_filename}", 'JPEG', quality=quality) image.save(f"{dest_path}/{dest_filename}", 'JPEG', quality=quality)
return new_width, new_height
except UnidentifiedImageError: except UnidentifiedImageError:
pass 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, path: str,
filename: str, filename: str,
angle: int, angle: float,
dest_path: Optional[str] = None, dest_path: Optional[str] = None,
dest_filename: Optional[str] = None dest_filename: Optional[str] = None
): ):
@@ -65,6 +197,59 @@ async def rotate_image(
dest_filename = filename dest_filename = filename
img = Image.open(f"{path}/{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) 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) img.save(f"{dest_path}/{dest_filename}", 'JPEG', quality=100)