diff --git a/Pipfile b/Pipfile index 9cc6364..80141f4 100644 --- a/Pipfile +++ b/Pipfile @@ -16,6 +16,7 @@ numpy = "*" Pillow = "*" requests = "*" opencv-python = "*" +opennsfw2= "*" [requires] python_version = "3.8" diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/Dockerfile b/apps/pyserver/Dockerfile index 588da22..e4c4f72 100644 --- a/apps/pyserver/Dockerfile +++ b/apps/pyserver/Dockerfile @@ -16,6 +16,8 @@ EXPOSE 5000 FROM base as debug RUN pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org ptvsd +ENV PYTHONPATH=/app + WORKDIR /app CMD ["python", "-m", "ptvsd", "--host", "0.0.0.0", "--port", "5678", \ "--wait", "--multiprocess", "-m", \ diff --git a/apps/pyserver/__init__.py b/apps/pyserver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/__init__.py b/apps/pyserver/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/api/__init__.py b/apps/pyserver/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/api/deps.py b/apps/pyserver/app/api/deps.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/api/v1/nstw.py b/apps/pyserver/app/api/v1/nstw.py new file mode 100644 index 0000000..a78575e --- /dev/null +++ b/apps/pyserver/app/api/v1/nstw.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter +from fastapi import APIRouter, UploadFile, File +from nsfw_detector import predict +from core.use_case.nstw.crud import CRUDNstw +from PIL import Image +import io + +TENSORFLOW_URL = "http://tensorflow:8501/v1/models/rfcn:predict" +router = APIRouter() + + +@router.get("/hello") +def hello(): + return {"hello": "world"} + + + +@router.post("/nstw/") +async def detect_nstw(file: UploadFile = File(...)): + """ + NSTW Model: Detects Explicit or Implicit Pornography in an Image + It returns a value from 0.99 to 0.01. Being 0.99 being safe (99% safe) + and 0.01 being unsafe (1% safe) + + Typically, the values indicate the following possible outcomes: + + => 0.99 would be an image without any kind of nudity captured + =<0.90 person without pants or shirt, or showing some part of the body, but not pornographic. + =< 0.80 person without pants or shirt, or showing some part of the body. + =< 0.20 person without pants and without shirt + =< 0.10 explicit pornographic content detected. + + Acceptable values to proceed: 0.95 - 0.99 + """ + try: + image_bytes = await file.read() + result = CRUDNstw.detect_nsfw(image_bytes) + return result + except Exception as e: + return {"error": str(e)} + + +model = predict.load_model("/app/nsfw_model/nsfw_mobilenet2.224x224.h5") + + +@router.post("/nsfw/") +async def detect_nsfw_labels(file: UploadFile = File(...)): + """ + NSFW Model: Detects Explicit or Implicit Pornography with a More Detailed Index + Faster model, but may be less sensitive to soft porn detection + + Categories detected: + drawings: 0.0057229697704315186, + "hentai": 0.0005879989475943148, + "neutral": 0.3628992736339569, + "porn": 0.011622844263911247, + sexy: 0.619166910648346 + + """ + try: + image_bytes = await file.read() + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + tmp_path = "/tmp/tmp.jpg" + image.save(tmp_path) + result = predict.classify(model, tmp_path) + return result + except Exception as e: + return {"error": str(e)} \ No newline at end of file diff --git a/apps/pyserver/app/api/v1/oracle.py b/apps/pyserver/app/api/v1/oracle.py new file mode 100644 index 0000000..c22afe2 --- /dev/null +++ b/apps/pyserver/app/api/v1/oracle.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter +from fastapi import FastAPI, File, UploadFile +from fastapi.responses import StreamingResponse +from starlette.responses import RedirectResponse +import io + + +from core.use_case.oracle.crud import CRUDOracle + +TENSORFLOW_URL = "http://tensorflow:8501/v1/models/rfcn:predict" + +router = APIRouter() + + +@router.get("/hello_oracle") +def hello_oracle(): + return {"hello": "world"} + + +@router.post("/get_predictions/") +async def get_predictions(file: UploadFile = File(...)): + """ + Submit a picture to be analyzed by the R-FCN model and retrieve the objects + identified on the scene. + """ + image_file = await file.read() + r = CRUDOracle.get_predictions(image_file, TENSORFLOW_URL) + + return r + + +@router.post("/get_predicted_image/") +async def get_predicted_image(file: UploadFile = File(...), detections_limit: int = 20): + """ + Submit a picture to be analyzed by the R-FCN model and get then download the picture + with all the objects identified in the scene highlighted. + + - **detections_limit** [int]: Define the limit of objects to be ploted in the returning image. + """ + try: + image_file = await file.read() + processed_image = CRUDOracle.get_predicted_image(image_file, TENSORFLOW_URL, detections_limit) + filename = file.filename.split(".")[0] + extension = file.filename.split(".")[-1] + filename = "".join([filename, " (processed).", extension]) + + return StreamingResponse(io.BytesIO(processed_image), + headers={'Content-Disposition': 'attachment; filename=' + filename}, + media_type="image/jpg") + + except Exception as e: + return {"error": str(e)} diff --git a/apps/pyserver/app/api/v1/router.py b/apps/pyserver/app/api/v1/router.py new file mode 100644 index 0000000..9f55832 --- /dev/null +++ b/apps/pyserver/app/api/v1/router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter, FastAPI + +from api.v1 import nstw, oracle + +api_router = APIRouter() +app = FastAPI() + +api_router.include_router(nstw.router, tags=["nstw"]) +api_router.include_router(oracle.router, tags=["oracle"]) diff --git a/apps/pyserver/app/core/__init__.py b/apps/pyserver/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/core/use_case/nstw/__init__.py b/apps/pyserver/app/core/use_case/nstw/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/core/use_case/nstw/crud.py b/apps/pyserver/app/core/use_case/nstw/crud.py new file mode 100644 index 0000000..49cbb44 --- /dev/null +++ b/apps/pyserver/app/core/use_case/nstw/crud.py @@ -0,0 +1,26 @@ +from PIL import Image +import io +import opennsfw2 as n2 + + +class CRUDNstw: + @staticmethod + def detect_nsfw(image_bytes: bytes): + # Carrega a imagem a partir dos bytes + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + + # Preprocessa a imagem conforme o modelo + processed_image = n2.preprocess_image(image, n2.Preprocessing.YAHOO) + + # Cria o modelo + model = n2.make_open_nsfw_model() + + # Realiza a predição + prediction = model.predict(processed_image[None, ...])[0][0] + + # Define o limiar para considerar como NSFW + threshold = 0.9 + is_nsfw = bool(prediction >= threshold) + detail = f"Probabilidade NSFW: {prediction:.2f}" + + return {"nsfw": is_nsfw, "detail": detail} diff --git a/apps/pyserver/app/core/use_case/nstw/dto.py b/apps/pyserver/app/core/use_case/nstw/dto.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/core/use_case/oracle/__init__.py b/apps/pyserver/app/core/use_case/oracle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/core/use_case/oracle/coco-labels-2014_2017.txt b/apps/pyserver/app/core/use_case/oracle/coco-labels-2014_2017.txt new file mode 100644 index 0000000..1f42c8e --- /dev/null +++ b/apps/pyserver/app/core/use_case/oracle/coco-labels-2014_2017.txt @@ -0,0 +1,80 @@ +person +bicycle +car +motorcycle +airplane +bus +train +truck +boat +traffic light +fire hydrant +stop sign +parking meter +bench +bird +cat +dog +horse +sheep +cow +elephant +bear +zebra +giraffe +backpack +umbrella +handbag +tie +suitcase +frisbee +skis +snowboard +sports ball +kite +baseball bat +baseball glove +skateboard +surfboard +tennis racket +bottle +wine glass +cup +fork +knife +spoon +bowl +banana +apple +sandwich +orange +broccoli +carrot +hot dog +pizza +donut +cake +chair +couch +potted plant +bed +dining table +toilet +tv +laptop +mouse +remote +keyboard +cell phone +microwave +oven +toaster +sink +refrigerator +book +clock +vase +scissors +teddy bear +hair drier +toothbrush \ No newline at end of file diff --git a/apps/pyserver/app/core/use_case/oracle/coco-labels-paper.txt b/apps/pyserver/app/core/use_case/oracle/coco-labels-paper.txt new file mode 100644 index 0000000..5378c6c --- /dev/null +++ b/apps/pyserver/app/core/use_case/oracle/coco-labels-paper.txt @@ -0,0 +1,91 @@ +person +bicycle +car +motorcycle +airplane +bus +train +truck +boat +traffic light +fire hydrant +street sign +stop sign +parking meter +bench +bird +cat +dog +horse +sheep +cow +elephant +bear +zebra +giraffe +hat +backpack +umbrella +shoe +eye glasses +handbag +tie +suitcase +frisbee +skis +snowboard +sports ball +kite +baseball bat +baseball glove +skateboard +surfboard +tennis racket +bottle +plate +wine glass +cup +fork +knife +spoon +bowl +banana +apple +sandwich +orange +broccoli +carrot +hot dog +pizza +donut +cake +chair +couch +potted plant +bed +mirror +dining table +window +desk +toilet +door +tv +laptop +mouse +remote +keyboard +cell phone +microwave +oven +toaster +sink +refrigerator +blender +book +clock +vase +scissors +teddy bear +hair drier +toothbrush +hair brush \ No newline at end of file diff --git a/apps/pyserver/app/core/use_case/oracle/crud.py b/apps/pyserver/app/core/use_case/oracle/crud.py new file mode 100644 index 0000000..8f72d1d --- /dev/null +++ b/apps/pyserver/app/core/use_case/oracle/crud.py @@ -0,0 +1,181 @@ +from six import BytesIO +import requests +import numpy as np +import cv2 + +from PIL import Image +from PIL import ImageColor +from PIL import ImageDraw +from PIL import ImageFont +import tempfile +import time +import os +import io + + + +class CRUDOracle: + + @staticmethod + def get_image_as_array(image): + image_data = BytesIO(image) + file_bytes = np.asarray(bytearray(image_data.read()), dtype=np.uint8) + img_array = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR) + + return img_array + + @staticmethod + def make_request(image, server_url): + """ Send request to the Tensorflow container """ + img_array = CRUDOracle.get_image_as_array(image) + # reshaping using cv2 instead of numpy as suggested in: + # https://github.com/tensorflow/models/issues/2503 + np_image = np.expand_dims(img_array, 0).tolist() + request_data = '{"instances" : %s}' % np_image + + r = requests.post(server_url, data=request_data) + + return r.json() + + @staticmethod + def get_predictions(image, server_url): + """ Get the filtered Predictions key from the TensorFlow request """ + predictions = CRUDOracle.make_request(image, server_url)["predictions"][0] + + classes_names = CRUDOracle.get_classnames_dict() + num_detections = int(predictions["num_detections"]) + # Filtering out the unused predictions + detection_boxes = predictions["detection_boxes"][:num_detections] + detection_classes = predictions["detection_classes"][:num_detections] + detection_classes_names = [] + for detection in detection_classes: + detection_classes_names.append(classes_names[detection - 1]) + detection_scores = predictions["detection_scores"][:num_detections] + + return {"num_detections": num_detections, + "detection_boxes": detection_boxes, + "detection_classes": detection_classes_names, + "detection_scores": detection_scores} + + @staticmethod + def draw_bounding_box_on_image(image, + ymin, + xmin, + ymax, + xmax, + color, + font, + thickness=4, + display_str_list=()): + """Adds a bounding box to an image.""" + draw = ImageDraw.Draw(image) + im_width, im_height = image.size + (left, right, top, bottom) = (xmin * im_width, xmax * im_width, + ymin * im_height, ymax * im_height) + draw.line([(left, top), (left, bottom), (right, bottom), (right, top), + (left, top)], + width=thickness, + fill=color) + + # If the total height of the display strings added to the top of the bounding + # box exceeds the top of the image, stack the strings below the bounding box + # instead of above. + display_str_heights = [font.getsize(ds)[1] for ds in display_str_list] + # Each display_str has a top and bottom margin of 0.05x. + total_display_str_height = (1 + 2 * 0.05) * sum(display_str_heights) + + if top > total_display_str_height: + text_bottom = top + else: + text_bottom = top + total_display_str_height + # Reverse list and print from bottom to top. + for display_str in display_str_list[::-1]: + text_width, text_height = font.getsize(display_str) + margin = np.ceil(0.05 * text_height) + draw.rectangle([(left, text_bottom - text_height - 2 * margin), + (left + text_width, text_bottom)], + fill=color) + draw.text((left + margin, text_bottom - text_height - margin), + display_str, + fill="black", + font=font) + text_bottom -= text_height - 2 * margin + + @staticmethod + def draw_boxes(image, boxes, class_names, scores, max_boxes=10, min_score=0.1): + """Overlay labeled boxes on an image with formatted scores and label names.""" + colors = list(ImageColor.colormap.values()) + + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/liberation/LiberationSansNarrow-Regular.ttf", 25) + except IOError: + print("Font not found, using default font.") + font = ImageFont.load_default() + + for i in range(min(len(boxes), max_boxes)): + if scores[i] >= min_score: + ymin, xmin, ymax, xmax = tuple(boxes[i]) + # display_str = "{}: {}%".format(class_names[i].decode("ascii"), + # int(100 * scores[i])) + display_str = "{}: {}%".format(class_names[i], int(100 * scores[i])) + color = colors[hash(class_names[i]) % len(colors)] + image_pil = Image.fromarray(np.uint8(image)).convert("RGB") + CRUDOracle.draw_bounding_box_on_image(image_pil, + ymin, + xmin, + ymax, + xmax, + color, + font, + display_str_list=[display_str]) + np.copyto(image, np.array(image_pil)) + return image + + @staticmethod + def save_img(image): + """ Save the image file locally""" + _, filename = tempfile.mkstemp(suffix=".jpg") + im = Image.fromarray(image) + im.save(filename) + + return filename + + @staticmethod + def get_classnames_dict(): + """ Get the classes instances from the COCO dataset """ + classes = {} + i = 0 + dir_path = os.path.dirname(os.path.realpath(__file__)) + classes_file = open(dir_path + "/coco-labels-paper.txt") + for line in classes_file: + classes[i] = line.split("\n")[0] + i += 1 + + return classes + + @staticmethod + def get_predicted_image(image, server_url, detections_limit): + """ Run the prediction, draw boxes around the objects in the image and + return the image as file """ + start_time = time.time() + img = CRUDOracle.get_image_as_array(image) + end_time = time.time() + + result = CRUDOracle.get_predictions(image, server_url) + + print("Found %d objects." % result["num_detections"]) + print("Inference time: ", end_time - start_time) + + image_with_boxes = CRUDOracle.draw_boxes(img, result["detection_boxes"], + result["detection_classes"], + result["detection_scores"], + detections_limit) + + filename = CRUDOracle.save_img(image_with_boxes) + + f = io.BytesIO() + f = open(filename, 'rb') + content = f.read() + f.close() + + return content \ No newline at end of file diff --git a/apps/pyserver/app/core/use_case/oracle/dto.py b/apps/pyserver/app/core/use_case/oracle/dto.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/main.py b/apps/pyserver/app/main.py index 6a2854f..04cf666 100644 --- a/apps/pyserver/app/main.py +++ b/apps/pyserver/app/main.py @@ -1,54 +1,29 @@ ################################################## -## Author: Tiago Prata (https://github.com/TiagoPrata) -## Date: 22-Mar-2021 +## Author: +## Date: ################################################## import uvicorn -from fastapi import FastAPI, File, UploadFile +from fastapi import FastAPI from starlette.responses import RedirectResponse -from fastapi.responses import StreamingResponse -import io -import objectdetection +from api.v1.router import api_router TENSORFLOW_URL = "http://tensorflow:8501/v1/models/rfcn:predict" app = FastAPI( - title="Python web server and TensorFlow", - description="This web interface allows image uploading for a TensorFlow container running a R-FCN pre-trained model for object identification", + title="Trained System for Feature Identification - TSFI", + description="wwww.coinnodes.tech -allows image uploading for a TensorFlow container running a R-FCN pre-trained model for object identification", version="1.0.0", ) +app.include_router(api_router, prefix="/api/v1") + + @app.get("/") def home_screen(): return RedirectResponse(url='/docs') -@app.post("/get_predictions/") -async def get_predictions(file: UploadFile = File(...)): - """ - Submit a picture to be analyzed by the R-FCN model and retrieve the objects - identified on the scene. - """ - image_file = await file.read() - r = objectdetection.get_predictions(image_file, TENSORFLOW_URL) - - return r - -@app.post("/get_predicted_image/") -async def get_predicted_image(file: UploadFile = File(...), detections_limit: int = 20): - """ - Submit a picture to be analyzed by the R-FCN model and get then download the picture - with all the objects identified in the scene highlighted. - - - **detections_limit** [int]: Define the limit of objects to be ploted in the returning image. - """ - image_file = await file.read() - processed_image = objectdetection.get_predicted_image(image_file, TENSORFLOW_URL, detections_limit) - filename = file.filename.split(".")[0] - extension = file.filename.split(".")[-1] - filename = "".join([filename, " (processed).", extension]) - - return StreamingResponse(io.BytesIO(processed_image), headers={'Content-Disposition': 'attachment; filename=' + filename}, media_type="image/jpg") if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=5000) \ No newline at end of file + uvicorn.run(app, host="0.0.0.0", port=5000) diff --git a/apps/pyserver/app/nsfw_model/__init__.py b/apps/pyserver/app/nsfw_model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pyserver/app/nsfw_model/nsfw_mobilenet2.224x224.h5 b/apps/pyserver/app/nsfw_model/nsfw_mobilenet2.224x224.h5 new file mode 100644 index 0000000..40a7a57 Binary files /dev/null and b/apps/pyserver/app/nsfw_model/nsfw_mobilenet2.224x224.h5 differ diff --git a/apps/pyserver/requirements.txt b/apps/pyserver/requirements.txt index 5c2f732..47b94af 100644 --- a/apps/pyserver/requirements.txt +++ b/apps/pyserver/requirements.txt @@ -1,25 +1,28 @@ --i https://pypi.org/simple -certifi==2020.12.5 -chardet==4.0.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' -click==7.1.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' -cycler==0.10.0 -fastapi==0.63.0 -h11==0.12.0; python_version >= '3.6' -idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -kiwisolver==1.3.1; python_version >= '3.6' -matplotlib==3.3.4 -numpy==1.20.1 -opencv-python==4.5.1.48 -pillow==8.1.2 -protobuf==3.15.6 -pydantic==1.8.1; python_full_version >= '3.6.1' -pyparsing==2.4.7; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' -python-dateutil==2.8.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -python-multipart==0.0.5 -requests==2.25.1 -six==1.15.0 -starlette==0.13.6; python_version >= '3.6' -tensorflow-hub==0.11.0 -typing-extensions==3.7.4.3 -urllib3==1.26.4; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' -uvicorn==0.13.4 +-i https://pypi.org/simple +certifi==2020.12.5 +chardet==4.0.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' +click==7.1.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' +cycler==0.10.0 +fastapi==0.63.0 +h11==0.12.0; python_version >= '3.6' +idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +kiwisolver==1.3.1; python_version >= '3.6' +matplotlib==3.3.4 +numpy==1.20.1 +opencv-python==4.5.1.48 +pillow==8.1.2 +protobuf==3.15.6 +pydantic==1.8.1; python_full_version >= '3.6.1' +pyparsing==2.4.7; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' +python-dateutil==2.8.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +python-multipart==0.0.5 +requests==2.25.1 +six==1.15.0 +starlette==0.13.6; python_version >= '3.6' +tensorflow-hub==0.7.0 +typing-extensions==3.7.4.3 +urllib3==1.26.4; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' +uvicorn==0.13.4 +opennsfw2 +tensorflow==2.8.4 +nsfw-detector==1.1.1 \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.yml similarity index 86% rename from docker-compose.prod.yml rename to docker-compose.yml index 512502a..390c6fc 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.yml @@ -2,6 +2,7 @@ version: '3' services: pyserver: + container_name: pyserver build: context: ./apps/pyserver target: prod @@ -11,7 +12,8 @@ services: - objid-network tensorflow: - image: intel/intel-optimized-tensorflow-serving:2.4.0 + container_name: tensorflow + image: tensorflow/serving:latest ports: - 8500:8500 - 8501:8501 diff --git a/readme.md b/readme.md index 3ba7653..58d8e54 100644 --- a/readme.md +++ b/readme.md @@ -17,21 +17,16 @@ ## How to use -1. Clone this repository: - ```shell - git clone https://github.com/TiagoPrata/FastAPI-TensorFlow-Docker.git - ``` - -2. Start containers: +1. **Start containers: ```shell - docker-compose -f docker-compose.prod.yml up -d + docker-compose -f docker-compose.yml up -d ``` *Note:* Edit the yml file to adjust the number of cores before starting the containers. -3. **That's it!** Now go to [http://localhost:5000](http://localhost:5000) and use it. +2. **That's it!** Now go to [http://localhost:5000](http://localhost:5000) and use it.
@@ -76,7 +71,7 @@ After any package update, re-export the Python dependencies. 3. Start the new containers: ```shell - docker-compose -f docker-compose.prod.yml up -d + docker-compose -f docker-compose.yml up -d ``` ### Debugging (bare metal)