-
Notifications
You must be signed in to change notification settings - Fork 28
Photo upload refactor #2733
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
Open
symroe
wants to merge
7
commits into
master
Choose a base branch
from
photo-upload-refactor
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Photo upload refactor #2733
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
b80e088
Move view helpers to views file
symroe abfcbc4
Move image resizing to an async task
symroe 3321ddf
Move Rekognition to an async task
symroe 597b264
Remove image processing management command
symroe b581d38
fixup! Move Rekognition to an async task
symroe 77779eb
Remove png converting from form save; kick off queue
symroe 4b10091
Refactor image resizing for better memory use
symroe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,95 +1,128 @@ | ||
| from io import BytesIO | ||
| from tempfile import NamedTemporaryFile | ||
| from typing import Optional, Tuple | ||
|
|
||
| import requests | ||
| from candidates.models.db import ActionType, LoggedAction | ||
| from candidates.views.version_data import get_client_ip | ||
| from django.http import HttpResponseRedirect | ||
| from django.shortcuts import render | ||
| from django.urls import reverse | ||
| from PIL import Image as PillowImage | ||
| from PIL import ImageOps | ||
|
|
||
| from .models import QueuedImage | ||
|
|
||
|
|
||
| def upload_photo_response(request, person, image_form, url_form): | ||
| return render( | ||
| request, | ||
| "moderation_queue/photo-upload-new.html", | ||
| { | ||
| "image_form": image_form, | ||
| "url_form": url_form, | ||
| "queued_images": QueuedImage.objects.filter( | ||
| person=person, decision="undecided" | ||
| ).order_by("created"), | ||
| "person": person, | ||
| }, | ||
| ) | ||
| # 15MB — Rekognition's S3 object size limit | ||
| MAX_IMAGE_BYTES = 15 * 1024 * 1024 | ||
|
|
||
|
|
||
| def image_form_valid_response(request, person, image_form): | ||
| # Make sure that we save the user that made the upload | ||
| queued_image = image_form.save(commit=False) | ||
| queued_image.user = request.user | ||
| queued_image.save() | ||
| # Record that action: | ||
| LoggedAction.objects.create( | ||
| user=request.user, | ||
| action_type=ActionType.PHOTO_UPLOAD, | ||
| ip_address=get_client_ip(request), | ||
| popit_person_new_version="", | ||
| person=person, | ||
| source=image_form.cleaned_data["justification_for_use"], | ||
| ) | ||
| return HttpResponseRedirect( | ||
| reverse("photo-upload-success", kwargs={"person_id": person.id}) | ||
| ) | ||
| def strip_alpha(photo): | ||
| if photo.mode in ("RGBA", "LA") or ( | ||
| photo.mode == "P" and "transparency" in photo.info | ||
| ): | ||
| background = PillowImage.new("RGB", photo.size, (255, 255, 255)) | ||
| alpha_img = photo.convert("RGBA") | ||
| background.paste(alpha_img, mask=alpha_img.getchannel("A")) | ||
| alpha_img.close() | ||
| return background | ||
|
|
||
| return photo.convert("RGB") | ||
|
|
||
| # 15MB — Rekognition's S3 object size limit | ||
| MAX_IMAGE_BYTES = 15 * 1024 * 1024 | ||
|
|
||
| def save_png_to_bytes(photo): | ||
| buf = BytesIO() | ||
| photo.save(buf, "PNG", optimize=True) | ||
| size = buf.tell() | ||
| buf.seek(0) | ||
| return buf, size | ||
|
|
||
| def convert_image_to_png(photo): | ||
| # Some uploaded images are CYMK, which gives you an error when | ||
| # you try to write them as PNG, so convert to RGBA (this is | ||
| # RGBA rather than RGB so that any alpha channel (transparency) | ||
| # is preserved). | ||
|
|
||
| def strip_exifdata(photo): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've not check this, but is this needed/doing anything? |
||
| photo.info.pop("exif", None) | ||
| photo.info.pop("icc_profile", None) | ||
| photo.info.pop("xmp", None) | ||
| photo.info.pop("XML:com.adobe.xmp", None) | ||
| return photo | ||
|
|
||
|
|
||
| def check_png_size(photo) -> Tuple[Optional[BytesIO], int]: | ||
| """ | ||
| Encode `photo` as PNG. | ||
|
|
||
| If the size is below MAX_IMAGE_BYTES then return the BytesIO buffer, | ||
| otherwise delete the in-memory object. | ||
|
|
||
| """ | ||
| buf = BytesIO() | ||
| photo.save(buf, "PNG") | ||
| size = buf.tell() | ||
|
|
||
| if size <= MAX_IMAGE_BYTES: | ||
| buf.seek(0) | ||
| return buf, size | ||
|
|
||
| buf.close() | ||
| return None, size | ||
|
|
||
|
|
||
| def convert_image_to_png(photo): | ||
| # If the photo is not already a PillowImage object | ||
| # coming from the form, then we need to | ||
| # open it as a PillowImage object before | ||
| # converting it to RGBA. | ||
| if not isinstance(photo, PillowImage.Image): | ||
| photo = PillowImage.open(photo).convert("RGBA") | ||
| else: | ||
| photo = photo.convert("RGBA") | ||
| converted = photo.copy().convert("RGB") | ||
| w, h = converted.size | ||
| photo = PillowImage.open(photo) | ||
|
|
||
| photo = ImageOps.exif_transpose(photo) | ||
| photo = strip_alpha(photo) | ||
| photo = strip_exifdata(photo) | ||
|
|
||
| w, h = photo.size | ||
|
|
||
| # Render at full size first; return immediately if already within the limit. | ||
| bytes_obj = BytesIO() | ||
| converted.save(bytes_obj, "PNG") | ||
| if bytes_obj.tell() <= MAX_IMAGE_BYTES: | ||
| return bytes_obj | ||
| # Try full-size first. | ||
| png_image, size = check_png_size(photo) | ||
| if png_image: | ||
| return png_image | ||
|
|
||
| # Binary search over scale factors (0–1) to find the largest image that | ||
| # still encodes to <= MAX_IMAGE_BYTES. | ||
| lo, hi = 0.0, 1.0 | ||
| best = bytes_obj # fallback; always replaced within a couple of iterations | ||
| for _ in range(20): | ||
| best = None | ||
|
|
||
| for _ in range(12): | ||
| mid = (lo + hi) / 2 | ||
| resized = converted.resize( | ||
| (max(1, int(w * mid)), max(1, int(h * mid))), PillowImage.LANCZOS | ||
|
|
||
| resized = photo.resize( | ||
| (max(1, int(w * mid)), max(1, int(h * mid))), | ||
| PillowImage.Resampling.LANCZOS, | ||
| ) | ||
| buf = BytesIO() | ||
| resized.save(buf, "PNG") | ||
| if buf.tell() <= MAX_IMAGE_BYTES: | ||
| lo = mid # this scale fits — search higher | ||
| best = buf | ||
|
|
||
| try: | ||
| png_image, size = check_png_size(resized) | ||
| finally: | ||
| resized.close() | ||
|
|
||
| if png_image is not None: | ||
| lo = mid # it fits, try larger | ||
|
|
||
| if best is not None: | ||
| best.close() | ||
|
|
||
| best = png_image | ||
| else: | ||
| hi = mid # too large — search lower | ||
| return best | ||
| hi = mid # too large, try smaller | ||
|
|
||
| if best is not None: | ||
| best.seek(0) | ||
| return best | ||
|
|
||
| # Worst case: the above has failed to find anything so we just | ||
| # resize the image to _something_. This is likely too lossy, but | ||
| # it's a failsafe to get some sort of image. | ||
| small = photo.thumbnail( | ||
| (800, 800), | ||
| PillowImage.Resampling.LANCZOS, | ||
| ) | ||
| try: | ||
| buf, _ = check_png_size(small) | ||
| return buf | ||
| finally: | ||
| small.close() | ||
|
|
||
|
|
||
| class ImageDownloadException(Exception): | ||
|
|
||
106 changes: 0 additions & 106 deletions
106
ynr/apps/moderation_queue/management/commands/moderation_queue_process_queued_images.py
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function save_png_to_bytes doesn't seem to be called anywhere