diff --git a/autofocus/predict/app/app.py b/autofocus/predict/app/app.py index ce7825b..108aa8d 100644 --- a/autofocus/predict/app/app.py +++ b/autofocus/predict/app/app.py @@ -1,115 +1,14 @@ -import mimetypes -import os -import time -from zipfile import ZipFile +from flask import Flask -from flask import Flask, jsonify, make_response, request -from werkzeug import secure_filename +from .routes.predict import predict_route +from .routes.predict_zip import predict_zip_route -from .model import predict_multiple, predict_single -from .utils import allowed_file, filter_image_files, list_zip_files - -# We are going to upload the files to the server as part of the request, so set tmp folder here. -UPLOAD_FOLDER = "/tmp/" -ALLOWED_EXTENSIONS = set( - k for k, v in mimetypes.types_map.items() if v.startswith("image/") -) app = Flask(__name__) app.config.from_object(__name__) -app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER - - -@app.route("/predict", methods=["GET", "POST"]) -def classify_single(): - """Classify a single image""" - if request.method == "POST": - file = request.files["file"] - - if not file: - return "No file sent." - - filename = secure_filename(file.filename) - - if allowed_file(filename, ALLOWED_EXTENSIONS): - file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename) - # this isn't super-optimal since it's saving the file to the server - file.save(file_path) - - app.logger.info("Classifying image %s" % (file_path)) - - # Get the predictions (output of the softmax) for this image - t = time.time() - predictions = predict_single(file_path) - dt = time.time() - t - app.logger.info("Execution time: %0.2f" % (dt * 1000.0)) - - os.remove(file_path) - - return jsonify(predictions) - else: - return "File type not allowed. File must be of type {allowed}".format( - allowed=ALLOWED_EXTENSIONS - ) - - -@app.route("/predict_zip", methods=["GET", "POST"]) -def classify_zip(): - """Classify all images from a zip file""" - if request.method == "POST": - file = request.files["file"] - - if not file: - return "No file sent." - - if not file.filename.split(".")[-1] == "zip": - return ".zip is the only compression format currently supported" - - filename = secure_filename(file.filename) - zip_file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename) - file.save(zip_file_path) - - zip_file = ZipFile(zip_file_path) - zip_file_list = list_zip_files(zip_file_path) - all_images = filter_image_files(zip_file_list, ALLOWED_EXTENSIONS) - - if len(all_images) == 0: - return "No image files detected in the zip file" - - # loop through images - start = 0 - increment = 500 - all_images_len = len(all_images) - - while start < all_images_len: - end = start + increment - if end > len(all_images): - end = len(all_images) - - # extract filenames - curr_file_list = all_images[start:end] - for filename in curr_file_list: - zip_file.extract(filename, path=app.config["UPLOAD_FOLDER"]) - - curr_file_list = [ - os.path.join(app.config["UPLOAD_FOLDER"], x) for x in curr_file_list - ] - - predictions = predict_multiple(curr_file_list) - - # remove files - for curr_file in curr_file_list: - os.remove(curr_file) - - return make_response(jsonify(predictions)) - - start = end + 1 - -@app.route("/hello") -def hello(): - """Just a test endpoint to make sure server is running""" - return "Hey there!\n" +app.register_blueprint(predict_route) +app.register_blueprint(predict_zip_route) if __name__ == "__main__": diff --git a/autofocus/predict/app/filesystem/TemporaryFile.py b/autofocus/predict/app/filesystem/TemporaryFile.py new file mode 100644 index 0000000..08a8bf6 --- /dev/null +++ b/autofocus/predict/app/filesystem/TemporaryFile.py @@ -0,0 +1,70 @@ +import os + +from werkzeug import secure_filename + + +UPLOAD_FOLDER = "/tmp/" + + +class TemporaryFile: + """ + Store a file and remove it upon destruction + + Parameters: + path: The path to the file + name: Secured filename (Can be empty) + """ + + def __init__(self, file=None, upload_path=UPLOAD_FOLDER): + """ + Constructor of File + + Save the file on the server if a file is given. + + Parameters: + file: Uploaded file object from flask + upload_path: The path to upload the file + """ + self.upload_folder = upload_path + if file: + self.setFromUploadedFile(file, upload_path) + + def __del__(self): + """ + Destructor of File + + Remove the file from the server. + """ + os.remove(self.path) + + def setFromUploadedFile(self, file, upload_path=None): + """ + Save file from uploaded file + + Parameters: + file: Uploaded file object from flask + upload_path: The path to upload the file + """ + self.name = secure_filename(file.filename) + self.path = self.name + if upload_path: + self.path = os.path.join(upload_path, self.path) + file.save(self.path) + + def setPath(self, path): + """ + Set the path to a saved file + + Parameters: + path: Path to the file + """ + self.path = path + + def getPath(self): + """ + Return the saved path + + Returns: + string: Path to the file + """ + return self.path diff --git a/autofocus/predict/app/filesystem/ZipArchive.py b/autofocus/predict/app/filesystem/ZipArchive.py new file mode 100644 index 0000000..1d735b7 --- /dev/null +++ b/autofocus/predict/app/filesystem/ZipArchive.py @@ -0,0 +1,87 @@ +import os +from zipfile import ZipFile + +from .TemporaryFile import TemporaryFile, UPLOAD_FOLDER +from ..validation.validation import allowed_file, ALLOWED_IMAGE_FILES + + +class ZipArchive: + """ + Archive of a zip file + + This class is to store and access a zip file. + + Parameters: + file: The storage of the zip file (gets removed from the os upon destructor call) + zip: Opened zip file + """ + + def __init__(self, file): + """ + Constructor of ZipFile + + Store the given file and open the zip file. + + Parameters: + file: Uploaded file from flask + upload_folder: The folder to save the zip file + """ + self.file = TemporaryFile(file) + self.zip = ZipFile(self.file.getPath()) + + def listFiles(self): + """ + List all files in the zip + + Returns: + array: Array of filenames + """ + return [file.filename for file in self.zip.infolist()] + + def listAllImages(self, extensions=ALLOWED_IMAGE_FILES): + """ + List all image files + + Lists all image files within the zip archive based on the given extensions + + Parameters: + extensions: Array of allowed image extensions + + Returns: + array: Array of filenames matching the extension + """ + return [file for file in self.listFiles() if allowed_file(file, extensions)] + + def hasImages(self, extensions=ALLOWED_IMAGE_FILES): + """ + Check for images in the zip file + + Parameters: + extensions: Array of allowed image extensions + + Returns: + boolean: True if zip has images + """ + return len(self.listAllImages(extensions)) > 0 + + def extractAll(self, path=UPLOAD_FOLDER, members=None): + """ + Extract all the given files + + Extractes all the given files and stores them as File objects. + Upon destruction of the array, files are getting removed from os. + + Parameters: + path: Path to store files + members: Files to extract + + Returns: + array: Array of extracted File objects + """ + self.zip.extractall(path, members) + extractedFiles = {} + for member in members: + file = TemporaryFile() + file.setPath(os.path.join(path, member)) + extractedFiles[member] = file + return extractedFiles diff --git a/autofocus/predict/app/filesystem/__init__.py b/autofocus/predict/app/filesystem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/autofocus/predict/app/model.py b/autofocus/predict/app/model.py deleted file mode 100644 index 1dbf831..0000000 --- a/autofocus/predict/app/model.py +++ /dev/null @@ -1,29 +0,0 @@ -from pathlib import Path - -from fastai.vision import load_learner, open_image - - -MODEL_DIR = Path(__file__).resolve().parents[1] / "models" -MODEL_NAME = "multilabel_model_20190407.pkl" -model = load_learner(MODEL_DIR, MODEL_NAME) -CLASSES = model.data.classes - - -def predict_single(path): - image = open_image(path) - pred_classes, preds, probs = model.predict(image) - probs = [prob.item() for prob in probs] - return dict(zip(CLASSES, probs)) - - -def predict_multiple(path_list): - predictions = {} - for path in path_list: - path_without_tmp = Path(*Path(path).parts[2:]) - predictions[str(path_without_tmp)] = predict_single(path) - return predictions - - -if __name__ == "__main__": - test_image_path = Path(__file__).parent / "test/flower.jpeg" - prediction = predict_single(test_image_path) diff --git a/autofocus/predict/app/prediction/prediction.py b/autofocus/predict/app/prediction/prediction.py new file mode 100644 index 0000000..ae5bb47 --- /dev/null +++ b/autofocus/predict/app/prediction/prediction.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from fastai.vision import load_learner, open_image + + +MODEL_DIR = Path(__file__).resolve().parents[2] / "models" +MODEL_NAME = "multilabel_model_20190407.pkl" +model = load_learner(MODEL_DIR, MODEL_NAME) +CLASSES = model.data.classes + + +def predict_multiple(files): + """ + Predict probabilities of multiple files + + Parameters: + files: Dict with File objects of image file + + Returns: + dict: Dictionary of probabilities for each file in files + """ + predictions = {} + for key in files: + predictions[key] = predict(files[key]) + return predictions + + +def predict(file): + """ + Predict probabilities of single file + + Parameters: + file: File object of image file + """ + image = open_image(file.getPath()) + # Get the predictions (output of the softmax) for this image + pred_classes, preds, probs = model.predict(image) + return getProbabilities([prob.item() for prob in probs]) + + +def getProbabilities(probabilities): + """ + Return formated Probabilities + + Returns: + dict: A dictionary of classes to probabilities + """ + return dict(zip(CLASSES, probabilities)) diff --git a/autofocus/predict/app/routes/__init__.py b/autofocus/predict/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/autofocus/predict/app/routes/predict.py b/autofocus/predict/app/routes/predict.py new file mode 100644 index 0000000..af9faff --- /dev/null +++ b/autofocus/predict/app/routes/predict.py @@ -0,0 +1,21 @@ +from flask import Blueprint, jsonify, request + +from ..filesystem.TemporaryFile import TemporaryFile +from ..prediction.prediction import predict +from ..validation.predict import validate_predict_request + + +predict_route = Blueprint("predict", __name__) + + +@predict_route.route("/predict", methods=["POST"]) +def classify_single(): + """Classify a single image""" + # Validate request + validate_predict_request(request) + + # Get File object + file = TemporaryFile(request.files["file"]) + + # Return ziped probabilities + return jsonify(predict(file)) diff --git a/autofocus/predict/app/routes/predict_zip.py b/autofocus/predict/app/routes/predict_zip.py new file mode 100644 index 0000000..2619f59 --- /dev/null +++ b/autofocus/predict/app/routes/predict_zip.py @@ -0,0 +1,27 @@ +from flask import Blueprint, jsonify, request + +from ..filesystem.ZipArchive import ZipArchive +from ..prediction.prediction import predict_multiple +from ..validation.predict_zip import validate_predict_zip_request +from ..validation.validation import abort_with_errors + + +predict_zip_route = Blueprint("predict_zip", __name__) + + +@predict_zip_route.route("/predict_zip", methods=["POST"]) +def classify_zip(): + """Classify all images from a zip file""" + # Validate request + validate_predict_zip_request(request) + + file = ZipArchive(request.files["file"]) + if not file.hasImages(): + error = {"file": "No image files detected in the zip file."} + abort_with_errors(error) + + # Extract files + files = file.extractAll(members=file.listAllImages()) + + # Make prediction + return jsonify(predict_multiple(files)) diff --git a/autofocus/predict/app/utils.py b/autofocus/predict/app/utils.py deleted file mode 100644 index ce201cc..0000000 --- a/autofocus/predict/app/utils.py +++ /dev/null @@ -1,46 +0,0 @@ -from pathlib import Path -from zipfile import ZipFile - - -def allowed_file(filename, allowed_extensions): - """ - Check for whether a filename is in the ALLOWED_EXTENSIONS - Args: - filename (str): filename to check - - Returns: - bool: whether the filename is in allowed extensions - - """ - return Path(filename).suffix.lower().replace(".", "") in allowed_extensions - - -def list_zip_files(path): - """ - List the files in a zip archive. - Args: - path(str): path to the zip file - - Returns: - list of files - - """ - - file = ZipFile(path) - all_files = file.infolist() - - return [x.filename for x in all_files] - - -def filter_image_files(file_list, img_extensions=["jpeg", "jpg", "png", "bmp", "gif"]): - """ - Filter the image files out of a list - - Args: - file_list(list): list of file strings - img_extensions(list): file extensions for image files - - Returns: - - """ - return [x for x in file_list if allowed_file(x, img_extensions)] diff --git a/autofocus/predict/app/validation/__init__.py b/autofocus/predict/app/validation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/autofocus/predict/app/validation/predict.py b/autofocus/predict/app/validation/predict.py new file mode 100644 index 0000000..7ae63f3 --- /dev/null +++ b/autofocus/predict/app/validation/predict.py @@ -0,0 +1,21 @@ +from .validation import abort_with_errors, allowed_file, ALLOWED_IMAGE_FILES + + +def validate_predict_request(request): + """ + Validate the given request + + Check if the request has a file and the extension is an allowed image extension. + """ + error = {} + + file = request.files.get("file", None) + if not file: + error["file"] = "No file given." + elif not allowed_file(file.filename, ALLOWED_IMAGE_FILES): + error["file"] = "File type not allowed. File must be of type {allowed}".format( + allowed=ALLOWED_IMAGE_FILES + ) + + if error: + abort_with_errors(error) diff --git a/autofocus/predict/app/validation/predict_zip.py b/autofocus/predict/app/validation/predict_zip.py new file mode 100644 index 0000000..2ab89a3 --- /dev/null +++ b/autofocus/predict/app/validation/predict_zip.py @@ -0,0 +1,24 @@ +from .validation import abort_with_errors, allowed_file, ALLOWED_ZIP_FILES + + +def validate_predict_zip_request(request): + """ + Validate the given request + + Check if the request has a file and the extension is ".zip". + + Returns: + error: Set of errors + """ + error = {} + + file = request.files.get("file", None) + if not file: + error["file"] = "No file given." + elif not allowed_file(file.filename, ALLOWED_ZIP_FILES): + error["file"] = "File type not allowed. File must be of type {allowed}".format( + allowed=ALLOWED_ZIP_FILES + ) + + if error: + abort_with_errors(error) diff --git a/autofocus/predict/app/validation/validation.py b/autofocus/predict/app/validation/validation.py new file mode 100644 index 0000000..d97e4e2 --- /dev/null +++ b/autofocus/predict/app/validation/validation.py @@ -0,0 +1,34 @@ +import mimetypes +from pathlib import Path + +from flask import abort, jsonify, make_response +from flask_api import status + + +ALLOWED_IMAGE_FILES = set( + k for k, v in mimetypes.types_map.items() if v.startswith("image/") +) +ALLOWED_ZIP_FILES = {".zip"} + + +def abort_with_errors(error): + """Abort with errors""" + abort( + make_response( + jsonify(status=status.HTTP_400_BAD_REQUEST, error=error), + status.HTTP_400_BAD_REQUEST, + ) + ) + + +def allowed_file(filename, allowed_extensions): + """ + Check for whether a filename is in the ALLOWED_EXTENSIONS + Args: + filename (str): filename to check + + Returns: + bool: whether the filename is in allowed extensions + + """ + return Path(filename).suffix.lower() in allowed_extensions diff --git a/autofocus/predict/requirements.txt b/autofocus/predict/requirements.txt index d1b17c1..a4469ff 100644 --- a/autofocus/predict/requirements.txt +++ b/autofocus/predict/requirements.txt @@ -2,3 +2,4 @@ Flask gunicorn numpy pillow +fastai