Added support for server resize, in-browser resize, or no resize

pull/384/head
Piero Toffanin 2018-02-19 15:50:26 -05:00
rodzic 3415220565
commit 2735942409
13 zmienionych plików z 208 dodań i 93 usunięć

Wyświetl plik

@ -145,7 +145,8 @@ class TaskViewSet(viewsets.ViewSet):
raise exceptions.ValidationError(detail="Cannot create task, you need at least 2 images") raise exceptions.ValidationError(detail="Cannot create task, you need at least 2 images")
with transaction.atomic(): with transaction.atomic():
task = models.Task.objects.create(project=project) task = models.Task.objects.create(project=project,
pending_action=pending_actions.RESIZE if 'resize_to' in request.data else None)
for image in files: for image in files:
models.ImageUpload.objects.create(task=task, image=image) models.ImageUpload.objects.create(task=task, image=image)
@ -155,7 +156,9 @@ class TaskViewSet(viewsets.ViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED) worker_tasks.process_task.delay(task.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, pk=None, project_pk=None, partial=False): def update(self, request, pk=None, project_pk=None, partial=False):

Wyświetl plik

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2018-02-19 19:46
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0016_public_task_uuids'),
]
operations = [
migrations.AddField(
model_name='task',
name='resize_to',
field=models.IntegerField(default=-1, help_text='When set to a value different than -1, indicates that the images for this task have been / will be resized to the size specified here before processing.'),
),
migrations.AlterField(
model_name='task',
name='pending_action',
field=models.IntegerField(blank=True, choices=[(1, 'CANCEL'), (2, 'REMOVE'), (3, 'RESTART'), (4, 'RESIZE')], db_index=True, help_text='A requested action to be performed on the task. The selected action will be performed by the worker at the next iteration.', null=True),
),
]

Wyświetl plik

