Commit
Β·
67919d4
1
Parent(s):
6ab520d
chore: Add model service files
Browse files- .devcontainer/api/devcontainer.json +12 -0
- .devcontainer/model/devcontainer.json +12 -0
- .devcontainer/ui/devcontainer.json +12 -0
- .github/workflows/main.yml +35 -0
- model/Dockerfile +19 -0
- model/__init__.py +0 -0
- model/ml_service.py +107 -0
- model/requirements.txt +5 -0
- model/settings.py +18 -0
- model/tests/__init__.py +0 -0
- model/tests/dog.jpeg +3 -0
- model/tests/test_model.py +18 -0
.devcontainer/api/devcontainer.json
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "ML Project - API",
|
3 |
+
"dockerComposeFile": "../../docker-compose-dev.yml",
|
4 |
+
"service": "api",
|
5 |
+
"workspaceFolder": "/src",
|
6 |
+
"customizations": {
|
7 |
+
"vscode": {
|
8 |
+
"extensions": ["ms-python.python"]
|
9 |
+
}
|
10 |
+
},
|
11 |
+
"shutdownAction": "none"
|
12 |
+
}
|
.devcontainer/model/devcontainer.json
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "ML Project - Model",
|
3 |
+
"dockerComposeFile": "../../docker-compose-dev.yml",
|
4 |
+
"service": "model",
|
5 |
+
"workspaceFolder": "/src",
|
6 |
+
"customizations": {
|
7 |
+
"vscode": {
|
8 |
+
"extensions": ["ms-python.python"]
|
9 |
+
}
|
10 |
+
},
|
11 |
+
"shutdownAction": "none"
|
12 |
+
}
|
.devcontainer/ui/devcontainer.json
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "ML Project - UI",
|
3 |
+
"dockerComposeFile": "../../docker-compose-dev.yml",
|
4 |
+
"service": "ui",
|
5 |
+
"workspaceFolder": "/src",
|
6 |
+
"customizations": {
|
7 |
+
"vscode": {
|
8 |
+
"extensions": ["ms-python.python"]
|
9 |
+
}
|
10 |
+
},
|
11 |
+
"shutdownAction": "none"
|
12 |
+
}
|
.github/workflows/main.yml
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# This is a basic workflow to help you get started with Actions
|
2 |
+
|
3 |
+
name: CI
|
4 |
+
|
5 |
+
# Controls when the workflow will run
|
6 |
+
on:
|
7 |
+
# Triggers the workflow on push or pull request events but only for the "master" branch
|
8 |
+
push:
|
9 |
+
branches: [ "master" ]
|
10 |
+
pull_request:
|
11 |
+
branches: [ "master" ]
|
12 |
+
|
13 |
+
# Allows you to run this workflow manually from the Actions tab
|
14 |
+
workflow_dispatch:
|
15 |
+
|
16 |
+
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
17 |
+
jobs:
|
18 |
+
# This workflow contains a single job called "build"
|
19 |
+
build:
|
20 |
+
# The type of runner that the job will run on
|
21 |
+
runs-on: ubuntu-latest
|
22 |
+
|
23 |
+
# Steps represent a sequence of tasks that will be executed as part of the job
|
24 |
+
steps:
|
25 |
+
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
26 |
+
- uses: actions/checkout@v3
|
27 |
+
|
28 |
+
- name: Black Check
|
29 |
+
uses: RojerGS/python-black-check@master
|
30 |
+
with:
|
31 |
+
line-length: '88'
|
32 |
+
|
33 |
+
# Run all the containers specified in docker-compose.yml file
|
34 |
+
- name: Test containers build
|
35 |
+
run: docker-compose up -d --build
|
model/Dockerfile
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.8.13 AS base
|
2 |
+
|
3 |
+
ENV PYTHONPATH=$PYTHONPATH:/src/
|
4 |
+
|
5 |
+
ADD requirements.txt .
|
6 |
+
RUN pip3 install -r requirements.txt
|
7 |
+
|
8 |
+
ENV PYTHONPATH=$PYTHONPATH:/src/
|
9 |
+
|
10 |
+
COPY ./ /src/
|
11 |
+
|
12 |
+
WORKDIR /src
|
13 |
+
|
14 |
+
FROM base AS test
|
15 |
+
RUN ["pytest", "-v", "/src/tests"]
|
16 |
+
|
17 |
+
FROM base AS build
|
18 |
+
ENTRYPOINT ["python3", "/src/ml_service.py"]
|
19 |
+
|
model/__init__.py
ADDED
File without changes
|
model/ml_service.py
ADDED
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import os
|
3 |
+
import time
|
4 |
+
|
5 |
+
import numpy as np
|
6 |
+
import redis
|
7 |
+
import settings
|
8 |
+
from tensorflow.keras.applications import ResNet50
|
9 |
+
from tensorflow.keras.applications.resnet50 import decode_predictions, preprocess_input
|
10 |
+
from tensorflow.keras.preprocessing import image
|
11 |
+
|
12 |
+
# Connect to Redis and assign to variable db
|
13 |
+
db = redis.Redis(
|
14 |
+
host=settings.REDIS_IP, port=settings.REDIS_PORT, db=settings.REDIS_DB_ID
|
15 |
+
)
|
16 |
+
|
17 |
+
# Load ML model
|
18 |
+
model = ResNet50(include_top=True, weights="imagenet")
|
19 |
+
|
20 |
+
|
21 |
+
def predict(image_name):
|
22 |
+
"""
|
23 |
+
Load image from the corresponding folder based on the image name
|
24 |
+
received, then, run our ML model to get predictions.
|
25 |
+
|
26 |
+
Parameters
|
27 |
+
----------
|
28 |
+
image_name : str
|
29 |
+
Image filename.
|
30 |
+
|
31 |
+
Returns
|
32 |
+
-------
|
33 |
+
class_name, pred_probability : tuple(str, float)
|
34 |
+
Model predicted class as a string and the corresponding confidence
|
35 |
+
score as a number.
|
36 |
+
"""
|
37 |
+
class_name = None
|
38 |
+
pred_probability = None
|
39 |
+
|
40 |
+
# Get image path
|
41 |
+
image_path = os.path.join(settings.UPLOAD_FOLDER, image_name)
|
42 |
+
|
43 |
+
# Load image
|
44 |
+
img = image.load_img(image_path, target_size=(224, 224))
|
45 |
+
|
46 |
+
# Apply preprocessing (convert to numpy array, match model input dimensions (including batch) and use the resnet50 preprocessing)
|
47 |
+
# Convert Pillow image to np.array
|
48 |
+
x = image.img_to_array(img)
|
49 |
+
|
50 |
+
# Add an extra dimension because the model is expecting as input a batch of images
|
51 |
+
x_batch = np.expand_dims(x, axis=0)
|
52 |
+
|
53 |
+
# Scaled pixels values
|
54 |
+
x_batch = preprocess_input(x_batch)
|
55 |
+
|
56 |
+
# Make predictions
|
57 |
+
predictions = model.predict(x_batch)
|
58 |
+
|
59 |
+
# Get predictions using model methods and decode predictions using resnet50 decode_predictions
|
60 |
+
top_pred = decode_predictions(predictions, top=1)[0][0] # imagenet_id, label, score
|
61 |
+
_, class_name, pred_probability = top_pred
|
62 |
+
|
63 |
+
# Convert probabilities to float and round it
|
64 |
+
pred_probability = round(float(pred_probability), 4)
|
65 |
+
|
66 |
+
return class_name, pred_probability
|
67 |
+
|
68 |
+
|
69 |
+
def classify_process():
|
70 |
+
"""
|
71 |
+
Loop indefinitely asking Redis for new jobs.
|
72 |
+
When a new job arrives, takes it from the Redis queue, uses the loaded ML
|
73 |
+
model to get predictions and stores the results back in Redis using
|
74 |
+
the original job ID so other services can see it was processed and access
|
75 |
+
the results.
|
76 |
+
|
77 |
+
Load image from the corresponding folder based on the image name
|
78 |
+
received, then, run our ML model to get predictions.
|
79 |
+
"""
|
80 |
+
while True:
|
81 |
+
# Take a new job from Redis
|
82 |
+
q = db.brpop(settings.REDIS_QUEUE)[1]
|
83 |
+
|
84 |
+
# Decode the JSON data for the given job
|
85 |
+
q = json.loads(q.decode("utf-8"))
|
86 |
+
|
87 |
+
# Important! Get and keep the original job ID
|
88 |
+
job_id = q["id"]
|
89 |
+
|
90 |
+
# Run the loaded ml model (use the predict() function)
|
91 |
+
prediction, score = predict(q["image_name"]) # π Verify image name
|
92 |
+
|
93 |
+
# Prepare a new JSON with the results
|
94 |
+
output = {"prediction": prediction, "score": score}
|
95 |
+
|
96 |
+
# Store the job results on Redis using the original
|
97 |
+
# job ID as the key
|
98 |
+
db.set(job_id, json.dumps(output))
|
99 |
+
|
100 |
+
# Sleep for a bit
|
101 |
+
time.sleep(settings.SERVER_SLEEP)
|
102 |
+
|
103 |
+
|
104 |
+
if __name__ == "__main__":
|
105 |
+
# Now launch process
|
106 |
+
print("Launching ML service...")
|
107 |
+
classify_process()
|
model/requirements.txt
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Pillow==9.0.1
|
2 |
+
pytest==7.1.1
|
3 |
+
redis==4.1.4
|
4 |
+
tensorflow==2.8.0
|
5 |
+
protobuf==3.20.0
|
model/settings.py
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
|
3 |
+
# We will store images uploaded by the user on this folder
|
4 |
+
UPLOAD_FOLDER = "uploads/"
|
5 |
+
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
6 |
+
|
7 |
+
# REDIS
|
8 |
+
|
9 |
+
# Queue name
|
10 |
+
REDIS_QUEUE = "service_queue"
|
11 |
+
# Port
|
12 |
+
REDIS_PORT = 6379
|
13 |
+
# DB Id
|
14 |
+
REDIS_DB_ID = 0
|
15 |
+
# Host IP
|
16 |
+
REDIS_IP = os.getenv("REDIS_IP", "redis")
|
17 |
+
# Sleep parameters which manages the interval between requests to our redis queue
|
18 |
+
SERVER_SLEEP = 0.05
|
model/tests/__init__.py
ADDED
File without changes
|
model/tests/dog.jpeg
ADDED
![]() |
Git LFS Details
|
model/tests/test_model.py
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import unittest
|
2 |
+
|
3 |
+
import ml_service
|
4 |
+
|
5 |
+
|
6 |
+
# π‘ NOTE Run test with:
|
7 |
+
# - python3 -m unittest -vvv tests.test_model
|
8 |
+
# - python3 tests/test_model.py
|
9 |
+
class TestMLService(unittest.TestCase):
|
10 |
+
def test_predict(self):
|
11 |
+
ml_service.settings.UPLOAD_FOLDER = "tests"
|
12 |
+
class_name, pred_probability = ml_service.predict("dog.jpeg")
|
13 |
+
self.assertEqual(class_name, "Eskimo_dog")
|
14 |
+
self.assertAlmostEqual(pred_probability, 0.9346, 5)
|
15 |
+
|
16 |
+
|
17 |
+
if __name__ == "__main__":
|
18 |
+
unittest.main(verbosity=2)
|