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

367 lines
13 KiB
Python

import json
import os
import posixpath
import numpy as np
import pytest
import mlflow
from mlflow.utils.file_utils import local_file_uri_to_path
from mlflow.utils.time import get_current_time_millis
@pytest.mark.parametrize("subdir", [None, ".", "dir", "dir1/dir2", "dir/.."])
def test_log_image_numpy(subdir):
import numpy as np
from PIL import Image
filename = "image.png"
artifact_file = filename if subdir is None else posixpath.join(subdir, filename)
image = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
with mlflow.start_run():
mlflow.log_image(image, artifact_file)
artifact_path = None if subdir is None else posixpath.normpath(subdir)
artifact_uri = mlflow.get_artifact_uri(artifact_path)
run_artifact_dir = local_file_uri_to_path(artifact_uri)
assert os.listdir(run_artifact_dir) == [filename]
logged_path = os.path.join(run_artifact_dir, filename)
loaded_image = np.asarray(Image.open(logged_path), dtype=np.uint8)
np.testing.assert_array_equal(loaded_image, image)
@pytest.mark.parametrize("subdir", [None, ".", "dir", "dir1/dir2", "dir/.."])
def test_log_image_pillow(subdir):
from PIL import Image, ImageChops
filename = "image.png"
artifact_file = filename if subdir is None else posixpath.join(subdir, filename)
image = Image.new("RGB", (100, 100))
with mlflow.start_run():
mlflow.log_image(image, artifact_file)
artifact_path = None if subdir is None else posixpath.normpath(subdir)
artifact_uri = mlflow.get_artifact_uri(artifact_path)
run_artifact_dir = local_file_uri_to_path(artifact_uri)
assert os.listdir(run_artifact_dir) == [filename]
logged_path = os.path.join(run_artifact_dir, filename)
loaded_image = Image.open(logged_path)
# How to check Pillow image equality: https://stackoverflow.com/a/6204954/6943581
assert ImageChops.difference(loaded_image, image).getbbox() is None
def test_log_image_raises_for_unsupported_objects():
with mlflow.start_run():
with pytest.raises(TypeError, match="Unsupported image object type"):
mlflow.log_image("not_image", "image.png")
@pytest.mark.parametrize(
"size",
[
(100, 100), # Grayscale (2D)
(100, 100, 1), # Grayscale (3D)
(100, 100, 3), # RGB
(100, 100, 4), # RGBA
],
)
def test_log_image_numpy_shape(size):
import numpy as np
filename = "image.png"
image = np.random.randint(0, 256, size=size, dtype=np.uint8)
with mlflow.start_run():
mlflow.log_image(image, filename)
artifact_uri = mlflow.get_artifact_uri()
run_artifact_dir = local_file_uri_to_path(artifact_uri)
assert os.listdir(run_artifact_dir) == [filename]
@pytest.mark.parametrize(
"dtype",
[
# Ref.: https://numpy.org/doc/stable/user/basics.types.html#array-types-and-conversions-between-types
"int8",
"int16",
"int32",
"int64",
"uint8",
"uint16",
"uint32",
"uint64",
"float16",
"float32",
"float64",
"bool",
],
)
def test_log_image_numpy_dtype(dtype):
import numpy as np
filename = "image.png"
image = np.random.randint(0, 2, size=(100, 100, 3)).astype(np.dtype(dtype))
with mlflow.start_run():
mlflow.log_image(image, filename)
artifact_uri = mlflow.get_artifact_uri()
run_artifact_dir = local_file_uri_to_path(artifact_uri)
assert os.listdir(run_artifact_dir) == [filename]
@pytest.mark.parametrize(
"array",
# 1 pixel images with out-of-range values
[[[-1]], [[256]], [[-0.1]], [[1.1]]],
)
def test_log_image_numpy_emits_warning_for_out_of_range_values(array):
import numpy as np
image = np.array(array).astype(type(array[0][0]))
if isinstance(array[0][0], int):
with (
mlflow.start_run(),
pytest.raises(ValueError, match="Integer pixel values out of acceptable range"),
):
mlflow.log_image(image, "image.png")
else:
with (
mlflow.start_run(),
pytest.warns(UserWarning, match="Float pixel values out of acceptable range"),
):
mlflow.log_image(image, "image.png")
def test_log_image_numpy_raises_exception_for_invalid_array_data_type():
import numpy as np
with mlflow.start_run(), pytest.raises(TypeError, match="Invalid array data type"):
mlflow.log_image(np.tile("a", (1, 1, 3)), "image.png")
def test_log_image_numpy_raises_exception_for_invalid_array_shape():
import numpy as np
with mlflow.start_run(), pytest.raises(ValueError, match="`image` must be a 2D or 3D array"):
mlflow.log_image(np.zeros((1,), dtype=np.uint8), "image.png")
def test_log_image_numpy_raises_exception_for_invalid_channel_length():
import numpy as np
with mlflow.start_run(), pytest.raises(ValueError, match="Invalid channel length"):
mlflow.log_image(np.zeros((1, 1, 5), dtype=np.uint8), "image.png")
def test_log_image_raises_exception_for_unsupported_image_object_type():
with mlflow.start_run(), pytest.raises(TypeError, match="Unsupported image object type"):
mlflow.log_image("not_image", "image.png")
def test_log_image_with_steps():
import numpy as np
from PIL import Image
image = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
with mlflow.start_run():
mlflow.log_image(image, key="dog", step=0, synchronous=True)
logged_path = "images/"
artifact_uri = mlflow.get_artifact_uri(logged_path)
run_artifact_dir = local_file_uri_to_path(artifact_uri)
files = os.listdir(run_artifact_dir)
# .png file for the image and .webp file for compressed image
assert len(files) == 2
for file in files:
assert file.startswith("dog+step+0")
logged_path = os.path.join(run_artifact_dir, file)
if file.endswith(".png"):
loaded_image = np.asarray(Image.open(logged_path), dtype=np.uint8)
np.testing.assert_array_equal(loaded_image, image)
elif file.endswith(".json"):
with open(logged_path) as f:
metadata = json.load(f)
assert metadata["filepath"].startswith("images/dog+step+0")
assert metadata["key"] == "dog"
assert metadata["step"] == 0
assert metadata["timestamp"] <= get_current_time_millis()
@pytest.mark.parametrize("step", [20, 26, 27])
def test_log_image_with_url_encoding_prone_steps(step):
"""Regression test: steps like 20, 26, 27 previously created %20, %26, %27 patterns
in filenames that got URL-decoded, corrupting the artifact path.
See https://github.com/mlflow/mlflow/issues/21085
"""
image = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
with mlflow.start_run():
mlflow.log_image(image, key="dog", step=step, synchronous=True)
artifact_uri = mlflow.get_artifact_uri("images/")
run_artifact_dir = local_file_uri_to_path(artifact_uri)
files = os.listdir(run_artifact_dir)
assert len(files) == 2
for file in files:
assert file.startswith(f"dog+step+{step}+timestamp+")
assert "%" not in file
def test_log_image_with_timestamp():
import numpy as np
from PIL import Image
image = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
with mlflow.start_run():
mlflow.log_image(image, key="dog", timestamp=100, synchronous=True)
logged_path = "images/"
artifact_uri = mlflow.get_artifact_uri(logged_path)
run_artifact_dir = local_file_uri_to_path(artifact_uri)
files = os.listdir(run_artifact_dir)
# .png file for the image, and .webp file for compressed image
assert len(files) == 2
for file in files:
assert file.startswith("dog+step+0")
logged_path = os.path.join(run_artifact_dir, file)
if file.endswith(".png"):
loaded_image = np.asarray(Image.open(logged_path), dtype=np.uint8)
np.testing.assert_array_equal(loaded_image, image)
elif file.endswith(".json"):
with open(logged_path) as f:
metadata = json.load(f)
assert metadata["filepath"].startswith("images/dog+step+0")
assert metadata["key"] == "dog"
assert metadata["step"] == 0
assert metadata["timestamp"] == 100
def test_duplicated_log_image_with_step():
"""
MLflow will save both files if there are multiple calls to log_image
with the same key and step.
"""
import numpy as np
image1 = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
image2 = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
with mlflow.start_run():
mlflow.log_image(image1, key="dog", step=100, synchronous=True)
mlflow.log_image(image2, key="dog", step=100, synchronous=True)
logged_path = "images/"
artifact_uri = mlflow.get_artifact_uri(logged_path)
run_artifact_dir = local_file_uri_to_path(artifact_uri)
files = os.listdir(run_artifact_dir)
assert len(files) == 2 * 2 # 2 images and 2 files per image
def test_duplicated_log_image_with_timestamp():
"""
MLflow will save both files if there are multiple calls to log_image
with the same key, step, and timestamp.
"""
import numpy as np
image1 = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
image2 = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
with mlflow.start_run():
mlflow.log_image(image1, key="dog", step=100, timestamp=100, synchronous=True)
mlflow.log_image(image2, key="dog", step=100, timestamp=100, synchronous=True)
logged_path = "images/"
artifact_uri = mlflow.get_artifact_uri(logged_path)
run_artifact_dir = local_file_uri_to_path(artifact_uri)
files = os.listdir(run_artifact_dir)
assert len(files) == 2 * 2
@pytest.mark.parametrize(
"args",
[
{"key": "image"},
{"step": 0},
{"timestamp": 0},
{"timestamp": 0, "step": 0},
["image"],
["image", 0],
],
)
def test_log_image_raises_exception_for_unexpected_arguments_used(args):
# It will overwrite if the user wants the exact same timestamp for the logged images
import numpy as np
exception = "The `artifact_file` parameter cannot be used in conjunction"
if isinstance(args, dict):
with mlflow.start_run(), pytest.raises(TypeError, match=exception):
mlflow.log_image(np.zeros((1,), dtype=np.uint8), "image.png", **args)
elif isinstance(args, list):
with mlflow.start_run(), pytest.raises(TypeError, match=exception):
mlflow.log_image(np.zeros((1,), dtype=np.uint8), "image.png", *args)
def test_log_image_raises_exception_for_missing_arguments():
import numpy as np
exception = "Invalid arguments: Please specify exactly one of `artifact_file` or `key`"
with mlflow.start_run(), pytest.raises(TypeError, match=exception):
mlflow.log_image(np.zeros((1,), dtype=np.uint8))
def test_async_log_image_flush():
import numpy as np
image1 = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
with mlflow.start_run():
for i in range(100):
mlflow.log_image(image1, key="dog", step=i, timestamp=i, synchronous=False)
mlflow.flush_artifact_async_logging()
logged_path = "images/"
artifact_uri = mlflow.get_artifact_uri(logged_path)
run_artifact_dir = local_file_uri_to_path(artifact_uri)
files = os.listdir(run_artifact_dir)
assert len(files) == 100 * 2
def test_log_image_with_slash_in_key():
image = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
with mlflow.start_run():
mlflow.log_image(image, key="category/name", step=5, synchronous=True)
logged_path = "images/"
artifact_uri = mlflow.get_artifact_uri(logged_path)
run_artifact_dir = local_file_uri_to_path(artifact_uri)
files = os.listdir(run_artifact_dir)
assert len(files) == 2
for file in files:
# '~' must be used instead of '#' as the separator
assert "category~name" in file
assert "#" not in file
run_id = mlflow.active_run().info.run_id
client = mlflow.MlflowClient()
artifacts = client.list_artifacts(run_id, path="images")
assert len(artifacts) == 2
for artifact in artifacts:
# download_artifacts must not raise MlflowException about '#' in path
local_path = client.download_artifacts(run_id, artifact.path)
assert os.path.exists(local_path)