@ -4,6 +4,9 @@ import shutil
import zipfile import zipfile
import uuid as uuid_module import uuid as uuid_module
import piexif
import re
from PIL import Image
from django.contrib.gis.gdal import GDALRaster from django.contrib.gis.gdal import GDALRaster
from django.contrib.gis.gdal import OGRGeometry from django.contrib.gis.gdal import OGRGeometry
from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.geos import GEOSGeometry
@ -22,6 +25,10 @@ from nodeodm.models import ProcessingNode
from webodm import settings from webodm import settings
from .project import Project from .project import Project
from functools import partial
from multiprocessing import cpu_count
from concurrent.futures import ThreadPoolExecutor
logger = logging.getLogger('app.logger') logger = logging.getLogger('app.logger')
@ -57,6 +64,47 @@ def validate_task_options(value):
raise ValidationError("Invalid options") raise ValidationError("Invalid options")
def resize_image(image_path, resize_to):
try:
im = Image.open(image_path)
path, ext = os.path.splitext(image_path)
resized_image_path = os.path.join(path + '.resized' + ext)
width, height = im.size
max_side = max(width, height)
if max_side < resize_to:
logger.warning('You asked to make {} bigger ({} --> {}), but we are not going to do that.'.format(image_path, max_side, resize_to))
im.close()
return {'path': image_path, 'resize_ratio': 1}
ratio = float(resize_to) / float(max_side)
resized_width = int(width * ratio)
resized_height = int(height * ratio)
im.thumbnail((resized_width, resized_height), Image.LANCZOS)
if 'exif' in im.info:
exif_dict = piexif.load(im.info['exif'])
exif_dict['Exif'][piexif.ExifIFD.PixelXDimension] = resized_width
exif_dict['Exif'][piexif.ExifIFD.PixelYDimension] = resized_height
im.save(resized_image_path, "JPEG", exif=piexif.dump(exif_dict), quality=100)
else:
im.save(resized_image_path, "JPEG", quality=100)
im.close()
# Delete original image, rename resized image to original
os.remove(image_path)
os.rename(resized_image_path, image_path)
logger.info("Resized {} to {}x{}".format(image_path, resized_width, resized_height))
except IOError as e:
logger.warning("Cannot resize {}: {}.".format(image_path, str(e)))
return None
return {'path': image_path, 'resize_ratio': ratio}
class Task(models.Model): class Task(models.Model):
ASSETS_MAP = { ASSETS_MAP = {
'all.zip': 'all.zip', 'all.zip': 'all.zip',
@ -85,6 +133,7 @@ class Task(models.Model):
(pending_actions.CANCEL, 'CANCEL'), (pending_actions.CANCEL, 'CANCEL'),
(pending_actions.REMOVE, 'REMOVE'), (pending_actions.REMOVE, 'REMOVE'),
(pending_actions.RESTART, 'RESTART'), (pending_actions.RESTART, 'RESTART'),
(pending_actions.RESIZE, 'RESIZE'),
) )
id = models.UUIDField(primary_key=True, default=uuid_module.uuid4, unique=True, serialize=False, editable=False) id = models.UUIDField(primary_key=True, default=uuid_module.uuid4, unique=True, serialize=False, editable=False)
@ -112,6 +161,7 @@ class Task(models.Model):
pending_action = models.IntegerField(choices=PENDING_ACTIONS, db_index=True, null=True, blank=True, help_text="A requested action to be performed on the task. The selected action will be performed by the worker at the next iteration.") pending_action = models.IntegerField(choices=PENDING_ACTIONS, db_index=True, null=True, blank=True, help_text="A requested action to be performed on the task. The selected action will be performed by the worker at the next iteration.")
public = models.BooleanField(default=False, help_text="A flag indicating whether this task is available to the public") public = models.BooleanField(default=False, help_text="A flag indicating whether this task is available to the public")
resize_to = models.IntegerField(default=-1, help_text="When set to a value different than -1, indicates that the images for this task have been / will be resized to the size specified here before processing.")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -227,6 +277,11 @@ class Task(models.Model):
""" """
try: try:
if self.pending_action == pending_actions.RESIZE:
self.resize_images()
self.pending_action = None
self.save()
if self.auto_processing_node and not self.status in [status_codes.FAILED, status_codes.CANCELED]: if self.auto_processing_node and not self.status in [status_codes.FAILED, status_codes.CANCELED]:
# No processing node assigned and need to auto assign # No processing node assigned and need to auto assign
if self.processing_node is None: if self.processing_node is None:
@ -515,8 +570,31 @@ class Task(models.Model):
self.pending_action = None self.pending_action = None
self.save() self.save()
def resize_images(self):
"""
Destructively resize this assets JPG images while retaining EXIF tags.
Resulting images are always converted to JPG.
TODO: add support for tiff files
:return list containing paths of resized images and resize ratios
"""
if self.resize_to < 0:
logger.warning("We were asked to resize images to {}, this might be an error.".format(self.resize_to))
return []
directory = full_task_directory_path(self.id, self.project.id)
images_path = [os.path.join(directory, f) for f in os.listdir(directory) if
re.match(r'.*\.jpe?g$', f, re.IGNORECASE)]
with ThreadPoolExecutor(max_workers=cpu_count()) as executor:
resized_images = list(filter(lambda i: i is not None, executor.map(
partial(resize_image, resize_to=self.resize_to),
images_path)))
return resized_images
class Meta: class Meta:
permissions = ( permissions = (
('view_task', 'Can view task'), ('view_task', 'Can view task'),
) )

Wyświetl plik

@ -1,3 +1,4 @@
CANCEL = 1 CANCEL = 1
REMOVE = 2 REMOVE = 2
RESTART = 3 RESTART = 3
RESIZE = 4

Wyświetl plik

