Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Implemented rotate image feature #885

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/exif_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class Tags:
RATING = "Rating"
IMAGE_HEIGHT = "ImageHeight"
IMAGE_WIDTH = "ImageWidth"
ORIENTATION = "Orientation"
DATE_TIME_ORIGINAL = "EXIF:DateTimeOriginal"
DATE_TIME = "EXIF:DateTime"
QUICKTIME_CREATE_DATE = "QuickTime:CreateDate"
Expand Down
18 changes: 18 additions & 0 deletions api/migrations/0050_photo_orientation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.1 on 2023-06-19 20:17

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0049_fix_metadata_files_as_main_files'),
]

operations = [
migrations.AddField(
model_name='photo',
name='orientation',
field=models.IntegerField(default=1),
),
]
53 changes: 52 additions & 1 deletion api/models/photo.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import api.util as util
from api.exif_tags import Tags
from api.im2txt.sample import im2txt
from api.models.file import File
from api.models.file import File, is_raw
from api.models.user import User, get_deleted_user
from api.places365.places365 import place365_instance
from api.semantic_search.semantic_search import semantic_search_instance
Expand Down Expand Up @@ -90,6 +90,7 @@ class Photo(models.Model):
focalLength35Equivalent = models.IntegerField(blank=True, null=True)
subjectDistance = models.FloatField(blank=True, null=True)
digitalZoomRatio = models.FloatField(blank=True, null=True)
orientation = models.IntegerField(default=1)

owner = models.ForeignKey(
User, on_delete=models.SET(get_deleted_user), default=None
Expand Down Expand Up @@ -151,8 +152,12 @@ def _save_metadata(self, modified_fields=None, use_sidecar=True):
if "timestamp" in modified_fields:
# To-Do: Only works for files and not for the sidecar file
tags_to_write[Tags.DATE_TIME] = self.timestamp
if "orientation" in modified_fields:
tags_to_write[Tags.ORIENTATION] = self.orientation
if tags_to_write:
util.write_metadata(self.main_file, tags_to_write, use_sidecar=use_sidecar)
if "orientation" in modified_fields:
self._regenerate_thumbnail(commit=False)

def _generate_captions_im2txt(self, commit=True):
image_path = self.thumbnail_big.path
Expand Down Expand Up @@ -264,6 +269,7 @@ def _generate_thumbnail(self, commit=True):
outputPath="thumbnails_big",
hash=self.image_hash,
fileType=".webp",
orientation=self.orientation,
)
else:
createThumbnailForVideo(
Expand All @@ -282,6 +288,7 @@ def _generate_thumbnail(self, commit=True):
outputPath="square_thumbnails",
hash=self.image_hash,
fileType=".webp",
orientation=self.orientation,
)
if self.video and not doesVideoThumbnailExists(
"square_thumbnails", self.image_hash
Expand All @@ -303,6 +310,7 @@ def _generate_thumbnail(self, commit=True):
outputPath="square_thumbnails_small",
hash=self.image_hash,
fileType=".webp",
orientation=self.orientation,
)
if self.video and not doesVideoThumbnailExists(
"square_thumbnails_small", self.image_hash
Expand Down Expand Up @@ -334,6 +342,22 @@ def _generate_thumbnail(self, commit=True):
)
raise e

def _regenerate_thumbnail(self, commit=True):
if doesStaticThumbnailExists("thumbnails_big", self.image_hash):
os.remove(self.thumbnail_big.path)
if doesStaticThumbnailExists("square_thumbnails", self.image_hash):
os.remove(self.square_thumbnail.path)
if doesStaticThumbnailExists("square_thumbnails_small", self.image_hash):
os.remove(self.square_thumbnail_small.path)
if doesVideoThumbnailExists("square_thumbnails", self.image_hash):
os.remove(self.square_thumbnail.path)
if doesVideoThumbnailExists("square_thumbnails_small", self.image_hash):
os.remove(self.square_thumbnail_small.path)
self._generate_thumbnail(commit=commit)
self._calculate_aspect_ratio(commit=commit)
self._generate_clip_embeddings(commit=commit)
self._extract_faces()

