fix(ml): stricter typing and request validation (#31856)

This commit is contained in:
Mert
2026-09-28 14:14:01 -04:00
committed by GitHub
parent 4d51dcafab
commit ab159b8118
16 changed files with 511 additions and 377 deletions
-7
View File
@@ -27,13 +27,6 @@ def pil_image() -> Image.Image:
return Image.new("RGB", (600, 800)) return Image.new("RGB", (600, 800))
@pytest.fixture
def mock_get_model() -> Iterator[mock.Mock]:
with mock.patch("immich_ml.models.cache.get_model_class") as mocked:
mocked.return_value.graph_options = ()
yield mocked
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def deployed_app() -> Iterator[TestClient]: def deployed_app() -> Iterator[TestClient]:
with TestClient(app) as client: with TestClient(app) as client:
+75 -91
View File
@@ -14,27 +14,29 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException
from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.responses import JSONResponse, PlainTextResponse
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
from PIL.Image import Image from PIL.Image import Image
from pydantic import ValidationError from pydantic import TypeAdapter, ValidationError
from starlette.formparsers import MultiPartParser from starlette.formparsers import MultiPartParser
from immich_ml import allocator from immich_ml import allocator
from immich_ml.models import get_model_deps
from immich_ml.models.base import InferenceModel from immich_ml.models.base import InferenceModel
from immich_ml.models.transforms import decode_pil from immich_ml.models.transforms import decode_pil
from immich_ml.sessions.ort import flush_denormals from immich_ml.sessions.ort import flush_denormals
from .config import PreloadModelData, log, settings from .config import PreloadModelData, log, settings
from .models.base import InferenceEntry
from .models.cache import ModelCache from .models.cache import ModelCache
from .pipeline import Clip, FacialRecognition, Ocr, PipelineRequest, Slot
from .schemas import ( from .schemas import (
InferenceEntries, FaceDetectionOptions,
InferenceEntry, FaceRecognitionOptions,
InferenceResponse, InferenceResponse,
ModelFormat, ModelFormat,
ModelIdentity, ModelIdentity,
ModelTask, Options,
ModelType, TextDetectionOptions,
PipelineRequest, TextRecognitionOptions,
T, TextualOptions,
VisualOptions,
) )
@@ -43,6 +45,7 @@ class ORJSONResponse(JSONResponse):
return orjson.dumps(content, option=orjson.OPT_SERIALIZE_NUMPY) return orjson.dumps(content, option=orjson.OPT_SERIALIZE_NUMPY)
PIPELINE_REQUEST = TypeAdapter(PipelineRequest)
MultiPartParser.spool_max_size = 2**26 # spools to disk if payload is 64 MiB or larger MultiPartParser.spool_max_size = 2**26 # spools to disk if payload is 64 MiB or larger
model_cache = ModelCache() model_cache = ModelCache()
@@ -84,49 +87,40 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
async def preload_models(preload: PreloadModelData) -> None: async def preload_models(preload: PreloadModelData) -> None:
log.info(f"Preloading models: clip:{preload.clip} facial_recognition:{preload.facial_recognition}") requests = [
*(PipelineRequest(clip=Clip(textual=Slot(name, TextualOptions()))) for name in _names(preload.clip.textual)),
*(PipelineRequest(clip=Clip(visual=Slot(name, VisualOptions()))) for name in _names(preload.clip.visual)),
*(
PipelineRequest(facial_recognition=FacialRecognition(detection=Slot(name, FaceDetectionOptions())))
for name in _names(preload.facial_recognition.detection)
),
*(
PipelineRequest(facial_recognition=FacialRecognition(recognition=Slot(name, FaceRecognitionOptions())))
for name in _names(preload.facial_recognition.recognition)
),
*(
PipelineRequest(
ocr=Ocr(detection=Slot(name, TextDetectionOptions(max_resolution=preload.ocr.max_resolution)))
)
for name in _names(preload.ocr.detection)
),
*(
PipelineRequest(ocr=Ocr(recognition=Slot(name, TextRecognitionOptions())))
for name in _names(preload.ocr.recognition)
),
]
for entry in (entry for request in requests for entry in request.entries()):
await _preload(entry)
async def load_models(model_string: str, model_type: ModelType, model_task: ModelTask, **options: Any) -> None:
for model_name in model_string.split(","):
model_name = model_name.strip()
model = model_cache.get(model_name, model_type, model_task, **options)
await load(model)
await attempt(model, model.build, MODEL_FILE_ERRORS)
if preload.clip.textual is not None: async def _preload[O: Options](entry: InferenceEntry[O]) -> None:
await load_models(preload.clip.textual, ModelType.TEXTUAL, ModelTask.SEARCH) log.info(f"Preloading: {entry}")
model = await load(model_cache.get(entry))
await attempt(model, model.build, MODEL_FILE_ERRORS)
if preload.clip.visual is not None:
await load_models(preload.clip.visual, ModelType.VISUAL, ModelTask.SEARCH)
if preload.facial_recognition.detection is not None: def _names(setting: str | None) -> list[str]:
await load_models( return [] if setting is None else [name.strip() for name in setting.split(",")]
preload.facial_recognition.detection,
ModelType.DETECTION,
ModelTask.FACIAL_RECOGNITION,
)
if preload.facial_recognition.recognition is not None:
await load_models(
preload.facial_recognition.recognition,
ModelType.RECOGNITION,
ModelTask.FACIAL_RECOGNITION,
)
if preload.ocr.detection is not None:
await load_models(
preload.ocr.detection,
ModelType.DETECTION,
ModelTask.OCR,
maxResolution=preload.ocr.max_resolution,
)
if preload.ocr.recognition is not None:
await load_models(
preload.ocr.recognition,
ModelType.RECOGNITION,
ModelTask.OCR,
)
async def update_state() -> AsyncGenerator[None, None]: async def update_state() -> AsyncGenerator[None, None]:
@@ -144,25 +138,15 @@ async def update_state() -> AsyncGenerator[None, None]:
release = asyncio.get_running_loop().call_later(5, allocator.release) release = asyncio.get_running_loop().call_later(5, allocator.release)
def get_entries(entries: str = Form()) -> InferenceEntries: def get_entries(entries: str = Form()) -> list[InferenceEntry[Any]]:
try: try:
request: PipelineRequest = orjson.loads(entries) found = list(PIPELINE_REQUEST.validate_json(entries).entries())
without_deps: list[InferenceEntry] = [] except (ValidationError, ValueError) as e:
with_deps: list[InferenceEntry] = []
for task, types in request.items():
for type, entry in types.items():
parsed: InferenceEntry = {
"name": entry["modelName"],
"task": task,
"type": type,
"options": entry.get("options", {}),
}
dep = get_model_deps(parsed["name"], type, task)
(with_deps if dep else without_deps).append(parsed)
return without_deps, with_deps
except (orjson.JSONDecodeError, ValidationError, KeyError, AttributeError) as e:
log.error(f"Invalid request format: {e}") log.error(f"Invalid request format: {e}")
raise HTTPException(422, "Invalid request format.") raise HTTPException(422, "Invalid request format.")
if not found:
raise HTTPException(422, "No model task requested.")
return found
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
@@ -180,7 +164,7 @@ def ping() -> PlainTextResponse:
@app.post("/predict", dependencies=[Depends(update_state)]) @app.post("/predict", dependencies=[Depends(update_state)])
async def predict( async def predict(
entries: InferenceEntries = Depends(get_entries), entries: list[InferenceEntry[Any]] = Depends(get_entries),
image: bytes | None = File(default=None), image: bytes | None = File(default=None),
text: str | None = Form(default=None), text: str | None = Form(default=None),
) -> Any: ) -> Any:
@@ -197,50 +181,50 @@ async def predict(
return ORJSONResponse(response) return ORJSONResponse(response)
async def run_inference(payload: Image | str, entries: InferenceEntries) -> InferenceResponse: async def run_inference(payload: Image | str, entries: list[InferenceEntry[Any]]) -> InferenceResponse:
outputs: dict[ModelIdentity, Any] = {} read: set[ModelIdentity] = set()
response: InferenceResponse = {}
async def _run_inference(entry: InferenceEntry) -> None: async def _run_inference[O: Options](entry: InferenceEntry[O]) -> Any:
model = model_cache.get(entry["name"], entry["type"], entry["task"], ttl=settings.model_ttl, **entry["options"]) model = model_cache.get(entry, ttl=settings.model_ttl)
inputs = [payload] read.update(model.depends)
for dep in model.depends: if missing := [dep for dep in model.depends if dep not in runs]:
try: raise HTTPException(400, f"{entry.model.__name__} depends on output of {missing[0]}")
inputs.append(outputs[dep]) inputs = [payload, *[await runs[dep] for dep in model.depends]]
except KeyError:
message = f"Task {entry['task']} of type {entry['type']} depends on output of {dep}"
raise HTTPException(400, message)
model = await load(model) model = await load(model)
output = await attempt(model, partial(model.predict, *inputs, **entry["options"]), MODEL_FILE_ERRORS) return await attempt(model, partial(model.predict, *inputs, options=entry.options), MODEL_FILE_ERRORS)
outputs[model.identity] = output
response[entry["task"]] = output
for stage in entries: # those that depend on another's output run after it runs = {entry.model.identity: asyncio.create_task(_run_inference(entry)) for entry in entries}
results = await asyncio.gather(*[_run_inference(entry) for entry in stage], return_exceptions=True) outputs = await asyncio.gather(*runs.values(), return_exceptions=True)
for result in results: for output in outputs:
if isinstance(result, BaseException): if isinstance(output, BaseException):
raise result raise output
response: InferenceResponse = { # a task answers with the output no other model of it reads
entry.model.identity[1].value: output
for entry, output in zip(entries, outputs)
if entry.model.identity not in read
}
if isinstance(payload, Image): if isinstance(payload, Image):
response["imageHeight"], response["imageWidth"] = payload.height, payload.width response["imageHeight"], response["imageWidth"] = payload.height, payload.width
return response return response
async def run(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: async def run[R](func: Callable[[], R]) -> R:
if thread_pool is None: if thread_pool is None:
return func(*args, **kwargs) return func()
partial_func = partial(func, *args, **kwargs) return await asyncio.get_running_loop().run_in_executor(thread_pool, func)
return await asyncio.get_running_loop().run_in_executor(thread_pool, partial_func)
async def load(model: InferenceModel) -> InferenceModel: async def load[O: Options](model: InferenceModel[O]) -> InferenceModel[O]:
if not model.loaded: if not model.loaded:
await attempt(model, model.load, (OSError, BadZipFile, *MODEL_FILE_ERRORS)) await attempt(model, model.load, (OSError, BadZipFile, *MODEL_FILE_ERRORS))
return model return model
async def attempt(model: InferenceModel, func: Callable[[], T], corrupt: tuple[type[Exception], ...]) -> T: async def attempt[O: Options, R](
def _attempt() -> T: model: InferenceModel[O], func: Callable[[], R], corrupt: tuple[type[Exception], ...]
) -> R:
def _attempt() -> R:
if not model.loaded and model.load_attempts > 1: if not model.loaded and model.load_attempts > 1:
raise HTTPException(500, f"Failed to load model '{model.model_name}'") raise HTTPException(500, f"Failed to load model '{model.model_name}'")
try: try:
@@ -1,42 +0,0 @@
from immich_ml.models.base import InferenceModel
from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEncoder
from immich_ml.models.clip.visual import OpenClipVisualEncoder
from immich_ml.models.ocr.detection import TextDetector
from immich_ml.models.ocr.recognition import TextRecognizer
from immich_ml.schemas import ModelSource, ModelTask, ModelType
from .constants import get_model_source
from .facial_recognition.detection import FaceDetector
from .facial_recognition.recognition import FaceRecognizer
def get_model_class(model_name: str, model_type: ModelType, model_task: ModelTask) -> type[InferenceModel]:
source = get_model_source(model_name)
match source, model_type, model_task:
case ModelSource.OPENCLIP | ModelSource.MCLIP, ModelType.VISUAL, ModelTask.SEARCH:
return OpenClipVisualEncoder
case ModelSource.OPENCLIP, ModelType.TEXTUAL, ModelTask.SEARCH:
return OpenClipTextualEncoder
case ModelSource.MCLIP, ModelType.TEXTUAL, ModelTask.SEARCH:
return MClipTextualEncoder
case ModelSource.INSIGHTFACE, ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION:
return FaceDetector
case ModelSource.INSIGHTFACE, ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION:
return FaceRecognizer
case ModelSource.PADDLE, ModelType.DETECTION, ModelTask.OCR:
return TextDetector
case ModelSource.PADDLE, ModelType.RECOGNITION, ModelTask.OCR:
return TextRecognizer
case _:
raise ValueError(f"Unknown model combination: {source}, {model_type}, {model_task}")
def get_model_deps(model_name: str, model_type: ModelType, model_task: ModelTask) -> list[tuple[ModelType, ModelTask]]:
return get_model_class(model_name, model_type, model_task).depends
+32 -8
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Hashable
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from threading import Lock from threading import Lock
from typing import Any, ClassVar from typing import Any, ClassVar, Self
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
@@ -13,9 +15,10 @@ import immich_ml.sessions.rknn as rknn
from immich_ml.sessions.ort import OrtSession from immich_ml.sessions.ort import OrtSession
from ..config import clean_name, log, settings from ..config import clean_name, log, settings
from ..schemas import ModelFormat, ModelIdentity, ModelSession, ModelTask, ModelType from ..schemas import ModelFormat, ModelIdentity, ModelSession, ModelSource, ModelTask, ModelType, Options
from ..sessions.ann import AnnSession from ..sessions.ann import AnnSession
from ..sessions.policy import ShapePolicy from ..sessions.policy import ShapePolicy
from .constants import get_model_source
_IGNORED_PATTERNS: dict[ModelFormat, list[str]] = { _IGNORED_PATTERNS: dict[ModelFormat, list[str]] = {
ModelFormat.ONNX: ["*.armnn", "*.rknn"], ModelFormat.ONNX: ["*.armnn", "*.rknn"],
@@ -24,11 +27,10 @@ _IGNORED_PATTERNS: dict[ModelFormat, list[str]] = {
} }
class InferenceModel(ABC): class InferenceModel[O: Options](ABC):
depends: ClassVar[list[ModelIdentity]] depends: ClassVar[list[ModelIdentity]]
identity: ClassVar[ModelIdentity] identity: ClassVar[ModelIdentity]
# options a graph is built for, so one instance cannot serve two values of them sources: ClassVar[tuple[ModelSource, ...]]
graph_options: ClassVar[tuple[str, ...]] = ()
# the shapes this model feeds, which pick the artifact and tell the engine what it may pin # the shapes this model feeds, which pick the artifact and tell the engine what it may pin
shape_policy: ShapePolicy = ShapePolicy() shape_policy: ShapePolicy = ShapePolicy()
threads: ClassVar[int] = 2 # how many threads one run takes for CPU inference threads: ClassVar[int] = 2 # how many threads one run takes for CPU inference
@@ -39,7 +41,6 @@ class InferenceModel(ABC):
cache_dir: Path | str | None = None, cache_dir: Path | str | None = None,
model_format: ModelFormat | None = None, model_format: ModelFormat | None = None,
session: ModelSession | None = None, session: ModelSession | None = None,
**model_kwargs: Any,
) -> None: ) -> None:
self.loaded = session is not None self.loaded = session is not None
self.load_attempts = 0 self.load_attempts = 0
@@ -50,6 +51,15 @@ class InferenceModel(ABC):
if session is not None: if session is not None:
self.session = session self.session = session
@classmethod
def graph(cls, options: O) -> Hashable:
"""What of its options the graph is built for: an instance serves only requests that agree on it."""
return None
@classmethod
def create(cls, model_name: str, options: O) -> Self:
return cls(model_name)
def download(self) -> None: def download(self) -> None:
if self.cached: if self.cached:
return return
@@ -87,9 +97,9 @@ class InferenceModel(ABC):
self.load() self.load()
self.session.warm() self.session.warm()
def predict(self, *inputs: Any, **model_kwargs: Any) -> Any: def predict(self, *inputs: Any, options: O) -> Any:
self.load() self.load()
return self._predict(*inputs, **model_kwargs) return self._predict(*inputs, options=options)
@abstractmethod @abstractmethod
def _predict(self, *inputs: Any, **model_kwargs: Any) -> Any: ... def _predict(self, *inputs: Any, **model_kwargs: Any) -> Any: ...
@@ -180,3 +190,17 @@ class InferenceModel(ABC):
return ModelFormat.ARMNN return ModelFormat.ARMNN
else: else:
return ModelFormat.ONNX return ModelFormat.ONNX
@dataclass(frozen=True)
class InferenceEntry[O: Options]:
model: type[InferenceModel[O]]
name: str
options: O
def __post_init__(self) -> None:
if get_model_source(self.name) not in self.model.sources:
raise ValueError(f"'{self.name}' is not a model {self.model.__name__} runs")
def __str__(self) -> str:
return f"{self.model.__name__}(name={self.name!r}, options={self.options})"
+10 -12
View File
@@ -1,25 +1,23 @@
import asyncio import asyncio
from collections.abc import Hashable
from typing import Any from typing import Any
from immich_ml import allocator from immich_ml import allocator
from immich_ml.models import get_model_class from immich_ml.models.base import InferenceEntry, InferenceModel
from immich_ml.models.base import InferenceModel from immich_ml.schemas import Options
from ..schemas import ModelTask, ModelType Key = tuple[type[InferenceModel[Any]], str, Hashable]
class ModelCache: class ModelCache:
def __init__(self) -> None: def __init__(self) -> None:
self._models: dict[tuple[Any, ...], InferenceModel] = {} self._models: dict[Key, InferenceModel[Any]] = {}
self._expiries: dict[tuple[Any, ...], asyncio.TimerHandle] = {} self._expiries: dict[Key, asyncio.TimerHandle] = {}
def get( def get[O: Options](self, entry: InferenceEntry[O], ttl: int | None = None) -> InferenceModel[O]:
self, model_name: str, model_type: ModelType, model_task: ModelTask, ttl: int | None = None, **options: Any key = (entry.model, entry.name, entry.model.graph(entry.options))
) -> InferenceModel:
model_cls = get_model_class(model_name, model_type, model_task)
key = (model_name, model_type, model_task, *(options.get(option) for option in model_cls.graph_options))
if key not in self._models: if key not in self._models:
self._models[key] = model_cls(model_name, **options) self._models[key] = entry.model.create(entry.name, entry.options)
elif key not in self._expiries: elif key not in self._expiries:
ttl = None # a model that was preloaded stays ttl = None # a model that was preloaded stays
if ttl: if ttl:
@@ -36,7 +34,7 @@ class ModelCache:
self._expiries.pop(key).cancel() self._expiries.pop(key).cancel()
allocator.release() allocator.release()
def _evict(self, key: tuple[Any, ...]) -> None: def _evict(self, key: Key) -> None:
if key not in self._models: if key not in self._models:
return return
del self._models[key], self._expiries[key] del self._models[key], self._expiries[key]
@@ -12,7 +12,7 @@ from immich_ml.config import log
from immich_ml.models.base import InferenceModel from immich_ml.models.base import InferenceModel
from immich_ml.models.constants import WEBLATE_TO_FLORES200 from immich_ml.models.constants import WEBLATE_TO_FLORES200
from immich_ml.models.transforms import clean_text, serialize_np_array from immich_ml.models.transforms import clean_text, serialize_np_array
from immich_ml.schemas import ModelSession, ModelTask, ModelType from immich_ml.schemas import ModelSession, ModelSource, ModelTask, ModelType, TextualOptions
def _mapped(model_path: Path, name: str) -> NDArray[Any]: def _mapped(model_path: Path, name: str) -> NDArray[Any]:
@@ -25,13 +25,13 @@ def _mapped(model_path: Path, name: str) -> NDArray[Any]:
return np.memmap(model_path.parent / data["location"], dtype=dtype, mode="r", offset=offset, shape=shape) return np.memmap(model_path.parent / data["location"], dtype=dtype, mode="r", offset=offset, shape=shape)
class BaseCLIPTextualEncoder(InferenceModel): class BaseCLIPTextualEncoder(InferenceModel[TextualOptions]):
depends = [] depends = []
identity = (ModelType.TEXTUAL, ModelTask.SEARCH) identity = (ModelType.TEXTUAL, ModelTask.SEARCH)
threads = 4 # lower search latency threads = 4 # lower search latency
def _predict(self, inputs: str, language: str | None = None) -> str: def _predict(self, inputs: str, options: TextualOptions) -> str:
tokens: dict[str, NDArray[Any]] = self.tokenize(inputs, language=language) tokens: dict[str, NDArray[Any]] = self.tokenize(inputs, language=options.language)
graph = self.session.for_shape(self.shape_policy.dims[0]) graph = self.session.for_shape(self.shape_policy.dims[0])
if self.embedding is not None: # a graph that left its token table to the host if self.embedding is not None: # a graph that left its token table to the host
tokens["token_embeds"] = self.embedding[next(iter(tokens.values()))] tokens["token_embeds"] = self.embedding[next(iter(tokens.values()))]
@@ -99,6 +99,8 @@ class BaseCLIPTextualEncoder(InferenceModel):
class OpenClipTextualEncoder(BaseCLIPTextualEncoder): class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
sources = (ModelSource.OPENCLIP,)
def _load_tokenizer(self) -> Tokenizer: def _load_tokenizer(self) -> Tokenizer:
context_length: int = self.text_cfg.get("context_length", 77) context_length: int = self.text_cfg.get("context_length", 77)
pad_token: str = self.tokenizer_cfg["pad_token"] pad_token: str = self.tokenizer_cfg["pad_token"]
@@ -129,6 +131,8 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
class MClipTextualEncoder(OpenClipTextualEncoder): class MClipTextualEncoder(OpenClipTextualEncoder):
sources = (ModelSource.MCLIP,)
def tokenize(self, text: str, language: str | None = None) -> dict[str, NDArray[np.int32]]: def tokenize(self, text: str, language: str | None = None) -> dict[str, NDArray[np.int32]]:
text = clean_text(text, canonicalize=self.canonicalize) text = clean_text(text, canonicalize=self.canonicalize)
tokens: Encoding = self.tokenizer.encode(text) tokens: Encoding = self.tokenizer.encode(text)
@@ -19,14 +19,14 @@ from immich_ml.models.transforms import (
serialize_np_array, serialize_np_array,
to_numpy, to_numpy,
) )
from immich_ml.schemas import ModelGraph, ModelInput, ModelSession, ModelTask, ModelType from immich_ml.schemas import ModelGraph, ModelInput, ModelSession, ModelSource, ModelTask, ModelType, VisualOptions
class BaseCLIPVisualEncoder(InferenceModel): class BaseCLIPVisualEncoder(InferenceModel[VisualOptions]):
depends = [] depends = []
identity = (ModelType.VISUAL, ModelTask.SEARCH) identity = (ModelType.VISUAL, ModelTask.SEARCH)
def _predict(self, inputs: Image.Image | bytes) -> str: def _predict(self, inputs: Image.Image | bytes, options: VisualOptions) -> str:
image = decode_pil(inputs) image = decode_pil(inputs)
session = self.session.for_shape(self.shape_policy.dims[0]) session = self.session.for_shape(self.shape_policy.dims[0])
res: NDArray[np.float32] = session.run(None, self.transform(session, image))[0][0] res: NDArray[np.float32] = session.run(None, self.transform(session, image))[0][0]
@@ -60,6 +60,8 @@ class BaseCLIPVisualEncoder(InferenceModel):
class OpenClipVisualEncoder(BaseCLIPVisualEncoder): class OpenClipVisualEncoder(BaseCLIPVisualEncoder):
sources = (ModelSource.OPENCLIP, ModelSource.MCLIP)
def _load(self) -> ModelSession: def _load(self) -> ModelSession:
size: list[int] | int = self.preprocess_cfg["size"] size: list[int] | int = self.preprocess_cfg["size"]
self.size = size[0] if isinstance(size, list) else size self.size = size[0] if isinstance(size, list) else size
@@ -4,18 +4,19 @@ from numpy.typing import NDArray
from immich_ml.models.base import InferenceModel from immich_ml.models.base import InferenceModel
from immich_ml.models.transforms import decode_pil, letterbox, normalize, widen from immich_ml.models.transforms import decode_pil, letterbox, normalize, widen
from immich_ml.schemas import FaceDetectionOutput, ModelTask, ModelType, Shape from immich_ml.schemas import FaceDetectionOptions, FaceDetectionOutput, ModelSource, ModelTask, ModelType, Shape
from immich_ml.sessions.policy import ShapePolicy from immich_ml.sessions.policy import ShapePolicy
from ._ops import decode_scrfd, nms from ._ops import decode_scrfd, nms
class FaceDetector(InferenceModel): class FaceDetector(InferenceModel[FaceDetectionOptions]):
depends = [] depends = []
identity = (ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION) identity = (ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)
sources = (ModelSource.INSIGHTFACE,)
shape_policy = ShapePolicy(dims=(Shape(batch=1, height=DET_SIZE, width=DET_SIZE),)) shape_policy = ShapePolicy(dims=(Shape(batch=1, height=DET_SIZE, width=DET_SIZE),))
def _predict(self, inputs: NDArray[np.uint8] | bytes, minScore: float) -> FaceDetectionOutput: def _predict(self, inputs: NDArray[np.uint8] | bytes, options: FaceDetectionOptions) -> FaceDetectionOutput:
session = self.session.for_shape(self.shape_policy.dims[0]) session = self.session.for_shape(self.shape_policy.dims[0])
canvas, scale = letterbox(decode_pil(inputs), DET_SIZE) canvas, scale = letterbox(decode_pil(inputs), DET_SIZE)
blob: NDArray[np.float32] | NDArray[np.uint8] = ( blob: NDArray[np.float32] | NDArray[np.uint8] = (
@@ -27,7 +28,7 @@ class FaceDetector(InferenceModel):
heads = [widen(head) for head in session.run(None, {session.get_inputs()[0].name: blob})] heads = [widen(head) for head in session.run(None, {session.get_inputs()[0].name: blob})]
scores, boxes, kps = decode_scrfd(heads, DET_SIZE) scores, boxes, kps = decode_scrfd(heads, DET_SIZE)
candidates = scores >= minScore candidates = scores >= options.min_score
scores, boxes, kps = scores[candidates], boxes[candidates] / scale, kps[candidates] / scale scores, boxes, kps = scores[candidates], boxes[candidates] / scale, kps[candidates] / scale
keep = nms(boxes, scores) keep = nms(boxes, scores)
@@ -7,15 +7,24 @@ from PIL import Image
from immich_ml.config import settings from immich_ml.config import settings
from immich_ml.models.base import InferenceModel from immich_ml.models.base import InferenceModel
from immich_ml.models.transforms import decode_pil, normalize, serialize_np_array from immich_ml.models.transforms import decode_pil, normalize, serialize_np_array
from immich_ml.schemas import FaceDetectionOutput, FacialRecognitionOutput, ModelTask, ModelType, Shape from immich_ml.schemas import (
FaceDetectionOutput,
FaceRecognitionOptions,
FacialRecognitionOutput,
ModelSource,
ModelTask,
ModelType,
Shape,
)
from immich_ml.sessions.policy import ShapePolicy, batches, runs from immich_ml.sessions.policy import ShapePolicy, batches, runs
from ._ops import ALIGNED_SIZE, align_face from ._ops import ALIGNED_SIZE, align_face
class FaceRecognizer(InferenceModel): class FaceRecognizer(InferenceModel[FaceRecognitionOptions]):
depends = [(ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)] depends = [(ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)]
identity = (ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION) identity = (ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION)
sources = (ModelSource.INSIGHTFACE,)
def __init__(self, model_name: str, **model_kwargs: Any) -> None: def __init__(self, model_name: str, **model_kwargs: Any) -> None:
super().__init__(model_name, **model_kwargs) super().__init__(model_name, **model_kwargs)
@@ -23,7 +32,10 @@ class FaceRecognizer(InferenceModel):
self.shape_policy = ShapePolicy(dims=tuple(Shape(batch) for batch in sizes)) self.shape_policy = ShapePolicy(dims=tuple(Shape(batch) for batch in sizes))
def _predict( def _predict(
self, inputs: NDArray[np.uint8] | bytes | Image.Image, faces: FaceDetectionOutput self,
inputs: NDArray[np.uint8] | bytes | Image.Image,
faces: FaceDetectionOutput,
options: FaceRecognitionOptions,
) -> FacialRecognitionOutput: ) -> FacialRecognitionOutput:
if faces["boxes"].shape[0] == 0: if faces["boxes"].shape[0] == 0:
return [] return []
@@ -1,5 +1,5 @@
from collections.abc import Sequence from collections.abc import Hashable, Sequence
from typing import Any from typing import Any, Self
import numpy as np import numpy as np
from immich_model.constants import ocr_canvases from immich_model.constants import ocr_canvases
@@ -7,7 +7,7 @@ from numpy.typing import NDArray
from PIL import Image from PIL import Image
from immich_ml.models.transforms import widen from immich_ml.models.transforms import widen
from immich_ml.schemas import ModelGraph, ModelTask, ModelType, Shape from immich_ml.schemas import ModelGraph, ModelTask, ModelType, Shape, TextDetectionOptions
from immich_ml.sessions.policy import ShapePolicy from immich_ml.sessions.policy import ShapePolicy
from .legacy import TextModel from .legacy import TextModel
@@ -15,17 +15,17 @@ from .postprocess import DBPostProcess
from .schemas import TextDetectionOutput from .schemas import TextDetectionOutput
class TextDetector(TextModel): class TextDetector(TextModel[TextDetectionOptions]):
depends = [] depends = []
identity = (ModelType.DETECTION, ModelTask.OCR) identity = (ModelType.DETECTION, ModelTask.OCR)
graph_options = ("maxResolution",)
def __init__(self, model_name: str, **model_kwargs: Any) -> None: def __init__(
self, model_name: str, max_resolution: int = TextDetectionOptions.max_resolution, **model_kwargs: Any
) -> None:
super().__init__(model_name, **model_kwargs) super().__init__(model_name, **model_kwargs)
short_side = model_kwargs.get("maxResolution", 736)
# RKNPU ships a binary per short side, which the label picks # RKNPU ships a binary per short side, which the label picks
canvases = tuple(Shape(batch=1, **canvas) for canvas in ocr_canvases(short_side)) canvases = tuple(Shape(batch=1, **canvas) for canvas in ocr_canvases(max_resolution))
self.shape_policy = ShapePolicy(dims=canvases, label=f"res{short_side}") self.shape_policy = ShapePolicy(dims=canvases, label=f"res{max_resolution}")
self.scale = np.float32(1.0 / 127.5) # (x/255 - 0.5) / 0.5 self.scale = np.float32(1.0 / 127.5) # (x/255 - 0.5) / 0.5
self._empty: TextDetectionOutput = { self._empty: TextDetectionOutput = {
"boxes": np.empty(0, dtype=np.float32), "boxes": np.empty(0, dtype=np.float32),
@@ -33,18 +33,24 @@ class TextDetector(TextModel):
} }
self.postprocess = DBPostProcess(thresh=0.3, max_candidates=1000, unclip_ratio=1.6, use_dilation=True) self.postprocess = DBPostProcess(thresh=0.3, max_candidates=1000, unclip_ratio=1.6, use_dilation=True)
def _predict( @classmethod
self, inputs: Image.Image, maxResolution: int = 736, minScore: float = 0.5, scoreMode: str = "fast" def graph(cls, options: TextDetectionOptions) -> Hashable:
) -> TextDetectionOutput: return options.max_resolution
@classmethod
def create(cls, model_name: str, options: TextDetectionOptions) -> Self:
return cls(model_name, max_resolution=options.max_resolution)
def _predict(self, inputs: Image.Image, options: TextDetectionOptions) -> TextDetectionOutput:
width, height = inputs.size width, height = inputs.size
if width < 32 or height < 32: if width < 32 or height < 32:
return self._empty return self._empty
session, image, content = self._transform(inputs, maxResolution) session, image, content = self._transform(inputs, options.max_resolution)
probs = widen(session.run(None, {session.get_inputs()[0].name: image})[0][0]) probs = widen(session.run(None, {session.get_inputs()[0].name: image})[0][0])
if probs.ndim == 3: if probs.ndim == 3:
probs = probs[0] probs = probs[0]
boxes, scores = self.postprocess(probs[: content[0], : content[1]], (height, width), minScore, scoreMode) boxes, scores = self.postprocess(probs[: content[0], : content[1]], (height, width), options.min_score)
if len(boxes) == 0: if len(boxes) == 0:
return self._empty return self._empty
order = self.reading_order(boxes) order = self.reading_order(boxes)
@@ -1,16 +1,16 @@
from typing import Any
from immich_ml.config import log, settings from immich_ml.config import log, settings
from immich_ml.models.base import InferenceModel from immich_ml.models.base import InferenceModel
from immich_ml.schemas import ModelFormat, ModelType from immich_ml.schemas import ModelFormat, ModelSource, ModelType, Options
# TODO: Remove once everything uses the new model graphs # TODO: Remove once everything uses the new model graphs
class TextModel(InferenceModel): class TextModel[O: Options](InferenceModel[O]):
def __init__(self, model_name: str, **model_kwargs: Any) -> None: sources = (ModelSource.PADDLE,)
if settings.legacy_models:
model_kwargs["model_format"] = ModelFormat.ONNX # the older exports come in no other format @property
super().__init__(model_name, **model_kwargs) def _model_format_default(self) -> ModelFormat:
# the older exports come in no other format
return ModelFormat.ONNX if settings.legacy_models else super()._model_format_default
def download(self) -> None: def download(self) -> None:
if not settings.legacy_models or self.cached: if not settings.legacy_models or self.cached:
@@ -9,7 +9,7 @@ from numpy.typing import NDArray
from PIL import Image from PIL import Image
from immich_ml.config import settings from immich_ml.config import settings
from immich_ml.schemas import ModelGraph, ModelSession, ModelTask, ModelType, Shape from immich_ml.schemas import ModelGraph, ModelSession, ModelTask, ModelType, Shape, TextRecognitionOptions
from immich_ml.sessions.policy import ShapePolicy, batches, runs from immich_ml.sessions.policy import ShapePolicy, batches, runs
from .ctc import GREEDY, CtcDecoder, probabilities from .ctc import GREEDY, CtcDecoder, probabilities
@@ -20,7 +20,7 @@ REC_HEIGHT = 48
SCALE = np.float32(1.0 / 127.5) SCALE = np.float32(1.0 / 127.5)
class TextRecognizer(TextModel): class TextRecognizer(TextModel[TextRecognitionOptions]):
depends = [(ModelType.DETECTION, ModelTask.OCR)] depends = [(ModelType.DETECTION, ModelTask.OCR)]
identity = (ModelType.RECOGNITION, ModelTask.OCR) identity = (ModelType.RECOGNITION, ModelTask.OCR)
@@ -53,7 +53,9 @@ class TextRecognizer(TextModel):
else CtcDecoder.from_file(self.model_dir / "charset.txt", greedy) else CtcDecoder.from_file(self.model_dir / "charset.txt", greedy)
) )
def _predict(self, img: Image.Image, texts: TextDetectionOutput, minScore: float = 0.9) -> TextRecognitionOutput: def _predict(
self, img: Image.Image, texts: TextDetectionOutput, options: TextRecognitionOptions
) -> TextRecognitionOutput:
boxes, box_scores = texts["boxes"], texts["scores"] boxes, box_scores = texts["boxes"], texts["scores"]
if boxes.shape[0] == 0: if boxes.shape[0] == 0:
return self._empty return self._empty
@@ -101,7 +103,7 @@ class TextRecognizer(TextModel):
boxes[:, :, 0] /= img.width boxes[:, :, 0] /= img.width
boxes[:, :, 1] /= img.height boxes[:, :, 1] /= img.height
valid = score_list > minScore valid = score_list > options.min_score
valid_list = valid.tolist() valid_list = valid.tolist()
return { return {
"box": boxes.reshape(-1, 8)[valid].reshape(-1), "box": boxes.reshape(-1, 8)[valid].reshape(-1),
+85
View File
@@ -0,0 +1,85 @@
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Annotated, Any
from pydantic import ConfigDict, Field, with_config
from .models.base import InferenceEntry, InferenceModel
from .models.clip.textual import BaseCLIPTextualEncoder, MClipTextualEncoder, OpenClipTextualEncoder
from .models.clip.visual import OpenClipVisualEncoder
from .models.constants import get_model_source
from .models.facial_recognition.detection import FaceDetector
from .models.facial_recognition.recognition import FaceRecognizer
from .models.ocr.detection import TextDetector
from .models.ocr.recognition import TextRecognizer
from .schemas import (
FaceDetectionOptions,
FaceRecognitionOptions,
ModelSource,
Options,
TextDetectionOptions,
TextRecognitionOptions,
TextualOptions,
VisualOptions,
)
STRICT = ConfigDict(extra="forbid")
def textual(model_name: str) -> type[BaseCLIPTextualEncoder]:
return MClipTextualEncoder if get_model_source(model_name) == ModelSource.MCLIP else OpenClipTextualEncoder
@with_config(STRICT)
@dataclass(frozen=True)
class Slot[O: Options]:
model_name: Annotated[str, Field(alias="modelName")]
options: Annotated[O, Field(default_factory=dict, validate_default=True)] # left out, it takes their defaults
def entry(self, model: type[InferenceModel[O]]) -> InferenceEntry[O]:
return InferenceEntry(model, self.model_name, self.options)
@with_config(STRICT)
@dataclass(frozen=True)
class Clip:
visual: Slot[VisualOptions] | None = None
textual: Slot[TextualOptions] | None = None
@with_config(STRICT)
@dataclass(frozen=True)
class FacialRecognition:
detection: Slot[FaceDetectionOptions] | None = None
recognition: Slot[FaceRecognitionOptions] | None = None
@with_config(STRICT)
@dataclass(frozen=True)
class Ocr:
detection: Slot[TextDetectionOptions] | None = None
recognition: Slot[TextRecognitionOptions] | None = None
@with_config(STRICT)
@dataclass(frozen=True)
class PipelineRequest:
clip: Clip = field(default_factory=Clip)
facial_recognition: Annotated[FacialRecognition, Field(alias="facial-recognition")] = field(
default_factory=FacialRecognition
)
ocr: Ocr = field(default_factory=Ocr)
def entries(self) -> Iterator[InferenceEntry[Any]]:
if (visual := self.clip.visual) is not None:
yield visual.entry(OpenClipVisualEncoder)
if (text := self.clip.textual) is not None:
yield text.entry(textual(text.model_name))
if (detection := self.facial_recognition.detection) is not None:
yield detection.entry(FaceDetector)
if (recognition := self.facial_recognition.recognition) is not None:
yield recognition.entry(FaceRecognizer)
if (boxes := self.ocr.detection) is not None:
yield boxes.entry(TextDetector)
if (reading := self.ocr.recognition) is not None:
yield reading.entry(TextRecognizer)
+28 -13
View File
@@ -1,10 +1,11 @@
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from enum import Enum from enum import Enum
from typing import Any, Literal, Protocol, TypeAlias, TypeVar from typing import Annotated, Any, Protocol, TypeAlias
import numpy as np import numpy as np
import numpy.typing as npt import numpy.typing as npt
from pydantic import ConfigDict, Field
from typing_extensions import TypedDict from typing_extensions import TypedDict
@@ -121,25 +122,39 @@ class DetectedFace(TypedDict):
FacialRecognitionOutput = list[DetectedFace] FacialRecognitionOutput = list[DetectedFace]
class PipelineEntry(TypedDict): class Options:
modelName: str __pydantic_config__ = ConfigDict(extra="ignore") # so that an older service ignores an option a newer server adds
options: dict[str, Any]
PipelineRequest = dict[ModelTask, dict[ModelType, PipelineEntry]] @dataclass(frozen=True)
class VisualOptions(Options):
pass
class InferenceEntry(TypedDict): @dataclass(frozen=True)
name: str class TextualOptions(Options):
task: ModelTask language: str | None = None
type: ModelType
options: dict[str, Any]
InferenceEntries = tuple[list[InferenceEntry], list[InferenceEntry]] @dataclass(frozen=True)
class FaceDetectionOptions(Options):
min_score: Annotated[float, Field(alias="minScore")] = 0.7
InferenceResponse = dict[ModelTask | Literal["imageHeight"] | Literal["imageWidth"], Any] @dataclass(frozen=True)
class FaceRecognitionOptions(Options):
pass
T = TypeVar("T") @dataclass(frozen=True)
class TextDetectionOptions(Options):
max_resolution: Annotated[int, Field(alias="maxResolution")] = 736
min_score: Annotated[float, Field(alias="minScore")] = 0.5
@dataclass(frozen=True)
class TextRecognitionOptions(Options):
min_score: Annotated[float, Field(alias="minScore")] = 0.9
InferenceResponse = dict[str, Any]
+3 -3
View File
@@ -6,16 +6,16 @@ uv = "0.8.15"
run = "uv sync --locked" run = "uv sync --locked"
[tasks.lint] [tasks.lint]
run = "uv run ruff check immich_ml" run = "uv run ruff check immich_ml scripts test_main.py conftest.py"
[tasks.test] [tasks.test]
run = "uv run pytest --cov=immich_ml --cov-report term-missing" run = "uv run pytest --cov=immich_ml --cov-report term-missing"
[tasks.format] [tasks.format]
run = "uv run ruff format immich_ml" run = "uv run ruff format immich_ml scripts test_main.py conftest.py"
[tasks.check] [tasks.check]
run = "uv run mypy --strict immich_ml/" run = "uv run mypy --strict immich_ml/ scripts/ test_main.py conftest.py"
[tasks.ci-unit] [tasks.ci-unit]
run = [ run = [
+211 -161
View File
@@ -28,15 +28,17 @@ from immich_ml import allocator
from immich_ml.config import MaxBatchSize, PreloadModelData, Settings, settings from immich_ml.config import MaxBatchSize, PreloadModelData, Settings, settings
from immich_ml.main import ( from immich_ml.main import (
MODEL_FILE_ERRORS, MODEL_FILE_ERRORS,
PIPELINE_REQUEST,
app, app,
attempt, attempt,
get_entries,
lifespan, lifespan,
load, load,
preload_models, preload_models,
update_state, update_state,
) )
from immich_ml.main import run_inference as run_request from immich_ml.main import run_inference as run_request
from immich_ml.models.base import InferenceModel from immich_ml.models.base import InferenceEntry, InferenceModel
from immich_ml.models.cache import ModelCache from immich_ml.models.cache import ModelCache
from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEncoder from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEncoder
from immich_ml.models.clip.visual import OpenClipVisualEncoder from immich_ml.models.clip.visual import OpenClipVisualEncoder
@@ -45,7 +47,19 @@ from immich_ml.models.facial_recognition.recognition import FaceRecognizer
from immich_ml.models.ocr.ctc import logits, probabilities from immich_ml.models.ocr.ctc import logits, probabilities
from immich_ml.models.ocr.detection import TextDetector from immich_ml.models.ocr.detection import TextDetector
from immich_ml.models.ocr.recognition import TextRecognizer from immich_ml.models.ocr.recognition import TextRecognizer
from immich_ml.schemas import ModelFormat, ModelTask, ModelType, Shape from immich_ml.schemas import (
FaceDetectionOptions,
FaceRecognitionOptions,
ModelFormat,
ModelSource,
ModelTask,
ModelType,
Shape,
TextDetectionOptions,
TextRecognitionOptions,
TextualOptions,
VisualOptions,
)
from immich_ml.sessions.ann import AnnSession from immich_ml.sessions.ann import AnnSession
from immich_ml.sessions.ort import Device, GraphSpec, OrtSession, flush_denormals, fresh, prepared from immich_ml.sessions.ort import Device, GraphSpec, OrtSession, flush_denormals, fresh, prepared
from immich_ml.sessions.policy import ShapePolicy, batches, runs from immich_ml.sessions.policy import ShapePolicy, batches, runs
@@ -123,7 +137,7 @@ class TestBase:
assert rknn_model_path(Path("/cache/visual")) == Path("/cache/visual/rknpu/rk3588/model.rknn") assert rknn_model_path(Path("/cache/visual")) == Path("/cache/visual/rknpu/rk3588/model.rknn")
mocker.patch.object(settings, "model_revision", "v2") # the older exports come as ONNX alone mocker.patch.object(settings, "model_revision", "v2") # the older exports come as ONNX alone
detector = TextDetector( detector = TextDetector(
"PP-OCRv5_mobile", cache_dir="/cache", model_format=ModelFormat.RKNN, maxResolution=1088 "PP-OCRv5_mobile", cache_dir="/cache", model_format=ModelFormat.RKNN, max_resolution=1088
) )
assert detector.model_path == Path("/cache/detection/rknpu/rk3588/res1088/model.rknn") assert detector.model_path == Path("/cache/detection/rknpu/rk3588/res1088/model.rknn")
@@ -1006,7 +1020,7 @@ class TestCLIP:
mocked.run.return_value = [[self.embedding]] mocked.run.return_value = [[self.embedding]]
clip_encoder = OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache") clip_encoder = OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache")
embedding_str = clip_encoder.predict(pil_image) embedding_str = clip_encoder.predict(pil_image, options=VisualOptions())
assert isinstance(embedding_str, str) assert isinstance(embedding_str, str)
embedding = orjson.loads(embedding_str) embedding = orjson.loads(embedding_str)
assert isinstance(embedding, list) assert isinstance(embedding, list)
@@ -1034,7 +1048,7 @@ class TestCLIP:
session = stub_session(expected_shape, outputs=[[self.embedding]], normalizes_input=normalizes_input) session = stub_session(expected_shape, outputs=[[self.embedding]], normalizes_input=normalizes_input)
mocker.patch.object(InferenceModel, "_make_session", return_value=session) mocker.patch.object(InferenceModel, "_make_session", return_value=session)
OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache").predict(pil_image) OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache").predict(pil_image, options=VisualOptions())
fed = session.run.call_args.args[1]["image"] fed = session.run.call_args.args[1]["image"]
assert fed.dtype == expected_dtype assert fed.dtype == expected_dtype
@@ -1056,7 +1070,7 @@ class TestCLIP:
# a stripe far enough left that a shortest-side crop of this frame would discard it # a stripe far enough left that a shortest-side crop of this frame would discard it
image = Image.new("RGB", (600, 200), "black") image = Image.new("RGB", (600, 200), "black")
image.paste(Image.new("RGB", (20, 200), "white"), (0, 0)) image.paste(Image.new("RGB", (20, 200), "white"), (0, 0))
OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache").predict(image) OpenClipVisualEncoder("ViT-B-32__openai", cache_dir="test_cache").predict(image, options=VisualOptions())
assert session.run.call_args.args[1]["image"][0, 0, 0].tolist() == [255, 255, 255] assert session.run.call_args.args[1]["image"][0, 0, 0].tolist() == [255, 255, 255]
@@ -1078,7 +1092,7 @@ class TestCLIP:
mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True) mocker.patch("immich_ml.models.clip.textual.Tokenizer.from_file", autospec=True)
clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache") clip_encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir="test_cache")
embedding_str = clip_encoder.predict("test search query") embedding_str = clip_encoder.predict("test search query", options=TextualOptions())
assert isinstance(embedding_str, str) assert isinstance(embedding_str, str)
embedding = orjson.loads(embedding_str) embedding = orjson.loads(embedding_str)
assert isinstance(embedding, list) assert isinstance(embedding, list)
@@ -1117,7 +1131,7 @@ class TestCLIP:
encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=tmp_path) encoder = OpenClipTextualEncoder("ViT-B-32__openai", cache_dir=tmp_path)
mocker.patch.object(encoder, "tokenize", return_value={"text": np.array([[1, 3, 2]], np.int32)}) mocker.patch.object(encoder, "tokenize", return_value={"text": np.array([[1, 3, 2]], np.int32)})
encoder.predict("test search query") encoder.predict("test search query", options=TextualOptions())
feed = session.run.call_args.args[1] feed = session.run.call_args.args[1]
assert list(feed) == ["text", "token_embeds"] # a binary reads its inputs in order assert list(feed) == ["text", "token_embeds"] # a binary reads its inputs in order
@@ -1352,7 +1366,7 @@ def expected_box(cell_x: int, cell_y: int) -> list[float]:
return [cx - 8, cy - 16, cx + 24, cy + 32] return [cx - 8, cy - 16, cx + 24, cy + 32]
M = TypeVar("M", bound=InferenceModel) M = TypeVar("M", bound=InferenceModel[Any])
def given_options(ort_session: mock.Mock) -> Any: def given_options(ort_session: mock.Mock) -> Any:
@@ -1400,7 +1414,7 @@ class TestFaceRecognition:
session = stub_session((1, 3, 640, 640), outputs=heads) session = stub_session((1, 3, 640, 640), outputs=heads)
face_detector.session = session face_detector.session = session
faces = face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.7) faces = face_detector.predict(Image.new("RGB", (640, 640)), options=FaceDetectionOptions(min_score=0.7))
assert isinstance(faces, dict) assert isinstance(faces, dict)
assert set(faces) == {"boxes", "scores", "landmarks"} assert set(faces) == {"boxes", "scores", "landmarks"}
@@ -1421,8 +1435,18 @@ class TestFaceRecognition:
face_detector.session = session face_detector.session = session
# the threshold is a request parameter, so the same loaded model must honour both # 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 (
assert face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.4)["boxes"].shape[0] == 2 face_detector.predict(Image.new("RGB", (640, 640)), options=FaceDetectionOptions(min_score=0.7))[
"boxes"
].shape[0]
== 1
)
assert (
face_detector.predict(Image.new("RGB", (640, 640)), options=FaceDetectionOptions(min_score=0.4))[
"boxes"
].shape[0]
== 2
)
def test_detection_scales_boxes_back_to_the_original_image( def test_detection_scales_boxes_back_to_the_original_image(
self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture
@@ -1434,7 +1458,7 @@ class TestFaceRecognition:
face_detector.session = session face_detector.session = session
# a 320x320 image is letterboxed up to 640, so coordinates come back halved # 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) faces = face_detector.predict(Image.new("RGB", (320, 320)), options=FaceDetectionOptions(min_score=0.7))
assert faces["boxes"].tolist() == [[v / 2 for v in expected_box(10, 10)]] assert faces["boxes"].tolist() == [[v / 2 for v in expected_box(10, 10)]]
assert np.allclose(faces["landmarks"][0], expected_landmarks(10, 10) / 2) assert np.allclose(faces["landmarks"][0], expected_landmarks(10, 10) / 2)
@@ -1459,7 +1483,9 @@ class TestFaceRecognition:
session = stub_session(("batch", 3, 112, 112), outputs=[embeddings], shapes=(Shape(batch=num_faces),)) session = stub_session(("batch", 3, 112, 112), outputs=[embeddings], shapes=(Shape(batch=num_faces),))
face_recognizer.session = session face_recognizer.session = session
faces = face_recognizer.predict(image, {"boxes": bbox, "landmarks": kpss, "scores": scores}) faces = face_recognizer.predict(
image, {"boxes": bbox, "landmarks": kpss, "scores": scores}, options=FaceRecognitionOptions()
)
assert isinstance(faces, list) assert isinstance(faces, list)
assert len(faces) == num_faces assert len(faces) == num_faces
@@ -1497,7 +1523,7 @@ class TestFaceRecognition:
expected_shape, outputs=make_scrfd_heads([(10, 10, 0.9)]), normalizes_input=normalizes_input expected_shape, outputs=make_scrfd_heads([(10, 10, 0.9)]), normalizes_input=normalizes_input
) )
faces = face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.7) faces = face_detector.predict(Image.new("RGB", (640, 640)), options=FaceDetectionOptions(min_score=0.7))
fed = face_detector.session.run.call_args.args[1]["input.1"] fed = face_detector.session.run.call_args.args[1]["input.1"]
assert fed.dtype == expected_dtype assert fed.dtype == expected_dtype
@@ -1531,7 +1557,7 @@ class TestFaceRecognition:
"landmarks": np.stack([expected_landmarks(1, 1), expected_landmarks(8, 8)]), "landmarks": np.stack([expected_landmarks(1, 1), expected_landmarks(8, 8)]),
} }
face_recognizer.predict(Image.new("RGB", (200, 200)), faces) face_recognizer.predict(Image.new("RGB", (200, 200)), faces, options=FaceRecognitionOptions())
fed = face_recognizer.session.run.call_args.args[1]["input.1"] fed = face_recognizer.session.run.call_args.args[1]["input.1"]
assert fed.dtype == expected_dtype assert fed.dtype == expected_dtype
@@ -1550,7 +1576,7 @@ class TestFaceRecognition:
"scores": np.empty(0, dtype=np.float32), "scores": np.empty(0, dtype=np.float32),
} }
assert face_recognizer.predict(pil_image, empty) == [] assert face_recognizer.predict(pil_image, empty, options=FaceRecognitionOptions()) == []
session.run.assert_not_called() session.run.assert_not_called()
def test_recognition_batches_when_batch_size_is_set( def test_recognition_batches_when_batch_size_is_set(
@@ -1569,7 +1595,7 @@ class TestFaceRecognition:
"landmarks": (np.random.rand(num_faces, 5, 2) * 100).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), "scores": np.array([0.67] * num_faces, dtype=np.float32),
} }
assert len(face_recognizer.predict(pil_image, faces)) == num_faces assert len(face_recognizer.predict(pil_image, faces, options=FaceRecognitionOptions())) == num_faces
assert session.run.call_count == 3 # 2 + 2 + 1 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] assert [c.args[1]["input.1"].shape[0] for c in session.run.call_args_list] == [2, 2, 1]
@@ -1638,10 +1664,10 @@ class TestOcr:
text_detector.session = stub_session((1, 3, 64, 64), outputs=[probs]) text_detector.session = stub_session((1, 3, 64, 64), outputs=[probs])
image = Image.new("RGB", (64, 64)) image = Image.new("RGB", (64, 64))
assert len(text_detector._predict(image, minScore=0.5)["boxes"]) == 1 assert len(text_detector._predict(image, TextDetectionOptions(min_score=0.5))["boxes"]) == 1
assert len(text_detector._predict(image, minScore=0.9)["boxes"]) == 0 assert len(text_detector._predict(image, TextDetectionOptions(min_score=0.9))["boxes"]) == 0
# the default must be unaffected by the request that just ran # the default must be unaffected by the request that just ran
assert len(text_detector._predict(image)["boxes"]) == 1 assert len(text_detector._predict(image, TextDetectionOptions())["boxes"]) == 1
def test_fetches_the_older_exports_from_where_rapidocr_hosts_them( def test_fetches_the_older_exports_from_where_rapidocr_hosts_them(
self, tmp_path: Path, snapshot_download: mock.Mock, mocker: MockerFixture self, tmp_path: Path, snapshot_download: mock.Mock, mocker: MockerFixture
@@ -1671,7 +1697,7 @@ class TestOcr:
box = np.array([[[0, 0], [crop, 0], [crop, 48], [0, 48]]], dtype=np.float32) box = np.array([[[0, 0], [crop, 0], [crop, 48], [0, 48]]], dtype=np.float32)
texts: Any = {"boxes": box, "scores": np.array([0.9], dtype=np.float32)} texts: Any = {"boxes": box, "scores": np.array([0.9], dtype=np.float32)}
text_recognizer._predict(image, texts) text_recognizer._predict(image, texts, TextRecognitionOptions())
fed = session.run.call_args.args[1]["input.1"] fed = session.run.call_args.args[1]["input.1"]
assert fed.dtype == np.uint8 and fed.shape == (1, 48, fed_width, 3) assert fed.dtype == np.uint8 and fed.shape == (1, 48, fed_width, 3)
@@ -1681,7 +1707,7 @@ class TestOcr:
def test_det_letterboxes_onto_the_canvas_the_session_takes( def test_det_letterboxes_onto_the_canvas_the_session_takes(
self, path: mock.Mock, stub_session: Callable[..., mock.Mock] self, path: mock.Mock, stub_session: Callable[..., mock.Mock]
) -> None: ) -> None:
text_detector = TextDetector("PP-OCRv5_mobile", cache_dir=path) text_detector = TextDetector("PP-OCRv5_mobile", max_resolution=64, cache_dir=path)
text_detector.session = stub_session( text_detector.session = stub_session(
(1, 64, 128, 3), (1, 64, 128, 3),
outputs=[np.zeros((1, 64, 128), dtype=np.float32)], outputs=[np.zeros((1, 64, 128), dtype=np.float32)],
@@ -1690,7 +1716,7 @@ class TestOcr:
shapes=(Shape(batch=1, height=64, width=128), Shape(batch=1, height=128, width=64)), shapes=(Shape(batch=1, height=64, width=128), Shape(batch=1, height=128, width=64)),
) )
text_detector._predict(Image.new("RGB", (100, 100), (10, 20, 30)), maxResolution=64) text_detector._predict(Image.new("RGB", (100, 100), (10, 20, 30)), TextDetectionOptions(max_resolution=64))
fed = text_detector.session.run.call_args.args[1]["image"] fed = text_detector.session.run.call_args.args[1]["image"]
assert fed.dtype == np.uint8 and fed.shape == (1, 64, 128, 3) # the canvas costing it the least downscale assert fed.dtype == np.uint8 and fed.shape == (1, 64, 128, 3) # the canvas costing it the least downscale
@@ -1714,7 +1740,7 @@ class TestOcr:
box = np.array([[[0, 0], [384, 0], [384, 48], [0, 48]]], dtype=np.float32) box = np.array([[[0, 0], [384, 0], [384, 48], [0, 48]]], dtype=np.float32)
texts: Any = {"boxes": box, "scores": np.array([0.9], dtype=np.float32)} texts: Any = {"boxes": box, "scores": np.array([0.9], dtype=np.float32)}
text_recognizer._predict(image, texts) text_recognizer._predict(image, texts, TextRecognitionOptions())
fed = session.run.call_args.args[1]["input.1"] fed = session.run.call_args.args[1]["input.1"]
assert fed.shape == (1, 48, 400, 3) # 384 wide in its own right, run at the compiled width above it assert fed.shape == (1, 48, 400, 3) # 384 wide in its own right, run at the compiled width above it
@@ -1736,9 +1762,9 @@ class TestOcr:
def texts() -> Any: # _predict normalizes the boxes in place, so each call needs its own def texts() -> Any: # _predict normalizes the boxes in place, so each call needs its own
return {"boxes": box.copy(), "scores": np.array([0.9], dtype=np.float32)} return {"boxes": box.copy(), "scores": np.array([0.9], dtype=np.float32)}
assert text_recognizer._predict(image, texts(), minScore=0.7)["text"] == ["hello"] assert text_recognizer._predict(image, texts(), TextRecognitionOptions(min_score=0.7))["text"] == ["hello"]
# the default (0.9) rejects a 0.8 score, and must be unaffected by the 0.7 request # the default (0.9) rejects a 0.8 score, and must be unaffected by the 0.7 request
assert text_recognizer._predict(image, texts())["text"] == [] assert text_recognizer._predict(image, texts(), TextRecognitionOptions())["text"] == []
def test_rec_decodes_the_half_precision_probabilities_a_host_decode_graph_emits(self) -> None: def test_rec_decodes_the_half_precision_probabilities_a_host_decode_graph_emits(self) -> None:
probs = np.zeros((1, 3, 4), dtype=np.float16) probs = np.zeros((1, 3, 4), dtype=np.float16)
@@ -1792,59 +1818,62 @@ class TestOcr:
assert {shape.batch for shape in text_recognizer.shape_policy.dims} == {1, 6} assert {shape.batch for shape in text_recognizer.shape_policy.dims} == {1, 6}
def stub_model() -> mock.MagicMock:
"""A model class that makes mocks, where only what the cache does with its entries matters."""
model = mock.MagicMock(sources=(ModelSource.INSIGHTFACE,))
model.graph.return_value = None
return model
@pytest.mark.asyncio @pytest.mark.asyncio
class TestCache: class TestCache:
async def test_caches(self, mock_get_model: mock.Mock) -> None: async def test_caches(self) -> None:
model_cache = ModelCache() model_cache, model = ModelCache(), stub_model()
model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION) model_cache.get(InferenceEntry(model, "buffalo_l", FaceRecognitionOptions()))
model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION) model_cache.get(InferenceEntry(model, "buffalo_l", FaceRecognitionOptions()))
assert len(model_cache._models) == 1 assert len(model_cache._models) == 1
mock_get_model.return_value.assert_called_once() model.create.assert_called_once()
async def test_separate_instances_per_graph_option(self) -> None: async def test_separate_instances_per_graph_option(self) -> None:
# the real TextDetector, so that dropping its graph_options fails this # the real TextDetector, so that keying it on anything but its resolution fails this
model_cache = ModelCache() model_cache = ModelCache()
for max_resolution in (736, 1088, 1088): for options in (
model_cache.get("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR, maxResolution=max_resolution) TextDetectionOptions(),
# maxResolution picks the graphs a detector builds, so instances cannot share them TextDetectionOptions(max_resolution=736, min_score=0.3),
TextDetectionOptions(max_resolution=1088),
):
model_cache.get(InferenceEntry(TextDetector, "PP-OCRv5_mobile", options))
# the resolution picks the graphs a detector builds, so instances cannot share them; a score does not
assert len(model_cache._models) == 2 assert len(model_cache._models) == 2
async def test_kwargs_used(self, mock_get_model: mock.Mock) -> None: async def test_creates_the_model_from_the_request_options(self) -> None:
model_cache = ModelCache() model = stub_model()
model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, cache_dir="test_cache") ModelCache().get(InferenceEntry(model, "buffalo_l", FaceDetectionOptions(min_score=0.3)))
mock_get_model.return_value.assert_called_once_with("test_model_name", cache_dir="test_cache") model.create.assert_called_once_with("buffalo_l", FaceDetectionOptions(min_score=0.3))
async def test_different_clip(self, mock_get_model: mock.Mock) -> None: async def test_separate_instances_per_model(self) -> None:
model_cache = ModelCache() model_cache = ModelCache()
model_cache.get("test_model_name", ModelType.VISUAL, ModelTask.SEARCH) visual = model_cache.get(InferenceEntry(OpenClipVisualEncoder, "ViT-B-32__openai", VisualOptions()))
model_cache.get("test_model_name", ModelType.TEXTUAL, ModelTask.SEARCH) text = model_cache.get(InferenceEntry(OpenClipTextualEncoder, "ViT-B-32__openai", TextualOptions()))
assert mock_get_model.call_args_list == [ assert isinstance(visual, OpenClipVisualEncoder) and isinstance(text, OpenClipTextualEncoder)
mock.call("test_model_name", ModelType.VISUAL, ModelTask.SEARCH),
mock.call("test_model_name", ModelType.TEXTUAL, ModelTask.SEARCH),
]
assert len(model_cache._models) == 2 assert len(model_cache._models) == 2
async def test_lets_go_of_a_model_unused_for_its_ttl_and_returns_its_memory( async def test_lets_go_of_a_model_unused_for_its_ttl_and_returns_its_memory(self, mocker: MockerFixture) -> None:
self, mock_get_model: mock.Mock, mocker: MockerFixture
) -> None:
events: list[str] = [] events: list[str] = []
class Model: class Loaded:
graph_options = ()
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
def __del__(self) -> None: def __del__(self) -> None:
events.append("destroyed") events.append("destroyed")
mock_get_model.return_value = Model model = stub_model()
model.create.side_effect = lambda name, options: Loaded()
mocker.patch("immich_ml.models.cache.allocator.release", side_effect=lambda: events.append("released")) mocker.patch("immich_ml.models.cache.allocator.release", side_effect=lambda: events.append("released"))
loop = mocker.patch("immich_ml.models.cache.asyncio.get_running_loop").return_value loop = mocker.patch("immich_ml.models.cache.asyncio.get_running_loop").return_value
model_cache = ModelCache() model_cache = ModelCache()
model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100) model_cache.get(InferenceEntry(model, "buffalo_l", FaceRecognitionOptions()), ttl=100)
delay, evict, key = loop.call_later.call_args.args delay, evict, key = loop.call_later.call_args.args
evict(key) evict(key)
@@ -1852,49 +1881,45 @@ class TestCache:
assert events == ["destroyed", "released"] assert events == ["destroyed", "released"]
assert not model_cache._models assert not model_cache._models
async def test_a_use_starts_the_ttl_over(self, mock_get_model: mock.Mock, mocker: MockerFixture) -> None: async def test_a_use_starts_the_ttl_over(self, mocker: MockerFixture) -> None:
loop = mocker.patch("immich_ml.models.cache.asyncio.get_running_loop").return_value loop = mocker.patch("immich_ml.models.cache.asyncio.get_running_loop").return_value
model_cache = ModelCache() model_cache, entry = ModelCache(), InferenceEntry(stub_model(), "buffalo_l", FaceRecognitionOptions())
model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100) model_cache.get(entry, ttl=100)
model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100) model_cache.get(entry, ttl=100)
loop.call_later.return_value.cancel.assert_called_once() loop.call_later.return_value.cancel.assert_called_once()
assert loop.call_later.call_count == 2 assert loop.call_later.call_count == 2
async def test_keeps_a_preloaded_model_whatever_ttl_a_request_names( async def test_keeps_a_preloaded_model_whatever_ttl_a_request_names(self, mocker: MockerFixture) -> None:
self, mock_get_model: mock.Mock, mocker: MockerFixture
) -> None:
loop = mocker.patch("immich_ml.models.cache.asyncio.get_running_loop").return_value loop = mocker.patch("immich_ml.models.cache.asyncio.get_running_loop").return_value
model_cache = ModelCache() model_cache, entry = ModelCache(), InferenceEntry(stub_model(), "buffalo_l", FaceRecognitionOptions())
model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION) model_cache.get(entry)
model_cache.get("test_model_name", ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION, ttl=100) model_cache.get(entry, ttl=100)
loop.call_later.assert_not_called() loop.call_later.assert_not_called()
async def test_loads_mclip(self) -> None: async def test_loads_mclip(self) -> None:
model_cache = ModelCache() request = PIPELINE_REQUEST.validate_python(
{"clip": {"textual": {"modelName": "XLM-Roberta-Large-Vit-B-32", "options": {}}}}
)
model = model_cache.get("XLM-Roberta-Large-Vit-B-32", ModelType.TEXTUAL, ModelTask.SEARCH) [entry] = request.entries()
model = ModelCache().get(entry)
assert isinstance(model, MClipTextualEncoder) assert isinstance(model, MClipTextualEncoder)
assert model.model_name == "XLM-Roberta-Large-Vit-B-32" assert model.model_name == "XLM-Roberta-Large-Vit-B-32"
async def test_raises_exception_if_invalid_model_type(self) -> None: async def test_refuses_a_model_its_slot_does_not_run(self) -> None:
invalid: Any = SimpleNamespace(value="invalid")
model_cache = ModelCache()
with pytest.raises(ValueError): with pytest.raises(ValueError):
model_cache.get("XLM-Roberta-Large-Vit-B-32", ModelType.TEXTUAL, invalid) InferenceEntry(OpenClipVisualEncoder, "buffalo_l", VisualOptions())
async def test_raises_exception_if_unknown_model_name(self) -> None:
model_cache = ModelCache()
async def test_refuses_an_unknown_model_name(self) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
model_cache.get("test_model_name", ModelType.TEXTUAL, ModelTask.SEARCH) InferenceEntry(OpenClipTextualEncoder, "test_model_name", TextualOptions())
async def test_preloads_clip_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None: async def test_preloads_clip_models(self, mocker: MockerFixture) -> None:
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = "ViT-B-32__openai" 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__CLIP__VISUAL"] = "ViT-B-32__openai"
@@ -1902,22 +1927,16 @@ class TestCache:
assert settings.preload is not None assert settings.preload is not None
assert settings.preload.clip.textual == "ViT-B-32__openai" assert settings.preload.clip.textual == "ViT-B-32__openai"
assert settings.preload.clip.visual == "ViT-B-32__openai" assert settings.preload.clip.visual == "ViT-B-32__openai"
get = mocker.patch("immich_ml.main.model_cache.get")
model_cache = ModelCache()
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
await preload_models(settings.preload) 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( assert {call.args[0] for call in get.call_args_list} >= {
self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock InferenceEntry(OpenClipTextualEncoder, "ViT-B-32__openai", TextualOptions()),
) -> None: InferenceEntry(OpenClipVisualEncoder, "ViT-B-32__openai", VisualOptions()),
}
async def test_preloads_facial_recognition_models(self, mocker: MockerFixture) -> None:
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s" os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s"
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s" os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s"
@@ -1925,20 +1944,16 @@ class TestCache:
assert settings.preload is not None assert settings.preload is not None
assert settings.preload.facial_recognition.detection == "buffalo_s" assert settings.preload.facial_recognition.detection == "buffalo_s"
assert settings.preload.facial_recognition.recognition == "buffalo_s" assert settings.preload.facial_recognition.recognition == "buffalo_s"
get = mocker.patch("immich_ml.main.model_cache.get")
model_cache = ModelCache()
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
await preload_models(settings.preload) 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,
)
async def test_preloads_ocr_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None: assert {call.args[0] for call in get.call_args_list} >= {
InferenceEntry(FaceDetector, "buffalo_s", FaceDetectionOptions()),
InferenceEntry(FaceRecognizer, "buffalo_s", FaceRecognitionOptions()),
}
async def test_preloads_ocr_models(self, mocker: MockerFixture) -> None:
os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile" os.environ["MACHINE_LEARNING_PRELOAD__OCR__DETECTION"] = "PP-OCRv5_mobile"
os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile" os.environ["MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION"] = "PP-OCRv5_mobile"
@@ -1946,52 +1961,15 @@ class TestCache:
assert settings.preload is not None assert settings.preload is not None
assert settings.preload.ocr.detection == "PP-OCRv5_mobile" assert settings.preload.ocr.detection == "PP-OCRv5_mobile"
assert settings.preload.ocr.recognition == "PP-OCRv5_mobile" assert settings.preload.ocr.recognition == "PP-OCRv5_mobile"
get = mocker.patch("immich_ml.main.model_cache.get")
model_cache = ModelCache()
monkeypatch.setattr("immich_ml.main.model_cache", model_cache)
await preload_models(settings.preload) 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,
)
mock_get_model.return_value.return_value.build.assert_called() # so that no request waits on a graph
async def test_preloads_all_models(self, monkeypatch: MonkeyPatch, mock_get_model: mock.Mock) -> None: assert {call.args[0] for call in get.call_args_list} >= {
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL"] = "ViT-B-32__openai" InferenceEntry(TextDetector, "PP-OCRv5_mobile", TextDetectionOptions(settings.preload.ocr.max_resolution)),
os.environ["MACHINE_LEARNING_PRELOAD__CLIP__VISUAL"] = "ViT-B-32__openai" InferenceEntry(TextRecognizer, "PP-OCRv5_mobile", TextRecognitionOptions()),
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION"] = "buffalo_s" }
os.environ["MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION"] = "buffalo_s" get.return_value.build.assert_called() # so that no request waits on a graph
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.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"
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("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),
mock.call("PP-OCRv5_mobile", ModelType.DETECTION, ModelTask.OCR),
mock.call("PP-OCRv5_mobile", ModelType.RECOGNITION, ModelTask.OCR),
],
any_order=True,
)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -2001,7 +1979,7 @@ class TestLoad:
mock_model.loaded = False mock_model.loaded = False
mock_model.load_attempts = 0 mock_model.load_attempts = 0
res = await load(mock_model) res: InferenceModel[Any] = await load(mock_model)
assert res is mock_model assert res is mock_model
mock_model.load.assert_called_once() mock_model.load.assert_called_once()
@@ -2011,7 +1989,7 @@ class TestLoad:
mock_model = mock.Mock(spec=InferenceModel) mock_model = mock.Mock(spec=InferenceModel)
mock_model.loaded = True mock_model.loaded = True
res = await load(mock_model) res: InferenceModel[Any] = await load(mock_model)
assert res is mock_model assert res is mock_model
mock_model.load.assert_not_called() mock_model.load.assert_not_called()
@@ -2025,7 +2003,7 @@ class TestLoad:
mock_model.loaded = False mock_model.loaded = False
mock_model.load_attempts = 0 mock_model.load_attempts = 0
res = await load(mock_model) res: InferenceModel[Any] = await load(mock_model)
assert res is mock_model assert res is mock_model
mock_model.unload.assert_called_once() # so that the retry holds one copy while it loads mock_model.unload.assert_called_once() # so that the retry holds one copy while it loads
@@ -2078,14 +2056,86 @@ class TestLoad:
mock_model.clear_cache.assert_not_called() mock_model.clear_cache.assert_not_called()
def test_reads_each_entrys_options_as_its_model_takes_them(tmp_path: Path) -> None:
request = {
"ocr": {
"detection": {"modelName": "PP-OCRv5_mobile", "options": {"minScore": 0.3, "cache_dir": str(tmp_path)}},
"recognition": {"modelName": "PP-OCRv5_mobile"},
},
"clip": {"textual": {"modelName": "ViT-B-32__openai", "options": {"language": "de"}}},
"facial-recognition": {"recognition": {"modelName": "buffalo_l"}},
}
entries = get_entries(json.dumps(request))
# what a request leaves out keeps the default, which a preload of the same model shares; what no model takes,
# such as where a model is cached, goes nowhere
assert {entry.options for entry in entries} == {
TextDetectionOptions(max_resolution=736, min_score=0.3),
TextRecognitionOptions(min_score=0.9),
TextualOptions(language="de"),
FaceRecognitionOptions(),
}
@pytest.mark.parametrize(
"request_",
[
{"ocr": {"detection": {"modelName": "PP-OCRv5_mobile", "options": {"minScore": "high"}}}},
{"clip": {"visual": {"modelName": "buffalo_l"}}}, # a model its slot does not run
{"facial_recognition": {"recognition": {"modelName": "buffalo_l"}}},
{"clip": {"visaul": {"modelName": "ViT-B-32__openai"}}},
{"clip": {"visual": {"modelName": "ViT-B-32__openai", "option": {}}}},
{},
],
)
def test_refuses_what_a_model_cannot_take(request_: dict[str, Any]) -> None:
with pytest.raises(HTTPException) as refused:
get_entries(json.dumps(request_))
assert refused.value.status_code == 422
@pytest.mark.asyncio
async def test_feeds_a_model_the_output_of_the_one_it_depends_on(mocker: MockerFixture) -> None:
detection = (ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)
models = {
FaceDetector: mock.Mock(spec=InferenceModel, depends=[], loaded=True),
FaceRecognizer: mock.Mock(spec=InferenceModel, depends=[detection], loaded=True),
}
models[FaceDetector].predict.return_value = "faces"
models[FaceRecognizer].predict.return_value = "embeddings"
mocker.patch("immich_ml.main.model_cache.get", side_effect=lambda entry, ttl: models[entry.model])
entries: list[InferenceEntry[Any]] = [ # the recognition first, so that only waiting on the detection feeds it
InferenceEntry(FaceRecognizer, "buffalo_l", FaceRecognitionOptions()),
InferenceEntry(FaceDetector, "buffalo_l", FaceDetectionOptions()),
]
response = await run_request("image", entries)
models[FaceRecognizer].predict.assert_called_once_with("image", "faces", options=FaceRecognitionOptions())
assert response == {"facial-recognition": "embeddings"}
@pytest.mark.asyncio
async def test_refuses_a_model_whose_input_the_request_does_not_ask_for(mocker: MockerFixture) -> None:
recognition = mock.Mock(spec=InferenceModel, depends=[FaceDetector.identity], loaded=True)
mocker.patch("immich_ml.main.model_cache.get", return_value=recognition)
entries: list[InferenceEntry[Any]] = [InferenceEntry(FaceRecognizer, "buffalo_l", FaceRecognitionOptions())]
with pytest.raises(HTTPException) as refused:
await run_request("image", entries)
assert refused.value.status_code == 400
recognition.predict.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_waits_for_every_entry_before_failing_the_request(mocker: MockerFixture) -> None: async def test_waits_for_every_entry_before_failing_the_request(mocker: MockerFixture) -> None:
finished = threading.Event() finished = threading.Event()
def model(task: ModelTask, load: Callable[[], None]) -> mock.Mock: def model(load: Callable[[], None]) -> mock.Mock:
stub = mock.Mock( stub = mock.Mock(spec=InferenceModel, depends=[], loaded=False, load_attempts=0)
spec=InferenceModel, depends=[], loaded=False, load_attempts=0, identity=(ModelType.VISUAL, task)
)
stub.load.side_effect = load stub.load.side_effect = load
return stub return stub
@@ -2094,18 +2144,18 @@ async def test_waits_for_every_entry_before_failing_the_request(mocker: MockerFi
finished.set() finished.set()
models = { models = {
ModelTask.SEARCH: model(ModelTask.SEARCH, slow), OpenClipVisualEncoder: model(slow),
ModelTask.OCR: model(ModelTask.OCR, mock.Mock(side_effect=RuntimeError("fails at once"))), TextDetector: model(mock.Mock(side_effect=RuntimeError("fails at once"))),
} }
mocker.patch("immich_ml.main.model_cache.get", side_effect=lambda name, type, task, **options: models[task]) mocker.patch("immich_ml.main.model_cache.get", side_effect=lambda entry, ttl: models[entry.model])
entries: Any = [ entries: list[InferenceEntry[Any]] = [
{"name": "a", "task": ModelTask.SEARCH, "type": ModelType.VISUAL, "options": {}}, InferenceEntry(OpenClipVisualEncoder, "ViT-B-32__openai", VisualOptions()),
{"name": "b", "task": ModelTask.OCR, "type": ModelType.DETECTION, "options": {}}, InferenceEntry(TextDetector, "PP-OCRv5_mobile", TextDetectionOptions()),
] ]
with ThreadPoolExecutor(2) as pool, pytest.raises(RuntimeError, match="fails at once"): with ThreadPoolExecutor(2) as pool, pytest.raises(RuntimeError, match="fails at once"):
mocker.patch("immich_ml.main.thread_pool", pool) mocker.patch("immich_ml.main.thread_pool", pool)
await run_request("text", (entries, [])) await run_request("text", entries)
assert finished.is_set() # or it would still be loading with no request counted as active assert finished.is_set() # or it would still be loading with no request counted as active