Skip to content

Commit 37abcd2

Browse files
authoredDec 10, 2019
Merge pull request #61 from Azure-Samples/UpgradingToLatestCustomVisionDockerImage
Upgrading to latest custom vision docker image
2 parents 40c8d24 + 33dcda6 commit 37abcd2

15 files changed

+324
-278
lines changed
 

‎.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
55

66
# User-specific files
7+
*.env
78
*.suo
89
*.user
910
*.userosscache

‎modules/CameraCapture/app/CameraCapture.py

+83-114
Large diffs are not rendered by default.

‎modules/CameraCapture/module.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"image": {
55
"repository": "$CONTAINER_REGISTRY_ADDRESS/cameracapture",
66
"tag": {
7-
"version": "0.2.8",
7+
"version": "0.2.11",
88
"platforms": {
99
"amd64": "./amd64.Dockerfile",
1010
"arm32v7": "./arm32v7.Dockerfile",
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
FROM tensorflow/tensorflow:latest-py3
1+
FROM python:3.7-slim
22

3-
RUN echo "BUILD MODULE: ImageClassifierService"
3+
RUN pip install -U pip
4+
RUN pip install numpy==1.17.3 tensorflow==2.0.0 flask pillow
45

5-
COPY /build/amd64-requirements.txt amd64-requirements.txt
6-
7-
# Install Python packages
8-
RUN pip install -r amd64-requirements.txt
9-
10-
ADD app /app
6+
COPY app /app
117

128
# Expose the port
139
EXPOSE 80
@@ -16,4 +12,4 @@ EXPOSE 80
1612
WORKDIR /app
1713

1814
# Run the flask server for the endpoints
19-
CMD python app.py
15+
CMD python -u app.py

‎modules/ImageClassifierService/app/app.py

+24-13
Original file line numberDiff line numberDiff line change
@@ -4,64 +4,75 @@
44
import io
55

66
# Imports for the REST API
7-
from flask import Flask, request
7+
from flask import Flask, request, jsonify
88

99
# Imports for image procesing
1010
from PIL import Image
11-
#import scipy
12-
#from scipy import misc
1311

1412
# Imports for prediction
1513
from predict import initialize, predict_image, predict_url
1614

1715
app = Flask(__name__)
1816

1917
# 4MB Max image size limit
20-
app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024
18+
app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024
2119

2220
# Default route just shows simple text
2321
@app.route('/')
2422
def index():
2523
return 'CustomVision.ai model host harness'
2624

2725
# Like the CustomVision.ai Prediction service /image route handles either
28-
# - octet-stream image file
26+
# - octet-stream image file
2927
# - a multipart/form-data with files in the imageData parameter
3028
@app.route('/image', methods=['POST'])
31-
def predict_image_handler():
29+
@app.route('/<project>/image', methods=['POST'])
30+
@app.route('/<project>/image/nostore', methods=['POST'])
31+
@app.route('/<project>/classify/iterations/<publishedName>/image', methods=['POST'])
32+
@app.route('/<project>/classify/iterations/<publishedName>/image/nostore', methods=['POST'])
33+
@app.route('/<project>/detect/iterations/<publishedName>/image', methods=['POST'])
34+
@app.route('/<project>/detect/iterations/<publishedName>/image/nostore', methods=['POST'])
35+
def predict_image_handler(project=None, publishedName=None):
3236
try:
3337
imageData = None
3438
if ('imageData' in request.files):
3539
imageData = request.files['imageData']
40+
elif ('imageData' in request.form):
41+
imageData = request.form['imageData']
3642
else:
3743
imageData = io.BytesIO(request.get_data())
3844

39-
#img = scipy.misc.imread(imageData)
4045
img = Image.open(imageData)
4146
results = predict_image(img)
42-
return json.dumps(results)
47+
return jsonify(results)
4348
except Exception as e:
4449
print('EXCEPTION:', str(e))
4550
return 'Error processing image', 500
4651

4752

4853
# Like the CustomVision.ai Prediction service /url route handles url's
4954
# in the body of hte request of the form:
50-
# { 'Url': '<http url>'}
55+
# { 'Url': '<http url>'}
5156
@app.route('/url', methods=['POST'])
52-
def predict_url_handler():
57+
@app.route('/<project>/url', methods=['POST'])
58+
@app.route('/<project>/url/nostore', methods=['POST'])
59+
@app.route('/<project>/classify/iterations/<publishedName>/url', methods=['POST'])
60+
@app.route('/<project>/classify/iterations/<publishedName>/url/nostore', methods=['POST'])
61+
@app.route('/<project>/detect/iterations/<publishedName>/url', methods=['POST'])
62+
@app.route('/<project>/detect/iterations/<publishedName>/url/nostore', methods=['POST'])
63+
def predict_url_handler(project=None, publishedName=None):
5364
try:
54-
image_url = json.loads(request.get_data())['Url']
65+
image_url = json.loads(request.get_data().decode('utf-8'))['url']
5566
results = predict_url(image_url)
56-
return json.dumps(results)
67+
return jsonify(results)
5768
except Exception as e:
5869
print('EXCEPTION:', str(e))
5970
return 'Error processing image'
6071

61-
6272
if __name__ == '__main__':
6373
# Load and intialize the model
6474
initialize()
6575

6676
# Run the server
6777
app.run(host='0.0.0.0', port=80)
78+
+187-87
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,221 @@
11

22
from urllib.request import urlopen
3+
from datetime import datetime
34

4-
import tensorflow.compat.v1 as tf
5+
import tensorflow as tf
56

67
from PIL import Image
78
import numpy as np
8-
# import scipy
9-
# from scipy import misc
109
import sys
11-
import os
1210

1311
filename = 'model.pb'
1412
labels_filename = 'labels.txt'
1513

16-
mean_values_b_g_r = (0, 0, 0)
14+
network_input_size = 0
1715

18-
size = (256, 256)
1916
output_layer = 'loss:0'
2017
input_node = 'Placeholder:0'
2118

22-
graph_def = tf.GraphDef()
19+
graph_def = tf.compat.v1.GraphDef()
2320
labels = []
2421

25-
2622
def initialize():
27-
print('Loading model...', end=''),
28-
with tf.gfile.FastGFile(filename, 'rb') as f:
23+
print('Loading model...',end=''),
24+
with open(filename, 'rb') as f:
2925
graph_def.ParseFromString(f.read())
3026
tf.import_graph_def(graph_def, name='')
27+
28+
# Retrieving 'network_input_size' from shape of 'input_node'
29+
with tf.compat.v1.Session() as sess:
30+
input_tensor_shape = sess.graph.get_tensor_by_name(input_node).shape.as_list()
31+
32+
assert len(input_tensor_shape) == 4
33+
assert input_tensor_shape[1] == input_tensor_shape[2]
34+
35+
global network_input_size
36+
network_input_size = input_tensor_shape[1]
37+
3138
print('Success!')
3239
print('Loading labels...', end='')
3340
with open(labels_filename, 'rt') as lf:
34-
for l in lf:
35-
l = l[:-1]
36-
labels.append(l)
41+
global labels
42+
labels = [l.strip() for l in lf.readlines()]
3743
print(len(labels), 'found. Success!')
3844

39-
40-
def crop_center(img, cropx, cropy):
41-
y, x, z = img.shape
42-
startx = x//2-(cropx//2)
43-
starty = y//2-(cropy//2)
44-
print('crop_center: ', x, 'x', y, 'to', cropx, 'x', cropy)
45+
def log_msg(msg):
46+
print("{}: {}".format(datetime.now(),msg))
47+
48+
def extract_bilinear_pixel(img, x, y, ratio, xOrigin, yOrigin):
49+
xDelta = (x + 0.5) * ratio - 0.5
50+
x0 = int(xDelta)
51+
xDelta -= x0
52+
x0 += xOrigin
53+
if x0 < 0:
54+
x0 = 0;
55+
x1 = 0;
56+
xDelta = 0.0;
57+
elif x0 >= img.shape[1]-1:
58+
x0 = img.shape[1]-1;
59+
x1 = img.shape[1]-1;
60+
xDelta = 0.0;
61+
else:
62+
x1 = x0 + 1;
63+
64+
yDelta = (y + 0.5) * ratio - 0.5
65+
y0 = int(yDelta)
66+
yDelta -= y0
67+
y0 += yOrigin
68+
if y0 < 0:
69+
y0 = 0;
70+
y1 = 0;
71+
yDelta = 0.0;
72+
elif y0 >= img.shape[0]-1:
73+
y0 = img.shape[0]-1;
74+
y1 = img.shape[0]-1;
75+
yDelta = 0.0;
76+
else:
77+
y1 = y0 + 1;
78+
79+
#Get pixels in four corners
80+
bl = img[y0, x0]
81+
br = img[y0, x1]
82+
tl = img[y1, x0]
83+
tr = img[y1, x1]
84+
#Calculate interpolation
85+
b = xDelta * br + (1. - xDelta) * bl
86+
t = xDelta * tr + (1. - xDelta) * tl
87+
pixel = yDelta * t + (1. - yDelta) * b
88+
return pixel
89+
90+
def extract_and_resize(img, targetSize):
91+
determinant = img.shape[1] * targetSize[0] - img.shape[0] * targetSize[1]
92+
if determinant < 0:
93+
ratio = float(img.shape[1]) / float(targetSize[1])
94+
xOrigin = 0
95+
yOrigin = int(0.5 * (img.shape[0] - ratio * targetSize[0]))
96+
elif determinant > 0:
97+
ratio = float(img.shape[0]) / float(targetSize[0])
98+
xOrigin = int(0.5 * (img.shape[1] - ratio * targetSize[1]))
99+
yOrigin = 0
100+
else:
101+
ratio = float(img.shape[0]) / float(targetSize[0])
102+
xOrigin = 0
103+
yOrigin = 0
104+
resize_image = np.empty((targetSize[0], targetSize[1], img.shape[2]), dtype=np.float32)
105+
for y in range(targetSize[0]):
106+
for x in range(targetSize[1]):
107+
resize_image[y, x] = extract_bilinear_pixel(img, x, y, ratio, xOrigin, yOrigin)
108+
return resize_image
109+
110+
def extract_and_resize_to_256_square(image):
111+
h, w = image.shape[:2]
112+
log_msg("crop_center: " + str(w) + "x" + str(h) +" and resize to " + str(256) + "x" + str(256))
113+
return extract_and_resize(image, (256, 256))
114+
115+
def crop_center(img,cropx,cropy):
116+
h, w = img.shape[:2]
117+
startx = max(0, w//2-(cropx//2))
118+
starty = max(0, h//2-(cropy//2))
119+
log_msg("crop_center: " + str(w) + "x" + str(h) +" to " + str(cropx) + "x" + str(cropy))
45120
return img[starty:starty+cropy, startx:startx+cropx]
46121

122+
def resize_down_to_1600_max_dim(image):
123+
w,h = image.size
124+
if h < 1600 and w < 1600:
125+
return image
126+
127+
new_size = (1600 * w // h, 1600) if (h > w) else (1600, 1600 * h // w)
128+
log_msg("resize: " + str(w) + "x" + str(h) + " to " + str(new_size[0]) + "x" + str(new_size[1]))
129+
if max(new_size) / max(image.size) >= 0.5:
130+
method = Image.BILINEAR
131+
else:
132+
method = Image.BICUBIC
133+
return image.resize(new_size, method)
47134

48135
def predict_url(imageUrl):
49-
print('Predicting from url: ', imageUrl)
136+
log_msg("Predicting from url: " +imageUrl)
50137
with urlopen(imageUrl) as testImage:
51-
# image = scipy.misc.imread(testImage)
52138
image = Image.open(testImage)
53139
return predict_image(image)
54140

55-
141+
def convert_to_nparray(image):
142+
# RGB -> BGR
143+
log_msg("Convert to numpy array")
144+
image = np.array(image)
145+
return image[:, :, (2,1,0)]
146+
147+
def update_orientation(image):
148+
exif_orientation_tag = 0x0112
149+
if hasattr(image, '_getexif'):
150+
exif = image._getexif()
151+
if exif != None and exif_orientation_tag in exif:
152+
orientation = exif.get(exif_orientation_tag, 1)
153+
log_msg('Image has EXIF Orientation: ' + str(orientation))
154+
# orientation is 1 based, shift to zero based and flip/transpose based on 0-based values
155+
orientation -= 1
156+
if orientation >= 4:
157+
image = image.transpose(Image.TRANSPOSE)
158+
if orientation == 2 or orientation == 3 or orientation == 6 or orientation == 7:
159+
image = image.transpose(Image.FLIP_TOP_BOTTOM)
160+
if orientation == 1 or orientation == 2 or orientation == 5 or orientation == 6:
161+
image = image.transpose(Image.FLIP_LEFT_RIGHT)
162+
return image
163+
56164
def predict_image(image):
57-
print('Predicting image')
58-
tf.reset_default_graph()
59-
tf.import_graph_def(graph_def, name='')
60-
61-
with tf.Session() as sess:
62-
prob_tensor = sess.graph.get_tensor_by_name(output_layer)
63-
64-
input_tensor_shape = sess.graph.get_tensor_by_name(
65-
'Placeholder:0').shape.as_list()
66-
network_input_size = input_tensor_shape[1]
67-
68-
# w = image.shape[0]
69-
# h = image.shape[1]
70-
w, h = image.size
71-
print('Image size', w, 'x', h)
72-
73-
# scaling
74-
if w > h:
75-
new_size = (int((float(size[1]) / h) * w), size[1], 3)
76-
else:
77-
new_size = (size[0], int((float(size[0]) / w) * h), 3)
78-
79-
# resize
80-
if not (new_size[0] == w and new_size[0] == h):
81-
print('Resizing to', new_size[0], 'x', new_size[1])
82-
#augmented_image = scipy.misc.imresize(image, new_size)
83-
augmented_image = np.asarray(
84-
image.resize((new_size[0], new_size[1])))
85-
else:
86-
augmented_image = np.asarray(image)
87-
88-
# crop center
89-
try:
90-
augmented_image = crop_center(
91-
augmented_image, network_input_size, network_input_size)
92-
except:
93-
return 'error: crop_center'
94-
95-
augmented_image = augmented_image.astype(float)
96-
97-
# RGB -> BGR
98-
red, green, blue = tf.split(
99-
axis=2, num_or_size_splits=3, value=augmented_image)
100-
101-
image_normalized = tf.concat(axis=2, values=[
102-
blue - mean_values_b_g_r[0],
103-
green - mean_values_b_g_r[1],
104-
red - mean_values_b_g_r[2],
105-
])
106-
107-
image_normalized = image_normalized.eval()
108-
image_normalized = np.expand_dims(image_normalized, axis=0)
109-
110-
predictions, = sess.run(prob_tensor, {input_node: image_normalized})
111-
112-
result = []
113-
idx = 0
114-
for p in predictions:
115-
truncated_probablity = np.float64(round(p, 8))
116-
if (truncated_probablity > 1e-8):
117-
result.append(
118-
{'Tag': labels[idx], 'Probability': truncated_probablity})
119-
idx += 1
120-
print('Results: ', str(result))
121-
return result
165+
166+
log_msg('Predicting image')
167+
try:
168+
if image.mode != "RGB":
169+
log_msg("Converting to RGB")
170+
image = image.convert("RGB")
171+
172+
w,h = image.size
173+
log_msg("Image size: " + str(w) + "x" + str(h))
174+
175+
# Update orientation based on EXIF tags
176+
image = update_orientation(image)
177+
178+
# If the image has either w or h greater than 1600 we resize it down respecting
179+
# aspect ratio such that the largest dimention is 1600
180+
image = resize_down_to_1600_max_dim(image)
181+
182+
# Convert image to numpy array
183+
image = convert_to_nparray(image)
184+
185+
# Crop the center square and resize that square down to 256x256
186+
resized_image = extract_and_resize_to_256_square(image)
187+
188+
# Crop the center for the specified network_input_Size
189+
cropped_image = crop_center(resized_image, network_input_size, network_input_size)
190+
191+
tf.compat.v1.reset_default_graph()
192+
tf.import_graph_def(graph_def, name='')
193+
194+
with tf.compat.v1.Session() as sess:
195+
prob_tensor = sess.graph.get_tensor_by_name(output_layer)
196+
predictions, = sess.run(prob_tensor, {input_node: [cropped_image] })
197+
198+
result = []
199+
for p, label in zip(predictions, labels):
200+
truncated_probablity = np.float64(round(p,8))
201+
if truncated_probablity > 1e-8:
202+
result.append({
203+
'tagName': label,
204+
'probability': truncated_probablity,
205+
'tagId': '',
206+
'boundingBox': None })
207+
208+
response = {
209+
'id': '',
210+
'project': '',
211+
'iteration': '',
212+
'created': datetime.utcnow().isoformat(),
213+
'predictions': result
214+
}
215+
216+
log_msg("Results: " + str(response))
217+
return response
218+
219+
except Exception as e:
220+
log_msg(str(e))
221+
return 'Error: Could not preprocess image for prediction. ' + str(e)
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,21 @@
1-
FROM balenalib/raspberrypi3:stretch
2-
# The balena base image for building apps on Raspberry Pi 3.
3-
# Raspbian Stretch required for piwheels support. https://downloads.raspberrypi.org/raspbian/images/raspbian-2019-04-09/
4-
5-
RUN echo "BUILD MODULE: ImageClassifierService"
1+
FROM balenalib/raspberrypi3-debian-python:3.7
62

73
RUN [ "cross-build-start" ]
84

9-
# Install dependencies
10-
RUN install_packages \
11-
python3 \
12-
python3-pip \
13-
python3-dev \
14-
build-essential \
15-
libopenjp2-7-dev \
16-
libtiff5-dev \
17-
zlib1g-dev \
18-
libjpeg-dev \
19-
libatlas-base-dev \
20-
wget
21-
22-
# Install Python packages
23-
COPY /build/arm32v7-requirements.txt ./
24-
RUN pip3 install --upgrade pip
25-
RUN pip3 install --upgrade setuptools
26-
RUN pip3 install --index-url=https://www.piwheels.org/simple -r arm32v7-requirements.txt
5+
RUN apt update && apt install -y libjpeg62-turbo libopenjp2-7 libtiff5 libatlas-base-dev
6+
RUN pip install absl-py six protobuf wrapt gast astor termcolor keras_applications keras_preprocessing --no-deps
7+
RUN pip install numpy==1.16 tensorflow==1.13.1 --extra-index-url 'https://www.piwheels.org/simple' --no-deps
8+
RUN pip install flask pillow --index-url 'https://www.piwheels.org/simple'
279

28-
# Cleanup
29-
RUN rm -rf /var/lib/apt/lists/* \
30-
&& apt-get -y autoremove
31-
32-
RUN [ "cross-build-end" ]
33-
34-
# Add the application
35-
ADD app /app
10+
COPY app /app
3611

3712
# Expose the port
3813
EXPOSE 80
3914

4015
# Set the working directory
4116
WORKDIR /app
4217

18+
RUN [ "cross-build-end" ]
19+
4320
# Run the flask server for the endpoints
44-
CMD ["python3","app.py"]
21+
CMD python -u app.py

‎modules/ImageClassifierService/build/amd64-requirements.txt

-3
This file was deleted.

‎modules/ImageClassifierService/build/arm32v7-requirements.txt

-4
This file was deleted.

‎modules/ImageClassifierService/module.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"image": {
55
"repository": "$CONTAINER_REGISTRY_ADDRESS/imageclassifierservice",
66
"tag": {
7-
"version": "0.2.5",
7+
"version": "0.2.6",
88
"platforms": {
99
"amd64": "./amd64.Dockerfile",
1010
"arm32v7": "./arm32v7.Dockerfile"
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
class MessageParser:
22
# Returns the highest probablity tag in the json object (takes the output as json.loads as input)
3-
def highestProbabilityTagMeetingThreshold(self, allTagsAndProbability, threshold):
3+
def highestProbabilityTagMeetingThreshold(self, message, threshold):
44
highestProbabilityTag = 'none'
55
highestProbability = 0
6-
for item in allTagsAndProbability:
7-
if item['Probability'] > highestProbability and item['Probability'] > threshold:
8-
highestProbability = item['Probability']
9-
highestProbabilityTag = item['Tag']
6+
for prediction in message['predictions']:
7+
if prediction['probability'] > highestProbability and prediction['probability'] > threshold:
8+
highestProbability = prediction['probability']
9+
highestProbabilityTag = prediction['tagName']
1010
return highestProbabilityTag

‎modules/SenseHatDisplay/app/__init__.py

Whitespace-only changes.

‎modules/SenseHatDisplay/app/main.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def __init__(self):
4747
self.client_protocol = protocol
4848
self.client = IoTHubModuleClient()
4949
self.client.create_from_environment(protocol)
50-
self.client.set_option("logtrace", 1) # enables MQTT logging
50+
self.client.set_option("logtrace", 0) # enables MQTT logging
5151
self.client.set_option("messageTimeout", 10000)
5252

5353
# sets the callback when a message arrives on "input1" queue. Messages sent to

‎modules/SenseHatDisplay/module.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"image": {
55
"repository": "$CONTAINER_REGISTRY_ADDRESS/sensehatdisplay",
66
"tag": {
7-
"version": "0.2.12",
7+
"version": "0.2.13",
88
"platforms": {
99
"arm32v7": "./arm32v7.Dockerfile",
1010
"test-arm32v7": "./test/test-arm32v7.Dockerfile"

‎modules/SenseHatDisplay/test/UnitTests.py

+6-7
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
1-
import app.MessageParser
21
import unittest
32
import json
43
import sys
54
sys.path.insert(0, '../')
6-
5+
import app.MessageParser
76

87
class UnitTests(unittest.TestCase):
98
def test_HighestProbabilityTagMeetingThreshold(self):
109
MessageParser = app.MessageParser.MessageParser()
1110
message1 = json.loads(
12-
"[{\"Tag\": \"banana\",\"Probability\": 0.4}, {\"Tag\": \"apple\",\"Probability\": 0.3}]")
11+
"{\"iteration\": \"\",\"id\": \"\",\"predictions\": [{\"probability\": 0.3,\"tagName\": \"Apple\",\"tagId\": \"\",\"boundingBox\": null},{\"probability\": 0.4,\"tagName\": \"Banana\",\"tagId\": \"\",\"boundingBox\": null}],\"project\": \"\",\"created\": \"2019-12-10T04:37:49.657555\"}")
1312
self.assertEqual(
1413
MessageParser.highestProbabilityTagMeetingThreshold(message1, 0.5), 'none')
1514
message2 = json.loads(
16-
"[{\"Tag\": \"banana\",\"Probability\": 0.4}, {\"Tag\": \"apple\",\"Probability\": 0.5}]")
15+
"{\"iteration\": \"\",\"id\": \"\",\"predictions\": [{\"probability\": 0.5,\"tagName\": \"Apple\",\"tagId\": \"\",\"boundingBox\": null},{\"probability\": 0.4,\"tagName\": \"Banana\",\"tagId\": \"\",\"boundingBox\": null}],\"project\": \"\",\"created\": \"2019-12-10T04:37:49.657555\"}")
1716
self.assertEqual(MessageParser.highestProbabilityTagMeetingThreshold(
18-
message2, 0.3), 'apple')
17+
message2, 0.3), 'Apple')
1918
message3 = json.loads(
20-
"[{\"Probability\": 0.038001421838998795, \"Tag\": \"apple\"}, {\"Probability\": 0.38567957282066345, \"Tag\": \"banana\"}]")
19+
"{\"iteration\": \"\",\"id\": \"\",\"predictions\": [{\"probability\": 0.038001421838998795,\"tagName\": \"Apple\",\"tagId\": \"\",\"boundingBox\": null},{\"probability\": 0.38567957282066345,\"tagName\": \"Banana\",\"tagId\": \"\",\"boundingBox\": null}],\"project\": \"\",\"created\": \"2019-12-10T04:37:49.657555\"}")
2120
self.assertEqual(MessageParser.highestProbabilityTagMeetingThreshold(
22-
message3, 0.3), 'banana')
21+
message3, 0.3), 'Banana')
2322

2423

2524
if __name__ == '__main__':

0 commit comments

Comments
 (0)
Please sign in to comment.