2023-09-09 05:02:44 -04:00
|
|
|
import json
|
2024-02-25 04:52:27 +05:30
|
|
|
import os
|
2023-06-27 19:21:33 -04:00
|
|
|
from io import BytesIO
|
2023-10-31 06:02:04 -04:00
|
|
|
from pathlib import Path
|
2024-02-11 17:58:56 -05:00
|
|
|
from random import randint
|
|
|
|
|
from types import SimpleNamespace
|
2023-10-31 06:02:04 -04:00
|
|
|
from typing import Any, Callable
|
2023-06-27 19:21:33 -04:00
|
|
|
from unittest import mock
|
|
|
|
|
|
2023-08-05 22:45:13 -04:00
|
|
|
import numpy as np
|
2024-01-21 18:22:39 -05:00
|
|
|
import onnxruntime as ort
|
2025-01-21 19:12:28 +01:00
|
|
|
import orjson
|
2023-06-27 19:21:33 -04:00
|
|
|
import pytest
|
2024-06-20 14:13:18 -04:00
|
|
|
from fastapi import HTTPException
|
2023-06-27 19:21:33 -04:00
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
from PIL import Image
|
2024-03-04 01:48:56 +01:00
|
|
|
from pytest import MonkeyPatch
|
2023-08-05 22:45:13 -04:00
|
|
|
from pytest_mock import MockerFixture
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2026-03-05 12:01:47 -05:00
|
|
|
from immich_ml.config import MaxBatchSize, Settings, settings
|
2025-03-27 15:49:09 -04:00
|
|
|
from immich_ml.main import load, preload_models
|
|
|
|
|
from immich_ml.models.base import InferenceModel
|
|
|
|
|
from immich_ml.models.cache import ModelCache
|
|
|
|
|
from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEncoder
|
|
|
|
|
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
|
|
|
|
from immich_ml.models.facial_recognition.detection import FaceDetector
|
|
|
|
|
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
|
2026-03-05 12:01:47 -05:00
|
|
|
from immich_ml.models.ocr.detection import TextDetector
|
|
|
|
|
from immich_ml.models.ocr.recognition import TextRecognizer
|
|
|
|
|
from immich_ml.models.ocr.schemas import OcrOptions
|
2025-11-06 12:55:11 -05:00
|
|
|
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
|
2025-03-27 15:49:09 -04:00
|
|
|
from immich_ml.sessions.ann import AnnSession
|
|
|
|
|
from immich_ml.sessions.ort import OrtSession
|
|
|
|
|
from immich_ml.sessions.rknn import RknnSession, run_inference
|
2023-06-27 19:21:33 -04:00
|
|
|
|
|
|
|
|
|
2026-05-26 20:41:56 +02:00
|
|
|
class FakeLock:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.enter = mock.Mock()
|
|
|
|
|
self.exit = mock.Mock()
|
|
|
|
|
|
|
|
|
|
def __enter__(self) -> None:
|
|
|
|
|
self.enter()
|
|
|
|
|
|
|
|
|
|
def __exit__(self, *args: object) -> None:
|
|
|
|
|
self.exit(*args)
|
|
|
|
|
|
|
|
|
|
|
2024-01-21 18:22:39 -05:00
|
|
|
class TestBase:
|
2026-05-26 20:41:56 +02:00
|
|
|
def test_sets_default_worker_timeout(self, monkeypatch: MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.delenv("DEVICE", raising=False)
|
|
|
|
|
monkeypatch.delenv("MACHINE_LEARNING_WORKER_TIMEOUT", raising=False)
|
|
|
|
|
|
|
|
|
|
assert Settings().worker_timeout == 300
|
|
|
|
|
|
|
|
|
|
def test_sets_rocm_default_worker_timeout(self, monkeypatch: MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("DEVICE", "rocm")
|
|
|
|
|
monkeypatch.delenv("MACHINE_LEARNING_WORKER_TIMEOUT", raising=False)
|
|
|
|
|
|
|
|
|
|
assert Settings().worker_timeout == 900
|
|
|
|
|
|
|
|
|
|
def test_worker_timeout_env_override(self, monkeypatch: MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("DEVICE", "rocm")
|
|
|
|
|
monkeypatch.setenv("MACHINE_LEARNING_WORKER_TIMEOUT", "1200")
|
|
|
|
|
|
|
|
|
|
assert Settings().worker_timeout == 1200
|
|
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
def test_sets_default_cache_dir(self) -> None:
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
|
|
|
|
|
|
|
|
|
|
assert encoder.cache_dir == Path(settings.cache_folder) / "clip" / "ViT-B-32__openai"
|
|
|
|
|
|
|
|
|
|
def test_sets_cache_dir_kwarg(self) -> None:
|
|
|
|
|
cache_dir = Path("/test_cache")
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=cache_dir)
|
|
|
|
|
|
|
|
|
|
assert encoder.cache_dir == cache_dir
|
|
|
|
|
|
2024-07-10 10:20:43 -04:00
|
|
|
def test_sets_default_model_format(self, mocker: MockerFixture) -> None:
|
2024-06-25 12:00:24 -04:00
|
|
|
mocker.patch.object(settings, "ann", True)
|
2025-03-27 15:49:09 -04:00
|
|
|
mocker.patch("immich_ml.sessions.ann.loader.is_available", False)
|
2024-06-25 12:00:24 -04:00
|
|
|
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
|
|
|
|
|
|
|
|
|
|
assert encoder.model_format == ModelFormat.ONNX
|
|
|
|
|
|
2024-07-10 10:20:43 -04:00
|
|
|
def test_sets_default_model_format_to_armnn_if_available(self, path: mock.Mock, mocker: MockerFixture) -> None:
|
2024-06-25 12:00:24 -04:00
|
|
|
mocker.patch.object(settings, "ann", True)
|
2025-03-27 15:49:09 -04:00
|
|
|
mocker.patch("immich_ml.sessions.ann.loader.is_available", True)
|
2024-06-25 12:00:24 -04:00
|
|
|
path.suffix = ".armnn"
|
|
|
|
|
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=path)
|
|
|
|
|
|
|
|
|
|
assert encoder.model_format == ModelFormat.ARMNN
|
|
|
|
|
|
2024-07-10 10:20:43 -04:00
|
|
|
def test_sets_model_format_kwarg(self, mocker: MockerFixture) -> None:
|
2024-06-25 12:00:24 -04:00
|
|
|
mocker.patch.object(settings, "ann", False)
|
2025-03-27 15:49:09 -04:00
|
|
|
mocker.patch("immich_ml.sessions.ann.loader.is_available", False)
|
2024-06-25 12:00:24 -04:00
|
|
|
|
2024-07-10 10:20:43 -04:00
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", model_format=ModelFormat.ARMNN)
|
2024-06-25 12:00:24 -04:00
|
|
|
|
|
|
|
|
assert encoder.model_format == ModelFormat.ARMNN
|
|
|
|
|
|
2025-03-18 00:04:08 +08:00
|
|
|
def test_sets_default_model_format_to_rknn_if_available(self, mocker: MockerFixture) -> None:
|
|
|
|
|
mocker.patch.object(settings, "rknn", True)
|
2025-03-27 15:49:09 -04:00
|
|
|
mocker.patch("immich_ml.sessions.rknn.is_available", True)
|
2025-03-18 00:04:08 +08:00
|
|
|
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
|
|
|
|
|
|
|
|
|
|
assert encoder.model_format == ModelFormat.RKNN
|
|
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
def test_casts_cache_dir_string_to_path(self) -> None:
|
|
|
|
|
cache_dir = "/test_cache"
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=cache_dir)
|
|
|
|
|
|
|
|
|
|
assert encoder.cache_dir == Path(cache_dir)
|
|
|
|
|
|
|
|
|
|
def test_clear_cache(self, rmtree: mock.Mock, path: mock.Mock, info: mock.Mock) -> None:
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=path)
|
|
|
|
|
encoder.clear_cache()
|
|
|
|
|
|
|
|
|
|
rmtree.assert_called_once_with(encoder.cache_dir)
|
|
|
|
|
info.assert_called_with(f"Cleared cache directory for model '{encoder.model_name}'.")
|
|
|
|
|
|
|
|
|
|
def test_clear_cache_warns_if_path_does_not_exist(
|
|
|
|
|
self, rmtree: mock.Mock, path: mock.Mock, warning: mock.Mock
|
|
|
|
|
) -> None:
|
|
|
|
|
path.return_value.exists.return_value = False
|
|
|
|
|
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=path)
|
|
|
|
|
encoder.clear_cache()
|
|
|
|
|
|
|
|
|
|
rmtree.assert_not_called()
|
|
|
|
|
warning.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_clear_cache_raises_exception_if_vulnerable_to_symlink_attack(
|
|
|
|
|
self, rmtree: mock.Mock, path: mock.Mock
|
|
|
|
|
) -> None:
|
|
|
|
|
rmtree.avoids_symlink_attacks = False
|
|
|
|
|
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=path)
|
|
|
|
|
with pytest.raises(RuntimeError):
|
|
|
|
|
encoder.clear_cache()
|
|
|
|
|
|
|
|
|
|
rmtree.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_clear_cache_replaces_file_with_dir_if_path_is_file(
|
|
|
|
|
self, rmtree: mock.Mock, path: mock.Mock, warning: mock.Mock
|
|
|
|
|
) -> None:
|
|
|
|
|
path.return_value.is_dir.return_value = False
|
|
|
|
|
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=path)
|
|
|
|
|
encoder.clear_cache()
|
|
|
|
|
|
|
|
|
|
rmtree.assert_not_called()
|
|
|
|
|
path.return_value.unlink.assert_called_once()
|
|
|
|
|
path.return_value.mkdir.assert_called_once()
|
|
|
|
|
warning.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_download(self, snapshot_download: mock.Mock) -> None:
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="/path/to/cache")
|
|
|
|
|
encoder.download()
|
|
|
|
|
|
|
|
|
|
snapshot_download.assert_called_once_with(
|
|
|
|
|
"immich-app/ViT-B-32__openai",
|
|
|
|
|
cache_dir=encoder.cache_dir,
|
|
|
|
|
local_dir=encoder.cache_dir,
|
2025-03-18 00:04:08 +08:00
|
|
|
ignore_patterns=["*.armnn", "*.rknn"],
|
2024-06-25 12:00:24 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_download_downloads_armnn_if_preferred_format(self, snapshot_download: mock.Mock) -> None:
|
2024-07-10 10:20:43 -04:00
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", model_format=ModelFormat.ARMNN)
|
2024-06-25 12:00:24 -04:00
|
|
|
encoder.download()
|
|
|
|
|
|
|
|
|
|
snapshot_download.assert_called_once_with(
|
|
|
|
|
"immich-app/ViT-B-32__openai",
|
|
|
|
|
cache_dir=encoder.cache_dir,
|
|
|
|
|
local_dir=encoder.cache_dir,
|
2025-03-18 00:04:08 +08:00
|
|
|
ignore_patterns=["*.rknn"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_download_downloads_rknn_if_preferred_format(self, snapshot_download: mock.Mock) -> None:
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", model_format=ModelFormat.RKNN)
|
|
|
|
|
encoder.download()
|
|
|
|
|
|
|
|
|
|
snapshot_download.assert_called_once_with(
|
|
|
|
|
"immich-app/ViT-B-32__openai",
|
|
|
|
|
cache_dir=encoder.cache_dir,
|
|
|
|
|
local_dir=encoder.cache_dir,
|
|
|
|
|
ignore_patterns=["*.armnn"],
|
2024-06-25 12:00:24 -04:00
|
|
|
)
|
|
|
|
|
|
2024-07-10 10:20:43 -04:00
|
|
|
def test_throws_exception_if_model_path_does_not_exist(
|
|
|
|
|
self, snapshot_download: mock.Mock, ort_session: mock.Mock, path: mock.Mock
|
|
|
|
|
) -> None:
|
|
|
|
|
path.return_value.__truediv__.return_value.__truediv__.return_value.is_file.return_value = False
|
|
|
|
|
|
|
|
|
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=path)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
|
|
|
encoder.load()
|
|
|
|
|
|
|
|
|
|
snapshot_download.assert_called_once()
|
|
|
|
|
ort_session.assert_not_called()
|
|
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
|
|
|
|
|
@pytest.mark.usefixtures("ort_session")
|
|
|
|
|
class TestOrtSession:
|
2024-01-21 18:22:39 -05:00
|
|
|
CPU_EP = ["CPUExecutionProvider"]
|
|
|
|
|
CUDA_EP = ["CUDAExecutionProvider", "CPUExecutionProvider"]
|
|
|
|
|
OV_EP = ["OpenVINOExecutionProvider", "CPUExecutionProvider"]
|
|
|
|
|
CUDA_EP_OUT_OF_ORDER = ["CPUExecutionProvider", "CUDAExecutionProvider"]
|
|
|
|
|
TRT_EP = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
|
2026-02-26 08:52:26 -08:00
|
|
|
ROCM_EP = ["MIGraphXExecutionProvider", "CPUExecutionProvider"]
|
2025-10-14 13:51:31 -04:00
|
|
|
COREML_EP = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
|
2024-01-21 18:22:39 -05:00
|
|
|
|
|
|
|
|
@pytest.mark.providers(CPU_EP)
|
|
|
|
|
def test_sets_cpu_provider(self, providers: list[str]) -> None:
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession("ViT-B-32__openai")
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.providers == self.CPU_EP
|
2024-01-21 18:22:39 -05:00
|
|
|
|
|
|
|
|
@pytest.mark.providers(CUDA_EP)
|
|
|
|
|
def test_sets_cuda_provider_if_available(self, providers: list[str]) -> None:
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession("ViT-B-32__openai")
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.providers == self.CUDA_EP
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
|
2024-01-21 18:22:39 -05:00
|
|
|
@pytest.mark.providers(OV_EP)
|
2024-06-25 12:00:24 -04:00
|
|
|
def test_sets_openvino_provider_if_available(self, providers: list[str], ov_device_ids: list[str]) -> None:
|
|
|
|
|
session = OrtSession("ViT-B-32__openai")
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.providers == self.OV_EP
|
2024-01-21 18:22:39 -05:00
|
|
|
|
|
|
|
|
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
|
|
|
|
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession("ViT-B-32__openai")
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.providers == self.CUDA_EP
|
2024-01-21 18:22:39 -05:00
|
|
|
|
|
|
|
|
@pytest.mark.providers(TRT_EP)
|
|
|
|
|
def test_ignores_unsupported_providers(self, providers: list[str]) -> None:
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession("ViT-B-32__openai")
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.providers == self.CUDA_EP
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2025-03-17 17:08:19 -04:00
|
|
|
@pytest.mark.providers(ROCM_EP)
|
|
|
|
|
def test_uses_rocm(self, providers: list[str]) -> None:
|
|
|
|
|
session = OrtSession("ViT-B-32__openai")
|
|
|
|
|
|
|
|
|
|
assert session.providers == self.ROCM_EP
|
|
|
|
|
|
2025-10-14 13:51:31 -04:00
|
|
|
@pytest.mark.providers(COREML_EP)
|
|
|
|
|
def test_uses_coreml(self, providers: list[str]) -> None:
|
|
|
|
|
session = OrtSession("ViT-B-32__openai")
|
|
|
|
|
|
|
|
|
|
assert session.providers == self.COREML_EP
|
|
|
|
|
|
2024-01-21 18:22:39 -05:00
|
|
|
def test_sets_provider_kwarg(self) -> None:
|
|
|
|
|
providers = ["CUDAExecutionProvider"]
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession("ViT-B-32__openai", providers=providers)
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.providers == providers
|
2024-02-11 17:58:56 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
|
|
|
|
|
def test_sets_default_provider_options(self, ov_device_ids: list[str]) -> None:
|
2025-11-06 12:55:11 -05:00
|
|
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
|
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
|
2024-02-11 17:58:56 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.provider_options == [
|
2025-11-06 12:55:11 -05:00
|
|
|
{
|
|
|
|
|
"device_type": "GPU.0",
|
|
|
|
|
"precision": "FP32",
|
|
|
|
|
"cache_dir": "/cache/ViT-B-32__openai/textual/openvino",
|
|
|
|
|
},
|
2024-02-11 17:58:56 -05:00
|
|
|
{"arena_extend_strategy": "kSameAsRequested"},
|
|
|
|
|
]
|
|
|
|
|
|
2026-03-07 19:40:43 +01:00
|
|
|
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
|
|
|
|
def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None:
|
2025-03-17 17:08:19 -04:00
|
|
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
2024-10-07 17:37:45 -04:00
|
|
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
|
|
|
|
|
2025-03-17 17:08:19 -04:00
|
|
|
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
2024-10-07 17:37:45 -04:00
|
|
|
|
2025-03-17 17:08:19 -04:00
|
|
|
assert session.provider_options == [
|
|
|
|
|
{
|
|
|
|
|
"device_type": "GPU.1",
|
|
|
|
|
"precision": "FP32",
|
|
|
|
|
"cache_dir": "/cache/ViT-B-32__openai/textual/openvino",
|
|
|
|
|
}
|
|
|
|
|
]
|
2024-10-07 17:37:45 -04:00
|
|
|
|
2026-03-07 19:40:43 +01:00
|
|
|
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
|
|
|
|
def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None:
|
2025-11-06 12:55:11 -05:00
|
|
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
|
|
|
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
|
|
|
|
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
|
|
|
|
|
|
|
|
|
|
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
|
|
|
|
|
|
|
|
assert session.provider_options == [
|
|
|
|
|
{
|
|
|
|
|
"device_type": "GPU.1",
|
|
|
|
|
"precision": "FP16",
|
|
|
|
|
"cache_dir": "/cache/ViT-B-32__openai/textual/openvino",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
2026-03-07 19:40:43 +01:00
|
|
|
@pytest.mark.ov_device_ids(["CPU"])
|
|
|
|
|
def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
|
|
|
|
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
|
|
|
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
|
|
|
|
|
|
|
|
assert session.provider_options == [
|
|
|
|
|
{
|
|
|
|
|
"device_type": "CPU",
|
|
|
|
|
"precision": "FP32",
|
|
|
|
|
"cache_dir": "/cache/ViT-B-32__openai/openvino",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
2025-03-17 17:08:19 -04:00
|
|
|
def test_sets_provider_options_for_cuda(self) -> None:
|
2024-10-07 17:37:45 -04:00
|
|
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
|
|
|
|
|
|
|
|
|
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider"])
|
|
|
|
|
|
2025-03-17 17:08:19 -04:00
|
|
|
assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
|
|
|
|
|
|
2026-02-26 08:52:26 -08:00
|
|
|
def test_sets_provider_options_for_rocm(self, mocker: MockerFixture) -> None:
|
|
|
|
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
2025-03-17 17:08:19 -04:00
|
|
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
2026-02-26 08:52:26 -08:00
|
|
|
mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir")
|
2025-03-17 17:08:19 -04:00
|
|
|
|
2026-02-26 08:52:26 -08:00
|
|
|
session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"])
|
2025-03-17 17:08:19 -04:00
|
|
|
|
2026-02-26 08:52:26 -08:00
|
|
|
assert session.provider_options == [
|
|
|
|
|
{
|
|
|
|
|
"device_id": "1",
|
|
|
|
|
"migraphx_model_cache_dir": "/cache/ViT-B-32__openai/textual/migraphx",
|
|
|
|
|
"migraphx_fp16_enable": "0",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
mkdir.assert_called_once_with(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
def test_sets_rocm_to_fp16_if_enabled(self, path: mock.Mock, mocker: MockerFixture) -> None:
|
|
|
|
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
|
|
|
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
|
|
|
|
mocker.patch.object(settings, "rocm_precision", ModelPrecision.FP16)
|
|
|
|
|
mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir")
|
|
|
|
|
|
|
|
|
|
session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"])
|
|
|
|
|
|
|
|
|
|
assert session.provider_options == [
|
|
|
|
|
{
|
|
|
|
|
"device_id": "1",
|
|
|
|
|
"migraphx_model_cache_dir": "/cache/ViT-B-32__openai/textual/migraphx",
|
|
|
|
|
"migraphx_fp16_enable": "1",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
mkdir.assert_called_once_with(parents=True, exist_ok=True)
|
2024-10-07 17:37:45 -04:00
|
|
|
|
2024-01-21 18:22:39 -05:00
|
|
|
def test_sets_provider_options_kwarg(self) -> None:
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession(
|
2024-01-21 18:22:39 -05:00
|
|
|
"ViT-B-32__openai",
|
|
|
|
|
providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"],
|
|
|
|
|
provider_options=[],
|
|
|
|
|
)
|
|
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.provider_options == []
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2025-04-10 13:06:33 -04:00
|
|
|
def test_sets_default_sess_options_if_cpu(self) -> None:
|
|
|
|
|
session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"])
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
|
|
|
|
|
assert session.sess_options.inter_op_num_threads == 1
|
|
|
|
|
assert session.sess_options.intra_op_num_threads == 2
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2026-03-07 19:40:43 +01:00
|
|
|
@pytest.mark.ov_device_ids(["CPU"])
|
|
|
|
|
def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
|
|
|
|
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
|
|
|
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
|
|
|
|
|
|
|
|
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
|
|
|
|
|
assert session.sess_options.inter_op_num_threads == 0
|
|
|
|
|
assert session.sess_options.intra_op_num_threads == 0
|
|
|
|
|
|
|
|
|
|
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
|
|
|
|
|
def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None:
|
|
|
|
|
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
|
|
|
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
|
|
|
|
|
|
|
|
assert session.sess_options.inter_op_num_threads == 0
|
|
|
|
|
assert session.sess_options.intra_op_num_threads == 0
|
|
|
|
|
|
2024-01-21 18:22:39 -05:00
|
|
|
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.sess_options.inter_op_num_threads == 0
|
|
|
|
|
assert session.sess_options.intra_op_num_threads == 0
|
2024-01-21 18:22:39 -05:00
|
|
|
|
|
|
|
|
def test_sets_default_sess_options_sets_threads_if_non_cpu_and_set_threads(self, mocker: MockerFixture) -> None:
|
2025-03-27 15:49:09 -04:00
|
|
|
mock_settings = mocker.patch("immich_ml.sessions.ort.settings", autospec=True)
|
2024-01-21 18:22:39 -05:00
|
|
|
mock_settings.model_inter_op_threads = 2
|
|
|
|
|
mock_settings.model_intra_op_threads = 4
|
|
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert session.sess_options.inter_op_num_threads == 2
|
|
|
|
|
assert session.sess_options.intra_op_num_threads == 4
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2025-10-14 13:51:31 -04:00
|
|
|
def test_uses_arena_if_enabled(self, mocker: MockerFixture) -> None:
|
|
|
|
|
mock_settings = mocker.patch("immich_ml.sessions.ort.settings", autospec=True)
|
|
|
|
|
mock_settings.model_inter_op_threads = 0
|
|
|
|
|
mock_settings.model_intra_op_threads = 0
|
|
|
|
|
mock_settings.model_arena = True
|
|
|
|
|
|
|
|
|
|
session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"])
|
|
|
|
|
|
|
|
|
|
assert session.sess_options.enable_cpu_mem_arena
|
|
|
|
|
|
|
|
|
|
def test_does_not_use_arena_if_disabled(self, mocker: MockerFixture) -> None:
|
|
|
|
|
mock_settings = mocker.patch("immich_ml.sessions.ort.settings", autospec=True)
|
|
|
|
|
mock_settings.model_inter_op_threads = 0
|
|
|
|
|
mock_settings.model_intra_op_threads = 0
|
|
|
|
|
mock_settings.model_arena = False
|
|
|
|
|
|
|
|
|
|
session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"])
|
|
|
|
|
|
|
|
|
|
assert not session.sess_options.enable_cpu_mem_arena
|
|
|
|
|
|
2024-01-21 18:22:39 -05:00
|
|
|
def test_sets_sess_options_kwarg(self) -> None:
|
|
|
|
|
sess_options = ort.SessionOptions()
|
2024-06-25 12:00:24 -04:00
|
|
|
session = OrtSession(
|
2024-01-21 18:22:39 -05:00
|
|
|
"ViT-B-32__openai",
|
|
|
|
|
providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"],
|
|
|
|
|
provider_options=[],
|
|
|
|
|
sess_options=sess_options,
|
|
|
|
|
)
|
|
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert sess_options is session.sess_options
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2026-05-26 20:41:56 +02:00
|
|
|
def test_serializes_rocm_first_run_for_new_input_signature(self, mocker: MockerFixture) -> None:
|
|
|
|
|
lock = FakeLock()
|
|
|
|
|
get_model_lock = mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
|
|
|
|
|
mocker.patch("immich_ml.sessions.ort._migraphx_compiled_inputs", set())
|
|
|
|
|
mocker.patch("immich_ml.sessions.ort.Path.mkdir")
|
|
|
|
|
session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["MIGraphXExecutionProvider"])
|
|
|
|
|
input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
|
|
|
|
|
|
|
|
|
|
session.run(None, input_feed)
|
|
|
|
|
session.run(None, input_feed)
|
|
|
|
|
|
|
|
|
|
lock.enter.assert_called_once()
|
|
|
|
|
lock.exit.assert_called_once()
|
|
|
|
|
get_model_lock.assert_called_once()
|
|
|
|
|
session.session.run.assert_has_calls([mock.call(None, input_feed, None), mock.call(None, input_feed, None)])
|
|
|
|
|
|
|
|
|
|
def test_serializes_rocm_run_for_each_new_input_signature(self, mocker: MockerFixture) -> None:
|
|
|
|
|
lock = FakeLock()
|
|
|
|
|
mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
|
|
|
|
|
mocker.patch("immich_ml.sessions.ort._migraphx_compiled_inputs", set())
|
|
|
|
|
mocker.patch("immich_ml.sessions.ort.Path.mkdir")
|
|
|
|
|
session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["MIGraphXExecutionProvider"])
|
|
|
|
|
input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
|
|
|
|
|
new_shape_input_feed = {"input": np.random.rand(2, 3, 224, 224).astype(np.float32)}
|
|
|
|
|
|
|
|
|
|
session.run(None, input_feed)
|
|
|
|
|
session.run(None, new_shape_input_feed)
|
|
|
|
|
|
|
|
|
|
assert lock.enter.call_count == 2
|
|
|
|
|
assert lock.exit.call_count == 2
|
|
|
|
|
session.session.run.assert_has_calls(
|
|
|
|
|
[mock.call(None, input_feed, None), mock.call(None, new_shape_input_feed, None)]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_does_not_serialize_non_rocm_run(self, mocker: MockerFixture) -> None:
|
|
|
|
|
lock = FakeLock()
|
|
|
|
|
get_model_lock = mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
|
|
|
|
|
session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["CPUExecutionProvider"])
|
|
|
|
|
input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
|
|
|
|
|
|
|
|
|
|
session.run(None, input_feed)
|
|
|
|
|
|
|
|
|
|
get_model_lock.assert_not_called()
|
|
|
|
|
lock.enter.assert_not_called()
|
|
|
|
|
session.session.run.assert_called_once_with(None, input_feed, None)
|
|
|
|
|
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
class TestAnnSession:
|
|
|
|
|
def test_creates_ann_session(self, ann_session: mock.Mock, info: mock.Mock) -> None:
|
|
|
|
|
model_path = mock.MagicMock(spec=Path)
|
|
|
|
|
cache_dir = mock.MagicMock(spec=Path)
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
AnnSession(model_path, cache_dir)
|
2024-01-28 10:31:59 -05:00
|
|
|
|
2024-07-20 21:59:27 +02:00
|
|
|
ann_session.assert_called_once_with(tuning_level=2, tuning_file=(cache_dir / "gpu-tuning.ann").as_posix())
|
2024-06-25 12:00:24 -04:00
|
|
|
ann_session.return_value.load.assert_called_once_with(
|
2024-07-20 21:59:27 +02:00
|
|
|
model_path.as_posix(), cached_network_path=model_path.with_suffix(".anncache").as_posix(), fp16=False
|
2024-06-25 12:00:24 -04:00
|
|
|
)
|
|
|
|
|
info.assert_has_calls(
|
|
|
|
|
[
|
|
|
|
|
mock.call("Loading ANN model %s ...", model_path),
|
|
|
|
|
mock.call("Loaded ANN model with ID %d", ann_session.return_value.load.return_value),
|
|
|
|
|
]
|
|
|
|
|
)
|
2024-01-28 10:31:59 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
def test_get_inputs(self, ann_session: mock.Mock) -> None:
|
|
|
|
|
ann_session.return_value.load.return_value = 123
|
|
|
|
|
ann_session.return_value.input_shapes = {123: [(1, 3, 224, 224)]}
|
|
|
|
|
session = AnnSession(Path("ViT-B-32__openai"))
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
inputs = session.get_inputs()
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert len(inputs) == 1
|
2026-08-08 12:07:30 -04:00
|
|
|
assert inputs[0].name == "input.1"
|
2024-06-25 12:00:24 -04:00
|
|
|
assert inputs[0].shape == (1, 3, 224, 224)
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
def test_get_outputs(self, ann_session: mock.Mock) -> None:
|
|
|
|
|
ann_session.return_value.load.return_value = 123
|
|
|
|
|
ann_session.return_value.output_shapes = {123: [(1, 3, 224, 224)]}
|
|
|
|
|
session = AnnSession(Path("ViT-B-32__openai"))
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
outputs = session.get_outputs()
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
assert len(outputs) == 1
|
2026-08-08 12:07:30 -04:00
|
|
|
assert outputs[0].name == "output.1"
|
2024-06-25 12:00:24 -04:00
|
|
|
assert outputs[0].shape == (1, 3, 224, 224)
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
def test_run(self, ann_session: mock.Mock, mocker: MockerFixture) -> None:
|
|
|
|
|
ann_session.return_value.load.return_value = 123
|
|
|
|
|
np_spy = mocker.spy(np, "ascontiguousarray")
|
|
|
|
|
session = AnnSession(Path("ViT-B-32__openai"))
|
|
|
|
|
[input1, input2] = [np.random.rand(1, 3, 224, 224).astype(np.float32) for _ in range(2)]
|
|
|
|
|
input_feed = {"input.1": input1, "input.2": input2}
|
2024-01-28 10:31:59 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
session.run(None, input_feed)
|
2024-01-28 10:31:59 -05:00
|
|
|
|
2024-06-25 12:00:24 -04:00
|
|
|
ann_session.return_value.execute.assert_called_once_with(123, [input1, input2])
|
2025-03-04 15:52:07 -05:00
|
|
|
assert np_spy.call_count == 2
|
2024-06-25 12:00:24 -04:00
|
|
|
np_spy.assert_has_calls([mock.call(input1), mock.call(input2)])
|
2024-01-28 10:31:59 -05:00
|
|
|
|
2024-01-21 18:22:39 -05:00
|
|
|
|
2025-03-18 00:04:08 +08:00
|
|
|
class TestRknnSession:
|
|
|
|
|
def test_creates_rknn_session(self, rknn_session: mock.Mock, info: mock.Mock, mocker: MockerFixture) -> None:
|
|
|
|
|
model_path = mock.MagicMock(spec=Path)
|
|
|
|
|
tpe = 1
|
2025-03-27 15:49:09 -04:00
|
|
|
mocker.patch("immich_ml.sessions.rknn.soc_name", "rk3566")
|
|
|
|
|
mocker.patch("immich_ml.sessions.rknn.is_available", True)
|
2025-03-18 00:04:08 +08:00
|
|
|
RknnSession(model_path)
|
|
|
|
|
|
|
|
|
|
rknn_session.assert_called_once_with(model_path=model_path.as_posix(), tpes=tpe, func=run_inference)
|
|
|
|
|
|
|
|
|
|
info.assert_has_calls([mock.call(f"Loaded RKNN model from {model_path} with {tpe} threads.")])
|
|
|
|
|
|
|
|
|
|
def test_run_rknn(self, rknn_session: mock.Mock, mocker: MockerFixture) -> None:
|
|
|
|
|
rknn_session.return_value.load.return_value = 123
|
|
|
|
|
np_spy = mocker.spy(np, "ascontiguousarray")
|
2025-03-27 15:49:09 -04:00
|
|
|
mocker.patch("immich_ml.sessions.rknn.soc_name", "rk3566")
|
2025-03-18 00:04:08 +08:00
|
|
|
session = RknnSession(Path("ViT-B-32__openai"))
|
|
|
|
|
[input1, input2] = [np.random.rand(1, 3, 224, 224).astype(np.float32) for _ in range(2)]
|
|
|
|
|
input_feed = {"input.1": input1, "input.2": input2}
|
|
|
|
|
|
|
|
|
|
session.run(None, input_feed)
|
|
|
|
|
|
|
|
|
|
rknn_session.return_value.put.assert_called_once_with([input1, input2])
|
2025-11-06 12:55:11 -05:00
|
|
|
assert np_spy.call_count == 2
|
2025-03-18 00:04:08 +08:00
|
|
|
np_spy.assert_has_calls([mock.call(input1), mock.call(input2)])
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 19:21:33 -04:00
|
|
|
class TestCLIP:
|
2023-08-05 22:45:13 -04:00
|
|
|
embedding = np.random.rand(512).astype(np.float32)
|
2023-10-31 06:02:04 -04:00
|
|
|
cache_dir = Path("test_cache")
|
|
|
|
|
|
|
|
|
|
def test_basic_image(
|
|
|
|
|
self,
|
|
|
|
|
pil_image: Image.Image,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
clip_model_cfg: dict[str, Any],
|
|
|
|
|
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
|
|
|
|
|
) -> None:
|
2024-06-06 23:09:47 -04:00
|
|
|
mocker.patch.object(OpenClipVisualEncoder, "download")
|
|
|
|
|
mocker.patch.object(OpenClipVisualEncoder, "model_cfg", clip_model_cfg)
|
|
|
|
|
mocker.patch.object(OpenClipVisualEncoder, "preprocess_cfg", clip_preprocess_cfg)
|
2024-01-11 18:26:46 +01:00
|
|
|
|
|
|
|
|
mocked = mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
|
|
|
|
|
mocked.run.return_value = [[self.embedding]]
|
2023-10-31 06:02:04 -04:00
|
|
|
|
2024-06-06 23:09:47 -04:00
|
|
|
clip_encoder = OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache")
|
2025-01-21 19:12:28 +01:00
|
|
|
embedding_str = clip_encoder.predict(pil_image)
|
|
|
|
|
assert isinstance(embedding_str, str)
|
|
|
|
|
embedding = orjson.loads(embedding_str)
|
|
|
|
|
assert isinstance(embedding, list)
|
|
|
|
|
assert len(embedding) == clip_model_cfg["embed_dim"]
|
2024-01-11 18:26:46 +01:00
|
|
|
mocked.run.assert_called_once()
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2023-10-31 06:02:04 -04:00
|
|
|
def test_basic_text(
|
|
|
|
|
self,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
clip_model_cfg: dict[str, Any],
|
2023-12-20 20:47:56 -05:00
|
|
|
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
2023-10-31 06:02:04 -04:00
|
|
|
) -> None:
|
2024-06-06 23:09:47 -04:00
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "download")
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg)
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
2024-01-11 18:26:46 +01:00
|
|
|
|
|
|
|
|
mocked = mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
|
|
|
|
|
mocked.run.return_value = [[self.embedding]]
|
2025-03-27 15:49:09 -04:00
|
|
|
mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True)
|
2023-10-31 06:02:04 -04:00
|
|
|
|
2024-06-06 23:09:47 -04:00
|
|
|
clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache")
|
2025-01-21 19:12:28 +01:00
|
|
|
embedding_str = clip_encoder.predict("test search query")
|
|
|
|
|
assert isinstance(embedding_str, str)
|
|
|
|
|
embedding = orjson.loads(embedding_str)
|
|
|
|
|
assert isinstance(embedding, list)
|
|
|
|
|
assert len(embedding) == clip_model_cfg["embed_dim"]
|
2024-01-11 18:26:46 +01:00
|
|
|
mocked.run.assert_called_once()
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2024-02-11 17:58:56 -05:00
|
|
|
def test_openclip_tokenizer(
|
|
|
|
|
self,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
clip_model_cfg: dict[str, Any],
|
|
|
|
|
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
|
|
|
|
) -> None:
|
2024-06-06 23:09:47 -04:00
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "download")
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg)
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
|
|
|
|
mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
|
2025-03-27 15:49:09 -04:00
|
|
|
mock_tokenizer = mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True).return_value
|
2024-02-11 17:58:56 -05:00
|
|
|
mock_ids = [randint(0, 50000) for _ in range(77)]
|
|
|
|
|
mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids)
|
|
|
|
|
|
2024-06-06 23:09:47 -04:00
|
|
|
clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache")
|
|
|
|
|
clip_encoder._load()
|
2024-08-18 11:05:10 -04:00
|
|
|
tokens = clip_encoder.tokenize("test search query")
|
2024-02-11 17:58:56 -05:00
|
|
|
|
|
|
|
|
assert "text" in tokens
|
|
|
|
|
assert isinstance(tokens["text"], np.ndarray)
|
|
|
|
|
assert tokens["text"].shape == (1, 77)
|
|
|
|
|
assert tokens["text"].dtype == np.int32
|
|
|
|
|
assert np.allclose(tokens["text"], np.array([mock_ids], dtype=np.int32), atol=0)
|
2024-08-18 11:05:10 -04:00
|
|
|
mock_tokenizer.encode.assert_called_once_with("test search query")
|
|
|
|
|
|
|
|
|
|
def test_openclip_tokenizer_canonicalizes_text(
|
|
|
|
|
self,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
clip_model_cfg: dict[str, Any],
|
|
|
|
|
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
|
|
|
|
) -> None:
|
|
|
|
|
clip_model_cfg["text_cfg"]["tokenizer_kwargs"] = {"clean": "canonicalize"}
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "download")
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg)
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
|
|
|
|
mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
|
2025-03-27 15:49:09 -04:00
|
|
|
mock_tokenizer = mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True).return_value
|
2024-08-18 11:05:10 -04:00
|
|
|
mock_ids = [randint(0, 50000) for _ in range(77)]
|
|
|
|
|
mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids)
|
|
|
|
|
|
|
|
|
|
clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache")
|
|
|
|
|
clip_encoder._load()
|
|
|
|
|
tokens = clip_encoder.tokenize("Test Search Query!")
|
|
|
|
|
|
|
|
|
|
assert "text" in tokens
|
|
|
|
|
assert isinstance(tokens["text"], np.ndarray)
|
|
|
|
|
assert tokens["text"].shape == (1, 77)
|
|
|
|
|
assert tokens["text"].dtype == np.int32
|
|
|
|
|
assert np.allclose(tokens["text"], np.array([mock_ids], dtype=np.int32), atol=0)
|
|
|
|
|
mock_tokenizer.encode.assert_called_once_with("test search query")
|
2024-02-11 17:58:56 -05:00
|
|
|
|
2025-03-31 11:06:57 -04:00
|
|
|
def test_openclip_tokenizer_adds_flores_token_for_nllb(
|
|
|
|
|
self,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
clip_model_cfg: dict[str, Any],
|
|
|
|
|
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
|
|
|
|
) -> None:
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "download")
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg)
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
|
|
|
|
mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
|
|
|
|
|
mock_tokenizer = mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True).return_value
|
|
|
|
|
mock_ids = [randint(0, 50000) for _ in range(77)]
|
|
|
|
|
mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids)
|
|
|
|
|
|
|
|
|
|
clip_encoder = OpenClipTextualEncoder("nllb-clip-base-siglip__mrl", cache_dir="test_cache")
|
|
|
|
|
clip_encoder._load()
|
|
|
|
|
clip_encoder.tokenize("test search query", language="de")
|
|
|
|
|
|
|
|
|
|
mock_tokenizer.encode.assert_called_once_with("deu_Latntest search query")
|
|
|
|
|
|
|
|
|
|
def test_openclip_tokenizer_removes_country_code_from_language_for_nllb_if_not_found(
|
|
|
|
|
self,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
clip_model_cfg: dict[str, Any],
|
|
|
|
|
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
|
|
|
|
) -> None:
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "download")
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg)
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
|
|
|
|
mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
|
|
|
|
|
mock_tokenizer = mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True).return_value
|
|
|
|
|
mock_ids = [randint(0, 50000) for _ in range(77)]
|
|
|
|
|
mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids)
|
|
|
|
|
|
|
|
|
|
clip_encoder = OpenClipTextualEncoder("nllb-clip-base-siglip__mrl", cache_dir="test_cache")
|
|
|
|
|
clip_encoder._load()
|
|
|
|
|
clip_encoder.tokenize("test search query", language="de-CH")
|
|
|
|
|
|
|
|
|
|
mock_tokenizer.encode.assert_called_once_with("deu_Latntest search query")
|
|
|
|
|
|
|
|
|
|
def test_openclip_tokenizer_falls_back_to_english_for_nllb_if_language_code_not_found(
|
|
|
|
|
self,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
clip_model_cfg: dict[str, Any],
|
|
|
|
|
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
|
|
|
|
warning: mock.Mock,
|
|
|
|
|
) -> None:
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "download")
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg)
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
|
|
|
|
mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
|
|
|
|
|
mock_tokenizer = mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True).return_value
|
|
|
|
|
mock_ids = [randint(0, 50000) for _ in range(77)]
|
|
|
|
|
mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids)
|
|
|
|
|
|
|
|
|
|
clip_encoder = OpenClipTextualEncoder("nllb-clip-base-siglip__mrl", cache_dir="test_cache")
|
|
|
|
|
clip_encoder._load()
|
|
|
|
|
clip_encoder.tokenize("test search query", language="unknown")
|
|
|
|
|
|
|
|
|
|
mock_tokenizer.encode.assert_called_once_with("eng_Latntest search query")
|
|
|
|
|
warning.assert_called_once_with("Language 'unknown' not found, defaulting to 'en'")
|
|
|
|
|
|
|
|
|
|
def test_openclip_tokenizer_does_not_add_flores_token_for_non_nllb_model(
|
|
|
|
|
self,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
clip_model_cfg: dict[str, Any],
|
|
|
|
|
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
|
|
|
|
) -> None:
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "download")
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "model_cfg", clip_model_cfg)
|
|
|
|
|
mocker.patch.object(OpenClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
|
|
|
|
mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
|
|
|
|
|
mock_tokenizer = mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True).return_value
|
|
|
|
|
mock_ids = [randint(0, 50000) for _ in range(77)]
|
|
|
|
|
mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids)
|
|
|
|
|
|
|
|
|
|
clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache")
|
|
|
|
|
clip_encoder._load()
|
|
|
|
|
clip_encoder.tokenize("test search query", language="de")
|
|
|
|
|
|
|
|
|
|
mock_tokenizer.encode.assert_called_once_with("test search query")
|
|
|
|
|
|
2024-02-11 17:58:56 -05:00
|
|
|
def test_mclip_tokenizer(
|
|
|
|
|
self,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
clip_model_cfg: dict[str, Any],
|
|
|
|
|
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
|
|
|
|
) -> None:
|
2024-06-06 23:09:47 -04:00
|
|
|
mocker.patch.object(MClipTextualEncoder, "download")
|
|
|
|
|
mocker.patch.object(MClipTextualEncoder, "model_cfg", clip_model_cfg)
|
|
|
|
|
mocker.patch.object(MClipTextualEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
|
|
|
|
mocker.patch.object(InferenceModel, "_make_session", autospec=True).return_value
|
2025-03-27 15:49:09 -04:00
|
|
|
mock_tokenizer = mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True).return_value
|
2024-02-11 17:58:56 -05:00
|
|
|
mock_ids = [randint(0, 50000) for _ in range(77)]
|
|
|
|
|
mock_attention_mask = [randint(0, 1) for _ in range(77)]
|
|
|
|
|
mock_tokenizer.encode.return_value = SimpleNamespace(ids=mock_ids, attention_mask=mock_attention_mask)
|
|
|
|
|
|
2024-06-06 23:09:47 -04:00
|
|
|
clip_encoder = MClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache")
|
|
|
|
|
clip_encoder._load()
|
2024-02-11 17:58:56 -05:00
|
|
|
tokens = clip_encoder.tokenize("test search query")
|
|
|
|
|
|
|
|
|
|
assert "input_ids" in tokens
|
|
|
|
|
assert "attention_mask" in tokens
|
|
|
|
|
assert isinstance(tokens["input_ids"], np.ndarray)
|
|
|
|
|
assert isinstance(tokens["attention_mask"], np.ndarray)
|
|
|
|
|
assert tokens["input_ids"].shape == (1, 77)
|
|
|
|
|
assert tokens["attention_mask"].shape == (1, 77)
|
|
|
|
|
assert np.allclose(tokens["input_ids"], np.array([mock_ids], dtype=np.int32), atol=0)
|
|
|
|
|
assert np.allclose(tokens["attention_mask"], np.array([mock_attention_mask], dtype=np.int32), atol=0)
|
|
|
|
|
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
def make_scrfd_heads(detections: list[tuple[int, int, float]]) -> list[np.ndarray]:
|
|
|
|
|
"""Build the 9 head tensors a SCRFD keypoint model emits at 640x640.
|
|
|
|
|
|
|
|
|
|
`detections` is a list of (cell_x, cell_y, score) placed on the stride-8 level.
|
|
|
|
|
Distances and keypoint offsets are fixed so the decoded geometry is known by
|
|
|
|
|
hand rather than derived from the code under test:
|
|
|
|
|
box = [cx - 1*8, cy - 2*8, cx + 3*8, cy + 4*8]
|
|
|
|
|
kps = [cx, cy] + [0, 1, 2, ... 9] * 8, reshaped to 5 points
|
|
|
|
|
"""
|
|
|
|
|
heads: list[np.ndarray] = []
|
|
|
|
|
counts = [(640 // stride) ** 2 * 2 for stride in (8, 16, 32)]
|
|
|
|
|
for channels in (1, 4, 10):
|
|
|
|
|
for n in counts:
|
|
|
|
|
heads.append(np.zeros((n, channels), dtype=np.float32))
|
|
|
|
|
for cell_x, cell_y, score in detections:
|
|
|
|
|
i = 2 * (cell_y * 80 + cell_x) # anchor-major, 2 anchors per cell
|
|
|
|
|
heads[0][i] = score
|
|
|
|
|
heads[3][i] = [1, 2, 3, 4]
|
|
|
|
|
heads[6][i] = np.arange(10)
|
|
|
|
|
return heads
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def expected_box(cell_x: int, cell_y: int) -> list[float]:
|
|
|
|
|
cx, cy = cell_x * 8, cell_y * 8
|
|
|
|
|
return [cx - 8, cy - 16, cx + 24, cy + 32]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def expected_landmarks(cell_x: int, cell_y: int) -> np.ndarray:
|
|
|
|
|
cx, cy = cell_x * 8, cell_y * 8
|
|
|
|
|
return (np.tile([cx, cy], 5) + np.arange(10) * 8).reshape(5, 2).astype(np.float32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def batch_axis(mocker: MockerFixture) -> SimpleNamespace:
|
|
|
|
|
"""Patches for the `_add_batch_axis` path: the onnx module it rewrites the model
|
|
|
|
|
through, and the download it would otherwise trigger on load."""
|
|
|
|
|
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
onnx=mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True),
|
|
|
|
|
update_dims=mocker.patch(
|
|
|
|
|
"immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 19:21:33 -04:00
|
|
|
class TestFaceRecognition:
|
2026-08-08 12:07:30 -04:00
|
|
|
def test_detection(self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture) -> None:
|
|
|
|
|
mocker.patch.object(FaceDetector, "load")
|
|
|
|
|
face_detector = FaceDetector("buffalo_s", cache_dir="test_cache")
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
session = stub_session((1, 3, 640, 640), outputs=make_scrfd_heads([(10, 10, 0.9), (50, 50, 0.8)]))
|
|
|
|
|
face_detector.session = session
|
2025-03-04 15:52:07 -05:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
faces = face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.7)
|
2023-08-05 22:45:13 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
assert isinstance(faces, dict)
|
|
|
|
|
assert set(faces) == {"boxes", "scores", "landmarks"}
|
|
|
|
|
# NMS returns highest score first
|
|
|
|
|
assert faces["boxes"].tolist() == [expected_box(10, 10), expected_box(50, 50)]
|
|
|
|
|
assert np.allclose(faces["scores"], [0.9, 0.8])
|
|
|
|
|
assert faces["landmarks"].shape == (2, 5, 2)
|
|
|
|
|
assert np.allclose(faces["landmarks"][0], expected_landmarks(10, 10))
|
|
|
|
|
assert np.allclose(faces["landmarks"][1], expected_landmarks(50, 50))
|
|
|
|
|
|
|
|
|
|
def test_detection_applies_min_score_per_request(
|
|
|
|
|
self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture
|
|
|
|
|
) -> None:
|
2024-06-06 23:09:47 -04:00
|
|
|
mocker.patch.object(FaceDetector, "load")
|
2026-08-08 12:07:30 -04:00
|
|
|
face_detector = FaceDetector("buffalo_s", cache_dir="test_cache")
|
2024-06-06 23:09:47 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
session = stub_session((1, 3, 640, 640), outputs=make_scrfd_heads([(10, 10, 0.9), (50, 50, 0.5)]))
|
|
|
|
|
face_detector.session = session
|
2024-06-06 23:09:47 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
# the threshold is a request parameter, so the same loaded model must honour both
|
|
|
|
|
assert face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.7)["boxes"].shape[0] == 1
|
|
|
|
|
assert face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.4)["boxes"].shape[0] == 2
|
2024-06-06 23:09:47 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
def test_detection_scales_boxes_back_to_the_original_image(
|
|
|
|
|
self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture
|
|
|
|
|
) -> None:
|
|
|
|
|
mocker.patch.object(FaceDetector, "load")
|
|
|
|
|
face_detector = FaceDetector("buffalo_s", cache_dir="test_cache")
|
|
|
|
|
|
|
|
|
|
session = stub_session((1, 3, 640, 640), outputs=make_scrfd_heads([(10, 10, 0.9)]))
|
|
|
|
|
face_detector.session = session
|
|
|
|
|
|
|
|
|
|
# a 320x320 image is letterboxed up to 640, so coordinates come back halved
|
|
|
|
|
faces = face_detector.predict(Image.new("RGB", (320, 320)), minScore=0.7)
|
|
|
|
|
|
|
|
|
|
assert faces["boxes"].tolist() == [[v / 2 for v in expected_box(10, 10)]]
|
|
|
|
|
assert np.allclose(faces["landmarks"][0], expected_landmarks(10, 10) / 2)
|
|
|
|
|
|
|
|
|
|
def test_recognition(self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture) -> None:
|
2023-08-05 22:45:13 -04:00
|
|
|
mocker.patch.object(FaceRecognizer, "load")
|
2026-06-04 02:52:08 +02:00
|
|
|
mocker.patch(
|
|
|
|
|
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
|
|
|
|
|
return_value=["CPUExecutionProvider"],
|
|
|
|
|
)
|
2026-08-08 12:07:30 -04:00
|
|
|
face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache")
|
2023-08-05 22:45:13 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
# a uniform grey image whose crops land wholly inside it, so every sampled
|
|
|
|
|
# pixel is 128 and the normalised value the session receives is exact
|
|
|
|
|
image = Image.new("RGB", (600, 800), (128, 128, 128))
|
2023-08-05 22:45:13 -04:00
|
|
|
num_faces = 2
|
2026-08-08 12:07:30 -04:00
|
|
|
arcface_dst = np.array(
|
|
|
|
|
[[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]],
|
|
|
|
|
dtype=np.float32,
|
|
|
|
|
)
|
|
|
|
|
kpss = np.stack([arcface_dst * 2 + [200, 300], arcface_dst * 2 + [220, 320]]).astype(np.float32)
|
2023-08-05 22:45:13 -04:00
|
|
|
bbox = np.random.rand(num_faces, 4).astype(np.float32)
|
2024-06-06 23:09:47 -04:00
|
|
|
scores = np.array([0.67] * num_faces).astype(np.float32)
|
2026-08-08 12:07:30 -04:00
|
|
|
embeddings = np.random.rand(num_faces, 512).astype(np.float32)
|
2023-08-05 22:45:13 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
session = stub_session(("batch", 3, 112, 112), outputs=[embeddings])
|
|
|
|
|
face_recognizer.session = session
|
2023-08-05 22:45:13 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
faces = face_recognizer.predict(image, {"boxes": bbox, "landmarks": kpss, "scores": scores})
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2024-06-06 23:09:47 -04:00
|
|
|
assert isinstance(faces, list)
|
2023-08-05 22:45:13 -04:00
|
|
|
assert len(faces) == num_faces
|
2023-06-27 19:21:33 -04:00
|
|
|
for face in faces:
|
2024-06-06 23:09:47 -04:00
|
|
|
assert set(face["boundingBox"]) == {"x1", "y1", "x2", "y2"}
|
|
|
|
|
assert all(isinstance(val, np.float32) for val in face["boundingBox"].values())
|
2026-08-08 12:07:30 -04:00
|
|
|
embedding = orjson.loads(face["embedding"])
|
2025-01-21 19:12:28 +01:00
|
|
|
assert isinstance(embedding, list)
|
|
|
|
|
assert len(embedding) == 512
|
2024-06-06 23:09:47 -04:00
|
|
|
assert isinstance(face.get("score", None), np.float32)
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
session.run.assert_called_once()
|
|
|
|
|
crops = session.run.call_args.args[1]["input.1"]
|
|
|
|
|
assert crops.shape == (num_faces, 3, 112, 112)
|
|
|
|
|
assert crops.dtype == np.float32
|
|
|
|
|
# mean/std 127.5, not raw 0-255. atol is loose enough for the float32 cancellation
|
|
|
|
|
# in normalize's scale-then-subtract, but still rejects a wrong mean or std
|
|
|
|
|
assert np.allclose(crops, (128 - 127.5) / 127.5, atol=1e-6)
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
def test_recognition_returns_early_without_faces(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
|
|
|
|
|
mocker.patch.object(FaceRecognizer, "load")
|
|
|
|
|
mocker.patch(
|
|
|
|
|
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
|
|
|
|
|
return_value=["CPUExecutionProvider"],
|
|
|
|
|
)
|
|
|
|
|
face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache")
|
|
|
|
|
session = mock.Mock()
|
|
|
|
|
face_recognizer.session = session
|
|
|
|
|
|
|
|
|
|
empty = {
|
|
|
|
|
"boxes": np.empty((0, 4), dtype=np.float32),
|
|
|
|
|
"landmarks": np.empty((0, 5, 2), dtype=np.float32),
|
|
|
|
|
"scores": np.empty(0, dtype=np.float32),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert face_recognizer.predict(pil_image, empty) == []
|
|
|
|
|
session.run.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_recognition_batches_when_batch_size_is_set(
|
|
|
|
|
self, pil_image: Image.Image, stub_session: Callable[..., mock.Mock], mocker: MockerFixture
|
2024-07-10 10:20:43 -04:00
|
|
|
) -> None:
|
2026-08-08 12:07:30 -04:00
|
|
|
mocker.patch.object(FaceRecognizer, "load")
|
|
|
|
|
mocker.patch(
|
|
|
|
|
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
|
|
|
|
|
return_value=["CPUExecutionProvider"],
|
2024-06-25 12:00:24 -04:00
|
|
|
)
|
2026-08-08 12:07:30 -04:00
|
|
|
face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache")
|
|
|
|
|
face_recognizer.batch_size = 2
|
|
|
|
|
|
|
|
|
|
num_faces = 5
|
|
|
|
|
session = stub_session((1, 3, 112, 112))
|
|
|
|
|
session.run.side_effect = lambda _, feed: [np.zeros((feed["input.1"].shape[0], 512), dtype=np.float32)]
|
|
|
|
|
face_recognizer.session = session
|
|
|
|
|
|
|
|
|
|
faces = {
|
|
|
|
|
"boxes": np.random.rand(num_faces, 4).astype(np.float32),
|
|
|
|
|
"landmarks": (np.random.rand(num_faces, 5, 2) * 100).astype(np.float32),
|
|
|
|
|
"scores": np.array([0.67] * num_faces, dtype=np.float32),
|
|
|
|
|
}
|
|
|
|
|
assert len(face_recognizer.predict(pil_image, faces)) == num_faces
|
|
|
|
|
assert session.run.call_count == 3 # 2 + 2 + 1
|
|
|
|
|
assert [c.args[1]["input.1"].shape[0] for c in session.run.call_args_list] == [2, 2, 1]
|
|
|
|
|
|
|
|
|
|
def test_recognition_adds_batch_axis_for_ort(
|
|
|
|
|
self, batch_axis: SimpleNamespace, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
|
|
|
|
) -> None:
|
2026-06-04 02:52:08 +02:00
|
|
|
mocker.patch(
|
|
|
|
|
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
|
|
|
|
|
return_value=["CPUExecutionProvider"],
|
|
|
|
|
)
|
2024-06-25 12:00:24 -04:00
|
|
|
ort_session.return_value.get_inputs.return_value = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))]
|
|
|
|
|
ort_session.return_value.get_outputs.return_value = [SimpleNamespace(name="output.1", shape=(1, 800))]
|
2024-07-10 10:20:43 -04:00
|
|
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
2024-06-25 12:00:24 -04:00
|
|
|
|
|
|
|
|
proto = mock.Mock()
|
|
|
|
|
|
|
|
|
|
input_dims = mock.Mock()
|
|
|
|
|
input_dims.name = "input.1"
|
|
|
|
|
input_dims.type.tensor_type.shape.dim = [SimpleNamespace(dim_value=size) for size in [1, 3, 224, 224]]
|
|
|
|
|
proto.graph.input = [input_dims]
|
|
|
|
|
|
|
|
|
|
output_dims = mock.Mock()
|
|
|
|
|
output_dims.name = "output.1"
|
|
|
|
|
output_dims.type.tensor_type.shape.dim = [SimpleNamespace(dim_value=size) for size in [1, 800]]
|
|
|
|
|
proto.graph.output = [output_dims]
|
|
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
batch_axis.onnx.load.return_value = proto
|
2024-06-25 12:00:24 -04:00
|
|
|
|
2024-07-10 10:20:43 -04:00
|
|
|
face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path)
|
2024-06-25 12:00:24 -04:00
|
|
|
face_recognizer.load()
|
|
|
|
|
|
2024-10-23 08:50:28 -04:00
|
|
|
assert face_recognizer.batch_size is None
|
2026-08-08 12:07:30 -04:00
|
|
|
batch_axis.update_dims.assert_called_once_with(
|
|
|
|
|
proto, {"input.1": ["batch", 3, 224, 224]}, {"output.1": ["batch", 800]}
|
|
|
|
|
)
|
|
|
|
|
batch_axis.onnx.save.assert_called_once_with(batch_axis.update_dims.return_value, face_recognizer.model_path)
|
2024-06-25 12:00:24 -04:00
|
|
|
|
2024-07-10 10:20:43 -04:00
|
|
|
def test_recognition_does_not_add_batch_axis_if_exists(
|
2026-08-08 12:07:30 -04:00
|
|
|
self, batch_axis: SimpleNamespace, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
2024-07-10 10:20:43 -04:00
|
|
|
) -> None:
|
2026-06-04 02:52:08 +02:00
|
|
|
mocker.patch(
|
|
|
|
|
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
|
|
|
|
|
return_value=["CPUExecutionProvider"],
|
|
|
|
|
)
|
2024-07-10 10:20:43 -04:00
|
|
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
2024-06-25 12:00:24 -04:00
|
|
|
|
|
|
|
|
inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))]
|
|
|
|
|
outputs = [SimpleNamespace(name="output.1", shape=("batch", 800))]
|
|
|
|
|
ort_session.return_value.get_inputs.return_value = inputs
|
|
|
|
|
ort_session.return_value.get_outputs.return_value = outputs
|
|
|
|
|
|
2024-07-10 10:20:43 -04:00
|
|
|
face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path)
|
2024-06-25 12:00:24 -04:00
|
|
|
face_recognizer.load()
|
|
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
# batching is available here, so the axis is only skipped because it already exists
|
2024-10-23 08:50:28 -04:00
|
|
|
assert face_recognizer.batch_size is None
|
2026-08-08 12:07:30 -04:00
|
|
|
batch_axis.update_dims.assert_not_called()
|
|
|
|
|
batch_axis.onnx.load.assert_not_called()
|
|
|
|
|
batch_axis.onnx.save.assert_not_called()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("session_fixture", "suffix", "model_kwargs", "available_providers"),
|
|
|
|
|
[
|
|
|
|
|
pytest.param("ann_session", ".armnn", {"model_format": ModelFormat.ARMNN}, None, id="armnn"),
|
|
|
|
|
pytest.param(
|
|
|
|
|
"ort_session", ".onnx", {}, ["OpenVINOExecutionProvider", "CPUExecutionProvider"], id="openvino"
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
"ort_session", ".onnx", {}, ["MIGraphXExecutionProvider", "CPUExecutionProvider"], id="migraphx"
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_recognition_does_not_add_batch_axis_when_batching_is_unsupported(
|
|
|
|
|
self,
|
|
|
|
|
request: pytest.FixtureRequest,
|
|
|
|
|
batch_axis: SimpleNamespace,
|
|
|
|
|
path: mock.Mock,
|
|
|
|
|
ort_pybind: mock.Mock,
|
|
|
|
|
mocker: MockerFixture,
|
|
|
|
|
session_fixture: str,
|
|
|
|
|
suffix: str,
|
|
|
|
|
model_kwargs: dict[str, Any],
|
|
|
|
|
available_providers: list[str] | None,
|
2026-05-26 20:41:56 +02:00
|
|
|
) -> None:
|
2026-08-08 12:07:30 -04:00
|
|
|
session = request.getfixturevalue(session_fixture)
|
|
|
|
|
ort_pybind.get_available_openvino_device_ids.return_value = ["CPU"]
|
2026-05-26 20:41:56 +02:00
|
|
|
mocker.patch(
|
|
|
|
|
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
|
2026-08-08 12:07:30 -04:00
|
|
|
return_value=available_providers,
|
2026-05-26 20:41:56 +02:00
|
|
|
)
|
2026-08-08 12:07:30 -04:00
|
|
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = suffix
|
|
|
|
|
session.return_value.get_inputs.return_value = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))]
|
|
|
|
|
session.return_value.get_outputs.return_value = [SimpleNamespace(name="output.1", shape=(1, 800))]
|
2026-05-26 20:41:56 +02:00
|
|
|
|
2026-08-08 12:07:30 -04:00
|
|
|
face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path, **model_kwargs)
|
2026-05-26 20:41:56 +02:00
|
|
|
face_recognizer.load()
|
|
|
|
|
|
|
|
|
|
assert face_recognizer.batch_size == 1
|
2026-08-08 12:07:30 -04:00
|
|
|
batch_axis.update_dims.assert_not_called()
|
|
|
|
|
batch_axis.onnx.load.assert_not_called()
|
|
|
|
|
batch_axis.onnx.save.assert_not_called()
|
2026-05-26 20:41:56 +02:00
|
|
|
|
2026-03-05 12:01:47 -05:00
|
|
|
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
|
|
|
|
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
|
|
|
|
|
|
|
|
|
|
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
|
|
|
|
|
|
|
|
|
|
assert recognizer.batch_size == 2
|
|
|
|
|
|
|
|
|
|
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
|
|
|
|
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
|
2026-06-04 02:52:08 +02:00
|
|
|
mocker.patch(
|
|
|
|
|
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
|
|
|
|
|
return_value=["CPUExecutionProvider"],
|
|
|
|
|
)
|
2026-03-05 12:01:47 -05:00
|
|
|
|
|
|
|
|
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
|
|
|
|
|
|
|
|
|
|
assert recognizer.batch_size is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOcr:
|
|
|
|
|
def test_set_det_min_score(self, path: mock.Mock) -> None:
|
|
|
|
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
|
|
|
|
|
|
|
|
text_detector = TextDetector("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
|
|
|
|
|
|
|
|
|
|
assert text_detector.postprocess.box_thresh == 0.8
|
|
|
|
|
|
|
|
|
|
def test_set_rec_min_score(self, path: mock.Mock) -> None:
|
|
|
|
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
|
|
|
|
|
|
|
|
text_recognizer = TextRecognizer("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
|
|
|
|
|
|
|
|
|
|
assert text_recognizer.min_score == 0.8
|
|
|
|
|
|
|
|
|
|
def test_set_rec_set_default_max_batch_size(
|
|
|
|
|
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
|
|
|
|
) -> None:
|
|
|
|
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
|
|
|
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
|
|
|
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
|
|
|
|
|
|
|
|
|
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
|
|
|
|
text_recognizer.load()
|
|
|
|
|
|
|
|
|
|
rapid_recognizer.assert_called_once_with(
|
2026-05-29 11:54:04 +09:00
|
|
|
OcrOptions(
|
2026-08-08 12:07:30 -04:00
|
|
|
session=ort_session.return_value,
|
|
|
|
|
rec_batch_num=6,
|
|
|
|
|
rec_img_shape=(3, 48, 320),
|
|
|
|
|
model_root_dir=text_recognizer.cache_dir,
|
2026-05-29 11:54:04 +09:00
|
|
|
)
|
2026-03-05 12:01:47 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
|
|
|
|
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
|
|
|
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
|
|
|
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
|
|
|
|
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=4))
|
|
|
|
|
|
|
|
|
|
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
|
|
|
|
text_recognizer.load()
|
|
|
|
|
|
|
|
|
|
rapid_recognizer.assert_called_once_with(
|
2026-05-29 11:54:04 +09:00
|
|
|
OcrOptions(
|
2026-08-08 12:07:30 -04:00
|
|
|
session=ort_session.return_value,
|
|
|
|
|
rec_batch_num=4,
|
|
|
|
|
rec_img_shape=(3, 48, 320),
|
|
|
|
|
model_root_dir=text_recognizer.cache_dir,
|
2026-05-29 11:54:04 +09:00
|
|
|
)
|
2026-03-05 12:01:47 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_ignore_other_custom_max_batch_size(
|
|
|
|
|
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
|
|
|
|
) -> None:
|
|
|
|
|
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
|
|
|
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
|
|
|
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
|
|
|
|
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=3))
|
|
|
|
|
|
|
|
|
|
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
|
|
|
|
text_recognizer.load()
|
|
|
|
|
|
|
|
|
|
rapid_recognizer.assert_called_once_with(
|
2026-05-29 11:54:04 +09:00
|
|
|
OcrOptions(
|
2026-08-08 12:07:30 -04:00
|
|
|
session=ort_session.return_value,
|
|
|
|
|
rec_batch_num=6,
|
|
|
|
|
rec_img_shape=(3, 48, 320),
|
|
|
|
|
model_root_dir=text_recognizer.cache_dir,
|
2026-05-29 11:54:04 +09:00
|
|
|
)
|
2026-03-05 12:01:47 -05:00
|
|
|
)
|
|
|
|
|
|
2023-06-27 19:21:33 -04:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestCache:
|
|
|
|
|
async def test_caches(self, mock_get_model: mock.Mock) -> None:
|
|
|
|
|
model_cache = ModelCache()
|
2024-06-06 23:09:47 -04:00
|
|
|
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION)
|
|
|
|
|
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION)
|
2023-06-27 19:21:33 -04:00
|
|
|
assert len(model_cache.cache._cache) == 1
|
|
|
|
|
mock_get_model.assert_called_once()
|
|
|
|
|
|
|
|
|
|
async def test_kwargs_used(self, mock_get_model: mock.Mock) -> None:
|
|
|
|
|
model_cache = ModelCache()
|
2024-06-06 23:09:47 -04:00
|
|
|
await model_cache.get(
|
|
|
|
|
"test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, cache_dir="test_cache"
|
|
|
|
|
)
|
|
|
|
|
mock_get_model.assert_called_once_with(
|
|
|
|
|
"test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, cache_dir="test_cache"
|
|
|
|
|
)
|
2023-06-27 19:21:33 -04:00
|
|
|
|
|
|
|
|
async def test_different_clip(self, mock_get_model: mock.Mock) -> None:
|
|
|
|
|
model_cache = ModelCache()
|
2024-06-06 23:09:47 -04:00
|
|
|
await model_cache.get("test_model_name", ModelType.VISUAL, ModelTask.SEARCH)
|
|
|
|
|
await model_cache.get("test_model_name", ModelType.TEXTUAL, ModelTask.SEARCH)
|
2023-06-27 19:21:33 -04:00
|
|
|
mock_get_model.assert_has_calls(
|
|
|
|
|
[
|
2024-06-06 23:09:47 -04:00
|
|
|
mock.call("test_model_name", ModelType.VISUAL, ModelTask.SEARCH),
|
|
|
|
|
mock.call("test_model_name", ModelType.TEXTUAL, ModelTask.SEARCH),
|
2023-06-27 19:21:33 -04:00
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
assert len(model_cache.cache._cache) == 2
|
|
|
|
|
|
2025-03-27 15:49:09 -04:00
|
|
|
@mock.patch("immich_ml.models.cache.OptimisticLock", autospec=True)
|
2023-06-27 19:21:33 -04:00
|
|
|
async def test_model_ttl(self, mock_lock_cls: mock.Mock, mock_get_model: mock.Mock) -> None:
|
2024-03-04 01:48:56 +01:00
|
|
|
model_cache = ModelCache()
|
2024-06-06 23:09:47 -04:00
|
|
|
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100)
|
2023-06-27 19:21:33 -04:00
|
|
|
mock_lock_cls.return_value.__aenter__.return_value.cas.assert_called_with(mock.ANY, ttl=100)
|
|
|
|
|
|
2025-03-27 15:49:09 -04:00
|
|
|
@mock.patch("immich_ml.models.cache.SimpleMemoryCache.expire")
|
2024-02-11 17:58:56 -05:00
|
|
|
async def test_revalidate_get(self, mock_cache_expire: mock.Mock, mock_get_model: mock.Mock) -> None:
|
2024-03-04 01:48:56 +01:00
|
|
|
model_cache = ModelCache(revalidate=True)
|
2024-06-06 23:09:47 -04:00
|
|
|
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100)
|
|
|
|
|
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100)
|
2023-06-27 19:21:33 -04:00
|
|
|
mock_cache_expire.assert_called_once_with(mock.ANY, 100)
|
|
|
|
|
|
2024-02-11 17:58:56 -05:00
|
|
|
async def test_profiling(self, mock_get_model: mock.Mock) -> None:
|
2024-03-04 01:48:56 +01:00
|
|
|
model_cache = ModelCache(profiling=True)
|
2024-06-06 23:09:47 -04:00
|
|
|
await model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100)
|
2024-02-11 17:58:56 -05:00
|
|
|
profiling = await model_cache.get_profiling()
|
|
|
|
|
assert isinstance(profiling, dict)
|
|
|
|
|
assert profiling == model_cache.cache.profiling
|
|
|
|
|
|
|
|
|
|
async def test_loads_mclip(self) -> None:
|
|
|
|
|
model_cache = ModelCache()
|
|
|
|
|
|
2024-06-06 23:09:47 -04:00
|
|
|
model = await model_cache.get("XLM-Roberta-Large-Vit-B-32", ModelType.TEXTUAL, ModelTask.SEARCH)
|
2024-02-11 17:58:56 -05:00
|
|
|
|
2024-06-06 23:09:47 -04:00
|
|
|
assert isinstance(model, MClipTextualEncoder)
|
2024-02-11 17:58:56 -05:00
|
|
|
assert model.model_name == "XLM-Roberta-Large-Vit-B-32"
|
|
|
|
|
|
|
|
|
|
async def test_raises_exception_if_invalid_model_type(self) -> None:
|
|
|
|
|
invalid: Any = SimpleNamespace(value="invalid")
|
|
|
|
|
model_cache = ModelCache()
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
2024-06-06 23:09:47 -04:00
|
|
|
await model_cache.get("XLM-Roberta-Large-Vit-B-32", ModelType.TEXTUAL, invalid)
|
2024-02-11 17:58:56 -05:00
|
|
|
|
|
|
|
|
async def test_raises_exception_if_unknown_model_name(self) -> None:
|
|
|
|
|
model_cache = ModelCache()
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
2024-06-06 23:09:47 -04:00
|
|
|
await model_cache.get("test_model_name", ModelType.TEXTUAL, ModelTask.SEARCH)
|
|
|
|
|
|
|
|
|
|
async def test_preloads_clip_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
|
2025-01-14 16:06:01 -06:00
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = "ViT-B-32__openai"
|
|
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"] = "ViT-B-32__openai"
|
2024-06-06 23:09:47 -04:00
|
|
|
|
|
|
|
|
settings = Settings()
|
|
|
|
|
assert settings.preload is not None
|
2025-01-14 16:06:01 -06:00
|
|
|
assert settings.preload.clip.textual == "ViT-B-32__openai"
|
|
|
|
|
assert settings.preload.clip.visual == "ViT-B-32__openai"
|
2024-06-06 23:09:47 -04:00
|
|
|
|
|
|
|
|
model_cache = ModelCache()
|
2025-03-27 15:49:09 -04:00
|
|
|
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
|
2024-06-06 23:09:47 -04:00
|
|
|
|
|
|
|
|
await preload_models(settings.preload)
|
|
|
|
|
mock_get_model.assert_has_calls(
|
|
|
|
|
[
|
|
|
|
|
mock.call("ViT-B-32__openai", ModelType.TEXTUAL, ModelTask.SEARCH),
|
|
|
|
|
mock.call("ViT-B-32__openai", ModelType.VISUAL, ModelTask.SEARCH),
|
|
|
|
|
],
|
|
|
|
|
any_order=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def test_preloads_facial_recognition_models(
|
|
|
|
|
self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock
|
|
|
|
|
) -> None:
|
2025-01-14 16:06:01 -06:00
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s"
|
|
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s"
|
2024-06-06 23:09:47 -04:00
|
|
|
|
|
|
|
|
settings = Settings()
|
|
|
|
|
assert settings.preload is not None
|
2025-01-14 16:06:01 -06:00
|
|
|
assert settings.preload.facial_recognition.detection == "buffalo_s"
|
|
|
|
|
assert settings.preload.facial_recognition.recognition == "buffalo_s"
|
2024-02-11 17:58:56 -05:00
|
|
|
|
2024-06-06 23:09:47 -04:00
|
|
|
model_cache = ModelCache()
|
2025-03-27 15:49:09 -04:00
|
|
|
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
|
2024-06-06 23:09:47 -04:00
|
|
|
|
|
|
|
|
await preload_models(settings.preload)
|
|
|
|
|
mock_get_model.assert_has_calls(
|
|
|
|
|
[
|
|
|
|
|
mock.call("buffalo_s", ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION),
|
|
|
|
|
mock.call("buffalo_s", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION),
|
|
|
|
|
],
|
|
|
|
|
any_order=True,
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-06 12:55:11 -05:00
|
|
|
async def test_preloads_ocr_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
|
|
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile"
|
|
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile"
|
|
|
|
|
|
|
|
|
|
settings = Settings()
|
|
|
|
|
assert settings.preload is not None
|
|
|
|
|
assert settings.preload.ocr.detection == "PP-OCRv5_mobile"
|
|
|
|
|
assert settings.preload.ocr.recognition == "PP-OCRv5_mobile"
|
|
|
|
|
|
|
|
|
|
model_cache = ModelCache()
|
|
|
|
|
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
|
|
|
|
|
|
|
|
|
|
await preload_models(settings.preload)
|
|
|
|
|
mock_get_model.assert_has_calls(
|
|
|
|
|
[
|
|
|
|
|
mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR),
|
|
|
|
|
mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR),
|
|
|
|
|
],
|
|
|
|
|
any_order=True,
|
|
|
|
|
)
|
|
|
|
|
|
2024-06-06 23:09:47 -04:00
|
|
|
async def test_preloads_all_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None:
|
2025-01-14 16:06:01 -06:00
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = "ViT-B-32__openai"
|
|
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"] = "ViT-B-32__openai"
|
|
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s"
|
|
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s"
|
2025-11-06 12:55:11 -05:00
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile"
|
|
|
|
|
os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile"
|
2024-03-04 01:48:56 +01:00
|
|
|
|
|
|
|
|
settings = Settings()
|
|
|
|
|
assert settings.preload is not None
|
2025-01-14 16:06:01 -06:00
|
|
|
assert settings.preload.clip.visual == "ViT-B-32__openai"
|
|
|
|
|
assert settings.preload.clip.textual == "ViT-B-32__openai"
|
|
|
|
|
assert settings.preload.facial_recognition.recognition == "buffalo_s"
|
|
|
|
|
assert settings.preload.facial_recognition.detection == "buffalo_s"
|
2025-11-06 12:55:11 -05:00
|
|
|
assert settings.preload.ocr.detection == "PP-OCRv5_mobile"
|
|
|
|
|
assert settings.preload.ocr.recognition == "PP-OCRv5_mobile"
|
2024-03-04 01:48:56 +01:00
|
|
|
|
|
|
|
|
model_cache = ModelCache()
|
2025-03-27 15:49:09 -04:00
|
|
|
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
|
2024-03-04 01:48:56 +01:00
|
|
|
|
|
|
|
|
await preload_models(settings.preload)
|
2024-06-06 23:09:47 -04:00
|
|
|
mock_get_model.assert_has_calls(
|
|
|
|
|
[
|
|
|
|
|
mock.call("ViT-B-32__openai", ModelType.TEXTUAL, ModelTask.SEARCH),
|
|
|
|
|
mock.call("ViT-B-32__openai", ModelType.VISUAL, ModelTask.SEARCH),
|
|
|
|
|
mock.call("buffalo_s", ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION),
|
|
|
|
|
mock.call("buffalo_s", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION),
|
2025-11-06 12:55:11 -05:00
|
|
|
mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR),
|
|
|
|
|
mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR),
|
2024-06-06 23:09:47 -04:00
|
|
|
],
|
|
|
|
|
any_order=True,
|
|
|
|
|
)
|
2024-03-04 01:48:56 +01:00
|
|
|
|
2024-02-11 17:58:56 -05:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestLoad:
|
|
|
|
|
async def test_load(self) -> None:
|
|
|
|
|
mock_model = mock.Mock(spec=InferenceModel)
|
|
|
|
|
mock_model.loaded = False
|
2024-06-20 14:13:18 -04:00
|
|
|
mock_model.load_attempts = 0
|
2024-02-11 17:58:56 -05:00
|
|
|
|
|
|
|
|
res = await load(mock_model)
|
|
|
|
|
|
|
|
|
|
assert res is mock_model
|
|
|
|
|
mock_model.load.assert_called_once()
|
|
|
|
|
mock_model.clear_cache.assert_not_called()
|
|
|
|
|
|
|
|
|
|
async def test_load_returns_model_if_loaded(self) -> None:
|
|
|
|
|
mock_model = mock.Mock(spec=InferenceModel)
|
|
|
|
|
mock_model.loaded = True
|
|
|
|
|
|
|
|
|
|
res = await load(mock_model)
|
|
|
|
|
|
|
|
|
|
assert res is mock_model
|
|
|
|
|
mock_model.load.assert_not_called()
|
|
|
|
|
|
|
|
|
|
async def test_load_clears_cache_and_retries_if_os_error(self) -> None:
|
|
|
|
|
mock_model = mock.Mock(spec=InferenceModel)
|
|
|
|
|
mock_model.model_name = "test_model_name"
|
2024-06-06 23:09:47 -04:00
|
|
|
mock_model.model_type = ModelType.VISUAL
|
|
|
|
|
mock_model.model_task = ModelTask.SEARCH
|
2024-02-11 17:58:56 -05:00
|
|
|
mock_model.load.side_effect = [OSError, None]
|
|
|
|
|
mock_model.loaded = False
|
2024-06-20 14:13:18 -04:00
|
|
|
mock_model.load_attempts = 0
|
2024-02-11 17:58:56 -05:00
|
|
|
|
|
|
|
|
res = await load(mock_model)
|
|
|
|
|
|
|
|
|
|
assert res is mock_model
|
|
|
|
|
mock_model.clear_cache.assert_called_once()
|
|
|
|
|
assert mock_model.load.call_count == 2
|
|
|
|
|
|
2024-07-10 10:20:43 -04:00
|
|
|
async def test_load_raises_if_os_error_and_already_retried(self) -> None:
|
2024-06-20 14:13:18 -04:00
|
|
|
mock_model = mock.Mock(spec=InferenceModel)
|
|
|
|
|
mock_model.model_name = "test_model_name"
|
|
|
|
|
mock_model.model_type = ModelType.VISUAL
|
|
|
|
|
mock_model.model_task = ModelTask.SEARCH
|
|
|
|
|
mock_model.loaded = False
|
|
|
|
|
mock_model.load_attempts = 2
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException):
|
|
|
|
|
await load(mock_model)
|
|
|
|
|
|
|
|
|
|
mock_model.clear_cache.assert_not_called()
|
|
|
|
|
mock_model.load.assert_not_called()
|
|
|
|
|
|
2025-03-18 00:04:08 +08:00
|
|
|
async def test_falls_back_to_onnx_if_other_format_does_not_exist(self, warning: mock.Mock) -> None:
|
2024-07-10 10:20:43 -04:00
|
|
|
mock_model = mock.Mock(spec=InferenceModel)
|
|
|
|
|
mock_model.model_name = "test_model_name"
|
|
|
|
|
mock_model.model_type = ModelType.VISUAL
|
|
|
|
|
mock_model.model_task = ModelTask.SEARCH
|
|
|
|
|
mock_model.model_format = ModelFormat.ARMNN
|
|
|
|
|
mock_model.loaded = False
|
|
|
|
|
mock_model.load_attempts = 0
|
|
|
|
|
error = FileNotFoundError()
|
|
|
|
|
mock_model.load.side_effect = [error, None]
|
|
|
|
|
|
|
|
|
|
await load(mock_model)
|
|
|
|
|
|
|
|
|
|
mock_model.clear_cache.assert_not_called()
|
|
|
|
|
assert mock_model.load.call_count == 2
|
2025-03-18 00:04:08 +08:00
|
|
|
warning.assert_called_once_with(
|
|
|
|
|
"ARMNN is available, but model 'test_model_name' does not support it.", exc_info=error
|
|
|
|
|
)
|
2024-07-10 10:20:43 -04:00
|
|
|
mock_model.model_format = ModelFormat.ONNX
|
|
|
|
|
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2026-04-27 18:14:34 +03:00
|
|
|
@pytest.mark.parametrize("size", [(0, 100), (100, 0), (0, 0)])
|
|
|
|
|
def test_predict_rejects_empty_image(size: tuple[int, int], deployed_app: TestClient) -> None:
|
|
|
|
|
with mock.patch("immich_ml.main.decode_pil", return_value=Image.new("RGB", size)):
|
|
|
|
|
response = deployed_app.post(
|
|
|
|
|
"http://localhost:3003/predict",
|
|
|
|
|
data={"entries": json.dumps({"clip": {"visual": {"modelName": "ViT-B-32__openai"}}})},
|
|
|
|
|
files={"image": b"fake image bytes"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
assert "zero" in response.json()["detail"].lower()
|
|
|
|
|
|
|
|
|
|
|
2024-10-13 18:00:21 -04:00
|
|
|
def test_root_endpoint(deployed_app: TestClient) -> None:
|
|
|
|
|
response = deployed_app.get("http://localhost:3003")
|
|
|
|
|
|
|
|
|
|
body = response.json()
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert body == {"message": "Immich ML"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ping_endpoint(deployed_app: TestClient) -> None:
|
|
|
|
|
response = deployed_app.get("http://localhost:3003/ping")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.text == "pong"
|
|
|
|
|
|
|
|
|
|
|
2023-06-27 19:21:33 -04:00
|
|
|
@pytest.mark.skipif(
|
|
|
|
|
not settings.test_full,
|
|
|
|
|
reason="More time-consuming since it deploys the app and loads models.",
|
|
|
|
|
)
|
2024-10-13 18:00:21 -04:00
|
|
|
class TestPredictionEndpoints:
|
2023-09-09 05:02:44 -04:00
|
|
|
def test_clip_image_endpoint(
|
|
|
|
|
self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient
|
|
|
|
|
) -> None:
|
2023-06-27 19:21:33 -04:00
|
|
|
byte_image = BytesIO()
|
|
|
|
|
pil_image.save(byte_image, format="jpeg")
|
2024-02-11 17:58:56 -05:00
|
|
|
expected = responses["clip"]["image"]
|
|
|
|
|
|
2023-06-27 19:21:33 -04:00
|
|
|
response = deployed_app.post(
|
2023-09-09 05:02:44 -04:00
|
|
|
"http://localhost:3003/predict",
|
2024-06-06 23:09:47 -04:00
|
|
|
data={"entries": json.dumps({"clip": {"visual": {"modelName": "ViT-B-32__openai"}}})},
|
2023-09-09 05:02:44 -04:00
|
|
|
files={"image": byte_image.getvalue()},
|
2023-06-27 19:21:33 -04:00
|
|
|
)
|
2024-02-11 17:58:56 -05:00
|
|
|
|
|
|
|
|
actual = response.json()
|
2023-06-27 19:21:33 -04:00
|
|
|
assert response.status_code == 200
|
2024-06-06 23:09:47 -04:00
|
|
|
assert isinstance(actual, dict)
|
2025-01-21 19:12:28 +01:00
|
|
|
embedding = actual.get("clip", None)
|
|
|
|
|
assert isinstance(embedding, str)
|
|
|
|
|
parsed_embedding = orjson.loads(embedding)
|
|
|
|
|
assert np.allclose(expected, parsed_embedding)
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2023-09-09 05:02:44 -04:00
|
|
|
def test_clip_text_endpoint(self, responses: dict[str, Any], deployed_app: TestClient) -> None:
|
2024-02-11 17:58:56 -05:00
|
|
|
expected = responses["clip"]["text"]
|
|
|
|
|
|
2023-06-27 19:21:33 -04:00
|
|
|
response = deployed_app.post(
|
2023-09-09 05:02:44 -04:00
|
|
|
"http://localhost:3003/predict",
|
|
|
|
|
data={
|
2024-06-06 23:09:47 -04:00
|
|
|
"entries": json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"clip": {"textual": {"modelName": "ViT-B-32__openai"}},
|
|
|
|
|
},
|
|
|
|
|
),
|
2023-09-09 05:02:44 -04:00
|
|
|
"text": "test search query",
|
|
|
|
|
},
|
2023-06-27 19:21:33 -04:00
|
|
|
)
|
2024-02-11 17:58:56 -05:00
|
|
|
|
|
|
|
|
actual = response.json()
|
2023-06-27 19:21:33 -04:00
|
|
|
assert response.status_code == 200
|
2024-06-06 23:09:47 -04:00
|
|
|
assert isinstance(actual, dict)
|
2025-01-21 19:12:28 +01:00
|
|
|
embedding = actual.get("clip", None)
|
|
|
|
|
assert isinstance(embedding, str)
|
|
|
|
|
parsed_embedding = orjson.loads(embedding)
|
|
|
|
|
assert np.allclose(expected, parsed_embedding)
|
2023-06-27 19:21:33 -04:00
|
|
|
|
2023-09-09 05:02:44 -04:00
|
|
|
def test_face_endpoint(self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient) -> None:
|
2023-06-27 19:21:33 -04:00
|
|
|
byte_image = BytesIO()
|
|
|
|
|
pil_image.save(byte_image, format="jpeg")
|
2023-09-09 05:02:44 -04:00
|
|
|
|
2023-06-27 19:21:33 -04:00
|
|
|
response = deployed_app.post(
|
2023-09-09 05:02:44 -04:00
|
|
|
"http://localhost:3003/predict",
|
|
|
|
|
data={
|
2024-06-06 23:09:47 -04:00
|
|
|
"entries": json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"facial-recognition": {
|
|
|
|
|
"detection": {"modelName": "buffalo_l", "options": {"minScore": 0.034}},
|
|
|
|
|
"recognition": {"modelName": "buffalo_l"},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
2023-09-09 05:02:44 -04:00
|
|
|
},
|
|
|
|
|
files={"image": byte_image.getvalue()},
|
2023-06-27 19:21:33 -04:00
|
|
|
)
|
2023-08-25 00:28:51 -04:00
|
|
|
|
2024-02-11 17:58:56 -05:00
|
|
|
actual = response.json()
|
|
|
|
|
assert response.status_code == 200
|
2024-06-06 23:09:47 -04:00
|
|
|
assert isinstance(actual, dict)
|
|
|
|
|
assert actual.get("imageHeight", None) == responses["imageHeight"]
|
|
|
|
|
assert actual.get("imageWidth", None) == responses["imageWidth"]
|
|
|
|
|
assert "facial-recognition" in actual and isinstance(actual["facial-recognition"], list)
|
|
|
|
|
assert len(actual["facial-recognition"]) == len(responses["facial-recognition"])
|
|
|
|
|
|
|
|
|
|
for expected_face, actual_face in zip(responses["facial-recognition"], actual["facial-recognition"]):
|
2024-02-11 17:58:56 -05:00
|
|
|
assert expected_face["boundingBox"] == actual_face["boundingBox"]
|
2025-01-21 19:12:28 +01:00
|
|
|
embedding = actual_face.get("embedding", None)
|
|
|
|
|
assert isinstance(embedding, str)
|
|
|
|
|
parsed_embedding = orjson.loads(embedding)
|
|
|
|
|
assert np.allclose(expected_face["embedding"], parsed_embedding)
|
2024-02-11 17:58:56 -05:00
|
|
|
assert np.allclose(expected_face["score"], actual_face["score"])
|