def _find_album_place(self):
return api.models.album_place.AlbumPlace.objects.filter(
Q(photos__in=[self])
Expand Down Expand Up @@ -538,6 +562,7 @@ def _extract_exif_data(self, commit=True):
focalLength35Equivalent,
subjectDistance,
digitalZoomRatio,
orientation,
) = get_metadata( # noqa: E501
self.main_file.path,
tags=[
Expand All @@ -553,6 +578,7 @@ def _extract_exif_data(self, commit=True):
Tags.FOCAL_LENGTH_35MM,
Tags.SUBJECT_DISTANCE,
Tags.DIGITAL_ZOOM_RATIO,
Tags.ORIENTATION,
],
try_sidecar=True,
)
Expand Down Expand Up @@ -582,6 +608,8 @@ def _extract_exif_data(self, commit=True):
self.subjectDistance = subjectDistance
if digitalZoomRatio and isinstance(digitalZoomRatio, numbers.Number):
self.digitalZoomRatio = digitalZoomRatio
if orientation and isinstance(orientation, numbers.Number):
self.orientation = orientation
if commit:
self.save()

Expand Down Expand Up @@ -812,6 +840,29 @@ def _get_dominant_color(self, palette_size=16):
self.save()
except Exception:
logger.info("Cannot calculate dominant color {} object".format(self))

def _rotate_image(self, delta_angle, flip_image=False):
if delta_angle % 360 == 0 and not flip_image:
return

user = User.objects.get(username=self.owner)
save_on_file = (user.save_metadata_to_disk == User.SaveMetadata.MEDIA_FILE and \
not is_raw(self.main_file.path))

old_orientation = self.orientation
angle, is_flipped = util.convert_exif_orientation_to_degrees(old_orientation)

angle += delta_angle
if flip_image:
is_flipped = not is_flipped

new_orientation = util.convert_degrees_to_exif_orientation(angle, is_flipped)
self.orientation = new_orientation

if save_on_file:
self.save()
return
self._regenerate_thumbnail()

def __str__(self):
return "%s" % self.image_hash
Expand Down
55 changes: 36 additions & 19 deletions api/thumbnails.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
from api.models.file import is_raw


def createThumbnail(inputPath, outputHeight, outputPath, hash, fileType):
def createThumbnail(inputPath, outputHeight, outputPath, hash, fileType, orientation):
try:
angle, is_flipped = util.convert_exif_orientation_to_degrees(orientation)
flip_direction = pyvips.Direction.HORIZONTAL if orientation in [5, 7] else pyvips.Direction.VERTICAL

if is_raw(inputPath):
if "thumbnails_big" in outputPath:
completePath = os.path.join(
Expand All @@ -23,30 +26,44 @@ def createThumbnail(inputPath, outputHeight, outputPath, hash, fileType):
}
response = requests.post("http://localhost:8003/", json=json).json()
return response["thumbnail"]
else:
bigThumbnailPath = os.path.join(
ownphotos.settings.MEDIA_ROOT, "thumbnails_big", hash + fileType
)
x = pyvips.Image.thumbnail(
bigThumbnailPath,
10000,
height=outputHeight,
size=pyvips.enums.Size.DOWN,
)
completePath = os.path.join(
ownphotos.settings.MEDIA_ROOT, outputPath, hash + fileType
).strip()
x.write_to_file(completePath, Q=95)
return completePath
else:

bigThumbnailPath = os.path.join(
ownphotos.settings.MEDIA_ROOT, "thumbnails_big", hash + fileType
)
x = pyvips.Image.thumbnail(
inputPath, 10000, height=outputHeight, size=pyvips.enums.Size.DOWN
bigThumbnailPath,
10000,
height=outputHeight,
no_rotate=True,
size=pyvips.enums.Size.DOWN,
)
if angle != 0:
x = x.rotate(angle)
if is_flipped:
x = x.flip(flip_direction)
completePath = os.path.join(
ownphotos.settings.MEDIA_ROOT, outputPath, hash + fileType
).strip()
x.write_to_file(completePath)
x.write_to_file(completePath, Q=95)
return completePath

x = pyvips.Image.thumbnail(
inputPath,
10000,
height=outputHeight,
no_rotate=True,
size=pyvips.enums.Size.DOWN,
)
if angle != 0:
x = x.rotate(angle)
if is_flipped:
x = x.flip(flip_direction)
completePath = os.path.join(
ownphotos.settings.MEDIA_ROOT, outputPath, hash + fileType
).strip()
x.write_to_file(completePath)
return completePath

except Exception as e:
util.logger.error("Could not create thumbnail for file {}".format(inputPath))
raise e
Expand Down
55 changes: 54 additions & 1 deletion api/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def write_metadata(media_file, tags, use_sidecar=True):
if use_sidecar:
file_path = get_sidecar_files_in_priority_order(media_file)[0]
else:
file_path = media_file
file_path = media_file.path

