Skip to content

Commit

Permalink
Merge pull request #48 from datalab-mi/develop
Browse files Browse the repository at this point in the history
v1.1 Develop
  • Loading branch information
leihuayi authored Apr 7, 2022
2 parents b81aa48 + 82af7f1 commit a5823b4
Show file tree
Hide file tree
Showing 59 changed files with 6,868 additions and 9,416 deletions.
6 changes: 6 additions & 0 deletions .github/release-drafter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ categories:
- title: '🐛 Bug Fixes'
labels:
- 'bug'
- title: '🔥 Enhancements'
labels:
- 'enhancement'
autolabeler:
- label: 'bug'
branch:
Expand All @@ -15,6 +18,9 @@ autolabeler:
branch:
- '/feature\/.+/'
- '/feat\/.+/'
- label: 'enhancement'
branch:
- '/enhancement\/.+/'
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
template: |
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI for pr
on:
pull_request:
types: [opened, reopened, synchronize]

jobs:
tag-pr:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Tag PR
uses: release-drafter/release-drafter@v5
with:
name: PR ${{ github.ref }}
prerelease: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

check-files-modified:
runs-on: ubuntu-latest
outputs:
changed_files: ${{ steps.changed-files.outputs.all_modified_files }}
steps:
- uses: actions/checkout@v2
- name: Verify Changed files
uses: tj-actions/[email protected]
id: changed-files
with:
base_sha: ${{ github.event.pull_request.base.sha }}

build-backend-and-test:
runs-on: ubuntu-latest
needs: check-files-modified
if: ( contains(needs.check-files-modified.outputs.changed_files, 'backend') )
steps:
- uses: actions/checkout@v2
- name: Build docker image for tests
run: docker build --target test -t basegun-back:tests backend/
- name: Start container
run: docker run --rm --name basegun_back_test -d basegun-back:tests
- name: Run tests
run: docker exec basegun_back_test python -m unittest discover -v
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
name: Push Docker image to GitHub container
name: CI for release
on:
push:
tags:
- 'v*'
pull_request:
types: [opened, reopened, synchronize]

jobs:
release_github:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- uses: actions/checkout@v2
- name: Get the version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
Expand All @@ -24,10 +22,8 @@ jobs:

build_and_push:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Check out the repo
uses: actions/checkout@v2
- uses: actions/checkout@v2
- name: Build the Docker image
run: TAG=action docker-compose -f docker-compose-prod.yml build
- name: Login to GHCR
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ env/
__pycache__
weights/
temp/
openrc.sh
openrc.sh
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
SHELL := /bin/bash
DOCKER := $(shell type -p docker)
DC := $(shell type -p docker-compose)
TAG := 1.0
TAG := 1.1

export

Expand Down Expand Up @@ -40,4 +40,4 @@ tag: show-current-tag

untag: show-current-tag
git tag -d v${TAG}
git push --delete v${TAG}
git push --delete origin v${TAG}
Empty file added apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 15 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
FROM python:3.9-slim-buster
FROM python:3.9-slim-buster as base
WORKDIR /app

