Files
2026-07-13 13:22:34 +08:00

194 lines
6.6 KiB
Python

"""
Example of a custom python function implementing image classifier with image preprocessing embedded
in the model.
"""
import base64
import importlib.metadata
import os
from io import BytesIO
from typing import Any
import keras
import numpy as np
import pandas as pd
import PIL
import tensorflow as tf
import yaml
from PIL import Image
import mlflow
from mlflow.utils import PYTHON_VERSION
from mlflow.utils.file_utils import TempDir
def decode_and_resize_image(raw_bytes, size):
"""
Read, decode and resize raw image bytes (e.g. raw content of a jpeg file).
Args:
raw_bytes: Image bits, e.g. jpeg image.
size: Requested output dimensions.
Returns:
Multidimensional numpy array representing the resized image.
"""
return np.asarray(Image.open(BytesIO(raw_bytes)).resize(size), dtype=np.float32)
class KerasImageClassifierPyfunc:
"""
Image classification model with embedded pre-processing.
This class is essentially an MLflow custom python function wrapper around a Keras model.
The wrapper provides image preprocessing so that the model can be applied to images directly.
The input to the model is base64 encoded image binary data (e.g. contents of a jpeg file).
The output is the predicted class label, predicted class id followed by probabilities for each
class.
The model declares current local versions of Keras, Tensorlow and pillow as dependencies in its
conda environment file.
"""
def __init__(self, graph, session, model, image_dims, domain):
self._graph = graph
self._session = session
self._model = model
self._image_dims = image_dims
self._domain = domain
probs_names = [f"p({x})" for x in domain]
self._column_names = ["predicted_label", "predicted_label_id"] + probs_names
def predict(
self,
input,
params: dict[str, Any] | None = None,
):
"""
Generate predictions for the data.
Args:
input: pandas.DataFrame with one column containing images to be scored. The image
column must contain base64 encoded binary content of the image files. The image
format must be supported by PIL (e.g. jpeg or png).
params: Additional parameters to pass to the model for inference.
Returns:
pandas.DataFrame containing predictions with the following schema:
Predicted class: string,
Predicted class index: int,
Probability(class==0): float,
...,
Probability(class==N): float,
"""
# decode image bytes from base64 encoding
def decode_img(x):
return pd.Series(base64.decodebytes(bytearray(x[0], encoding="utf8")))
images = input.apply(axis=1, func=decode_img)
probs = self._predict_images(images)
m, n = probs.shape
label_idx = np.argmax(probs, axis=1)
labels = np.array([self._domain[i] for i in label_idx], dtype=str).reshape(m, 1)
output_data = np.concatenate((labels, label_idx.reshape(m, 1), probs), axis=1)
res = pd.DataFrame(columns=self._column_names, data=output_data)
res.index = input.index
return res
def _predict_images(self, images):
"""
Generate predictions for input images.
Args:
images: Binary image data.
Returns:
Predicted probabilities for each class.
"""
def preprocess_f(z):
return decode_and_resize_image(z, self._image_dims[:2])
x = np.array(images[images.columns[0]].apply(preprocess_f).tolist())
with self._graph.as_default():
with self._session.as_default():
return self._model.predict(x)
def log_model(keras_model, signature, artifact_path, image_dims, domain):
"""
Log a KerasImageClassifierPyfunc model as an MLflow artifact for the current run.
Args:
keras_model: Keras model to be saved.
signature: Model signature.
artifact_path: Run-relative artifact path this model is to be saved to.
image_dims: Image dimensions the Keras model expects.
domain: Labels for the classes this model can predict.
"""
with TempDir() as tmp:
data_path = tmp.path("image_model")
os.mkdir(data_path)
conf = {"image_dims": "/".join(map(str, image_dims)), "domain": "/".join(map(str, domain))}
with open(os.path.join(data_path, "conf.yaml"), "w") as f:
yaml.safe_dump(conf, stream=f)
keras_path = os.path.join(data_path, "keras_model")
mlflow.tensorflow.save_model(model=keras_model, path=keras_path)
conda_env = tmp.path("conda_env.yaml")
with open(conda_env, "w") as f:
f.write(
conda_env_template.format(
python_version=PYTHON_VERSION,
keras_version=keras.__version__,
tf_name=tf.__name__, # can have optional -gpu suffix
tf_version=tf.__version__,
pip_version=importlib.metadata.version("pip"),
pillow_version=PIL.__version__,
)
)
mlflow.pyfunc.log_model(
name=artifact_path,
signature=signature,
loader_module=__name__,
code_paths=[__file__],
data_path=data_path,
conda_env=conda_env,
)
def _load_pyfunc(path):
"""
Load the KerasImageClassifierPyfunc model.
"""
with open(os.path.join(path, "conf.yaml")) as f:
conf = yaml.safe_load(f)
keras_model_path = os.path.join(path, "keras_model")
domain = conf["domain"].split("/")
image_dims = np.array([int(x) for x in conf["image_dims"].split("/")], dtype=np.int32)
# NOTE: TensorFlow based models depend on global state (Graph and Session) given by the context.
# To make sure we score the model in the same session as we loaded it in, we create a new
# session and a new graph here and store them with the model.
with tf.Graph().as_default() as g:
with tf.Session().as_default() as sess:
keras.backend.set_session(sess)
keras_model = mlflow.tensorflow.load_model(keras_model_path)
return KerasImageClassifierPyfunc(g, sess, keras_model, image_dims, domain=domain)
conda_env_template = """
name: flower_classifier
channels:
- conda-forge
dependencies:
- python=={python_version}
- pip=={pip_version}
- pip:
- mlflow>=1.6
- pillow=={pillow_version}
- keras=={keras_version}
- {tf_name}=={tf_version}
"""