try:
logger.info(f"Writing {tags} to {file_path}")
Expand All @@ -166,3 +166,56 @@ def write_metadata(media_file, tags, use_sidecar=True):
finally:
if terminate_et:
et.terminate()

def convert_exif_orientation_to_degrees(orientation):
"""
Function to convert EXIF Orientation values to a rotation in degrees
and a boolean indicating if the image is flipped.
Orientation value is an integer, 1 through 8.
The math works better if we make the range from 0 to 7.
Rotation is assumed to be clockwise.
"""
if orientation not in range(1, 9):
return 0, False
this_orientation = orientation - 1
is_flipped = this_orientation in [1, 3, 4, 6]
# Re-flip flipped orientation
if is_flipped:
flip_delta = 1 if this_orientation % 2 == 0 else -1
this_orientation = this_orientation + flip_delta
angle = 0
if this_orientation == 0:
angle = 0
elif this_orientation == 5:
angle = 90
elif this_orientation == 2:
angle = 180
elif this_orientation == 7:
angle = 270

return angle, is_flipped

def convert_degrees_to_exif_orientation(angle, is_flipped=False):
"""
Reverse of the function above.
angle needs to be a multiple of 90, and it's clockwise.
Negative values are treated as counter-clockwise rotation.
"""
CLOCKWISE = 1
COUNTER_CLOCKWISE = -1

angle = int(round(angle / 90.0) * 90)
turns = int(angle / 90)
direction = CLOCKWISE if turns >= 0 else COUNTER_CLOCKWISE
turns = abs(turns)
orientation = 0
for _i in range(turns):
step = 5
if (orientation == 7 and direction == CLOCKWISE or
orientation == 0 and direction == COUNTER_CLOCKWISE):
step = 1
orientation = (orientation + step * direction) % 8
if is_flipped:
flip_delta = 1 if orientation % 2 == 0 else -1
orientation = orientation + flip_delta
return orientation + 1
26 changes: 24 additions & 2 deletions api/views/photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
StandardResultsSetPagination,
)

PHOTO_OWNER_ERROR_MESSAGE = "you are not the owner of this photo"


class RecentlyAddedPhotoListViewSet(ListViewSet):
serializer_class = PigPhotoSerilizer
Expand Down Expand Up @@ -504,7 +506,7 @@ def post(self, request, format=None):
photo = Photo.objects.get(image_hash=image_hash)
if photo.owner != request.user:
return Response(
{"status": False, "message": "you are not the owner of this photo"},
{"status": False, "message": PHOTO_OWNER_ERROR_MESSAGE},
status=400,
)

Expand All @@ -523,7 +525,7 @@ def post(self, request, format=None):
photo = Photo.objects.get(image_hash=image_hash)
if photo.owner != request.user:
return Response(
{"status": False, "message": "you are not the owner of this photo"},
{"status": False, "message": PHOTO_OWNER_ERROR_MESSAGE},
status=400,
)

Expand Down Expand Up @@ -577,3 +579,23 @@ def delete(self, request):
return Response(status=status.HTTP_200_OK)
else:
return Response(status=status.HTTP_400_BAD_REQUEST)

class RotatePhoto(APIView):
permission_classes = (IsOwnerOrReadOnly,)

def post(self, request, format=None):
data = dict(request.data)
image_hash = data["image_hash"]

angle = data["angle"]
flip = data.get("flip", False)

photo = Photo.objects.get(image_hash=image_hash)
if photo.owner != request.user:
return Response(
{"status": False, "message": PHOTO_OWNER_ERROR_MESSAGE},
status=400,
)

photo._rotate_image(angle, flip)
return Response({"status": True})
1 change: 1 addition & 0 deletions ownphotos/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ def post(self, request, *args, **kwargs):
re_path(r"^api/photosedit/share", photos.SetPhotosShared.as_view()),
re_path(r"^api/photosedit/generateim2txt", photos.GeneratePhotoCaption.as_view()),
re_path(r"^api/photosedit/savecaption", photos.SavePhotoCaption.as_view()),
re_path(r"^api/photosedit/rotate", photos.RotatePhoto.as_view()),
re_path(r"^api/useralbum/share", views.SetUserAlbumShared.as_view()),
re_path(r"^api/trainfaces", faces.TrainFaceView.as_view()),
re_path(r"^api/clusterfaces", dataviz.ClusterFaceView.as_view()),
Expand Down