# librairies necessary for image processing
RUN apt update && apt install -y \
libgl1-mesa-glx \
libglib2.0-0 \
curl \
&& rm -rf /var/lib/apt/lists/*

# install python libraries
Expand All @@ -14,5 +15,17 @@ RUN pip install --upgrade pip \
&& rm -r /root/.cache

# launch website
RUN curl -o model.pth https://storage.gra.cloud.ovh.net/v1/AUTH_df731a99a3264215b973b3dee70a57af/basegun-public/models/EffB7_2022-03-30_08/EffB7_2022-03-30_08.pth
COPY src/ src/
CMD ["uvicorn", "src.main:app", "--reload", "--host", "0.0.0.0", "--port", "5000"]
RUN mkdir -p src/weights && mv model.pth src/weights/model.pth

FROM base as dev
CMD ["uvicorn", "src.main:app", "--reload", "--host", "0.0.0.0", "--port", "5000"]

FROM base as test
RUN pip install requests && rm -r /root/.cache
COPY tests/ tests/
CMD ["uvicorn", "src.main:app", "--reload", "--host", "0.0.0.0", "--port", "5000"]

FROM base as prod
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "5000"]
22 changes: 13 additions & 9 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,17 @@ From this folder

## Install

**Download model** EfficientNetB7 from OVH Cloud, put it in src/weights/ :
```
ovhai data download GRA basegun-public models/EffB7_2022-03-01_17/EffB7_2022-03-01_17.pth -o src/weights/
```

### Without Docker
1. Activate python environment with Python=3.8 (conda or pyenv)
2. `pip install -r requirements.txt -f https://download.pytorch.org/whl/cpu/torch_stable.html`
2. Download model `curl https://storage.gra.cloud.ovh.net/v1/AUTH_df731a99a3264215b973b3dee70a57af/basegun-public/models/EffB7_2022-03-01_17/EffB7_2022-03-01_17.pth -o src/weights/model.pth`
3. `pip install -r requirements.txt -f https://download.pytorch.org/whl/cpu/torch_stable.html`

### With Docker
1. Create variable HTTP_PROXY in your shell for the Ministry proxy
2. Build image `docker build -t basegun-back:dev .`
Build image
```bash
docker build --target dev -t basegun-back:dev .
```
If you are in a network blocked with proxy, remember to add arg `--build_arg https_proxy` where `https_proxy` is a variable already set in your env.


## Run
Expand All @@ -31,6 +30,11 @@ uvicorn src.main:app --reload --host 0.0.0.0 --port 5000

### With Docker
```bash
docker run --rm -d -p 5000:5000 basegun-back:dev -e PATH_IMGS=/tmp/basegun/
docker run --rm -d -p 5000:5000 --name basegun_back basegun-back:dev -e PATH_IMGS=/tmp/basegun/
```
Remember afterwards to stop container `docker stop basegun_back`

## Run tests
1. Build image to target test `docker build --target test -t basegun-back:test .`
2. Start container `docker run --rm --name basegun_back_test -d basegun-back:test`
3. Execute tests `docker exec basegun_back_test python -m unittest discover -v`
30 changes: 22 additions & 8 deletions backend/src/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import shutil, os
import shutil
import os
from uuid import uuid4
from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.responses import PlainTextResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from src.model import load_model_inference, test_image
from PIL import UnidentifiedImageError
from src.model import load_model_inference, predict_image

app = FastAPI()

Expand All @@ -16,7 +19,7 @@

MODEL_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"weights/EffB7_2022-03-01_17.pth")
"weights/model.pth")

if "PATH_IMGS" in os.environ:
PATH_IMGS = os.environ["PATH_IMGS"]
Expand All @@ -25,6 +28,7 @@
os.path.dirname(os.path.abspath(__file__)),
"../../frontend/public/temp"))
print("WARNING: The variable PATH_IMGS is not set. Using", PATH_IMGS)
os.makedirs(PATH_IMGS, exist_ok = True)

# allow requests from front-end
app.add_middleware(
Expand All @@ -40,19 +44,29 @@
model = load_model_inference(MODEL_PATH)


@app.get("/")
@app.get("/", response_class=PlainTextResponse)
def home():
return "Basegun backend"


@app.post("/upload")
async def imageupload(image: UploadFile = File(...)):
if model:
input_path = os.path.join(PATH_IMGS, image.filename)
input_path = os.path.join(
PATH_IMGS, # store image in PATH_IMGS folder
# rename with uuid for secure filename but keep original file ext
str(uuid4()) + os.path.splitext(image.filename)[1]
)
with open(f'{input_path}', "wb") as buffer:
shutil.copyfileobj(image.file, buffer)
label, confidence = test_image(model, input_path)
print("Finished processing, result:", input_path, label, confidence)
try:
label, confidence = predict_image(model, input_path)
print("Finished processing, result:", input_path, label, confidence)
except UnidentifiedImageError:
raise HTTPException(status_code=400, detail="Corrupted image file")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

else:
raise HTTPException(status_code=404, detail="Model not found")
return {"file_name": input_path, "label": label, "confidence": confidence}
Expand Down
63 changes: 49 additions & 14 deletions backend/src/model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
from typing import Union
from PIL import Image
from torch import Tensor
import numpy as np
import torch
import torchvision.models as Model
Expand Down Expand Up @@ -83,18 +83,33 @@ def __call__(self, image):


def build_model(model: Model) -> Model:
# freeze first layers
"""Create the model structure
Args:
model (Model): raw torchvision model
Returns:
Model: modified model with classification layer size len(CLASSES)
"""
# freeze all layers except classification - very important
for param in model.parameters():
param.requires_grad = False
# Parameters of newly constructed modules have requires_grad=True by default
# replace last layer of model for our number of classes
num_ftrs = model.classifier[1].in_features
# to try later : add batch normalization and dropout
model.classifier[1] = torch.nn.Linear(num_ftrs, len(CLASSES))
model = model.to(device)
return model


def load_model_inference(state_dict_path: str) -> Model:
"""Load model structure and weights
Args:
state_dict_path (str): path to model (.pth file)
Returns:
Model: loaded model ready for prediction
"""
model = build_model(MODEL_TORCH())
# Initialize model with the pretrained weights
model.load_state_dict(torch.load(state_dict_path, map_location=device)['model_state_dict'])
Expand All @@ -104,19 +119,39 @@ def load_model_inference(state_dict_path: str) -> Model:
return model


loader = transforms.Compose([
ConvertRgb(),
Rescale(INPUT_SIZE),
RandomPad(INPUT_SIZE),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
def prepare_input(image: Image) -> torch.Tensor:
"""Convert a PIL Image to model-compatible input
Args:
image (Image): input image
Returns:
torch.Tensor: converted image
(shape (1, 3, size, size), normalized on ImageNet)
"""
loader = transforms.Compose([
ConvertRgb(),
Rescale(INPUT_SIZE),
RandomPad(INPUT_SIZE),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
image = loader(image).float()
return image.unsqueeze(0).to(device)


def test_image(model, path):
def predict_image(model: Model, path: str) -> Union[str, float]:
"""Run the model prediction on an image
Args:
model (Model): classification model
path (str): path to input image
Returns:
Union[str, float]: (label, confidence) of best class predicted
"""
im = Image.open(path)
image = loader(im).float()
image = image.unsqueeze(0).to(device)
image = prepare_input(im)
output = model(image)
probs = torch.nn.functional.softmax(output, dim=1).detach().numpy()[0]
res = [(CLASSES[i], round(probs[i]*100,2)) for i in range(len(CLASSES))]
Expand Down
Empty file added backend/tests/__init__.py
Empty file.
Binary file added backend/tests/revolver.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import sys
import unittest
import os
import requests
from PIL import Image, ImageChops
from src.main import PATH_IMGS

class TestModel(unittest.TestCase):
def __init__(self, *args, **kwargs):
super(TestModel, self).__init__(*args, **kwargs)
self.url = "http://localhost:5000"
self.assertTrue(os.path.exists(PATH_IMGS))

def test_home(self):
"""Checks that the route / is alive"""
r = requests.get(self.url)
self.assertEqual(r.text, "Basegun backend")

def test_upload(self):
"""Checks that the file upload works properly"""
path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"revolver.jpg")
with open(path, 'rb') as f:
r = requests.post(self.url + "/upload", files={"image": f})
res = r.json()

# checks that the input file has been written
self.assertTrue("file_name" in res.keys())
output_path = os.path.join(PATH_IMGS, res["file_name"])
self.assertTrue(os.path.exists(output_path))
# checks that written file is exactly the same as input file
with Image.open(path) as image_one:
with Image.open(output_path) as image_two:
self.assertEqual(image_one.size, image_two.size)
diff = ImageChops.difference(image_one, image_two)
self.assertFalse(diff.getbbox())
# checks that the json result is as expected
self.assertTrue("label" in res.keys())
self.assertEqual(res["label"], "revolver")
self.assertTrue("confidence" in res.keys())
self.assertAlmostEqual(res["confidence"], 99.05, places=1)
Loading

0 comments on commit a5823b4

Please sign in to comment.