feat(ml): optimized model inference (#31750)
* add immich_model * graph per shape * decouple ocr from ort * prepared graphs * use f16 for accelerators * preload all shapes * warm-up pass after preload * keep precision for fp32 graphs * ensure padding for text recognition * optimize postprocessing for fp16 outputs * handle raw ctc logits * use f16 output for rknn * mmap token embedding * read device metadata
This commit is contained in:
@@ -154,33 +154,33 @@ Redis (Sentinel) URL example JSON before encoding:
|
|||||||
|
|
||||||
## Machine Learning
|
## Machine Learning
|
||||||
|
|
||||||
| Variable | Description | Default | Containers |
|
| Variable | Description | Default | Containers |
|
||||||
| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :--------------- |
|
| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-------------------------: | :--------------- |
|
||||||
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
||||||
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
||||||
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
||||||
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
|
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
|
||||||
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
|
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
|
||||||
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
|
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
|
||||||
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
|
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
|
||||||
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
|
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
|
||||||
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `300` (`900` if using ROCm) | machine learning |
|
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `300` (`900` if using ROCm) | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__OCR__MAX_RESOLUTION` | Resolution to preload the OCR detection model(s) for (must match the server's OCR resolution) | `736` | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
||||||
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
|
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
||||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
|
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | `4` | machine learning |
|
||||||
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
|
||||||
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
|
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
||||||
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
|
||||||
| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
|
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
||||||
|
|
||||||
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
|
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
VIRTUAL_ENV=/opt/venv
|
VIRTUAL_ENV=/opt/venv
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends g++
|
RUN apt-get update && apt-get install -y --no-install-recommends g++ git
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.8.15@sha256:a5727064a0de127bdb7c9d3c1383f3a9ac307d9f2d8a391edc7896c54289ced0 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.8.15@sha256:a5727064a0de127bdb7c9d3c1383f3a9ac307d9f2d8a391edc7896c54289ced0 /uv /uvx /bin/
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any, Callable, Iterator
|
from typing import Any, Callable, Iterator
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -10,6 +11,15 @@ from PIL import Image
|
|||||||
from immich_ml.config import log
|
from immich_ml.config import log
|
||||||
from immich_ml.main import app
|
from immich_ml.main import app
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.models.base import InferenceModel
|
||||||
|
from immich_ml.schemas import Shape
|
||||||
|
from immich_ml.sessions.ort import Device
|
||||||
|
|
||||||
|
TEST_ASSETS = Path(__file__).parent.parent / "e2e/test-assets"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def asset() -> Callable[[str], bytes]:
|
||||||
|
return lambda path: (TEST_ASSETS / path).read_bytes()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -20,6 +30,7 @@ def pil_image() -> Image.Image:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_get_model() -> Iterator[mock.Mock]:
|
def mock_get_model() -> Iterator[mock.Mock]:
|
||||||
with mock.patch("immich_ml.models.cache.get_model_class") as mocked:
|
with mock.patch("immich_ml.models.cache.get_model_class") as mocked:
|
||||||
|
mocked.return_value.graph_options = ()
|
||||||
yield mocked
|
yield mocked
|
||||||
|
|
||||||
|
|
||||||
@@ -103,6 +114,15 @@ def providers(request: pytest.FixtureRequest) -> Iterator[mock.Mock]:
|
|||||||
yield providers
|
yield providers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def gpus() -> Iterator[None]:
|
||||||
|
with (
|
||||||
|
mock.patch("immich_ml.sessions.ort._intel_gpu", return_value=Device("12.71.4-128eu", "26.22.38646.4")),
|
||||||
|
mock.patch("immich_ml.sessions.ort._amd_gpu", return_value=Device("gfx1100", "7.2.0")),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def ort_pybind() -> Iterator[mock.Mock]:
|
def ort_pybind() -> Iterator[mock.Mock]:
|
||||||
with mock.patch("immich_ml.sessions.ort.ort.capi._pybind_state") as mocked:
|
with mock.patch("immich_ml.sessions.ort.ort.capi._pybind_state") as mocked:
|
||||||
@@ -120,15 +140,29 @@ def ov_device_ids(request: pytest.FixtureRequest, ort_pybind: mock.Mock) -> Iter
|
|||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def ort_session() -> Iterator[mock.Mock]:
|
def ort_session() -> Iterator[mock.Mock]:
|
||||||
with mock.patch("immich_ml.sessions.ort.ort.InferenceSession") as mocked:
|
# a graph is opened as it is: preparing one takes a child process and real files
|
||||||
|
with (
|
||||||
|
mock.patch("immich_ml.sessions.ort.ort.InferenceSession") as mocked,
|
||||||
|
mock.patch("immich_ml.sessions.ort.prepared", side_effect=lambda spec: spec.model_path),
|
||||||
|
):
|
||||||
yield mocked
|
yield mocked
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def stub_session() -> Callable[..., mock.Mock]:
|
def stub_session() -> Callable[..., mock.Mock]:
|
||||||
def _make(shape: tuple[Any, ...], outputs: Any = None, name: str = "input.1") -> mock.Mock:
|
def _make(
|
||||||
|
shape: tuple[Any, ...],
|
||||||
|
outputs: Any = None,
|
||||||
|
name: str = "input.1",
|
||||||
|
normalizes_input: bool = False,
|
||||||
|
shapes: tuple[Shape, ...] = (Shape(batch=1),),
|
||||||
|
) -> mock.Mock:
|
||||||
session = mock.Mock()
|
session = mock.Mock()
|
||||||
session.get_inputs.return_value = [SimpleNamespace(name=name, shape=shape)]
|
session.get_inputs.return_value = [SimpleNamespace(name=name, shape=shape)]
|
||||||
|
session.normalizes_input = normalizes_input
|
||||||
|
session.shapes = shapes
|
||||||
|
session.batches = tuple(sorted({shape.batch for shape in shapes}, reverse=True))
|
||||||
|
session.for_shape.return_value = session # a stub is the session and the one graph in it
|
||||||
if outputs is not None:
|
if outputs is not None:
|
||||||
session.run.return_value = outputs
|
session.run.return_value = outputs
|
||||||
return session
|
return session
|
||||||
@@ -139,12 +173,16 @@ def stub_session() -> Callable[..., mock.Mock]:
|
|||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def ann_session() -> Iterator[mock.Mock]:
|
def ann_session() -> Iterator[mock.Mock]:
|
||||||
with mock.patch("immich_ml.sessions.ann.Ann") as mocked:
|
with mock.patch("immich_ml.sessions.ann.Ann") as mocked:
|
||||||
|
mocked.return_value.input_shapes.__getitem__.return_value = [(1, 3, 112, 112)] # armnn compiles one batch
|
||||||
yield mocked
|
yield mocked
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def rknn_session() -> Iterator[mock.Mock]:
|
def rknn_session() -> Iterator[mock.Mock]:
|
||||||
with mock.patch("immich_ml.sessions.rknn.RknnPoolExecutor") as mocked:
|
with mock.patch("immich_ml.sessions.rknn.RknnPoolExecutor") as mocked:
|
||||||
|
mocked.return_value.custom_string = "" # an unstamped binary, i.e. one that wants normalized input
|
||||||
|
compiled = SimpleNamespace(name="input", shape=(1, 224, 224, 3)) # the compiler reports NHWC either way
|
||||||
|
mocked.return_value.inputs = [compiled, compiled]
|
||||||
yield mocked
|
yield mocked
|
||||||
|
|
||||||
|
|
||||||
@@ -188,6 +226,7 @@ def exception() -> Iterator[mock.Mock]:
|
|||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def snapshot_download() -> Iterator[mock.Mock]:
|
def snapshot_download() -> Iterator[mock.Mock]:
|
||||||
|
# a download that leaves the model cached, as one that found the artifact does
|
||||||
with (
|
with (
|
||||||
mock.patch("immich_ml.models.base.snapshot_download") as mocked,
|
mock.patch("immich_ml.models.base.snapshot_download") as mocked,
|
||||||
mock.patch.object(InferenceModel, "cached", new_callable=mock.PropertyMock, side_effect=lambda: mocked.called),
|
mock.patch.object(InferenceModel, "cached", new_callable=mock.PropertyMock, side_effect=lambda: mocked.called),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from rich.logging import RichHandler
|
|||||||
from uvicorn import Server
|
from uvicorn import Server
|
||||||
from uvicorn.workers import UvicornWorker
|
from uvicorn.workers import UvicornWorker
|
||||||
|
|
||||||
from .schemas import ModelOrganization, ModelPrecision
|
from .schemas import ModelOrganization
|
||||||
|
|
||||||
|
|
||||||
class ClipSettings(BaseModel):
|
class ClipSettings(BaseModel):
|
||||||
@@ -29,6 +29,7 @@ class FacialRecognitionSettings(BaseModel):
|
|||||||
class OcrSettings(BaseModel):
|
class OcrSettings(BaseModel):
|
||||||
recognition: str | None = None
|
recognition: str | None = None
|
||||||
detection: str | None = None
|
detection: str | None = None
|
||||||
|
max_resolution: int = 736 # must match the server's OCR setting, or the preloaded model will not be used
|
||||||
|
|
||||||
|
|
||||||
class PreloadModelData(BaseModel):
|
class PreloadModelData(BaseModel):
|
||||||
@@ -38,8 +39,8 @@ class PreloadModelData(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MaxBatchSize(BaseModel):
|
class MaxBatchSize(BaseModel):
|
||||||
facial_recognition: int | None = None
|
facial_recognition: int = 4
|
||||||
ocr: int | None = None
|
ocr: int = 6
|
||||||
|
|
||||||
|
|
||||||
def default_worker_timeout() -> int:
|
def default_worker_timeout() -> int:
|
||||||
@@ -71,9 +72,7 @@ class Settings(BaseSettings):
|
|||||||
rknn: bool = True
|
rknn: bool = True
|
||||||
rknn_threads: int = 1
|
rknn_threads: int = 1
|
||||||
preload: PreloadModelData | None = None
|
preload: PreloadModelData | None = None
|
||||||
max_batch_size: MaxBatchSize | None = None
|
max_batch_size: MaxBatchSize = MaxBatchSize()
|
||||||
openvino_precision: ModelPrecision = ModelPrecision.FP32
|
|
||||||
rocm_precision: ModelPrecision = ModelPrecision.FP32
|
|
||||||
model_organization: ModelOrganization = ModelOrganization.APP
|
model_organization: ModelOrganization = ModelOrganization.APP
|
||||||
model_revision: str = "main"
|
model_revision: str = "main"
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ async def preload_models(preload: PreloadModelData) -> None:
|
|||||||
model_name = model_name.strip()
|
model_name = model_name.strip()
|
||||||
model = model_cache.get(model_name, model_type, model_task, **options)
|
model = model_cache.get(model_name, model_type, model_task, **options)
|
||||||
await load(model)
|
await load(model)
|
||||||
|
await attempt(model, model.build, MODEL_FILE_ERRORS)
|
||||||
|
|
||||||
if preload.clip.textual is not None:
|
if preload.clip.textual is not None:
|
||||||
await load_models(preload.clip.textual, ModelType.TEXTUAL, ModelTask.SEARCH)
|
await load_models(preload.clip.textual, ModelType.TEXTUAL, ModelTask.SEARCH)
|
||||||
@@ -117,6 +118,7 @@ async def preload_models(preload: PreloadModelData) -> None:
|
|||||||
preload.ocr.detection,
|
preload.ocr.detection,
|
||||||
ModelType.DETECTION,
|
ModelType.DETECTION,
|
||||||
ModelTask.OCR,
|
ModelTask.OCR,
|
||||||
|
maxResolution=preload.ocr.max_resolution,
|
||||||
)
|
)
|
||||||
|
|
||||||
if preload.ocr.recognition is not None:
|
if preload.ocr.recognition is not None:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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, ModelTask, ModelType
|
||||||
from ..sessions.ann import AnnSession
|
from ..sessions.ann import AnnSession
|
||||||
|
from ..sessions.policy import ShapePolicy
|
||||||
|
|
||||||
_IGNORED_PATTERNS: dict[ModelFormat, list[str]] = {
|
_IGNORED_PATTERNS: dict[ModelFormat, list[str]] = {
|
||||||
ModelFormat.ONNX: ["*.armnn", "*.rknn"],
|
ModelFormat.ONNX: ["*.armnn", "*.rknn"],
|
||||||
@@ -26,6 +27,10 @@ _IGNORED_PATTERNS: dict[ModelFormat, list[str]] = {
|
|||||||
class InferenceModel(ABC):
|
class InferenceModel(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
|
||||||
|
graph_options: ClassVar[tuple[str, ...]] = ()
|
||||||
|
# the shapes this model feeds, which pick the artifact and tell the engine what it may pin
|
||||||
|
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
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -78,6 +83,10 @@ class InferenceModel(ABC):
|
|||||||
del self.session
|
del self.session
|
||||||
self.loaded = False
|
self.loaded = False
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
self.load()
|
||||||
|
self.session.warm()
|
||||||
|
|
||||||
def predict(self, *inputs: Any, **model_kwargs: Any) -> Any:
|
def predict(self, *inputs: Any, **model_kwargs: Any) -> Any:
|
||||||
self.load()
|
self.load()
|
||||||
return self._predict(*inputs, **model_kwargs)
|
return self._predict(*inputs, **model_kwargs)
|
||||||
@@ -115,23 +124,19 @@ class InferenceModel(ABC):
|
|||||||
case ModelFormat.ARMNN:
|
case ModelFormat.ARMNN:
|
||||||
return AnnSession(self.model_path)
|
return AnnSession(self.model_path)
|
||||||
case ModelFormat.ONNX:
|
case ModelFormat.ONNX:
|
||||||
return OrtSession(self.model_path, threads=self.threads)
|
return OrtSession(self.model_path, self.shape_policy, threads=self.threads)
|
||||||
case ModelFormat.RKNN:
|
case ModelFormat.RKNN:
|
||||||
return rknn.RknnSession(self.model_path)
|
return rknn.RknnSession(self.model_path)
|
||||||
|
|
||||||
def model_path_for_format(self, model_format: ModelFormat) -> Path:
|
|
||||||
model_path_prefix = rknn.model_prefix if model_format == ModelFormat.RKNN else None
|
|
||||||
if model_path_prefix:
|
|
||||||
return self.model_dir / model_path_prefix / f"model.{model_format}"
|
|
||||||
return self.model_dir / f"model.{model_format}"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_dir(self) -> Path:
|
def model_dir(self) -> Path:
|
||||||
return self.cache_dir / self.model_type.value
|
return self.cache_dir / self.model_type.value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_path(self) -> Path:
|
def model_path(self) -> Path:
|
||||||
return self.model_path_for_format(self.model_format)
|
if self.model_format == ModelFormat.RKNN:
|
||||||
|
return rknn.model_path(self.model_dir, self.shape_policy.label)
|
||||||
|
return self.model_dir / f"model.{self.model_format}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model_task(self) -> ModelTask:
|
def model_task(self) -> ModelTask:
|
||||||
@@ -151,7 +156,8 @@ class InferenceModel(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def _cache_dir_default(self) -> Path:
|
def _cache_dir_default(self) -> Path:
|
||||||
return settings.cache_folder / self.model_task.value / self.model_name
|
cache_dir = settings.cache_folder / self.model_task.value / self.model_name
|
||||||
|
return cache_dir if settings.legacy_models else cache_dir / settings.model_revision
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cached(self) -> bool:
|
def cached(self) -> bool:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ModelCache:
|
|||||||
self, model_name: str, model_type: ModelType, model_task: ModelTask, ttl: int | None = None, **options: Any
|
self, model_name: str, model_type: ModelType, model_task: ModelTask, ttl: int | None = None, **options: Any
|
||||||
) -> InferenceModel:
|
) -> InferenceModel:
|
||||||
model_cls = get_model_class(model_name, model_type, model_task)
|
model_cls = get_model_class(model_name, model_type, model_task)
|
||||||
key = (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] = model_cls(model_name, **options)
|
||||||
elif key not in self._expiries:
|
elif key not in self._expiries:
|
||||||
|
|||||||
@@ -15,18 +15,33 @@ 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, ModelTask, ModelType
|
||||||
|
|
||||||
|
|
||||||
|
def _mapped(model_path: Path, name: str) -> NDArray[Any]:
|
||||||
|
import onnx
|
||||||
|
|
||||||
|
initializer = next(i for i in onnx.load(model_path, load_external_data=False).graph.initializer if i.name == name)
|
||||||
|
data = {entry.key: entry.value for entry in initializer.external_data}
|
||||||
|
dtype = onnx.helper.tensor_dtype_to_np_dtype(initializer.data_type)
|
||||||
|
offset, shape = int(data.get("offset", 0)), tuple(initializer.dims)
|
||||||
|
return np.memmap(model_path.parent / data["location"], dtype=dtype, mode="r", offset=offset, shape=shape)
|
||||||
|
|
||||||
|
|
||||||
class BaseCLIPTextualEncoder(InferenceModel):
|
class BaseCLIPTextualEncoder(InferenceModel):
|
||||||
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, language: str | None = None) -> str:
|
||||||
tokens = self.tokenize(inputs, language=language)
|
tokens: dict[str, NDArray[Any]] = self.tokenize(inputs, language=language)
|
||||||
res: NDArray[np.float32] = self.session.run(None, tokens)[0][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
|
||||||
|
tokens["token_embeds"] = self.embedding[next(iter(tokens.values()))]
|
||||||
|
res: NDArray[np.float32] = graph.run(None, {node.name: tokens[node.name] for node in graph.get_inputs()})[0][0]
|
||||||
return serialize_np_array(res)
|
return serialize_np_array(res)
|
||||||
|
|
||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
session = super()._load()
|
session = super()._load()
|
||||||
|
table = session.for_shape(self.shape_policy.dims[0]).get_metadata().get("embedding")
|
||||||
|
self.embedding = _mapped(self.model_dir / "model.onnx", table) if table is not None else None
|
||||||
log.debug(f"Loading tokenizer for CLIP model '{self.model_name}'")
|
log.debug(f"Loading tokenizer for CLIP model '{self.model_name}'")
|
||||||
self.tokenizer = self._load_tokenizer()
|
self.tokenizer = self._load_tokenizer()
|
||||||
tokenizer_kwargs: dict[str, Any] | None = self.text_cfg.get("tokenizer_kwargs")
|
tokenizer_kwargs: dict[str, Any] | None = self.text_cfg.get("tokenizer_kwargs")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from immich_ml.models.transforms import (
|
|||||||
serialize_np_array,
|
serialize_np_array,
|
||||||
to_numpy,
|
to_numpy,
|
||||||
)
|
)
|
||||||
from immich_ml.schemas import ModelSession, ModelTask, ModelType
|
from immich_ml.schemas import ModelGraph, ModelInput, ModelSession, ModelTask, ModelType
|
||||||
|
|
||||||
|
|
||||||
class BaseCLIPVisualEncoder(InferenceModel):
|
class BaseCLIPVisualEncoder(InferenceModel):
|
||||||
@@ -28,11 +28,12 @@ class BaseCLIPVisualEncoder(InferenceModel):
|
|||||||
|
|
||||||
def _predict(self, inputs: Image.Image | bytes) -> str:
|
def _predict(self, inputs: Image.Image | bytes) -> str:
|
||||||
image = decode_pil(inputs)
|
image = decode_pil(inputs)
|
||||||
res: NDArray[np.float32] = self.session.run(None, self.transform(image))[0][0]
|
session = self.session.for_shape(self.shape_policy.dims[0])
|
||||||
|
res: NDArray[np.float32] = session.run(None, self.transform(session, image))[0][0]
|
||||||
return serialize_np_array(res)
|
return serialize_np_array(res)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]:
|
def transform(self, session: ModelGraph, image: Image.Image) -> ModelInput:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -69,11 +70,12 @@ class OpenClipVisualEncoder(BaseCLIPVisualEncoder):
|
|||||||
|
|
||||||
return super()._load()
|
return super()._load()
|
||||||
|
|
||||||
def transform(self, image: Image.Image) -> dict[str, NDArray[np.float32]]:
|
def transform(self, session: ModelGraph, image: Image.Image) -> ModelInput:
|
||||||
image = self._resize(image)
|
image = self._resize(image)
|
||||||
image_np = to_numpy(image)
|
if session.normalizes_input:
|
||||||
image_np = normalize(image_np, self.mean, self.std)
|
rgb: NDArray[np.uint8] = np.asarray(image if image.mode == "RGB" else image.convert("RGB"))
|
||||||
return {"image": np.expand_dims(image_np.transpose(2, 0, 1), 0)}
|
return {"image": rgb[None]}
|
||||||
|
return {"image": normalize(to_numpy(image), self.mean, self.std).transpose(2, 0, 1)[None]}
|
||||||
|
|
||||||
def _resize(self, image: Image.Image) -> Image.Image:
|
def _resize(self, image: Image.Image) -> Image.Image:
|
||||||
match self.preprocess_cfg.get("resize_mode", "shortest"): # open_clip's default for older configs
|
match self.preprocess_cfg.get("resize_mode", "shortest"): # open_clip's default for older configs
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ from functools import lru_cache
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from immich_model.constants import FACE_DETECTION_SIZE as DET_SIZE
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from immich_ml.models.transforms import ensure_dims
|
from immich_ml.models.transforms import ensure_dims
|
||||||
|
|
||||||
DET_SIZE = 640
|
|
||||||
ALIGNED_SIZE = 112
|
ALIGNED_SIZE = 112
|
||||||
|
|
||||||
# the FPN levels for which the fused detector emits a (scores, boxes, kps) triple,
|
# the FPN levels for which the fused detector emits a (scores, boxes, kps) triple,
|
||||||
@@ -69,6 +69,5 @@ def umeyama(src: NDArray[np.float32], dst: NDArray[np.float32]) -> NDArray[np.fl
|
|||||||
return np.hstack([scale * rotation, translation[:, None]], dtype=np.float32)
|
return np.hstack([scale * rotation, translation[:, None]], dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
def align_face(image: NDArray[np.uint8], kps: NDArray[np.float32]) -> NDArray[np.float32]:
|
def align_face(image: NDArray[np.uint8], kps: NDArray[np.float32], crop: NDArray[np.uint8]) -> None:
|
||||||
matrix = umeyama(kps, ARCFACE_DST)
|
cv2.warpAffine(image, umeyama(kps, ARCFACE_DST), (ALIGNED_SIZE, ALIGNED_SIZE), dst=crop)
|
||||||
return cv2.warpAffine(image, matrix, (ALIGNED_SIZE, ALIGNED_SIZE)).astype(np.float32)
|
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
from immich_model.constants import FACE_DETECTION_SIZE as DET_SIZE
|
||||||
from numpy.typing import NDArray
|
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
|
from immich_ml.models.transforms import decode_pil, letterbox, normalize, widen
|
||||||
from immich_ml.schemas import FaceDetectionOutput, ModelTask, ModelType
|
from immich_ml.schemas import FaceDetectionOutput, ModelTask, ModelType, Shape
|
||||||
|
from immich_ml.sessions.policy import ShapePolicy
|
||||||
|
|
||||||
from ._ops import DET_SIZE, decode_scrfd, nms
|
from ._ops import decode_scrfd, nms
|
||||||
|
|
||||||
|
|
||||||
class FaceDetector(InferenceModel):
|
class FaceDetector(InferenceModel):
|
||||||
depends = []
|
depends = []
|
||||||
identity = (ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)
|
identity = (ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)
|
||||||
|
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, minScore: float) -> FaceDetectionOutput:
|
||||||
|
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 = normalize(canvas.astype(np.float32), mean=127.5, std=128).transpose(2, 0, 1)[None]
|
blob: NDArray[np.float32] | NDArray[np.uint8] = (
|
||||||
|
canvas[None]
|
||||||
|
if session.normalizes_input
|
||||||
|
else normalize(canvas.astype(np.float32), 127.5, 128).transpose(2, 0, 1)[None]
|
||||||
|
)
|
||||||
|
|
||||||
input_name = self.session.get_inputs()[0].name
|
heads = [widen(head) for head in session.run(None, {session.get_inputs()[0].name: blob})]
|
||||||
heads = self.session.run(None, {input_name: blob})
|
|
||||||
scores, boxes, kps = decode_scrfd(heads, DET_SIZE)
|
scores, boxes, kps = decode_scrfd(heads, DET_SIZE)
|
||||||
|
|
||||||
candidates = scores >= minScore
|
candidates = scores >= minScore
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import onnx
|
|
||||||
import onnxruntime as ort
|
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from onnx.tools.update_model_dims import update_inputs_outputs_dims
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from immich_ml.config import log, 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 (
|
from immich_ml.schemas import FaceDetectionOutput, FacialRecognitionOutput, ModelTask, ModelType, Shape
|
||||||
FaceDetectionOutput,
|
from immich_ml.sessions.policy import ShapePolicy, batches, runs
|
||||||
FacialRecognitionOutput,
|
|
||||||
ModelFormat,
|
|
||||||
ModelSession,
|
|
||||||
ModelTask,
|
|
||||||
ModelType,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ._ops import align_face
|
from ._ops import ALIGNED_SIZE, align_face
|
||||||
|
|
||||||
|
|
||||||
class FaceRecognizer(InferenceModel):
|
class FaceRecognizer(InferenceModel):
|
||||||
@@ -29,15 +19,8 @@ class FaceRecognizer(InferenceModel):
|
|||||||
|
|
||||||
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)
|
||||||
max_batch_size = settings.max_batch_size and settings.max_batch_size.facial_recognition
|
sizes = batches(settings.max_batch_size.facial_recognition)
|
||||||
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
|
self.shape_policy = ShapePolicy(dims=tuple(Shape(batch) for batch in sizes))
|
||||||
|
|
||||||
def _load(self) -> ModelSession:
|
|
||||||
session = self._make_session()
|
|
||||||
if (not self.batch_size or self.batch_size > 1) and str(session.get_inputs()[0].shape[0]) != "batch":
|
|
||||||
self._add_batch_axis(self.model_path)
|
|
||||||
session = self._make_session()
|
|
||||||
return session
|
|
||||||
|
|
||||||
def _predict(
|
def _predict(
|
||||||
self, inputs: NDArray[np.uint8] | bytes | Image.Image, faces: FaceDetectionOutput
|
self, inputs: NDArray[np.uint8] | bytes | Image.Image, faces: FaceDetectionOutput
|
||||||
@@ -45,20 +28,26 @@ class FaceRecognizer(InferenceModel):
|
|||||||
if faces["boxes"].shape[0] == 0:
|
if faces["boxes"].shape[0] == 0:
|
||||||
return []
|
return []
|
||||||
image = np.asarray(decode_pil(inputs), dtype=np.uint8)
|
image = np.asarray(decode_pil(inputs), dtype=np.uint8)
|
||||||
crops = np.stack([align_face(image, kps).transpose(2, 0, 1) for kps in faces["landmarks"]])
|
landmarks = faces["landmarks"]
|
||||||
embeddings = self._predict_batch(normalize(crops, mean=127.5, std=127.5))
|
sizes = runs(len(landmarks), self.session.batches)
|
||||||
|
crops: NDArray[np.float32] | NDArray[np.uint8] = np.empty(
|
||||||
|
(len(landmarks), ALIGNED_SIZE, ALIGNED_SIZE, 3), dtype=np.uint8
|
||||||
|
)
|
||||||
|
for crop, kps in zip(crops, landmarks):
|
||||||
|
align_face(image, kps, crop)
|
||||||
|
if not self.session.for_shape(Shape(sizes[0])).normalizes_input:
|
||||||
|
crops = normalize(crops.transpose(0, 3, 1, 2).astype(np.float32, order="C"), mean=127.5, std=127.5)
|
||||||
|
embeddings = self._predict_batch(crops, sizes)
|
||||||
return self.postprocess(faces, embeddings)
|
return self.postprocess(faces, embeddings)
|
||||||
|
|
||||||
def _predict_batch(self, crops: NDArray[np.float32]) -> NDArray[np.float32]:
|
def _predict_batch(self, crops: NDArray[np.float32] | NDArray[np.uint8], sizes: list[int]) -> NDArray[np.float32]:
|
||||||
input_name = self.session.get_inputs()[0].name
|
embeddings = []
|
||||||
if not self.batch_size or crops.shape[0] <= self.batch_size:
|
start = 0
|
||||||
return self.session.run(None, {input_name: crops})[0]
|
for size in sizes:
|
||||||
|
session = self.session.for_shape(Shape(size))
|
||||||
batches = [
|
embeddings.append(session.run(None, {session.get_inputs()[0].name: crops[start : start + size]})[0])
|
||||||
self.session.run(None, {input_name: crops[i : i + self.batch_size]})[0]
|
start += size
|
||||||
for i in range(0, crops.shape[0], self.batch_size)
|
return embeddings[0] if len(embeddings) == 1 else np.concatenate(embeddings, axis=0)
|
||||||
]
|
|
||||||
return np.concatenate(batches, axis=0)
|
|
||||||
|
|
||||||
def postprocess(self, faces: FaceDetectionOutput, embeddings: NDArray[np.float32]) -> FacialRecognitionOutput:
|
def postprocess(self, faces: FaceDetectionOutput, embeddings: NDArray[np.float32]) -> FacialRecognitionOutput:
|
||||||
return [
|
return [
|
||||||
@@ -69,24 +58,3 @@ class FaceRecognizer(InferenceModel):
|
|||||||
}
|
}
|
||||||
for (x1, y1, x2, y2), embedding, score in zip(faces["boxes"], embeddings, faces["scores"])
|
for (x1, y1, x2, y2), embedding, score in zip(faces["boxes"], embeddings, faces["scores"])
|
||||||
]
|
]
|
||||||
|
|
||||||
def _add_batch_axis(self, model_path: Path) -> None:
|
|
||||||
log.debug(f"Adding batch axis to model {model_path}")
|
|
||||||
proto = onnx.load(model_path)
|
|
||||||
static_input_dims = [shape.dim_value for shape in proto.graph.input[0].type.tensor_type.shape.dim[1:]]
|
|
||||||
static_output_dims = [shape.dim_value for shape in proto.graph.output[0].type.tensor_type.shape.dim[1:]]
|
|
||||||
input_dims = {proto.graph.input[0].name: ["batch"] + static_input_dims}
|
|
||||||
output_dims = {proto.graph.output[0].name: ["batch"] + static_output_dims}
|
|
||||||
updated_proto = update_inputs_outputs_dims(proto, input_dims, output_dims)
|
|
||||||
onnx.save(updated_proto, model_path)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _batch_size_default(self) -> int | None:
|
|
||||||
providers = ort.get_available_providers()
|
|
||||||
if (
|
|
||||||
self.model_format == ModelFormat.ONNX
|
|
||||||
and "MIGraphXExecutionProvider" not in providers
|
|
||||||
and "OpenVINOExecutionProvider" not in providers
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
return 1
|
|
||||||
|
|||||||
@@ -1,38 +1,83 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Self
|
from typing import Any, Self
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from immich_ml.models.transforms import widen
|
||||||
|
|
||||||
def greedy(outputs: Sequence[NDArray[Any]]) -> tuple[NDArray[np.int32], NDArray[np.float32]]:
|
|
||||||
"""Models with an in-graph argmax head emit (indices, probs); the rest emit raw logits."""
|
def picked(indices: NDArray[np.int32], confidence: NDArray[Any]) -> tuple[NDArray[np.int32], NDArray[np.float32]]:
|
||||||
if len(outputs) == 2:
|
return indices, widen(confidence)
|
||||||
return outputs[0], outputs[1]
|
|
||||||
(probs,) = outputs
|
|
||||||
indices = probs.argmax(axis=2)
|
def kept(indices: NDArray[np.integer[Any]]) -> NDArray[np.bool_]:
|
||||||
return indices.astype(np.int32), np.take_along_axis(probs, indices[:, :, None], axis=2)[:, :, 0]
|
"""The steps a greedy CTC decode reads a character from: not blank, and not a repeat of the step before."""
|
||||||
|
keep = np.empty(indices.shape, dtype=bool)
|
||||||
|
keep[:, 0] = True
|
||||||
|
np.not_equal(indices[:, 1:], indices[:, :-1], out=keep[:, 1:])
|
||||||
|
keep &= indices != 0
|
||||||
|
return keep
|
||||||
|
|
||||||
|
|
||||||
|
def logits(raw: NDArray[Any]) -> tuple[NDArray[np.int32], NDArray[np.float32]]:
|
||||||
|
steps = raw.reshape(-1, raw.shape[-1]) # a binary keeps a unit axis between the steps and the classes
|
||||||
|
indices = _argmax(steps)
|
||||||
|
keep = kept(indices.reshape(raw.shape[:2])).ravel()
|
||||||
|
rows = widen(steps[keep]) # a confidence is only read where a character is kept
|
||||||
|
best = rows[np.arange(len(rows)), indices[keep]]
|
||||||
|
# the winner's softmax, summing only the few classes within exp(-16) of it rather than every class
|
||||||
|
near = rows > (best - 16)[:, None]
|
||||||
|
counts = np.count_nonzero(near, axis=1)
|
||||||
|
confidence = np.zeros(len(steps), np.float32)
|
||||||
|
if len(rows):
|
||||||
|
weights = np.exp(rows[near] - np.repeat(best, counts))
|
||||||
|
confidence[keep] = 1 / np.add.reduceat(weights, np.cumsum(counts) - counts)
|
||||||
|
return indices.reshape(raw.shape[:2]).astype(np.int32), confidence.reshape(raw.shape[:2])
|
||||||
|
|
||||||
|
|
||||||
|
def _argmax(steps: NDArray[Any]) -> NDArray[np.intp]:
|
||||||
|
if steps.dtype != np.float16:
|
||||||
|
return steps.argmax(axis=1)
|
||||||
|
bits = steps.view(np.int16) # a half's bits order like its value where it is not negative
|
||||||
|
indices = bits.argmax(axis=1)
|
||||||
|
negative = np.flatnonzero(bits[np.arange(len(bits)), indices] < 0) # a step with no logit above zero
|
||||||
|
if len(negative):
|
||||||
|
flipped = bits[negative] ^ ((bits[negative] >> 15) & np.int16(0x7FFF)) # negatives' order restored
|
||||||
|
indices[negative] = flipped.argmax(axis=1)
|
||||||
|
return indices
|
||||||
|
|
||||||
|
|
||||||
|
def probabilities(probs: NDArray[Any]) -> tuple[NDArray[np.int32], NDArray[np.float32]]:
|
||||||
|
# probabilities are non-negative, so half-precision bits order like their values and need no widening to compare
|
||||||
|
indices = (probs.view(np.uint16) if probs.dtype == np.float16 else probs).argmax(axis=2)
|
||||||
|
return indices.astype(np.int32), widen(np.take_along_axis(probs, indices[:, :, None], axis=2)[:, :, 0])
|
||||||
|
|
||||||
|
|
||||||
|
Greedy = Callable[..., tuple[NDArray[np.int32], NDArray[np.float32]]]
|
||||||
|
GREEDY: dict[str, Greedy] = {"ctc_indices": picked, "ctc_logits": logits, "logits_softmax": probabilities}
|
||||||
|
|
||||||
|
|
||||||
class CtcDecoder:
|
class CtcDecoder:
|
||||||
def __init__(self, charset: list[str]):
|
def __init__(self, charset: list[str], greedy: Greedy):
|
||||||
self.charset = ["", *charset, " "] # PP-OCR: blank at 0, space last
|
self.charset = ["", *charset, " "] # PP-OCR: blank at 0, space last
|
||||||
|
self.greedy = greedy
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_file(cls, charset_path: Path) -> Self:
|
def from_file(cls, charset_path: Path, greedy: Greedy) -> Self:
|
||||||
if not charset_path.is_file():
|
if not charset_path.is_file():
|
||||||
raise FileNotFoundError(f"Recognition charset not found: {charset_path}")
|
raise FileNotFoundError(f"Recognition charset not found: {charset_path}")
|
||||||
return cls(charset_path.read_text(encoding="utf-8").splitlines())
|
return cls(charset_path.read_text(encoding="utf-8").splitlines(), greedy)
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self.charset)
|
return len(self.charset)
|
||||||
|
|
||||||
|
def __call__(self, outputs: Sequence[NDArray[Any]]) -> tuple[list[str], NDArray[np.float32]]:
|
||||||
|
return self.decode(*self.greedy(*outputs))
|
||||||
|
|
||||||
def decode(self, indices: NDArray[np.int32], probs: NDArray[np.float32]) -> tuple[list[str], NDArray[np.float32]]:
|
def decode(self, indices: NDArray[np.int32], probs: NDArray[np.float32]) -> tuple[list[str], NDArray[np.float32]]:
|
||||||
keep = np.empty(indices.shape, dtype=bool)
|
keep = kept(indices)
|
||||||
keep[:, 0] = True
|
|
||||||
np.not_equal(indices[:, 1:], indices[:, :-1], out=keep[:, 1:]) # repeats before blanks
|
|
||||||
keep &= indices != 0
|
|
||||||
|
|
||||||
scores: NDArray[np.float32] = np.where(keep, probs, 0).sum(1)
|
scores: NDArray[np.float32] = np.where(keep, probs, 0).sum(1)
|
||||||
scores /= np.maximum(keep.sum(1), 1) # an all-blank row sums to 0, so it stays 0
|
scores /= np.maximum(keep.sum(1), 1) # an all-blank row sums to 0, so it stays 0
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
from functools import cached_property
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from immich_model.constants import ocr_canvases
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
|
||||||
from rapidocr.utils.download_file import DownloadFile, DownloadFileInput
|
|
||||||
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
|
|
||||||
from rapidocr.utils.typings import ModelType as RapidModelType
|
|
||||||
|
|
||||||
from immich_ml.config import log
|
from immich_ml.models.transforms import widen
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.schemas import ModelGraph, ModelTask, ModelType, Shape
|
||||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
from immich_ml.sessions.policy import ShapePolicy
|
||||||
from immich_ml.sessions.ort import OrtSession
|
|
||||||
|
|
||||||
|
from .legacy import TextModel
|
||||||
from .postprocess import DBPostProcess
|
from .postprocess import DBPostProcess
|
||||||
from .schemas import TextDetectionOutput
|
from .schemas import TextDetectionOutput
|
||||||
|
|
||||||
|
|
||||||
class TextDetector(InferenceModel):
|
class TextDetector(TextModel):
|
||||||
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, **model_kwargs: Any) -> None:
|
||||||
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
|
super().__init__(model_name, **model_kwargs)
|
||||||
|
short_side = model_kwargs.get("maxResolution", 736)
|
||||||
|
# RKNPU ships a binary per short side, which the label picks
|
||||||
|
canvases = tuple(Shape(batch=1, **canvas) for canvas in ocr_canvases(short_side))
|
||||||
|
self.shape_policy = ShapePolicy(dims=canvases, label=f"res{short_side}")
|
||||||
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),
|
||||||
@@ -31,28 +33,6 @@ class TextDetector(InferenceModel):
|
|||||||
}
|
}
|
||||||
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 _download(self) -> None:
|
|
||||||
model_info = InferSession.get_model_url(
|
|
||||||
FileInfo(
|
|
||||||
engine_type=EngineType.ONNXRUNTIME,
|
|
||||||
ocr_version=OCRVersion.PPOCRV5,
|
|
||||||
task_type=TaskType.DET,
|
|
||||||
lang_type=LangDet.CH,
|
|
||||||
model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
download_params = DownloadFileInput(
|
|
||||||
file_url=model_info["model_dir"],
|
|
||||||
sha256=model_info["SHA256"],
|
|
||||||
save_path=self.model_path,
|
|
||||||
logger=log,
|
|
||||||
)
|
|
||||||
DownloadFile.run(download_params)
|
|
||||||
|
|
||||||
def _load(self) -> ModelSession:
|
|
||||||
# TODO: support other runtime sessions
|
|
||||||
return OrtSession(self.model_path)
|
|
||||||
|
|
||||||
def _predict(
|
def _predict(
|
||||||
self, inputs: Image.Image, maxResolution: int = 736, minScore: float = 0.5, scoreMode: str = "fast"
|
self, inputs: Image.Image, maxResolution: int = 736, minScore: float = 0.5, scoreMode: str = "fast"
|
||||||
) -> TextDetectionOutput:
|
) -> TextDetectionOutput:
|
||||||
@@ -60,9 +40,8 @@ class TextDetector(InferenceModel):
|
|||||||
if width < 32 or height < 32:
|
if width < 32 or height < 32:
|
||||||
return self._empty
|
return self._empty
|
||||||
|
|
||||||
image, content = self._transform(inputs, maxResolution)
|
session, image, content = self._transform(inputs, maxResolution)
|
||||||
input_name = self.session.get_inputs()[0].name
|
probs = widen(session.run(None, {session.get_inputs()[0].name: image})[0][0])
|
||||||
probs = self.session.run(None, {input_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), minScore, scoreMode)
|
||||||
@@ -71,27 +50,46 @@ class TextDetector(InferenceModel):
|
|||||||
order = self.reading_order(boxes)
|
order = self.reading_order(boxes)
|
||||||
return {"boxes": boxes[order], "scores": scores[order]}
|
return {"boxes": boxes[order], "scores": scores[order]}
|
||||||
|
|
||||||
@cached_property
|
def _transform(self, img: Image.Image, max_resolution: int) -> tuple[ModelGraph, NDArray[Any], tuple[int, int]]:
|
||||||
def raw_input(self) -> bool:
|
|
||||||
return self.session.get_inputs()[0].shape[-1] == 3 # NHWC models handle normalization and transpose internally
|
|
||||||
|
|
||||||
def _transform(self, img: Image.Image, max_resolution: int) -> tuple[NDArray[Any], tuple[int, int]]:
|
|
||||||
ratio = min(max_resolution / min(img.height, img.width), 1.0)
|
ratio = min(max_resolution / min(img.height, img.width), 1.0)
|
||||||
resize_h = max(self._round32(img.height * ratio), 32)
|
content_h = max(self._round32(img.height * ratio), 32)
|
||||||
resize_w = max(self._round32(img.width * ratio), 32)
|
content_w = max(self._round32(img.width * ratio), 32)
|
||||||
resized = img.resize((resize_w, resize_h), resample=Image.Resampling.LANCZOS)
|
canvas = self._canvas(self.session.shapes, content_h, content_w)
|
||||||
|
session = self.session.for_shape(Shape(batch=1, height=canvas[0], width=canvas[1]))
|
||||||
|
# a compiled canvas may be smaller than the image was sized for, so it shrinks again
|
||||||
|
scale = min(canvas[0] / content_h, canvas[1] / content_w, 1.0)
|
||||||
|
content_h = min(max(self._round32(content_h * scale), 32), canvas[0])
|
||||||
|
content_w = min(max(self._round32(content_w * scale), 32), canvas[1])
|
||||||
|
resized = img.resize((content_w, content_h), resample=Image.Resampling.LANCZOS)
|
||||||
if resized.mode != "RGB":
|
if resized.mode != "RGB":
|
||||||
resized = resized.convert("RGB")
|
resized = resized.convert("RGB")
|
||||||
array = np.asarray(resized)
|
array = np.asarray(resized)
|
||||||
if self.raw_input:
|
content = (content_h, content_w)
|
||||||
return array[None], (resize_h, resize_w)
|
height, width = canvas
|
||||||
|
if session.normalizes_input:
|
||||||
|
if canvas == content:
|
||||||
|
return session, array[None], content
|
||||||
|
image = np.zeros((1, height, width, 3), dtype=np.uint8) # what is left of the canvas stays black
|
||||||
|
image[0, :content_h, :content_w] = array
|
||||||
|
return session, image, content
|
||||||
# reverse plane order gets the BGR swap and the CHW transpose for free
|
# reverse plane order gets the BGR swap and the CHW transpose for free
|
||||||
out = np.empty((1, 3, resize_h, resize_w), dtype=np.float32)
|
out = np.empty((1, 3, height, width), dtype=np.float32)
|
||||||
|
if canvas != content: # -1.0 is what the graph would have made of a black pixel
|
||||||
|
out[0, :, content_h:] = -1.0
|
||||||
|
out[0, :, :content_h, content_w:] = -1.0
|
||||||
for channel in range(3):
|
for channel in range(3):
|
||||||
plane = out[0, 2 - channel]
|
plane = out[0, 2 - channel, :content_h, :content_w]
|
||||||
np.multiply(array[:, :, channel], self.scale, out=plane)
|
np.multiply(array[:, :, channel], self.scale, out=plane)
|
||||||
plane -= 1.0
|
plane -= 1.0
|
||||||
return out, (resize_h, resize_w)
|
return session, out, content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _canvas(shapes: Sequence[Shape], height: int, width: int) -> tuple[int, int]:
|
||||||
|
return min(
|
||||||
|
((shape.height, shape.width) for shape in shapes if shape.height and shape.width),
|
||||||
|
key=lambda canvas: (-min(canvas[0] / height, canvas[1] / width, 1.0), canvas[0] * canvas[1]),
|
||||||
|
default=(height, width),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _round32(value: float) -> int:
|
def _round32(value: float) -> int:
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from immich_ml.config import log, settings
|
||||||
|
from immich_ml.models.base import InferenceModel
|
||||||
|
from immich_ml.schemas import ModelFormat, ModelType
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Remove once everything uses the new model graphs
|
||||||
|
class TextModel(InferenceModel):
|
||||||
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
|
if settings.legacy_models:
|
||||||
|
model_kwargs["model_format"] = ModelFormat.ONNX # the older exports come in no other format
|
||||||
|
super().__init__(model_name, **model_kwargs)
|
||||||
|
|
||||||
|
def download(self) -> None:
|
||||||
|
if not settings.legacy_models or self.cached:
|
||||||
|
return super().download()
|
||||||
|
# here, as only an install still on the older exports has a use for it
|
||||||
|
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||||
|
from rapidocr.utils.download_file import DownloadFile, DownloadFileInput
|
||||||
|
from rapidocr.utils.typings import EngineType, LangDet, LangRec, OCRVersion, TaskType
|
||||||
|
from rapidocr.utils.typings import ModelType as RapidModelType
|
||||||
|
|
||||||
|
detects = self.model_type == ModelType.DETECTION
|
||||||
|
language = self.model_name.split("__")[0] if "__" in self.model_name else "CH"
|
||||||
|
model_info = InferSession.get_model_url(
|
||||||
|
FileInfo(
|
||||||
|
engine_type=EngineType.ONNXRUNTIME,
|
||||||
|
ocr_version=OCRVersion.PPOCRV5,
|
||||||
|
task_type=TaskType.DET if detects else TaskType.REC,
|
||||||
|
lang_type=LangDet.CH if detects else LangRec[language],
|
||||||
|
model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
f"Downloading {self.model_type} model '{self.model_name}' to {self.model_path}. This may take a while."
|
||||||
|
)
|
||||||
|
DownloadFile.run(
|
||||||
|
DownloadFileInput(
|
||||||
|
file_url=model_info["model_dir"], sha256=model_info["SHA256"], save_path=self.model_path, logger=log
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,77 +1,57 @@
|
|||||||
import math
|
import math
|
||||||
from functools import cached_property
|
from bisect import bisect_left
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from immich_model.constants import OCR_RECOGNITION_WIDTHS
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from rapidocr import LangRec
|
|
||||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
|
||||||
from rapidocr.utils.download_file import DownloadFile, DownloadFileInput
|
|
||||||
from rapidocr.utils.typings import EngineType, OCRVersion, TaskType
|
|
||||||
from rapidocr.utils.typings import ModelType as RapidModelType
|
|
||||||
|
|
||||||
from immich_ml.config import log, settings
|
from immich_ml.config import settings
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.schemas import ModelGraph, ModelSession, ModelTask, ModelType, Shape
|
||||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
from immich_ml.sessions.policy import ShapePolicy, batches, runs
|
||||||
from immich_ml.sessions.ort import OrtSession
|
|
||||||
|
|
||||||
from .ctc import CtcDecoder, greedy
|
from .ctc import GREEDY, CtcDecoder, probabilities
|
||||||
|
from .legacy import TextModel
|
||||||
from .schemas import TextDetectionOutput, TextRecognitionOutput
|
from .schemas import TextDetectionOutput, TextRecognitionOutput
|
||||||
|
|
||||||
REC_HEIGHT = 48
|
REC_HEIGHT = 48
|
||||||
REC_BASE_RATIO = 320 / REC_HEIGHT # PP-OCR's rec_image_shape floor
|
|
||||||
SCALE = np.float32(1.0 / 127.5)
|
SCALE = np.float32(1.0 / 127.5)
|
||||||
|
|
||||||
|
|
||||||
class TextRecognizer(InferenceModel):
|
class TextRecognizer(TextModel):
|
||||||
depends = [(ModelType.DETECTION, ModelTask.OCR)]
|
depends = [(ModelType.DETECTION, ModelTask.OCR)]
|
||||||
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
||||||
|
|
||||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
|
|
||||||
self._empty: TextRecognitionOutput = {
|
self._empty: TextRecognitionOutput = {
|
||||||
"box": np.empty(0, dtype=np.float32),
|
"box": np.empty(0, dtype=np.float32),
|
||||||
"boxScore": np.empty(0, dtype=np.float32),
|
"boxScore": np.empty(0, dtype=np.float32),
|
||||||
"text": [],
|
"text": [],
|
||||||
"textScore": np.empty(0, dtype=np.float32),
|
"textScore": np.empty(0, dtype=np.float32),
|
||||||
}
|
}
|
||||||
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
|
super().__init__(model_name, **model_kwargs)
|
||||||
max_batch_size = settings.max_batch_size and settings.max_batch_size.ocr
|
self.decoder: CtcDecoder | None = None
|
||||||
self.batch_size = max_batch_size if max_batch_size else 6
|
sizes = batches(settings.max_batch_size.ocr)
|
||||||
|
self.shape_policy = ShapePolicy(
|
||||||
def _download(self) -> None:
|
dims=tuple(Shape(batch, width=width) for width in OCR_RECOGNITION_WIDTHS for batch in sizes)
|
||||||
model_info = InferSession.get_model_url(
|
|
||||||
FileInfo(
|
|
||||||
engine_type=EngineType.ONNXRUNTIME,
|
|
||||||
ocr_version=OCRVersion.PPOCRV5,
|
|
||||||
task_type=TaskType.REC,
|
|
||||||
lang_type=self.language,
|
|
||||||
model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
download_params = DownloadFileInput(
|
|
||||||
file_url=model_info["model_dir"],
|
|
||||||
sha256=model_info["SHA256"],
|
|
||||||
save_path=self.model_path,
|
|
||||||
logger=log,
|
|
||||||
)
|
|
||||||
DownloadFile.run(download_params)
|
|
||||||
|
|
||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
# TODO: support other runtimes
|
session = super()._load()
|
||||||
session = OrtSession(self.model_path)
|
# the widths it was compiled for, narrowest first: none where it takes any
|
||||||
self.decoder = (
|
self.widths = tuple(sorted({shape.width for shape in session.shapes if shape.width is not None}))
|
||||||
CtcDecoder(character.splitlines())
|
|
||||||
if (character := session.get_metadata().get("character")) is not None
|
|
||||||
else CtcDecoder.from_file(self.model_dir / "charset.txt")
|
|
||||||
)
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
@cached_property
|
def _decoder(self, session: ModelGraph) -> CtcDecoder:
|
||||||
def raw_input(self) -> bool:
|
character = session.get_metadata().get("character")
|
||||||
return self.session.get_inputs()[0].shape[-1] == 3 # NHWC models handle normalization and transpose internally
|
greedy = GREEDY.get(session.get_outputs()[0].name, probabilities)
|
||||||
|
return (
|
||||||
|
CtcDecoder(character.splitlines(), greedy)
|
||||||
|
if character is not None
|
||||||
|
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, minScore: float = 0.9) -> TextRecognitionOutput:
|
||||||
boxes, box_scores = texts["boxes"], texts["scores"]
|
boxes, box_scores = texts["boxes"], texts["scores"]
|
||||||
@@ -85,28 +65,35 @@ class TextRecognizer(InferenceModel):
|
|||||||
|
|
||||||
text_list: list[str] = [""] * len(order)
|
text_list: list[str] = [""] * len(order)
|
||||||
score_list = np.zeros(len(order), dtype=np.float32)
|
score_list = np.zeros(len(order), dtype=np.float32)
|
||||||
input_name = self.session.get_inputs()[0].name
|
sized = runs(len(order), self.session.batches)
|
||||||
for start in range(0, len(order), self.batch_size):
|
bounds = [sum(sized[:index]) for index in range(len(sized))]
|
||||||
chunk = order[start : start + self.batch_size]
|
chunked = [order[start : start + size] for start, size in zip(bounds, sized)]
|
||||||
width = int(REC_HEIGHT * max(REC_BASE_RATIO, ratios[chunk[-1]]))
|
batch_widths = [self._width(int(REC_HEIGHT * ratios[chunk[-1]])) for chunk in chunked]
|
||||||
images: NDArray[Any] = (
|
graphs = [self.session.for_shape(Shape(len(chunk), width=width)) for chunk, width in zip(chunked, batch_widths)]
|
||||||
np.full((len(chunk), REC_HEIGHT, width, 3), 127, dtype=np.uint8)
|
raw = graphs[0].normalizes_input
|
||||||
if self.raw_input
|
# one buffer per request, sized for the largest batch and reshaped for each
|
||||||
else np.zeros((len(chunk), 3, REC_HEIGHT, width), dtype=np.float32)
|
sizes = [len(chunk) * REC_HEIGHT * width * 3 for chunk, width in zip(chunked, batch_widths)]
|
||||||
)
|
buffer = np.empty(max(sizes), dtype=np.uint8 if raw else np.float32)
|
||||||
|
for chunk, width, size, session in zip(chunked, batch_widths, sizes, graphs):
|
||||||
|
shape = (len(chunk), REC_HEIGHT, width, 3) if raw else (len(chunk), 3, REC_HEIGHT, width)
|
||||||
|
images: NDArray[Any] = buffer[:size].reshape(shape)
|
||||||
for i, index in enumerate(chunk):
|
for i, index in enumerate(chunk):
|
||||||
crop = self._crop(img, coeffs[index], widths[index], heights[index], upright[index])
|
crop = self._crop(img, coeffs[index], widths[index], heights[index], upright[index])
|
||||||
resized_w = max(1, min(width, math.ceil(REC_HEIGHT * ratios[index])))
|
resized_w = max(1, min(width, math.ceil(REC_HEIGHT * ratios[index])))
|
||||||
if self.raw_input:
|
if raw:
|
||||||
cv2.resize(crop, (resized_w, REC_HEIGHT), dst=images[i, :, :resized_w])
|
cv2.resize(crop, (resized_w, REC_HEIGHT), dst=images[i, :, :resized_w])
|
||||||
|
images[i, :, resized_w:] = 127 # the pad the crop is read against, over whatever was there
|
||||||
continue
|
continue
|
||||||
resized = cv2.resize(crop, (resized_w, REC_HEIGHT))
|
resized = cv2.resize(crop, (resized_w, REC_HEIGHT))
|
||||||
view = images[i, :, :, :resized_w]
|
view = images[i, :, :, :resized_w]
|
||||||
np.multiply(resized.transpose(2, 0, 1)[::-1], SCALE, out=view) # [::-1] is the RGB -> BGR swap
|
np.multiply(resized.transpose(2, 0, 1)[::-1], SCALE, out=view) # [::-1] is the RGB -> BGR swap
|
||||||
view -= 1.0
|
view -= 1.0
|
||||||
|
images[i, :, :, resized_w:] = 0
|
||||||
|
|
||||||
out_indices, out_probs = greedy(self.session.run(None, {input_name: images}))
|
outputs = session.run(None, {session.get_inputs()[0].name: images})
|
||||||
chunk_texts, chunk_scores = self.decoder.decode(out_indices, out_probs)
|
if self.decoder is None:
|
||||||
|
self.decoder = self._decoder(session)
|
||||||
|
chunk_texts, chunk_scores = self.decoder(outputs)
|
||||||
for index, text, score in zip(chunk, chunk_texts, chunk_scores):
|
for index, text, score in zip(chunk, chunk_texts, chunk_scores):
|
||||||
text_list[index] = text
|
text_list[index] = text
|
||||||
score_list[index] = score
|
score_list[index] = score
|
||||||
@@ -123,6 +110,11 @@ class TextRecognizer(InferenceModel):
|
|||||||
"textScore": score_list[valid],
|
"textScore": score_list[valid],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _width(self, width: int) -> int:
|
||||||
|
if not self.widths: # room past the text in proportion to it, as the recognizer reads a line worse without
|
||||||
|
return min(OCR_RECOGNITION_WIDTHS[-1], max(OCR_RECOGNITION_WIDTHS[0], math.ceil(width * 1.25)))
|
||||||
|
return self.widths[min(bisect_left(self.widths, width), len(self.widths) - 1)]
|
||||||
|
|
||||||
def _crop_geometry(
|
def _crop_geometry(
|
||||||
self, boxes: NDArray[np.float32]
|
self, boxes: NDArray[np.float32]
|
||||||
) -> tuple[NDArray[np.int32], NDArray[np.int32], NDArray[np.float32]]:
|
) -> tuple[NDArray[np.int32], NDArray[np.int32], NDArray[np.float32]]:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import string
|
import string
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -43,10 +44,6 @@ def get_pil_resampling(resample: str) -> Image.Resampling:
|
|||||||
return _PIL_RESAMPLING_METHODS[resample.lower()]
|
return _PIL_RESAMPLING_METHODS[resample.lower()]
|
||||||
|
|
||||||
|
|
||||||
def pil_to_cv2(image: Image.Image) -> NDArray[np.uint8]:
|
|
||||||
return cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR) # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
def decode_pil(image_bytes: bytes | Image.Image | NDArray[np.uint8]) -> Image.Image:
|
def decode_pil(image_bytes: bytes | Image.Image | NDArray[np.uint8]) -> Image.Image:
|
||||||
image: Image.Image
|
image: Image.Image
|
||||||
match image_bytes:
|
match image_bytes:
|
||||||
@@ -75,6 +72,13 @@ def serialize_np_array(arr: NDArray[np.float32]) -> str:
|
|||||||
return orjson.dumps(arr, option=orjson.OPT_SERIALIZE_NUMPY).decode()
|
return orjson.dumps(arr, option=orjson.OPT_SERIALIZE_NUMPY).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def widen(array: NDArray[Any]) -> NDArray[Any]:
|
||||||
|
"""Single precision from half through OpenCV, as numpy converts one value at a time without native half support."""
|
||||||
|
if array.dtype != np.float16:
|
||||||
|
return array
|
||||||
|
return cv2.convertFp16(array.reshape(-1, array.shape[-1]).view(np.int16)).reshape(array.shape) # int16 carries half
|
||||||
|
|
||||||
|
|
||||||
def letterbox(image: NDArray[np.uint8] | Image.Image, size: int) -> tuple[NDArray[np.uint8], float]:
|
def letterbox(image: NDArray[np.uint8] | Image.Image, size: int) -> tuple[NDArray[np.uint8], float]:
|
||||||
if isinstance(image, Image.Image):
|
if isinstance(image, Image.Image):
|
||||||
image = np.asarray(image)
|
image = np.asarray(image)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Literal, Protocol, TypeVar
|
from typing import Any, Literal, Protocol, TypeAlias, TypeVar
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import numpy.typing as npt
|
import numpy.typing as npt
|
||||||
@@ -44,11 +45,6 @@ class ModelSource(StrEnum):
|
|||||||
PADDLE = "paddle"
|
PADDLE = "paddle"
|
||||||
|
|
||||||
|
|
||||||
class ModelPrecision(StrEnum):
|
|
||||||
FP16 = "FP16"
|
|
||||||
FP32 = "FP32"
|
|
||||||
|
|
||||||
|
|
||||||
class ModelOrganization(StrEnum):
|
class ModelOrganization(StrEnum):
|
||||||
APP = "immich-app"
|
APP = "immich-app"
|
||||||
TESTING = "immich-testing"
|
TESTING = "immich-testing"
|
||||||
@@ -62,16 +58,29 @@ class SessionNode(Protocol):
|
|||||||
def name(self) -> str: ...
|
def name(self) -> str: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def shape(self) -> tuple[int, ...]: ...
|
def shape(self) -> tuple[int | str, ...]: ... # ORT names a symbolic dim rather than sizing it
|
||||||
|
|
||||||
|
|
||||||
class ModelSession(Protocol):
|
ModelTensor: TypeAlias = npt.NDArray[np.float32] | npt.NDArray[np.int32] | npt.NDArray[np.uint8]
|
||||||
|
ModelInput = Mapping[str, ModelTensor]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Shape:
|
||||||
|
batch: int
|
||||||
|
height: int | None = None
|
||||||
|
width: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pins(self) -> dict[str, int]:
|
||||||
|
return {name: size for name, size in asdict(self).items() if size is not None}
|
||||||
|
|
||||||
|
|
||||||
|
class ModelGraph(Protocol):
|
||||||
def run(
|
def run(
|
||||||
self,
|
self,
|
||||||
output_names: list[str] | None,
|
output_names: list[str] | None,
|
||||||
input_feed: dict[str, npt.NDArray[np.float32]]
|
input_feed: ModelInput,
|
||||||
| dict[str, npt.NDArray[np.int32]]
|
|
||||||
| dict[str, npt.NDArray[np.uint8]],
|
|
||||||
run_options: Any = None,
|
run_options: Any = None,
|
||||||
) -> list[npt.NDArray[np.float32]]: ...
|
) -> list[npt.NDArray[np.float32]]: ...
|
||||||
|
|
||||||
@@ -81,6 +90,21 @@ class ModelSession(Protocol):
|
|||||||
|
|
||||||
def get_metadata(self) -> dict[str, str]: ...
|
def get_metadata(self) -> dict[str, str]: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def normalizes_input(self) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSession(Protocol):
|
||||||
|
@property
|
||||||
|
def shapes(self) -> tuple[Shape, ...]: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def batches(self) -> tuple[int, ...]: ...
|
||||||
|
|
||||||
|
def for_shape(self, shape: Shape) -> ModelGraph: ...
|
||||||
|
|
||||||
|
def warm(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class FaceDetectionOutput(TypedDict):
|
class FaceDetectionOutput(TypedDict):
|
||||||
boxes: npt.NDArray[np.float32]
|
boxes: npt.NDArray[np.float32]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import numpy as np
|
|||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from immich_ml.config import log, settings
|
from immich_ml.config import log, settings
|
||||||
from immich_ml.schemas import SessionNode
|
from immich_ml.schemas import ModelInput, SessionNode, Shape
|
||||||
|
|
||||||
from .loader import Ann
|
from .loader import Ann
|
||||||
|
|
||||||
@@ -29,6 +29,9 @@ class AnnSession:
|
|||||||
fp16=settings.ann_fp16_turbo,
|
fp16=settings.ann_fp16_turbo,
|
||||||
)
|
)
|
||||||
log.info("Loaded ANN model with ID %d", self.model)
|
log.info("Loaded ANN model with ID %d", self.model)
|
||||||
|
batch = self.ann.input_shapes[self.model][0][0]
|
||||||
|
self.shapes = (Shape(batch),) # the artifact is compiled for one shape
|
||||||
|
self.batches = (batch,)
|
||||||
|
|
||||||
def __del__(self) -> None:
|
def __del__(self) -> None:
|
||||||
self.ann.unload(self.model)
|
self.ann.unload(self.model)
|
||||||
@@ -46,10 +49,20 @@ class AnnSession:
|
|||||||
def get_metadata(self) -> dict[str, str]:
|
def get_metadata(self) -> dict[str, str]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def normalizes_input(self) -> bool:
|
||||||
|
return False # no ARM-NN artifact is built from a graph that carries its own preprocessing
|
||||||
|
|
||||||
|
def for_shape(self, shape: Shape) -> AnnSession:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def warm(self) -> None:
|
||||||
|
pass # the artifact is loaded whole when the session opens
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
self,
|
self,
|
||||||
output_names: list[str] | None,
|
output_names: list[str] | None,
|
||||||
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]] | dict[str, NDArray[np.uint8]],
|
input_feed: ModelInput,
|
||||||
run_options: Any = None,
|
run_options: Any = None,
|
||||||
) -> list[NDArray[np.float32]]:
|
) -> list[NDArray[np.float32]]:
|
||||||
inputs: list[NDArray[np.float32]] = [np.ascontiguousarray(v) for v in input_feed.values()]
|
inputs: list[NDArray[np.float32]] = [np.ascontiguousarray(v) for v in input_feed.values()]
|
||||||
|
|||||||
@@ -1,77 +1,56 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import pickle
|
||||||
import platform
|
import platform
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import cache, cached_property
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Any
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import onnxruntime as ort
|
import onnxruntime as ort
|
||||||
|
from immich_model.runtime import RewriteContext, RewritePlan, plan_rewrites
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from immich_ml.models.constants import SUPPORTED_PROVIDERS
|
from immich_ml.schemas import ModelInput, SessionNode, Shape
|
||||||
from immich_ml.schemas import ModelPrecision, SessionNode
|
|
||||||
|
|
||||||
from ..config import log, settings
|
from ..config import log, settings
|
||||||
|
from .policy import ShapePolicy
|
||||||
|
|
||||||
MigraphxInputSignature = tuple[tuple[str, str, tuple[int, ...]], ...]
|
# the one provider that handles a free dim well; the rest miscompile it, recompile inside the run,
|
||||||
|
# or partition around it, so they are handed one graph per shape instead
|
||||||
_migraphx_registry_lock = Lock()
|
DYNAMIC_PROVIDERS = frozenset({"CPUExecutionProvider"})
|
||||||
_migraphx_model_locks: dict[str, Lock] = {}
|
|
||||||
_migraphx_compiled_inputs: set[tuple[str, MigraphxInputSignature]] = set()
|
|
||||||
|
|
||||||
|
|
||||||
def _migraphx_get_model_lock(model_key: str) -> Lock:
|
def _label(pins: Mapping[str, int]) -> str:
|
||||||
with _migraphx_registry_lock:
|
return "_".join(f"{name}{size}" for name, size in pins.items())
|
||||||
lock = _migraphx_model_locks.get(model_key)
|
|
||||||
if lock is None:
|
|
||||||
lock = Lock()
|
|
||||||
_migraphx_model_locks[model_key] = lock
|
|
||||||
return lock
|
|
||||||
|
|
||||||
|
|
||||||
def _migraphx_has_compiled_input(key: tuple[str, MigraphxInputSignature]) -> bool:
|
UNREADABLE = 3 # how the preparing child says its source does not parse
|
||||||
with _migraphx_registry_lock:
|
DTYPES = {
|
||||||
return key in _migraphx_compiled_inputs
|
"tensor(float)": np.float32,
|
||||||
|
"tensor(float16)": np.float16,
|
||||||
|
"tensor(uint8)": np.uint8,
|
||||||
|
"tensor(int32)": np.int32,
|
||||||
|
"tensor(int64)": np.int64,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _migraphx_mark_compiled_input(key: tuple[str, MigraphxInputSignature]) -> None:
|
class OrtGraph:
|
||||||
with _migraphx_registry_lock:
|
def __init__(self, spec: GraphSpec) -> None:
|
||||||
_migraphx_compiled_inputs.add(key)
|
self.session = spec.session(prepared(spec))
|
||||||
|
image = self.session.get_inputs()[0]
|
||||||
|
# whether the graph scales and shifts the image itself, so the host hands over raw pixels
|
||||||
def _migraphx_input_signature(input_feed: dict[str, NDArray[Any]]) -> MigraphxInputSignature:
|
self.normalizes_input = image.type == "tensor(uint8)"
|
||||||
return tuple((name, str(value.dtype), tuple(value.shape)) for name, value in sorted(input_feed.items()))
|
# CoreML's rewrite moves the layout change out of the graph, so whoever applied it performs it
|
||||||
|
self.channels_first = self.normalizes_input and len(image.shape) == 4 and image.shape[1] == 3
|
||||||
|
|
||||||
class OrtSession:
|
|
||||||
session: ort.InferenceSession
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
model_path: Path | str,
|
|
||||||
providers: list[str] | None = None,
|
|
||||||
provider_options: list[dict[str, Any]] | None = None,
|
|
||||||
sess_options: ort.SessionOptions | None = None,
|
|
||||||
threads: int = 2,
|
|
||||||
):
|
|
||||||
self.model_path = Path(model_path)
|
|
||||||
self.threads = threads
|
|
||||||
self.providers = providers if providers is not None else self._providers_default
|
|
||||||
self.disabled_optimizers = _disabled_optimizers_default(self.providers)
|
|
||||||
log.debug(f"Setting disabled_optimizers to {self.disabled_optimizers}")
|
|
||||||
self.provider_options = provider_options if provider_options is not None else self._provider_options_default
|
|
||||||
self.sess_options = sess_options if sess_options is not None else self._sess_options_default
|
|
||||||
self.session = ort.InferenceSession(
|
|
||||||
self.model_path.as_posix(),
|
|
||||||
providers=self.providers,
|
|
||||||
provider_options=self.provider_options,
|
|
||||||
sess_options=self.sess_options,
|
|
||||||
disabled_optimizers=self.disabled_optimizers,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_inputs(self) -> Sequence[SessionNode]:
|
def get_inputs(self) -> Sequence[SessionNode]:
|
||||||
inputs: Sequence[SessionNode] = self.session.get_inputs()
|
inputs: Sequence[SessionNode] = self.session.get_inputs()
|
||||||
@@ -88,52 +67,91 @@ class OrtSession:
|
|||||||
def run(
|
def run(
|
||||||
self,
|
self,
|
||||||
output_names: list[str] | None,
|
output_names: list[str] | None,
|
||||||
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]] | dict[str, NDArray[np.uint8]],
|
input_feed: ModelInput,
|
||||||
run_options: Any = None,
|
run_options: Any = None,
|
||||||
) -> list[NDArray[np.float32]]:
|
) -> list[NDArray[np.float32]]:
|
||||||
if "MIGraphXExecutionProvider" in self.providers:
|
if self.channels_first:
|
||||||
model_key = self.model_path.resolve().as_posix()
|
input_feed = {name: frames.transpose(0, 3, 1, 2) for name, frames in input_feed.items()}
|
||||||
input_key = (model_key, _migraphx_input_signature(input_feed))
|
outputs: list[NDArray[Any]] = self.session.run(output_names, input_feed, run_options)
|
||||||
if not _migraphx_has_compiled_input(input_key):
|
|
||||||
model_lock = _migraphx_get_model_lock(model_key)
|
|
||||||
with model_lock:
|
|
||||||
if not _migraphx_has_compiled_input(input_key):
|
|
||||||
outputs: list[NDArray[np.float32]] = self.session.run(output_names, input_feed, run_options)
|
|
||||||
_migraphx_mark_compiled_input(input_key)
|
|
||||||
return outputs
|
|
||||||
|
|
||||||
outputs = self.session.run(output_names, input_feed, run_options)
|
|
||||||
return outputs
|
|
||||||
|
|
||||||
outputs = self.session.run(output_names, input_feed, run_options)
|
|
||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
@property
|
|
||||||
def providers(self) -> list[str]:
|
|
||||||
return self._providers
|
|
||||||
|
|
||||||
@providers.setter
|
class Device(NamedTuple):
|
||||||
def providers(self, providers: list[str]) -> None:
|
kind: str # what the provider compiles for, which names the directory
|
||||||
log.info(f"Setting execution providers to {providers}, in descending order of preference")
|
version: str # what the facts go stale under
|
||||||
self._providers = providers
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GraphSpec:
|
||||||
|
model_path: Path
|
||||||
|
pins: Mapping[str, int]
|
||||||
|
overrides: Sequence[tuple[str, int]]
|
||||||
|
providers: list[str]
|
||||||
|
disabled_optimizers: list[str]
|
||||||
|
threads: int = 2 # for the CPU, where they are not configured
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _providers_default(self) -> list[str]:
|
def provider(self) -> str:
|
||||||
available_providers = set(ort.get_available_providers())
|
return self.providers[0] # the rest take only what it cannot run
|
||||||
log.debug(f"Available ORT providers: {available_providers}")
|
|
||||||
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
@cached_property
|
||||||
|
def directory(self) -> Path:
|
||||||
|
provider = self.provider.removesuffix("ExecutionProvider").lower()
|
||||||
|
kind = self.device.kind if self.device else ""
|
||||||
|
return self.model_path.parent / provider / kind / _label(self.pins)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def device(self) -> Device | None:
|
||||||
|
match self.provider:
|
||||||
|
case "CPUExecutionProvider":
|
||||||
|
return Device(platform.machine(), _cpu()) # a prepack built for other instruction sets fails at runtime
|
||||||
|
case "OpenVINOExecutionProvider" if self.openvino_device.startswith("GPU"):
|
||||||
|
return _intel_gpu(self.openvino_device)
|
||||||
|
case "MIGraphXExecutionProvider":
|
||||||
|
return _amd_gpu(int(settings.device_id))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def openvino_device(self) -> str:
|
||||||
|
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||||
|
return f"GPU.{settings.device_id}" if any(d.startswith("GPU") for d in device_ids) else "CPU"
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def manifest(self) -> Path:
|
||||||
|
return self.directory / "manifest.json"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
def plan(self) -> RewritePlan:
|
||||||
|
return _plan(self.provider)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def half(self) -> bool:
|
||||||
|
return not (settings.legacy_models or self.provider == "CPUExecutionProvider")
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def facts(self) -> Facts:
|
||||||
|
return Facts(
|
||||||
|
half=self.half,
|
||||||
|
onnxruntime=ort.__version__,
|
||||||
|
# workers on devices of one kind share what is prepared
|
||||||
|
options={key: value for key, value in self.provider_options[0].items() if not key.startswith("device")},
|
||||||
|
overrides=list(self.overrides),
|
||||||
|
disabled_optimizers=self.disabled_optimizers,
|
||||||
|
rewrite=None if settings.legacy_models else self.plan.digest, # which were never tested with the rewriter
|
||||||
|
device=self.device.version if self.device else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def session(self, graph: Path, sess_options: ort.SessionOptions | None = None) -> ort.InferenceSession:
|
||||||
|
return ort.InferenceSession(
|
||||||
|
graph.as_posix(),
|
||||||
|
providers=self.providers,
|
||||||
|
provider_options=self.provider_options,
|
||||||
|
sess_options=sess_options or self.sess_options(),
|
||||||
|
disabled_optimizers=self.disabled_optimizers,
|
||||||
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
def provider_options(self) -> list[dict[str, Any]]:
|
def provider_options(self) -> list[dict[str, Any]]:
|
||||||
return self._provider_options
|
|
||||||
|
|
||||||
@provider_options.setter
|
|
||||||
def provider_options(self, provider_options: list[dict[str, Any]]) -> None:
|
|
||||||
log.debug(f"Setting execution provider options to {provider_options}")
|
|
||||||
self._provider_options = provider_options
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _provider_options_default(self) -> list[dict[str, Any]]:
|
|
||||||
provider_options = []
|
provider_options = []
|
||||||
for provider in self.providers:
|
for provider in self.providers:
|
||||||
match provider:
|
match provider:
|
||||||
@@ -142,80 +160,245 @@ class OrtSession:
|
|||||||
case "CUDAExecutionProvider":
|
case "CUDAExecutionProvider":
|
||||||
options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id}
|
options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id}
|
||||||
case "MIGraphXExecutionProvider":
|
case "MIGraphXExecutionProvider":
|
||||||
migraphx_dir = self.model_path.parent / "migraphx"
|
options = {"device_id": settings.device_id, "migraphx_model_cache_dir": self.directory.as_posix()}
|
||||||
# MIGraphX does not create the underlying folder and will crash if it does not exist
|
|
||||||
migraphx_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
options = {
|
|
||||||
"device_id": settings.device_id,
|
|
||||||
"migraphx_model_cache_dir": migraphx_dir.as_posix(),
|
|
||||||
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
|
|
||||||
}
|
|
||||||
case "OpenVINOExecutionProvider":
|
case "OpenVINOExecutionProvider":
|
||||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
options = {"device_type": self.openvino_device, "cache_dir": self.directory.as_posix()}
|
||||||
# Check for available devices, preferring GPU over CPU
|
if not self.half: # the GPU would otherwise run an fp32 graph at fp16
|
||||||
gpu_devices = [d for d in device_ids if d.startswith("GPU")]
|
options["precision"] = "FP32"
|
||||||
if gpu_devices:
|
|
||||||
device_type = f"GPU.{settings.device_id}"
|
|
||||||
log.debug(f"OpenVINO: Using GPU device {device_type}")
|
|
||||||
else:
|
|
||||||
device_type = "CPU"
|
|
||||||
log.debug("OpenVINO: No GPU found, using CPU")
|
|
||||||
options = {
|
|
||||||
"device_type": device_type,
|
|
||||||
"precision": settings.openvino_precision.value,
|
|
||||||
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
|
|
||||||
}
|
|
||||||
case "CoreMLExecutionProvider":
|
case "CoreMLExecutionProvider":
|
||||||
options = {
|
options = {
|
||||||
"ModelFormat": "MLProgram",
|
"ModelFormat": "MLProgram",
|
||||||
"MLComputeUnits": "ALL",
|
"MLComputeUnits": "ALL",
|
||||||
"SpecializationStrategy": "FastPrediction",
|
"SpecializationStrategy": "FastPrediction",
|
||||||
"AllowLowPrecisionAccumulationOnGPU": "1",
|
"AllowLowPrecisionAccumulationOnGPU": "1",
|
||||||
"ModelCacheDirectory": (self.model_path.parent / "coreml").as_posix(),
|
"ModelCacheDirectory": self.directory.as_posix(),
|
||||||
}
|
}
|
||||||
case _:
|
case _:
|
||||||
options = {}
|
options = {}
|
||||||
provider_options.append(options)
|
provider_options.append(options)
|
||||||
|
log.debug(f"Setting execution provider options to {provider_options}")
|
||||||
return provider_options
|
return provider_options
|
||||||
|
|
||||||
@property
|
|
||||||
def sess_options(self) -> ort.SessionOptions:
|
def sess_options(self) -> ort.SessionOptions:
|
||||||
return self._sess_options
|
|
||||||
|
|
||||||
@sess_options.setter
|
|
||||||
def sess_options(self, sess_options: ort.SessionOptions) -> None:
|
|
||||||
log.debug(f"Setting execution_mode to {sess_options.execution_mode.name}")
|
|
||||||
log.debug(f"Setting inter_op_num_threads to {sess_options.inter_op_num_threads}")
|
|
||||||
log.debug(f"Setting intra_op_num_threads to {sess_options.intra_op_num_threads}")
|
|
||||||
self._sess_options = sess_options
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _sess_options_default(self) -> ort.SessionOptions:
|
|
||||||
sess_options = ort.SessionOptions()
|
sess_options = ort.SessionOptions()
|
||||||
|
sess_options.enable_cpu_mem_arena = settings.model_arena
|
||||||
# some CPUs slow down many times over on subnormal operands, and ORT clears the flush in its threads otherwise
|
# some CPUs slow down many times over on subnormal operands, and ORT clears the flush in its threads otherwise
|
||||||
sess_options.add_session_config_entry("session.set_denormal_as_zero", "1")
|
sess_options.add_session_config_entry("session.set_denormal_as_zero", "1")
|
||||||
sess_options.enable_cpu_mem_arena = settings.model_arena
|
|
||||||
|
|
||||||
# avoid thread contention between models
|
# avoid thread contention between models
|
||||||
# Set inter_op threads
|
# Set inter_op threads
|
||||||
if settings.model_inter_op_threads > 0:
|
if settings.model_inter_op_threads > 0:
|
||||||
sess_options.inter_op_num_threads = settings.model_inter_op_threads
|
sess_options.inter_op_num_threads = settings.model_inter_op_threads
|
||||||
# these defaults work well for CPU, but bottleneck GPU
|
# these defaults work well for CPU, but bottleneck GPU
|
||||||
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
elif settings.model_inter_op_threads == 0 and self.provider == "CPUExecutionProvider":
|
||||||
sess_options.inter_op_num_threads = 1
|
sess_options.inter_op_num_threads = 1
|
||||||
|
|
||||||
# Set intra_op threads
|
# Set intra_op threads
|
||||||
if settings.model_intra_op_threads > 0:
|
if settings.model_intra_op_threads > 0:
|
||||||
sess_options.intra_op_num_threads = settings.model_intra_op_threads
|
sess_options.intra_op_num_threads = settings.model_intra_op_threads
|
||||||
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
elif settings.model_intra_op_threads == 0 and self.provider == "CPUExecutionProvider":
|
||||||
sess_options.intra_op_num_threads = self.threads
|
sess_options.intra_op_num_threads = self.threads
|
||||||
|
|
||||||
if sess_options.inter_op_num_threads > 1:
|
if sess_options.inter_op_num_threads > 1:
|
||||||
sess_options.execution_mode = ort.ExecutionMode.ORT_PARALLEL
|
sess_options.execution_mode = ort.ExecutionMode.ORT_PARALLEL
|
||||||
|
|
||||||
|
for dimension, size in self.overrides:
|
||||||
|
sess_options.add_free_dimension_override_by_name(dimension, size)
|
||||||
|
|
||||||
|
log.debug(f"Setting execution_mode to {sess_options.execution_mode.name}")
|
||||||
|
log.debug(f"Setting inter_op_num_threads to {sess_options.inter_op_num_threads}")
|
||||||
|
log.debug(f"Setting intra_op_num_threads to {sess_options.intra_op_num_threads}")
|
||||||
return sess_options
|
return sess_options
|
||||||
|
|
||||||
|
|
||||||
|
class Facts(BaseModel):
|
||||||
|
onnxruntime: str
|
||||||
|
options: dict[str, Any]
|
||||||
|
overrides: list[tuple[str, int]]
|
||||||
|
disabled_optimizers: list[str]
|
||||||
|
rewrite: str | None = None
|
||||||
|
device: str | None = None
|
||||||
|
half: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class Manifest(BaseModel):
|
||||||
|
facts: Facts
|
||||||
|
graph: str # relative to the artifact's directory
|
||||||
|
|
||||||
|
|
||||||
|
def fresh(spec: GraphSpec) -> Path | None:
|
||||||
|
try:
|
||||||
|
manifest = Manifest.model_validate_json(spec.manifest.read_bytes())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return spec.model_path.parent / manifest.graph if manifest.facts == spec.facts else None
|
||||||
|
|
||||||
|
|
||||||
|
def prepared(spec: GraphSpec) -> Path:
|
||||||
|
if (graph := fresh(spec)) is None:
|
||||||
|
log.info(f"Preparing {spec.model_path} for {spec.provider} {dict(spec.pins)}")
|
||||||
|
# child process prepares the graph to avoid memory overhead in the main process
|
||||||
|
child = subprocess.run([sys.executable, "-m", "immich_ml.sessions.prepare"], input=pickle.dumps(spec))
|
||||||
|
if (graph := fresh(spec)) is None:
|
||||||
|
if child.returncode == UNREADABLE:
|
||||||
|
raise InvalidProtobuf(f"{spec.model_path} could not be read")
|
||||||
|
raise RuntimeError(f"Preparing {spec.model_path} failed; the output above says why")
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _plan(provider: str) -> RewritePlan:
|
||||||
|
version = tuple(int(piece) for piece in ort.__version__.split(".")[:3])
|
||||||
|
return plan_rewrites(RewriteContext(target=provider, ort_version=version))
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _cpu() -> str:
|
||||||
|
try:
|
||||||
|
cpuinfo = Path("/proc/cpuinfo").read_text()
|
||||||
|
except OSError:
|
||||||
|
return platform.machine()
|
||||||
|
return next(line for line in cpuinfo.splitlines() if line.startswith(("flags", "Features")))
|
||||||
|
|
||||||
|
|
||||||
|
class OrtSession:
|
||||||
|
def __init__(
|
||||||
|
self, model_path: Path | str, policy: ShapePolicy, providers: list[str] | None = None, threads: int = 2
|
||||||
|
) -> None:
|
||||||
|
self.model_path = Path(model_path)
|
||||||
|
self.policy = policy
|
||||||
|
self.providers = providers if providers is not None else _providers_default()
|
||||||
|
log.info(f"Setting execution providers to {self.providers}, in descending order of preference")
|
||||||
|
self.disabled_optimizers = _disabled_optimizers_default(self.providers)
|
||||||
|
log.debug(f"Setting disabled_optimizers to {self.disabled_optimizers}")
|
||||||
|
self.threads = threads
|
||||||
|
self.dynamic = self.providers[0] in DYNAMIC_PROVIDERS
|
||||||
|
# the shapes to snap onto, where a dim a shape leaves out takes any size
|
||||||
|
self.shapes = (policy.pinned,) if self.dynamic else policy.dims
|
||||||
|
self.batches = tuple(sorted({shape.batch for shape in self.shapes}, reverse=True))
|
||||||
|
self.graphs: dict[Shape, OrtGraph] = {}
|
||||||
|
self.locks: dict[Shape, Lock] = {}
|
||||||
|
if self.dynamic or len(policy.dims) == 1:
|
||||||
|
self.for_shape(policy.dims[0]) # the one graph there is, so nothing waits on a shape
|
||||||
|
|
||||||
|
def for_shape(self, shape: Shape) -> OrtGraph:
|
||||||
|
if self.dynamic: # a batch gains nothing here, and a graph that knows it has one row optimizes further
|
||||||
|
shape = self.shapes[0]
|
||||||
|
if (graph := self.graphs.get(shape)) is not None:
|
||||||
|
return graph
|
||||||
|
if not self.dynamic and shape not in self.policy.dims: # every shape admitted is another graph to compile
|
||||||
|
raise ValueError(f"Asked for {shape}, which is none of {list(self.policy.dims)}")
|
||||||
|
with self.locks.setdefault(shape, Lock()): # requests arrive together for a graph none of them has
|
||||||
|
if (graph := self.graphs.get(shape)) is None:
|
||||||
|
log.debug(f"Building a graph for {shape}")
|
||||||
|
spec = GraphSpec(
|
||||||
|
self.model_path,
|
||||||
|
shape.pins,
|
||||||
|
_overrides(self.policy, shape.pins),
|
||||||
|
self.providers,
|
||||||
|
self.disabled_optimizers,
|
||||||
|
self.threads,
|
||||||
|
)
|
||||||
|
graph = self.graphs[shape] = OrtGraph(spec)
|
||||||
|
return graph
|
||||||
|
|
||||||
|
def warm(self) -> None:
|
||||||
|
"""Opens a graph for every shape and runs it once before opening the next: OpenVINO defers work to the first
|
||||||
|
run of a graph it imports from its cache, which importing another graph first can corrupt."""
|
||||||
|
for shape in self.policy.dims[:1] if self.dynamic else self.policy.dims:
|
||||||
|
session = self.for_shape(shape).session
|
||||||
|
sizes = dict(_overrides(self.policy, shape.pins)) # for a dim the graph leaves free
|
||||||
|
feed = {
|
||||||
|
node.name: np.zeros(
|
||||||
|
[dim if isinstance(dim, int) else sizes.get(dim, 1) for dim in node.shape], DTYPES[node.type]
|
||||||
|
)
|
||||||
|
for node in session.get_inputs()
|
||||||
|
}
|
||||||
|
session.run(None, feed)
|
||||||
|
|
||||||
|
|
||||||
|
def _overrides(policy: ShapePolicy, pins: Mapping[str, int]) -> list[tuple[str, int]]:
|
||||||
|
order = tuple(policy.dims[0].pins)
|
||||||
|
named = []
|
||||||
|
for dimension, size in pins.items():
|
||||||
|
position = order.index(dimension) # a graph that names several dims takes the first pin for all
|
||||||
|
named += [(dimension, size), (f"DynamicDimension.{position}", size)]
|
||||||
|
named.append(("None", size) if position == 0 else ("?", size))
|
||||||
|
return named
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _intel_gpu(device: str) -> Device:
|
||||||
|
"""The IP version and execution units OpenVINO keys its blobs by, and the driver it checks them against."""
|
||||||
|
ov = ctypes.CDLL(str(Path(ort.__file__).parent / "capi" / "libopenvino_c.so"))
|
||||||
|
core, wanted = ctypes.c_void_p(), ctypes.c_char_p()
|
||||||
|
ov.ov_core_create(ctypes.byref(core))
|
||||||
|
ov.ov_core_get_property(core, device.encode(), b"DEVICE_UUID", ctypes.byref(wanted))
|
||||||
|
uuid = wanted.value
|
||||||
|
ov.ov_free(wanted)
|
||||||
|
ov.ov_core_free(core)
|
||||||
|
cl, gpu = ctypes.CDLL("libOpenCL.so.1"), ctypes.c_uint64(1 << 2) # CL_DEVICE_TYPE_GPU
|
||||||
|
platforms, devices, count = (ctypes.c_void_p * 8)(), (ctypes.c_void_p * 8)(), ctypes.c_uint32()
|
||||||
|
cl.clGetPlatformIDs(8, platforms, ctypes.byref(count))
|
||||||
|
for vendor in platforms[: count.value]:
|
||||||
|
found = ctypes.c_uint32()
|
||||||
|
cl.clGetDeviceIDs(ctypes.c_void_p(vendor), gpu, 8, devices, ctypes.byref(found))
|
||||||
|
for handle in devices[: found.value]:
|
||||||
|
own, driver = (ctypes.c_ubyte * 16)(), ctypes.create_string_buffer(64)
|
||||||
|
ip, units = ctypes.c_uint32(), ctypes.c_uint32()
|
||||||
|
# CL_DEVICE_UUID_KHR, CL_DRIVER_VERSION, CL_DEVICE_IP_VERSION_INTEL, CL_DEVICE_MAX_COMPUTE_UNITS
|
||||||
|
for key, value in ((0x106A, own), (0x102D, driver), (0x4250, ip), (0x1002, units)):
|
||||||
|
cl.clGetDeviceInfo(ctypes.c_void_p(handle), key, ctypes.sizeof(value), ctypes.byref(value), None)
|
||||||
|
if bytes(own).hex().encode() == uuid:
|
||||||
|
version = f"{ip.value >> 22}.{ip.value >> 14 & 0xFF}.{ip.value & 0x3FFF}"
|
||||||
|
return Device(f"{version}-{units.value}eu", driver.value.decode())
|
||||||
|
raise LookupError(f"OpenCL has no GPU with the UUID of OpenVINO's {device}")
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _amd_gpu(index: int) -> Device:
|
||||||
|
"""The arch MIGraphX keys its programs by, and the MIGraphX and HIP it loads, which its keys leave out."""
|
||||||
|
# the provider finds ROCm through its runpath
|
||||||
|
ctypes.CDLL(str(Path(ort.__file__).parent / "capi" / "libonnxruntime_providers_migraphx.so"))
|
||||||
|
with open("/proc/self/maps") as maps:
|
||||||
|
loaded = {Path(path).name.split(".so")[0]: Path(path) for *_, path in map(str.split, maps) if ".so" in path}
|
||||||
|
hip, hsa = ctypes.CDLL(str(loaded["libamdhip64"])), ctypes.CDLL(str(loaded["libhsa-runtime64"]))
|
||||||
|
wanted, arches = ctypes.create_string_buffer(64), {}
|
||||||
|
hip.hipDeviceGetPCIBusId(wanted, 64, index) # honors HIP_VISIBLE_DEVICES, unlike HSA
|
||||||
|
|
||||||
|
def visit(agent: int, _: int | None) -> int:
|
||||||
|
name, bdf, domain = ctypes.create_string_buffer(64), ctypes.c_uint32(), ctypes.c_uint32()
|
||||||
|
# HSA_AGENT_INFO_NAME, HSA_AMD_AGENT_INFO_BDFID, HSA_AMD_AGENT_INFO_DOMAIN
|
||||||
|
for key, value in ((0, name), (0xA006, bdf), (0xA00F, domain)):
|
||||||
|
hsa.hsa_agent_get_info(ctypes.c_uint64(agent), key, ctypes.byref(value))
|
||||||
|
bus, device, function = bdf.value >> 8 & 0xFF, bdf.value >> 3 & 0x1F, bdf.value & 0x7
|
||||||
|
arches[f"{domain.value:04x}:{bus:02x}:{device:02x}.{function:01x}"] = name.value.decode()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
hsa.hsa_init()
|
||||||
|
hsa.hsa_iterate_agents(ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_uint64, ctypes.c_void_p)(visit), None)
|
||||||
|
return Device(arches[wanted.value.decode()], f"{loaded['libmigraphx'].name} {loaded['libamdhip64'].name}")
|
||||||
|
|
||||||
|
|
||||||
|
def flush_denormals() -> None:
|
||||||
|
"""Reads subnormal floats as zero on the calling thread, which ORT does only on its own threads."""
|
||||||
|
if sys.platform != "linux" or platform.machine() != "x86_64":
|
||||||
|
return
|
||||||
|
libm = ctypes.CDLL("libm.so.6")
|
||||||
|
env = (ctypes.c_uint32 * 8)() # glibc's fenv_t here: the x87 environment, then MXCSR
|
||||||
|
libm.fegetenv(env)
|
||||||
|
env[7] |= 0x8040 # denormals are zero, flush to zero
|
||||||
|
libm.fesetenv(env)
|
||||||
|
|
||||||
|
|
||||||
|
def _providers_default() -> list[str]:
|
||||||
|
from immich_ml.models.constants import SUPPORTED_PROVIDERS # here, as the preparing child has no use for the models
|
||||||
|
|
||||||
|
available_providers = set(ort.get_available_providers())
|
||||||
|
log.debug(f"Available ORT providers: {available_providers}")
|
||||||
|
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
||||||
|
|
||||||
|
|
||||||
def _disabled_optimizers_default(providers: list[str]) -> list[str]:
|
def _disabled_optimizers_default(providers: list[str]) -> list[str]:
|
||||||
disabled_optimizers: list[str] = []
|
disabled_optimizers: list[str] = []
|
||||||
if platform.machine() in ("arm64", "aarch64"): # as macOS and Linux name the same architecture
|
if platform.machine() in ("arm64", "aarch64"): # as macOS and Linux name the same architecture
|
||||||
@@ -226,14 +409,3 @@ def _disabled_optimizers_default(providers: list[str]) -> list[str]:
|
|||||||
disabled_optimizers.append("MatMulAddFusion")
|
disabled_optimizers.append("MatMulAddFusion")
|
||||||
|
|
||||||
return disabled_optimizers
|
return disabled_optimizers
|
||||||
|
|
||||||
|
|
||||||
def flush_denormals() -> None:
|
|
||||||
"""Reads subnormal floats as zero on the calling thread, which ORT does only on its own threads."""
|
|
||||||
if sys.platform != "linux" or platform.machine() != "x86_64":
|
|
||||||
return
|
|
||||||
libm = ctypes.CDLL("libm.so.6")
|
|
||||||
env = (ctypes.c_uint32 * 8)() # glibc's fenv_t here: the x87 environment, then MXCSR
|
|
||||||
libm.fegetenv(env)
|
|
||||||
env[7] |= 0x8040 # denormals are zero, flush to zero
|
|
||||||
libm.fesetenv(env)
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from immich_ml.schemas import Shape
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ShapePolicy:
|
||||||
|
dims: tuple[Shape, ...] = (Shape(batch=1),)
|
||||||
|
label: str = "" # the artifact subdirectory, for a submodel that ships more than one set
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pinned(self) -> Shape:
|
||||||
|
first = self.dims[0]
|
||||||
|
return Shape(
|
||||||
|
batch=1,
|
||||||
|
height=first.height if all(shape.height == first.height for shape in self.dims) else None,
|
||||||
|
width=first.width if all(shape.width == first.width for shape in self.dims) else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def batches(maximum: int) -> tuple[int, ...]:
|
||||||
|
return (1, maximum) if maximum > 1 else (1,)
|
||||||
|
|
||||||
|
|
||||||
|
def runs(total: int, batches: Sequence[int]) -> list[int]:
|
||||||
|
runs = []
|
||||||
|
while total > 0:
|
||||||
|
runs.append(max(size for size in batches if size <= total))
|
||||||
|
total -= runs[-1]
|
||||||
|
return runs
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import fcntl
|
||||||
|
import pickle
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from shutil import rmtree
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import onnx
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
from immich_model.onnx.f16 import derive
|
||||||
|
from immich_model.runtime import apply_rewrites
|
||||||
|
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from .ort import UNREADABLE, GraphSpec, Manifest, fresh
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
spec: GraphSpec = pickle.loads(sys.stdin.buffer.read())
|
||||||
|
with spec.model_path.with_suffix(".lock").open("w") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX) # several workers may want the same graph
|
||||||
|
if fresh(spec) is None:
|
||||||
|
try:
|
||||||
|
prepare(spec)
|
||||||
|
except (InvalidProtobuf, NoSuchFile, DecodeError):
|
||||||
|
sys.exit(UNREADABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(spec: GraphSpec) -> None:
|
||||||
|
rmtree(spec.directory, ignore_errors=True) # whatever is here was made under other facts, or not finished
|
||||||
|
spec.directory.mkdir(parents=True)
|
||||||
|
graph = rewritten(spec, narrowed(spec))
|
||||||
|
if spec.provider == "CPUExecutionProvider":
|
||||||
|
graph = prepacked(spec, graph) # allows mmap'ing the file instead of prepacking to dirty heap memory
|
||||||
|
elif spec.directory.as_posix() in spec.provider_options[0].values():
|
||||||
|
spec.session(graph) # the provider keeps what it compiles, in the directory it was given
|
||||||
|
manifest = Manifest(facts=spec.facts, graph=graph.relative_to(spec.model_path.parent).as_posix())
|
||||||
|
spec.manifest.write_text(manifest.model_dump_json()) # last, so that it means complete
|
||||||
|
|
||||||
|
|
||||||
|
def narrowed(spec: GraphSpec) -> Path:
|
||||||
|
if not spec.half:
|
||||||
|
return spec.model_path
|
||||||
|
destination = spec.directory / "model_fp16.onnx"
|
||||||
|
derive(spec.model_path, destination)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def rewritten(spec: GraphSpec, graph: Path) -> Path:
|
||||||
|
written: Path = graph if settings.legacy_models else apply_rewrites(graph, spec.plan)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def prepacked(spec: GraphSpec, graph: Path) -> Path:
|
||||||
|
destination = spec.directory / "model.onnx"
|
||||||
|
options = spec.sess_options()
|
||||||
|
options.optimized_model_filepath = destination.as_posix()
|
||||||
|
options.add_session_config_entry("session.optimized_model_external_initializers_file_name", "model.data")
|
||||||
|
options.add_session_config_entry("session.save_external_prepacked_constant_initializers", "1")
|
||||||
|
options.add_session_config_entry("session.optimized_model_external_initializers_min_size_in_bytes", "1024")
|
||||||
|
spec.session(graph, options)
|
||||||
|
align(destination)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def align(destination: Path) -> None:
|
||||||
|
"""Re-lays the data file so every region starts aligned. ORT pads only large regions, so one odd length skews
|
||||||
|
all that follow, and MLAS reads a packed weight with aligned loads: it faults, and reads the rest slower."""
|
||||||
|
model = onnx.load(destination.as_posix(), load_external_data=False)
|
||||||
|
length_at: dict[int, int] = {}
|
||||||
|
offsets: list[Any] = []
|
||||||
|
packs: list[tuple[Any, str, list[list[str]]]] = []
|
||||||
|
for tensor in model.graph.initializer:
|
||||||
|
fields = {entry.key: entry for entry in tensor.external_data}
|
||||||
|
for key, entry in fields.items():
|
||||||
|
if key == "offset":
|
||||||
|
length_at[int(entry.value)] = int(fields["length"].value)
|
||||||
|
offsets.append(entry)
|
||||||
|
elif key.startswith("prepacked_"):
|
||||||
|
blob_key, *rest = entry.value.split("|")
|
||||||
|
triples = [item.split(";") for item in rest]
|
||||||
|
length_at.update((int(offset), int(length)) for offset, length, _ in triples)
|
||||||
|
packs.append((entry, blob_key, triples))
|
||||||
|
|
||||||
|
moved, cursor = {}, 0
|
||||||
|
for offset in sorted(length_at):
|
||||||
|
cursor += -cursor % (4096 if length_at[offset] > 1 << 20 else 64)
|
||||||
|
moved[offset] = cursor
|
||||||
|
cursor += length_at[offset]
|
||||||
|
|
||||||
|
data = destination.with_suffix(".data")
|
||||||
|
with data.open("rb") as source, data.with_suffix(".aligned").open("wb") as target:
|
||||||
|
for offset in sorted(length_at):
|
||||||
|
source.seek(offset)
|
||||||
|
target.seek(moved[offset]) # what it skips reads as zeros
|
||||||
|
target.write(source.read(length_at[offset]))
|
||||||
|
data.with_suffix(".aligned").replace(data)
|
||||||
|
|
||||||
|
for entry in offsets:
|
||||||
|
entry.value = str(moved[int(entry.value)])
|
||||||
|
for entry, blob_key, triples in packs:
|
||||||
|
entry.value = "|".join([blob_key, *(f"{moved[int(o)]};{n};{c}" for o, n, c in triples)])
|
||||||
|
onnx.save(model, destination.as_posix())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,78 +1,84 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, NamedTuple
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import orjson
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from immich_ml.config import log, settings
|
from immich_ml.config import log, settings
|
||||||
from immich_ml.schemas import SessionNode
|
from immich_ml.schemas import ModelInput, ModelTensor, SessionNode, Shape
|
||||||
|
|
||||||
from .rknnpool import RknnPoolExecutor, is_available, soc_name
|
from .rknnpool import RknnNode, RknnPoolExecutor, is_available, native_outputs, soc_name
|
||||||
|
|
||||||
is_available = is_available and settings.rknn
|
is_available = is_available and settings.rknn
|
||||||
model_prefix = Path("rknpu") / soc_name if is_available and soc_name is not None else None
|
model_prefix = Path("rknpu") / soc_name if is_available and soc_name is not None else None
|
||||||
|
|
||||||
|
|
||||||
def run_inference(rknn_lite: Any, input: list[NDArray[np.float32]]) -> list[NDArray[np.float32]]:
|
def model_path(model_dir: Path, variant: str = "") -> Path:
|
||||||
outputs: list[NDArray[np.float32]] = rknn_lite.inference(inputs=input, data_format="nchw")
|
return (model_dir / model_prefix if model_prefix else model_dir) / variant / "model.rknn"
|
||||||
return outputs
|
|
||||||
|
|
||||||
|
|
||||||
input_output_mapping: dict[str, dict[str, Any]] = {
|
def run_inference(rknn_lite: Any, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[np.float32]]:
|
||||||
"detection": {
|
rknn_lite.rknn_runtime.set_inputs(inputs, None, data_format)
|
||||||
"input": {"norm_tensor:0": (1, 3, 640, 640)},
|
rknn_lite.rknn_runtime.run(False)
|
||||||
"output": {
|
return native_outputs(rknn_lite)
|
||||||
"norm_tensor:1": (12800, 1),
|
|
||||||
"norm_tensor:2": (3200, 1),
|
|
||||||
"norm_tensor:3": (800, 1),
|
def input_layout(compiled: tuple[int, ...], array: ModelTensor) -> str | None:
|
||||||
"norm_tensor:4": (12800, 4),
|
if array.ndim != 4:
|
||||||
"norm_tensor:5": (3200, 4),
|
return None # token ids carry no layout
|
||||||
"norm_tensor:6": (800, 4),
|
if array.shape[3] == compiled[3]:
|
||||||
"norm_tensor:7": (12800, 10),
|
return "nhwc"
|
||||||
"norm_tensor:8": (3200, 10),
|
if array.shape[1] == compiled[3]:
|
||||||
"norm_tensor:9": (800, 10),
|
return "nchw"
|
||||||
},
|
raise ValueError(f"binary takes {compiled[3]} channels, fed {list(array.shape)}")
|
||||||
},
|
|
||||||
"recognition": {"input": {"norm_tensor:0": (1, 3, 112, 112)}, "output": {"norm_tensor:1": (1, 512)}},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RknnSession:
|
class RknnSession:
|
||||||
def __init__(self, model_path: Path) -> None:
|
def __init__(self, model_path: Path) -> None:
|
||||||
self.model_type = "detection" if "detection" in model_path.parts else "recognition"
|
|
||||||
self.tpe = settings.rknn_threads
|
self.tpe = settings.rknn_threads
|
||||||
|
|
||||||
log.info(f"Loading RKNN model from {model_path} with {self.tpe} threads.")
|
log.info(f"Loading RKNN model from {model_path} with {self.tpe} threads.")
|
||||||
self.rknnpool = RknnPoolExecutor(model_path=model_path.as_posix(), tpes=self.tpe, func=run_inference)
|
self.rknnpool = RknnPoolExecutor(model_path=model_path.as_posix(), tpes=self.tpe, func=run_inference)
|
||||||
log.info(f"Loaded RKNN model from {model_path} with {self.tpe} threads.")
|
log.info(f"Loaded RKNN model from {model_path} with {self.tpe} threads.")
|
||||||
|
# the shapes the binary was compiled for, among which the runtime routes itself
|
||||||
|
batch = self.rknnpool.inputs[0].shape[0]
|
||||||
|
declared = orjson.loads(self.rknnpool.custom_string) if self.rknnpool.custom_string else {}
|
||||||
|
self.shapes = tuple(Shape(batch, **dims) for dims in declared.get("dims", [{}]))
|
||||||
|
self.metadata = {key: value for key, value in declared.items() if key != "dims"}
|
||||||
|
self.batches = (batch,)
|
||||||
|
|
||||||
def get_inputs(self) -> list[SessionNode]:
|
def for_shape(self, shape: Shape) -> RknnSession:
|
||||||
return [RknnNode(name=k, shape=v) for k, v in input_output_mapping[self.model_type]["input"].items()]
|
return self
|
||||||
|
|
||||||
def get_outputs(self) -> list[SessionNode]:
|
def warm(self) -> None:
|
||||||
return [RknnNode(name=k, shape=v) for k, v in input_output_mapping[self.model_type]["output"].items()]
|
pass # the binary is loaded whole when the session opens
|
||||||
|
|
||||||
|
def get_inputs(self) -> Sequence[SessionNode]:
|
||||||
|
return self.rknnpool.inputs
|
||||||
|
|
||||||
|
def get_outputs(self) -> Sequence[SessionNode]:
|
||||||
|
return self.rknnpool.outputs
|
||||||
|
|
||||||
def get_metadata(self) -> dict[str, str]:
|
def get_metadata(self) -> dict[str, str]:
|
||||||
return {}
|
return self.metadata
|
||||||
|
|
||||||
|
@property
|
||||||
|
def normalizes_input(self) -> bool:
|
||||||
|
# the compiler transposes and casts every binary, so only what the exporter declared can say
|
||||||
|
return bool(self.rknnpool.custom_string)
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
self,
|
self,
|
||||||
output_names: list[str] | None,
|
output_names: list[str] | None,
|
||||||
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]] | dict[str, NDArray[np.uint8]],
|
input_feed: ModelInput,
|
||||||
run_options: Any = None,
|
run_options: Any = None,
|
||||||
) -> list[NDArray[np.float32]]:
|
) -> list[NDArray[np.float32]]:
|
||||||
input_data: list[NDArray[np.float32]] = [np.ascontiguousarray(v) for v in input_feed.values()]
|
inputs = [array if array.flags.c_contiguous else array.copy() for array in input_feed.values()]
|
||||||
res = self.rknnpool.run(input_data)
|
return self.rknnpool.run(inputs, input_layout(self.rknnpool.inputs[0].shape, inputs[0]))
|
||||||
if res is None:
|
|
||||||
raise RuntimeError("RKNN inference failed!")
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class RknnNode(NamedTuple):
|
__all__ = ["RknnSession", "RknnNode", "is_available", "soc_name", "model_path"]
|
||||||
name: str
|
|
||||||
shape: tuple[int, ...]
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["RknnSession", "RknnNode", "is_available", "soc_name", "model_prefix"]
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
# This code is from leafqycc/rknn-multi-threaded
|
# This code is from leafqycc/rknn-multi-threaded
|
||||||
# Following Apache License 2.0
|
# Following Apache License 2.0
|
||||||
|
|
||||||
|
import ctypes
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Any, Callable, NamedTuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from immich_ml.config import log
|
from immich_ml.config import log
|
||||||
from immich_ml.models.constants import RKNN_COREMASK_SUPPORTED_SOCS, RKNN_SUPPORTED_SOCS
|
from immich_ml.models.constants import RKNN_COREMASK_SUPPORTED_SOCS, RKNN_SUPPORTED_SOCS
|
||||||
|
from immich_ml.schemas import ModelTensor
|
||||||
|
|
||||||
|
|
||||||
def get_soc(device_tree_path: Path | str) -> str | None:
|
def get_soc(device_tree_path: Path | str) -> str | None:
|
||||||
@@ -30,7 +32,7 @@ def get_soc(device_tree_path: Path | str) -> str | None:
|
|||||||
soc_name = None
|
soc_name = None
|
||||||
is_available = False
|
is_available = False
|
||||||
try:
|
try:
|
||||||
from rknnlite.api import RKNNLite
|
from rknnlite.api import RKNNLite, rknn_runtime
|
||||||
|
|
||||||
soc_name = get_soc("/proc/device-tree/compatible")
|
soc_name = get_soc("/proc/device-tree/compatible")
|
||||||
is_available = soc_name is not None
|
is_available = soc_name is not None
|
||||||
@@ -38,6 +40,82 @@ except ImportError:
|
|||||||
log.debug("RKNN is not available")
|
log.debug("RKNN is not available")
|
||||||
|
|
||||||
|
|
||||||
|
class RknnNode(NamedTuple):
|
||||||
|
name: str
|
||||||
|
shape: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
_CUSTOM_STRING_QUERY = 7 # RKNN_QUERY_CUSTOM_STRING
|
||||||
|
# RKNN_QUERY_OUTPUT_ATTR, and RKNN_QUERY_CURRENT_OUTPUT_ATTR for the shape a dynamic binary last produced
|
||||||
|
_OUTPUT_QUERY, _CURRENT_OUTPUT_QUERY = 2, 15
|
||||||
|
_DTYPES = {0: np.float32, 1: np.float16} # rknn_tensor_type
|
||||||
|
|
||||||
|
|
||||||
|
class _CustomString(ctypes.Structure):
|
||||||
|
_fields_ = [("string", ctypes.c_char * 1024)]
|
||||||
|
|
||||||
|
|
||||||
|
class _TensorAttr(ctypes.Structure): # rknn_tensor_attr
|
||||||
|
_fields_ = [
|
||||||
|
("index", ctypes.c_uint32),
|
||||||
|
("n_dims", ctypes.c_uint32),
|
||||||
|
("dims", ctypes.c_uint32 * 16),
|
||||||
|
("name", ctypes.c_char * 256),
|
||||||
|
("n_elems", ctypes.c_uint32),
|
||||||
|
("size", ctypes.c_uint32),
|
||||||
|
("fmt", ctypes.c_int),
|
||||||
|
("type", ctypes.c_int),
|
||||||
|
("qnt_type", ctypes.c_int),
|
||||||
|
("fl", ctypes.c_int8),
|
||||||
|
("zp", ctypes.c_int32),
|
||||||
|
("scale", ctypes.c_float),
|
||||||
|
("w_stride", ctypes.c_uint32),
|
||||||
|
("size_with_stride", ctypes.c_uint32),
|
||||||
|
("pass_through", ctypes.c_uint8),
|
||||||
|
("h_stride", ctypes.c_uint32),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def native_outputs(rknn_lite: "RKNNLite") -> list[NDArray[Any]]:
|
||||||
|
"""The outputs in the precision the NPU computed them in, which RKNNLite would widen to fp32 on the CPU."""
|
||||||
|
runtime: Any = rknn_lite.rknn_runtime
|
||||||
|
count = runtime.get_in_out_num()[1]
|
||||||
|
buffers = (rknn_runtime.RKNNOutput * count)()
|
||||||
|
for index, buffer in enumerate(buffers):
|
||||||
|
buffer.index = index
|
||||||
|
if runtime.lib.rknn_outputs_get(runtime.context, count, buffers, None) != 0:
|
||||||
|
raise RuntimeError("RKNN inference failed!")
|
||||||
|
query = _CURRENT_OUTPUT_QUERY if runtime.is_dynamic_shape else _OUTPUT_QUERY
|
||||||
|
outputs = []
|
||||||
|
for buffer in buffers:
|
||||||
|
attr = _TensorAttr(index=buffer.index)
|
||||||
|
runtime.lib.rknn_query(runtime.context, query, ctypes.byref(attr), ctypes.sizeof(attr))
|
||||||
|
data = ctypes.cast(buffer.buf, ctypes.POINTER(ctypes.c_char * buffer.size)).contents
|
||||||
|
outputs.append(np.frombuffer(data, _DTYPES[attr.type]).reshape(attr.dims[: attr.n_dims]).copy())
|
||||||
|
runtime.lib.rknn_outputs_release(runtime.context, count, buffers)
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def tensor_nodes(rknn_lite: "RKNNLite") -> tuple[list[RknnNode], list[RknnNode]]:
|
||||||
|
runtime = rknn_lite.rknn_runtime
|
||||||
|
n_inputs, n_outputs = runtime.get_in_out_num()
|
||||||
|
|
||||||
|
def node(index: int, is_output: bool) -> RknnNode:
|
||||||
|
attr = runtime.get_tensor_attr(index, is_output=is_output)
|
||||||
|
return RknnNode(attr.name.decode(), tuple(attr.dims[: attr.n_dims]))
|
||||||
|
|
||||||
|
return [node(i, False) for i in range(n_inputs)], [node(i, True) for i in range(n_outputs)]
|
||||||
|
|
||||||
|
|
||||||
|
def custom_string(rknn_lite: "RKNNLite") -> str:
|
||||||
|
runtime: Any = rknn_lite.rknn_runtime
|
||||||
|
buffer = _CustomString()
|
||||||
|
# rknnlite declares argtypes on the shared handle, so pass plain values and let ctypes convert
|
||||||
|
runtime.lib.rknn_query(runtime.context, _CUSTOM_STRING_QUERY, ctypes.byref(buffer), ctypes.sizeof(buffer))
|
||||||
|
declared: str = buffer.string.decode()
|
||||||
|
return declared
|
||||||
|
|
||||||
|
|
||||||
def init_rknn(model_path: str) -> "RKNNLite":
|
def init_rknn(model_path: str) -> "RKNNLite":
|
||||||
if not is_available:
|
if not is_available:
|
||||||
raise RuntimeError("rknn is not available!")
|
raise RuntimeError("rknn is not available!")
|
||||||
@@ -63,21 +141,23 @@ class RknnPoolExecutor:
|
|||||||
self,
|
self,
|
||||||
model_path: str,
|
model_path: str,
|
||||||
tpes: int,
|
tpes: int,
|
||||||
func: Callable[["RKNNLite", list[NDArray[np.float32]]], list[NDArray[np.float32]]],
|
func: Callable[["RKNNLite", list[ModelTensor], str | None], list[NDArray[np.float32]]],
|
||||||
) -> None:
|
) -> None:
|
||||||
self.tpes = tpes
|
self.tpes = tpes
|
||||||
self.rknn_pool = [init_rknn(model_path) for _ in range(tpes)]
|
self.rknn_pool = [init_rknn(model_path) for _ in range(tpes)]
|
||||||
|
self.inputs, self.outputs = tensor_nodes(self.rknn_pool[0])
|
||||||
|
self.custom_string = custom_string(self.rknn_pool[0])
|
||||||
self.pool = ThreadPoolExecutor(max_workers=tpes)
|
self.pool = ThreadPoolExecutor(max_workers=tpes)
|
||||||
self.func = func
|
self.func = func
|
||||||
self.num = 0
|
self.num = 0
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
def run(self, inputs: list[NDArray[np.float32]]) -> list[NDArray[np.float32]]:
|
def run(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[np.float32]]:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
idx = self.num % self.tpes
|
idx = self.num % self.tpes
|
||||||
self.num += 1
|
self.num += 1
|
||||||
|
|
||||||
fut = self.pool.submit(self.func, self.rknn_pool[idx], inputs)
|
fut = self.pool.submit(self.func, self.rknn_pool[idx], inputs, data_format)
|
||||||
return fut.result()
|
return fut.result()
|
||||||
|
|
||||||
def release(self) -> None:
|
def release(self) -> None:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ dependencies = [
|
|||||||
"fastapi>=0.95.2,<1.0",
|
"fastapi>=0.95.2,<1.0",
|
||||||
"gunicorn>=21.1.0",
|
"gunicorn>=21.1.0",
|
||||||
"huggingface-hub>=1.0,<2.0",
|
"huggingface-hub>=1.0,<2.0",
|
||||||
|
"immich-model",
|
||||||
"numpy>=2.4.0,<3.0",
|
"numpy>=2.4.0,<3.0",
|
||||||
"onnx>=1.22.0",
|
"onnx>=1.22.0",
|
||||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||||
@@ -58,6 +59,7 @@ compile-bytecode = true
|
|||||||
override-dependencies = ["opencv-python ; sys_platform == 'never'"] # only install the headless version
|
override-dependencies = ["opencv-python ; sys_platform == 'never'"] # only install the headless version
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
|
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "88d2b64ff95ce7117760660681d95e0e1308819b" }
|
||||||
onnxruntime-gpu = { index = "onnxruntime-cuda-12" }
|
onnxruntime-gpu = { index = "onnxruntime-cuda-12" }
|
||||||
|
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1073
-504
File diff suppressed because it is too large
Load Diff
Generated
+72
-3
@@ -682,6 +682,7 @@ dependencies = [
|
|||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
{ name = "huggingface-hub" },
|
{ name = "huggingface-hub" },
|
||||||
|
{ name = "immich-model" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "onnx" },
|
{ name = "onnx" },
|
||||||
{ name = "opencv-python-headless" },
|
{ name = "opencv-python-headless" },
|
||||||
@@ -762,6 +763,7 @@ requires-dist = [
|
|||||||
{ name = "fastapi", specifier = ">=0.95.2,<1.0" },
|
{ name = "fastapi", specifier = ">=0.95.2,<1.0" },
|
||||||
{ name = "gunicorn", specifier = ">=21.1.0" },
|
{ name = "gunicorn", specifier = ">=21.1.0" },
|
||||||
{ name = "huggingface-hub", specifier = ">=1.0,<2.0" },
|
{ name = "huggingface-hub", specifier = ">=1.0,<2.0" },
|
||||||
|
{ name = "immich-model", git = "https://github.com/immich-app/ml-models.git?rev=88d2b64ff95ce7117760660681d95e0e1308819b" },
|
||||||
{ name = "numpy", specifier = ">=2.4.0,<3.0" },
|
{ name = "numpy", specifier = ">=2.4.0,<3.0" },
|
||||||
{ name = "onnx", specifier = ">=1.22.0" },
|
{ name = "onnx", specifier = ">=1.22.0" },
|
||||||
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" },
|
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" },
|
||||||
@@ -824,6 +826,18 @@ types = [
|
|||||||
{ name = "types-ujson", specifier = ">=5.10.0.20240515" },
|
{ name = "types-ujson", specifier = ">=5.10.0.20240515" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "immich-model"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = { git = "https://github.com/immich-app/ml-models.git?rev=88d2b64ff95ce7117760660681d95e0e1308819b#88d2b64ff95ce7117760660681d95e0e1308819b" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "onnx" },
|
||||||
|
{ name = "onnx-ir" },
|
||||||
|
{ name = "onnxscript" },
|
||||||
|
{ name = "safetensors" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -1284,6 +1298,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/00/50/257a880384a1dd502d543b0067945074d63cd17d0840e958355bc8197da8/onnx-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8e268cdc0547e3949799ffd4a44451dc2b9080b57d0824a2db680b6ec65506f0", size = 17231391, upload-time = "2026-06-15T12:50:03.047Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/50/257a880384a1dd502d543b0067945074d63cd17d0840e958355bc8197da8/onnx-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8e268cdc0547e3949799ffd4a44451dc2b9080b57d0824a2db680b6ec65506f0", size = 17231391, upload-time = "2026-06-15T12:50:03.047Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "onnx-ir"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "ml-dtypes" },
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "onnx" },
|
||||||
|
{ name = "sympy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/c2/61194cec0dbc5622273c0ebd592d37cc1dca0d7f1a744f02edd45ac905a3/onnx_ir-1.0.0.tar.gz", hash = "sha256:9e261f25fde8da9612ae5cb43b3b374d5ff469c04af0363cad588b2bb000b812", size = 163121, upload-time = "2026-08-11T14:49:46.895Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/cd/6d1637172eb59c7b18ac90ed089d1f599a11fe0e63b4db2d017f3bb38a32/onnx_ir-1.0.0-py3-none-any.whl", hash = "sha256:e578f0d608d3062866b48223616eb2d10a6d6d01f8b8faac596129034f483cc7", size = 185849, upload-time = "2026-08-11T14:49:45.524Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "onnxruntime"
|
name = "onnxruntime"
|
||||||
version = "1.30.0"
|
version = "1.30.0"
|
||||||
@@ -1371,6 +1401,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "onnxscript"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "ml-dtypes" },
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "onnx" },
|
||||||
|
{ name = "onnx-ir" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0a/01/3e3fab8d643ca097ea4aa9e51246643699dfaaa0650589744fe44bc46651/onnxscript-0.7.2.tar.gz", hash = "sha256:2c664f6383d10f332a4d47b2876dcab16dba84909fe703656b19abc281fda165", size = 646719, upload-time = "2026-09-09T17:06:44.567Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/3b/06260997cdc41138e58718588a6c87d0eb342bbe0dda8a6aae91d163c384/onnxscript-0.7.2-py3-none-any.whl", hash = "sha256:d0e7121c6a1eefd608058928e111cbdb76709f70d269ff0d07aee493bd1d13c9", size = 754215, upload-time = "2026-09-09T17:06:46.442Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opencv-python"
|
name = "opencv-python"
|
||||||
version = "4.11.0.86"
|
version = "4.11.0.86"
|
||||||
@@ -2034,6 +2081,28 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "safetensors"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "setuptools"
|
name = "setuptools"
|
||||||
version = "80.9.0"
|
version = "80.9.0"
|
||||||
@@ -2123,14 +2192,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sympy"
|
name = "sympy"
|
||||||
version = "1.12"
|
version = "1.14.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mpmath" },
|
{ name = "mpmath" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/57/3485a1a3dff51bfd691962768b14310dae452431754bfc091250be50dd29/sympy-1.12.tar.gz", hash = "sha256:ebf595c8dac3e0fdc4152c51878b498396ec7f30e7a914d6071e674d49420fb8", size = 6722203, upload-time = "2023-05-10T18:23:00.378Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/05/e6600db80270777c4a64238a98d442f0fd07cc8915be2a1c16da7f2b9e74/sympy-1.12-py3-none-any.whl", hash = "sha256:c3588cd4295d0c0f603d0f2ae780587e64e2efeedb3521e46b9bb1d08d184fa5", size = 5742435, upload-time = "2023-05-10T18:22:14.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user