@ -1,6 +1,7 @@
const CANCEL = 1, const CANCEL = 1,
REMOVE = 2, REMOVE = 2,
RESTART = 3; RESTART = 3,
RESIZE = 4;
let pendingActions = { let pendingActions = {
[CANCEL]: { [CANCEL]: {
@ -11,6 +12,9 @@ let pendingActions = {
}, },
[RESTART]: { [RESTART]: {
descr: "Restarting..." descr: "Restarting..."
},
[RESIZE]: {
descr: "Resizing images..."
} }
}; };
@ -18,6 +22,7 @@ export default {
CANCEL: CANCEL, CANCEL: CANCEL,
REMOVE: REMOVE, REMOVE: REMOVE,
RESTART: RESTART, RESTART: RESTART,
RESIZE: RESIZE,
description: function(pendingAction) { description: function(pendingAction) {
if (pendingActions[pendingAction]) return pendingActions[pendingAction].descr; if (pendingActions[pendingAction]) return pendingActions[pendingAction].descr;

Wyświetl plik

@ -0,0 +1,26 @@
const dict = [
{k: 'NO', v: 0, human: "No"}, // Don't resize
{k: 'YES', v: 1, human: "Yes"}, // Resize on server
{k: 'YESINBROWSER', v: 2, human: "Yes (In browser)"} // Resize on browser
];
const exp = {
all: () => dict.map(d => d.v),
fromString: (s) => {
let v = parseInt(s);
if (!isNaN(v) && v >= 0 && v <= 2) return v;
else return 0;
},
toHuman: (v) => {
for (let i in dict){
if (dict[i].v === v) return dict[i].human;
}
throw new Error("Invalid value: " + v);
}
};
dict.forEach(en => {
exp[en.k] = en.v;
});
export default exp;

Wyświetl plik

@ -3,6 +3,7 @@ import React from 'react';
import EditTaskForm from './EditTaskForm'; import EditTaskForm from './EditTaskForm';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Storage from '../classes/Storage'; import Storage from '../classes/Storage';
import ResizeModes from '../classes/ResizeModes';
class NewTaskPanel extends React.Component { class NewTaskPanel extends React.Component {
static defaultProps = { static defaultProps = {
@ -25,14 +26,14 @@ class NewTaskPanel extends React.Component {
this.state = { this.state = {
name: props.name, name: props.name,
editTaskFormLoaded: false, editTaskFormLoaded: false,
resize: Storage.getItem('do_resize') !== null ? Storage.getItem('do_resize') == "1" : true, resizeMode: Storage.getItem('resize_mode') === null ? ResizeModes.YES : ResizeModes.fromString(Storage.getItem('resize_mode')),
resizeSize: parseInt(Storage.getItem('resize_size')) || 2048 resizeSize: parseInt(Storage.getItem('resize_size')) || 2048
}; };
this.save = this.save.bind(this); this.save = this.save.bind(this);
this.handleFormTaskLoaded = this.handleFormTaskLoaded.bind(this); this.handleFormTaskLoaded = this.handleFormTaskLoaded.bind(this);
this.getTaskInfo = this.getTaskInfo.bind(this); this.getTaskInfo = this.getTaskInfo.bind(this);
this.setResize = this.setResize.bind(this); this.setResizeMode = this.setResizeMode.bind(this);
this.handleResizeSizeChange = this.handleResizeSizeChange.bind(this); this.handleResizeSizeChange = this.handleResizeSizeChange.bind(this);
} }
@ -40,7 +41,7 @@ class NewTaskPanel extends React.Component {
e.preventDefault(); e.preventDefault();
this.taskForm.saveLastPresetToStorage(); this.taskForm.saveLastPresetToStorage();
Storage.setItem('resize_size', this.state.resizeSize); Storage.setItem('resize_size', this.state.resizeSize);
Storage.setItem('do_resize', this.state.resize ? "1" : "0"); Storage.setItem('resize_mode', this.state.resizeMode);
if (this.props.onSave) this.props.onSave(this.getTaskInfo()); if (this.props.onSave) this.props.onSave(this.getTaskInfo());
} }
@ -54,13 +55,14 @@ class NewTaskPanel extends React.Component {
getTaskInfo(){ getTaskInfo(){
return Object.assign(this.taskForm.getTaskInfo(), { return Object.assign(this.taskForm.getTaskInfo(), {
resizeTo: (this.state.resize && this.state.resizeSize > 0) ? this.state.resizeSize : null resizeSize: this.state.resizeSize,
resizeMode: this.state.resizeMode
}); });
} }
setResize(flag){ setResizeMode(v){
return e => { return e => {
this.setState({resize: flag}); this.setState({resizeMode: v});
} }
} }
@ -91,23 +93,19 @@ class NewTaskPanel extends React.Component {
<div className="col-sm-10"> <div className="col-sm-10">
<div className="btn-group"> <div className="btn-group">
<button type="button" className="btn btn-default dropdown-toggle" data-toggle="dropdown"> <button type="button" className="btn btn-default dropdown-toggle" data-toggle="dropdown">
{this.state.resize ? {ResizeModes.toHuman(this.state.resizeMode)} <span className="caret"></span>
"Yes" : "No"} <span className="caret"></span>
</button> </button>
<ul className="dropdown-menu"> <ul className="dropdown-menu">
<li> {ResizeModes.all().map(mode =>
<a href="javascript:void(0);" <li key={mode}>
onClick={this.setResize(true)}> <a href="javascript:void(0);"
<i style={{opacity: this.state.resize ? 1 : 0}} className="fa fa-check"></i> Yes</a> onClick={this.setResizeMode(mode)}>
</li> <i style={{opacity: this.state.resizeMode === mode ? 1 : 0}} className="fa fa-check"></i> {ResizeModes.toHuman(mode)}</a>
<li> </li>
<a href="javascript:void(0);" )}
onClick={this.setResize(false)}>
<i style={{opacity: !this.state.resize ? 1 : 0}} className="fa fa-check"></i> Skip</a>
</li>
</ul> </ul>
</div> </div>
<div className={"resize-control " + (!this.state.resize ? "hide" : "")}> <div className={"resize-control " + (this.state.resizeMode === ResizeModes.NO ? "hide" : "")}>
<input <input
type="number" type="number"
step="100" step="100"

Wyświetl plik

@ -11,6 +11,7 @@ import Dropzone from '../vendor/dropzone';
import csrf from '../django/csrf'; import csrf from '../django/csrf';
import HistoryNav from '../classes/HistoryNav'; import HistoryNav from '../classes/HistoryNav';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ResizeModes from '../classes/ResizeModes';
import $ from 'jquery'; import $ from 'jquery';
class ProjectListItem extends React.Component { class ProjectListItem extends React.Component {
@ -180,6 +181,10 @@ class ProjectListItem extends React.Component {
if (!formData.has || !formData.has("options")) formData.append("options", JSON.stringify(taskInfo.options)); if (!formData.has || !formData.has("options")) formData.append("options", JSON.stringify(taskInfo.options));
if (!formData.has || !formData.has("processing_node")) formData.append("processing_node", taskInfo.selectedNode.id); if (!formData.has || !formData.has("processing_node")) formData.append("processing_node", taskInfo.selectedNode.id);
if (!formData.has || !formData.has("auto_processing_node")) formData.append("auto_processing_node", taskInfo.selectedNode.key == "auto"); if (!formData.has || !formData.has("auto_processing_node")) formData.append("auto_processing_node", taskInfo.selectedNode.key == "auto");
if (taskInfo.resizeMode === ResizeModes.YES){
if (!formData.has || !formData.has("resize_to")) formData.append("resize_to", taskInfo.resizeSize);
}
}); });
} }
} }
@ -225,8 +230,8 @@ class ProjectListItem extends React.Component {
this.dz._taskInfo = taskInfo; // Allow us to access the task info from dz this.dz._taskInfo = taskInfo; // Allow us to access the task info from dz
// Update dropzone settings // Update dropzone settings
if (taskInfo.resizeTo !== null){ if (taskInfo.resizeMode === ResizeModes.YESINBROWSER){
this.dz.options.resizeWidth = taskInfo.resizeTo; this.dz.options.resizeWidth = taskInfo.resizeSize;
this.dz.options.resizeQuality = 1.0; this.dz.options.resizeQuality = 1.0;
this.setUploadState({resizing: true, editing: false}); this.setUploadState({resizing: true, editing: false});

Wyświetl plik

@ -351,7 +351,7 @@ class TaskListItem extends React.Component {
let status = statusCodes.description(task.status); let status = statusCodes.description(task.status);
if (status === "") status = "Uploading images"; if (status === "") status = "Uploading images";
if (!task.processing_node) status = ""; if (!task.processing_node) status = "Waiting for a node...";
if (task.pending_action !== null) status = pendingActions.description(task.pending_action); if (task.pending_action !== null) status = pendingActions.description(task.pending_action);
let expanded = ""; let expanded = "";

Wyświetl plik

@ -69,6 +69,8 @@ if [ "$WO_SSL" = "YES" ]; then
proto="https" proto="https"
fi fi
./worker.sh scheduler start
congrats(){ congrats(){
(sleep 5; echo (sleep 5; echo
echo -e "\033[92m" echo -e "\033[92m"

Wyświetl plik

@ -322,6 +322,8 @@ CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['json'] CELERY_ACCEPT_CONTENT = ['json']
CELERY_INCLUDE=['worker.tasks'] CELERY_INCLUDE=['worker.tasks']
CELERY_WORKER_REDIRECT_STDOUTS = False
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
if TESTING: if TESTING:
CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_ALWAYS_EAGER = True

Wyświetl plik

@ -9,7 +9,9 @@ usage(){
echo "This program manages the background worker processes. WebODM requires at least one background process worker to be running at all times." echo "This program manages the background worker processes. WebODM requires at least one background process worker to be running at all times."
echo echo
echo "Command list:" echo "Command list:"
echo " start Start background worker" echo " start Start background worker"
echo " scheduler start Start background worker scheduler"
echo " scheduler stop Stop background worker scheduler"
exit exit
} }
@ -51,12 +53,41 @@ start(){
action=$1 action=$1
echo "Starting worker using broker at $WO_BROKER" echo "Starting worker using broker at $WO_BROKER"
celery -A worker worker --loglevel=info celery -A worker worker --loglevel=warn > /dev/null
}
start_scheduler(){
stop_scheduler
if [[ ! -f ./celerybeat.pid ]]; then
celery -A worker beat &
else
echo "Scheduler already running (celerybeat.pid exists)."
fi
}
stop_scheduler(){
if [[ -f ./celerybeat.pid ]]; then
kill -9 $(cat ./celerybeat.pid) 2>/dev/null
rm ./celerybeat.pid 2>/dev/null
echo "Scheduler has shutdown."
else
echo "Scheduler is not running."
fi
} }
if [[ $1 = "start" ]]; then if [[ $1 = "start" ]]; then
environment_check environment_check
start start
elif [[ $1 = "scheduler" ]]; then
if [[ $2 = "start" ]]; then
environment_check
start_scheduler
elif [[ $2 = "stop" ]]; then
environment_check
stop_scheduler
else
usage
fi
else else
usage usage
fi fi

Wyświetl plik

@ -1,24 +1,17 @@
import os
import traceback import traceback
import re from celery.utils.log import get_task_logger
import piexif
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Count from django.db.models import Count
from django.db.models import Q from django.db.models import Q
from app.models import Project from app.models import Project
from app.models import Task from app.models import Task
from webodm import settings
from nodeodm import status_codes from nodeodm import status_codes
from nodeodm.models import ProcessingNode from nodeodm.models import ProcessingNode
from webodm import settings
from .celery import app from .celery import app
from celery.utils.log import get_task_logger
from django.db import transaction
from PIL import Image
from functools import partial
from multiprocessing import Pool, cpu_count
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
@ -84,57 +77,3 @@ def process_pending_tasks():
for task in tasks: for task in tasks:
process_task.delay(task.id) process_task.delay(task.id)
@app.task
def resize_image(image_path, resize_to):
try:
exif_dict = piexif.load(image_path)
im = Image.open(image_path)
path, ext = os.path.splitext(image_path)
resized_image_path = os.path.join(path + '.resized' + ext)
width, height = im.size
max_side = max(width, height)
if max_side < resize_to:
logger.warning('We are making {} bigger ({} --> {})'.format(image_path, max_side, resize_to))
ratio = float(resize_to) / float(max_side)
resized_width = int(width * ratio)
resized_height = int(height * ratio)
im.thumbnail((resized_width, resized_height), Image.LANCZOS)
if len(exif_dict['Exif']) > 0:
exif_dict['Exif'][piexif.ExifIFD.PixelXDimension] = resized_width
exif_dict['Exif'][piexif.ExifIFD.PixelYDimension] = resized_height
im.save(resized_image_path, "JPEG", exif=piexif.dump(exif_dict), quality=100)
else:
im.save(resized_image_path, "JPEG", quality=100)
im.close()
# Delete original image, rename resized image to original
os.remove(image_path)
os.rename(resized_image_path, image_path)
logger.info("Resized {}".format(os.path.basename(resized_image_path)))
except IOError as e:
logger.warning("Cannot resize {}: {}.".format(image_path, str(e)))
return None
return image_path
@app.task
def resize_images(directory, resize_to):
"""
Destructively resize a directory of JPG images while retaining EXIF tags.
Resulting images are always converted to JPG.
TODO: add support for tiff files
:return list containing paths of resized images
"""
images_path = [os.path.join(directory, f) for f in os.listdir(directory) if re.match(r'.*\.jpe?g$', f, re.IGNORECASE)]
resized_images = list(filter(lambda i: i is not None, Pool(cpu_count()).map(
partial(resize_image, resize_to=resize_to),
images_path)))
return resized_images