32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377 | class TeachableMachine:
"""
Create a TeachableMachine object to run pre-trained AI models.
"""
SUPPORTED_TYPES = {"keras", "h5"}
IMAGE_SIZE = (224, 224)
def __init__(
self,
model_path="keras_model.h5",
labels_file_path="labels.txt",
model_type="h5",
) -> None:
self._model_type = model_type.lower()
if self._model_type not in self.SUPPORTED_TYPES:
raise ValueError(
f"Unsupported model type: {self._model_type}. Use 'keras' or 'h5'."
)
np.set_printoptions(suppress=True)
self._load_model(model_path)
self._load_labels(labels_file_path)
print("Teachable Machine Object is created successfully.")
def _load_model(self, model_path: str):
try:
self._model = load_model(
model_path,
compile=False,
custom_objects={"DepthwiseConv2D": _CompatDepthwiseConv2D},
)
return
except IOError as e:
print("LoadingModelError: Error while loading Teachable Machine model")
raise IOError("Error loading model") from e
except Exception as e:
# Some Teachable Machine .h5 exports save the model as a Sequential
# that wraps nested Sequential/Functional submodels (Sequential >
# [InputLayer, Sequential > [InputLayer, Functional backbone,
# GlobalAveragePooling2D], Sequential > [InputLayer, Dense, Dense]]).
# Keras 3's legacy H5 loader mis-rebuilds that shape: when the
# Functional backbone is called as a plain layer inside
# Sequential.build(), its single output comes back wrapped in a
# length-1 list, which the next layer then treats as two separate
# inputs, failing with e.g. "expects 1 input(s), but it received 2
# input tensors". Fall back to a manual reconstruction for that case.
try:
self._model = self._load_nested_sequential_h5(model_path)
return
except Exception:
pass
print("LoadingModelError: Error while loading Teachable Machine model")
raise FileNotFoundError("Model file not found") from e
def _load_nested_sequential_h5(self, model_path: str):
"""
Rebuild a Teachable Machine .h5 export whose Sequential wraps nested
Sequential/Functional submodels, working around the Keras 3 legacy H5
loader bug described in `_load_model`.
Flattens the nested Sequential wrappers (keeping only the first
InputLayer and leaving any Functional submodels intact), rebuilds the
layers in order via the Functional API -- unwrapping the length-1
list a Functional submodel's call returns -- and then restores
weights by matching each leaf layer's name against the H5 file's
saved weight paths, sidestepping Keras's built-in weight loader
(which expects the now-discarded nested-Sequential grouping).
"""
import h5py
from keras import Model
from keras.layers import Input
from keras.src.legacy.saving import saving_utils as legacy_saving_utils
custom_objects = {"DepthwiseConv2D": _CompatDepthwiseConv2D}
with h5py.File(model_path, "r") as f:
raw_config = f.attrs.get("model_config")
if raw_config is None:
raise ValueError("No model_config found in H5 file")
if isinstance(raw_config, bytes):
raw_config = raw_config.decode("utf-8")
full_config = json.loads(raw_config)
if full_config.get("class_name") != "Sequential":
raise ValueError("Not a nested-Sequential export; nothing to flatten")
flat_layers = self._flatten_sequential_layers(full_config)
if not flat_layers or flat_layers[0]["class_name"] != "InputLayer":
raise ValueError("Unexpected model shape after flattening")
input_shape = flat_layers[0]["config"]["batch_input_shape"][1:]
x = Input(shape=input_shape)
model_input = x
leaf_layers = []
for layer_config in flat_layers[1:]:
if layer_config["class_name"] == "Functional":
sub_model = Model.from_config(
layer_config["config"], custom_objects=custom_objects
)
x = sub_model(x)
if isinstance(x, (list, tuple)) and len(x) == 1:
x = x[0]
leaf_layers.extend(sub_model.layers)
else:
layer = legacy_saving_utils.model_from_config(
layer_config, custom_objects=custom_objects
)
x = layer(x)
leaf_layers.append(layer)
model = Model(inputs=model_input, outputs=x)
self._restore_weights_by_leaf_name(model_path, leaf_layers)
return model
@staticmethod
def _flatten_sequential_layers(layer_config, _seen_input=None):
"""
Inline nested Sequential layer lists, keeping only the first
InputLayer seen overall and leaving Functional submodels intact.
"""
if _seen_input is None:
_seen_input = [False]
if layer_config["class_name"] != "Sequential":
return [layer_config]
flattened = []
for sub_layer_config in layer_config["config"]["layers"]:
if sub_layer_config["class_name"] == "InputLayer":
if not _seen_input[0]:
flattened.append(sub_layer_config)
_seen_input[0] = True
continue
flattened.extend(
TeachableMachine._flatten_sequential_layers(
sub_layer_config, _seen_input
)
)
return flattened
@staticmethod
def _restore_weights_by_leaf_name(model_path, leaf_layers):
"""
Load weights for `leaf_layers` by matching each layer's name against
the H5 file's saved weight paths, regardless of the (now-discarded)
nested-Sequential grouping those paths were originally saved under.
"""
import h5py
with h5py.File(model_path, "r") as f:
root = f["model_weights"]
leaf_paths = {}
def walk(group):
"""Recurse into `group`, indexing every leaf layer's saved
weight paths (from its 'weight_names' attribute) by leaf
layer name."""
weight_names = group.attrs.get("weight_names")
if weight_names is not None:
for weight_name in weight_names:
if isinstance(weight_name, bytes):
weight_name = weight_name.decode("utf-8")
full_path = f"{group.name}/{weight_name}"
leaf_name = (
weight_name.rsplit("/", 1)[0]
if "/" in weight_name
else group.name.rsplit("/", 1)[-1]
)
leaf_paths.setdefault(leaf_name, []).append(full_path)
for key in group:
item = group[key]
if isinstance(item, h5py.Group):
walk(item)
walk(root)
missing = []
for layer in leaf_layers:
if not layer.weights:
continue
paths = leaf_paths.get(layer.name)
if paths is None:
missing.append(layer.name)
continue
layer.set_weights([np.asarray(f[path]) for path in paths])
if missing:
raise ValueError(f"Could not find saved weights for layers: {missing}")
def _load_labels(self, labels_file_path):
try:
with open(labels_file_path, "r") as file:
self._labels = file.readlines()
except IOError as e:
print("LoadingLabelsError: Error while loading labels.txt file")
raise IOError("Error loading labels") from e
except Exception as e:
print("LoadingLabelsError: Error while loading labels.txt file")
raise FileNotFoundError("Labels file not found") from e
def _open_image(self, image_path):
"""
Open an image file and convert it to RGB mode.
Parameters:
image_path (str): Path to the image file.
Returns:
PIL.Image.Image: Opened image in RGB mode.
"""
try:
return Image.open(image_path).convert("RGB")
except FileNotFoundError as e:
print("ImageNotFound: Error in image file.")
raise FileNotFoundError("Image file not found") from e
except Exception as e:
print("ImageTypeError: Error while opening or converting image")
raise TypeError("Unsupported image type") from e
def classify_image(self, image_path: str):
"""
Classify an image using the pre-trained model.
Parameters:
image_path (str): Path of the image to be classified.
Returns:
dict: Classification results including class name, index, confidence and predictions.
"""
image = self._open_image(image_path)
return self._get_image_classification(image)
def _get_image_classification(self, image):
data = self._preprocess_image(image)
prediction = self._model.predict(data)
class_index = np.argmax(prediction)
class_name = self._labels[class_index].strip()
class_confidence = prediction[0][class_index]
return {
"class_name": class_name,
"highest_class_name": class_name,
"highest_class_id": class_index,
"class_index": class_index,
"class_id": class_index,
"predictions": prediction[0],
"all_predictions": prediction[0],
"class_confidence": class_confidence,
"highest_class_confidence": class_confidence,
}
def _preprocess_image(self, image):
image = ImageOps.fit(image, self.IMAGE_SIZE, Image.Resampling.LANCZOS)
image_array = np.asarray(image)
normalized_image_array = (image_array.astype(np.float32) / 127.5) - 1
return np.expand_dims(normalized_image_array, axis=0)
def classify_and_show(self, image_path: str, convert_to_bgr=True):
"""
Classify an image and show the prediction results on the image.
Parameters:
image_path (str): Path of the input image to be classified.
convert_to_bgr (bool, optional): Whether to convert the image to BGR format for OpenCV.
If False, the image will be returned in RGB format. Default is True.
Returns:
tuple: (classification_result, image_with_prediction)
classification_result (dict): Classification results including class name, index, confidence and predictions.
image_with_prediction (np.ndarray or PIL.Image.Image): The image with prediction results drawn on it.
Returns a NumPy array in BGR format if convert_to_bgr is True.
Otherwise, returns a PIL.Image.Image in RGB format.
"""
classification_result = self.classify_image(image_path)
image_with_prediction = self.show_prediction_on_image(
image_path, classification_result, convert_to_bgr=convert_to_bgr
)
return classification_result, image_with_prediction
def show_prediction_on_image(
self, image_path: str, classification_result=None, convert_to_bgr=True
):
"""
Show the prediction results on the image and return the modified image.
Parameters:
image_path (str): Path of the input image to be classified.
classification_result (dict, optional): Pre-computed classification result.
If not provided, the method will classify the image.
convert_to_bgr (bool, optional): Whether to convert the image to BGR format for OpenCV.
If False, the image will be returned in RGB format. Default is True.
Returns:
np.ndarray or PIL.Image.Image: The image with prediction results drawn on it.
Returns a NumPy array in BGR format if convert_to_bgr is True.
Otherwise, returns a PIL.Image.Image in RGB format.
"""
image = self._open_image(image_path)
if classification_result is None:
classification_result = self._get_image_classification(image)
class_name = classification_result["class_name"]
confidence = classification_result["class_confidence"]
confidence_percent = confidence * 100
text = f"{class_name}: {confidence_percent:.2f}%"
draw = ImageDraw.Draw(image)
font_size = int(image.height * 0.04) # 4% of the image height
try:
# Not bundled with Pillow on every platform (notably Windows),
# where a bare name can't be resolved and raises OSError.
font = ImageFont.truetype("DejaVuSans-Bold.ttf", font_size)
except OSError:
font = ImageFont.load_default(size=font_size)
# ImageDraw.textsize was removed in Pillow 10; use textbbox instead.
# A first measurement at the origin gives the text's extent, used only
# to decide where to place it.
_, top, _, bottom = draw.textbbox((0, 0), text, font=font)
text_height = bottom - top
position = (10, image.height - text_height - 10)
# textbbox's (left, top, right, bottom) are offsets from the point
# passed in, and that offset (particularly `top`, from font ascent)
# is usually not zero -- reusing the origin-measured box as if it
# started exactly at `position` shifts it up and clips the text's
# descenders. Re-measure at the actual draw position instead, and
# pad it a little so the box comfortably covers the glyphs.
padding = 4
box = draw.textbbox(position, text, font=font)
draw.rectangle(
[box[0] - padding, box[1] - padding, box[2] + padding, box[3] + padding],
fill=(0, 0, 0, 128),
)
draw.text(position, text, font=font, fill=(255, 255, 255))
if convert_to_bgr:
image_2_numpy_arr = np.array(image)
# Convert RGB to BGR
image_2_numpy_arr = image_2_numpy_arr[:, :, ::-1]
return image_2_numpy_arr
return image
|