Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docker/dockerfiles/Dockerfile.onnx.cpu
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ RUN pip3 install "inference-models[model-blob-cache]"

RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then pip3 install -r requirements/requirements.vino.txt; rm -rf ~/.cache/pip; fi

# Pillow-SIMD (x86-64 only), beside Pillow under /opt so the wheel stays the `PIL` package.
RUN mkdir -p /opt/pillow_simd && if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
pip3 install --no-cache-dir --no-deps --target /opt/pillow_simd "pillow-simd @ git+https://github.com/uploadcare/pillow-simd.git@633de896bd7b493e03396d31e1a30f04a7a5ea80" && \
python3 docker/scripts/verify_pillow_simd.py; \
fi


WORKDIR /notebooks
COPY examples/notebooks .
Expand Down
6 changes: 6 additions & 0 deletions docker/dockerfiles/Dockerfile.onnx.cpu.dev
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ RUN pip3 install dist/inference_cli*.whl dist/inference_core*.whl dist/inference
RUN pip3 install watchdog[watchmedo]
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then pip3 install -r requirements/requirements.vino.txt; rm -rf ~/.cache/pip; fi

# Pillow-SIMD (x86-64 only), beside Pillow under /opt so the wheel stays the `PIL` package.
RUN mkdir -p /opt/pillow_simd && if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
pip3 install --no-cache-dir --no-deps --target /opt/pillow_simd "pillow-simd @ git+https://github.com/uploadcare/pillow-simd.git@633de896bd7b493e03396d31e1a30f04a7a5ea80" && \
python3 docker/scripts/verify_pillow_simd.py; \
fi


WORKDIR /notebooks
COPY examples/notebooks .
Expand Down
7 changes: 7 additions & 0 deletions docker/dockerfiles/Dockerfile.onnx.cu13.gpu
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,12 @@ RUN /bin/make create_wheels_for_gpu_notebook && \
dist/inference_sdk*.whl \
"setuptools~=83.0.0"

# Pillow-SIMD (x86-64 only), beside Pillow under /opt so the wheel stays the `PIL` package.
ARG TARGETARCH
RUN mkdir -p /opt/pillow_simd && if [ "${TARGETARCH}" = "amd64" ]; then \
python3 -m pip install --no-cache-dir --no-deps --target /opt/pillow_simd "pillow-simd @ git+https://github.com/uploadcare/pillow-simd.git@633de896bd7b493e03396d31e1a30f04a7a5ea80" && \
python3 docker/scripts/verify_pillow_simd.py; \
fi

RUN find /usr/local/lib/python3.12 -type d \
\( -name cv2 -o -name 'opencv_python.libs' -o -name 'opencv_contrib_python.libs' -o -name 'opencv_python_headless.libs' -o -name 'opencv_contrib_python_headless.libs' \) \
Expand Down Expand Up @@ -371,6 +377,7 @@ COPY --from=gstreamer-runtime /opt/gstreamer /opt/gstreamer
COPY --from=opencv-builder /opt/opencv-runtime /opt/opencv
COPY --from=gstreamer-cuda-tensor-bridge-builder /opt/roboflow/lib /opt/roboflow/lib
COPY --from=builder /usr/local/lib/python3.12 /usr/local/lib/python3.12
COPY --from=builder /opt/pillow_simd /opt/pillow_simd
COPY --from=builder /usr/local/bin /usr/local/bin

ENV PATH=/opt/gstreamer/bin:/opt/ffmpeg/bin:/usr/local/cuda/bin:$PATH \
Expand Down
8 changes: 8 additions & 0 deletions docker/dockerfiles/Dockerfile.onnx.gpu
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ RUN /bin/make create_wheels_for_gpu_notebook && \
dist/inference_sdk*.whl \
"setuptools~=83.0.0"

# Pillow-SIMD (x86-64 only), beside Pillow under /opt so the wheel stays the `PIL` package.
ARG TARGETARCH
RUN mkdir -p /opt/pillow_simd && if [ "${TARGETARCH}" = "amd64" ]; then \
python3 -m pip install --no-cache-dir --no-deps --target /opt/pillow_simd "pillow-simd @ git+https://github.com/uploadcare/pillow-simd.git@633de896bd7b493e03396d31e1a30f04a7a5ea80" && \
python3 docker/scripts/verify_pillow_simd.py; \
fi

RUN find /usr/local/lib/python3.10 -type d \
\( -name cv2 -o -name 'opencv_python.libs' -o -name 'opencv_contrib_python.libs' -o -name 'opencv_python_headless.libs' -o -name 'opencv_contrib_python_headless.libs' \) \
-prune -exec rm -rf {} + && \
Expand Down Expand Up @@ -350,6 +357,7 @@ COPY --from=gstreamer-runtime /opt/gstreamer /opt/gstreamer
COPY --from=opencv-builder /opt/opencv-runtime /opt/opencv
COPY --from=gstreamer-cuda-tensor-bridge-builder /opt/roboflow/lib /opt/roboflow/lib
COPY --from=builder /usr/local/lib/python3.10 /usr/local/lib/python3.10
COPY --from=builder /opt/pillow_simd /opt/pillow_simd
COPY --from=builder /usr/local/bin /usr/local/bin

ENV PATH=/opt/gstreamer/bin:/opt/ffmpeg/bin:/usr/local/cuda/bin:$PATH \
Expand Down
7 changes: 7 additions & 0 deletions docker/dockerfiles/Dockerfile.onnx.gpu.dev
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ RUN ln -s /usr/bin/python3 /usr/bin/python
RUN /bin/make create_wheels_for_gpu_notebook
RUN pip3 install --no-cache-dir dist/inference_cli*.whl dist/inference_core*.whl dist/inference_gpu*.whl dist/inference_sdk*.whl "setuptools~=83.0.0"

# Pillow-SIMD (x86-64 only), beside Pillow under /opt so the wheel stays the `PIL` package.
ARG TARGETARCH
RUN mkdir -p /opt/pillow_simd && if [ "${TARGETARCH}" = "amd64" ]; then \
pip3 install --no-cache-dir --no-deps --target /opt/pillow_simd "pillow-simd @ git+https://github.com/uploadcare/pillow-simd.git@633de896bd7b493e03396d31e1a30f04a7a5ea80" && \
python3 docker/scripts/verify_pillow_simd.py; \
fi

WORKDIR /notebooks
COPY examples/notebooks .

Expand Down
41 changes: 41 additions & 0 deletions docker/scripts/verify_pillow_simd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Build-time check: standard Pillow is intact and Pillow-SIMD loads beside it.

Standalone on purpose: the CPU image installs `inference-models` from PyPI, so
the loader inside the package is not available at every build.
"""

import importlib
import importlib.util
import os
import sys

import numpy as np
import PIL
import PIL.Image

ROOT = os.environ.get("INFERENCE_MODELS_PILLOW_SIMD_PATH", "/opt/pillow_simd")
PACKAGE_DIR = os.path.join(ROOT, "PIL")

major, minor = (int(part) for part in PIL.__version__.split(".")[:2])
assert (major, minor) >= (12, 3), f"standard Pillow replaced: {PIL.__version__}"

spec = importlib.util.spec_from_file_location(
"PILSIMD",
os.path.join(PACKAGE_DIR, "__init__.py"),
submodule_search_locations=[PACKAGE_DIR],
)
module = importlib.util.module_from_spec(spec)
sys.modules["PILSIMD"] = module
spec.loader.exec_module(module)
simd_image = importlib.import_module("PILSIMD.Image")
assert "post" in module.__version__, f"not a Pillow-SIMD build: {module.__version__}"
assert simd_image.core is not PIL.Image.core, "Pillow-SIMD shares the wheel's extension"

source = np.random.default_rng(0).integers(0, 256, (96, 128, 3), dtype=np.uint8)
simd_out = np.asarray(
simd_image.fromarray(source).resize((32, 32), simd_image.BILINEAR)
)
std_out = np.asarray(PIL.Image.fromarray(source).resize((32, 32), PIL.Image.BILINEAR))
max_diff = int(np.abs(simd_out.astype(np.int16) - std_out.astype(np.int16)).max())
assert max_diff <= 1, f"Pillow-SIMD resize differs from Pillow by {max_diff}"
print(f"Pillow {PIL.__version__} + Pillow-SIMD {module.__version__} at {ROOT}: ok")
7 changes: 7 additions & 0 deletions inference_models/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

### Added

- The RF-DETR numpy preprocessor resizes through Pillow-SIMD when a build is
present under `INFERENCE_MODELS_PILLOW_SIMD_PATH` (default `/opt/pillow_simd`),
loaded beside standard Pillow under its own package name. Standard Pillow
stays in use when the directory is absent or the build does not load.

### Fixed

- `YOLONasForObjectDetectionTRT` concatenated TRT outputs on the default CUDA stream without ordering against the post-processing stream, which could yield phantom detections under GPU contention. Concatenation now runs on the inference stream and is synchronised before post-processing.
Expand Down
42 changes: 42 additions & 0 deletions inference_models/inference_models/models/common/pillow_simd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""`Image` from a Pillow-SIMD build, loaded beside standard Pillow under its own package name.

Pillow-SIMD tracks an older Pillow release than the wheel the images ship, so
it must not replace `PIL`. The Docker images install it under
`/opt/pillow_simd`; importing this module loads that package as `PILSIMD` and
exposes only its `Image` module. The import raises `ModuleNotFoundError` when
no build is under the path and `ImportError` when the build does not load, so
a caller falls back with `except ImportError`.
"""

import importlib
import importlib.util
import os
import sys

from inference_models.logger import LOGGER

PILLOW_SIMD_PATH_ENV = "INFERENCE_MODELS_PILLOW_SIMD_PATH"
DEFAULT_PILLOW_SIMD_PATH = "/opt/pillow_simd"
PILLOW_SIMD_ALIAS = "PILSIMD"

_root = os.environ.get(PILLOW_SIMD_PATH_ENV, DEFAULT_PILLOW_SIMD_PATH)
_package_dir = os.path.join(_root, "PIL") if _root else ""
_init_path = os.path.join(_package_dir, "__init__.py")
if not _package_dir or not os.path.isfile(_init_path):
raise ModuleNotFoundError(f"No Pillow-SIMD build under {_root or '<empty path>'}")

try:
_spec = importlib.util.spec_from_file_location(
PILLOW_SIMD_ALIAS, _init_path, submodule_search_locations=[_package_dir]
)
_package = importlib.util.module_from_spec(_spec)
sys.modules[PILLOW_SIMD_ALIAS] = _package
_spec.loader.exec_module(_package)
Image = importlib.import_module(f"{PILLOW_SIMD_ALIAS}.Image")
except Exception as error:
for name in [m for m in sys.modules if m.split(".")[0] == PILLOW_SIMD_ALIAS]:
del sys.modules[name]
LOGGER.warning("Pillow-SIMD at %s did not load: %s", _root, error)
raise ImportError(f"Pillow-SIMD at {_root} did not load") from error

LOGGER.info("Pillow-SIMD %s loaded from %s", _package.__version__, _root)
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import numpy as np
import torch
import torchvision.transforms.functional as TF
from PIL import Image

from inference_models import PreProcessingOverrides
from inference_models.entities import ColorFormat, ImageDimensions
Expand Down Expand Up @@ -49,6 +48,11 @@
)
from inference_models.utils.environment import get_integer_from_env

try:
from inference_models.models.common.pillow_simd import Image
except ImportError: # no build under the path, or one that does not load on this host
from PIL import Image


def resolve_rfdetr_preprocessor_max_workers(max_workers: Optional[int] = None) -> int:
"""Resolve the explicit or environment-selected preprocessing worker limit.
Expand Down Expand Up @@ -337,7 +341,11 @@ def _pre_process_numpy(
static_crop_offset=static_crop_offset,
)

resized = TF.resize(pil, (target_size.height, target_size.width), antialias=True)
# Called on the image, not through torchvision: a Pillow-SIMD image is not
# a `PIL.Image.Image` instance, and the result is the same PIL bilinear resize.
resized = np.array(
pil.resize((target_size.width, target_size.height), Image.BILINEAR)
)
tensor = TF.to_tensor(resized)
tensor = _apply_normalization(tensor, network_input)
return tensor, meta
Expand Down
5 changes: 5 additions & 0 deletions inference_models/tests/unit_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import os

# The unit tests compare image pipelines against standard Pillow, so a
# Pillow-SIMD build on the host must not bind into the modules under test.
os.environ["INFERENCE_MODELS_PILLOW_SIMD_PATH"] = ""
136 changes: 136 additions & 0 deletions inference_models/tests/unit_tests/models/common/test_pillow_simd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import importlib
import os
import sys
import textwrap

import numpy as np
import PIL.Image
import pytest
import torch

from inference_models.models.common.roboflow.model_packages import (
ColorMode,
ImagePreProcessing,
NetworkInputDefinition,
ResizeMode,
TrainingInputSize,
)

LOADER = "inference_models.models.common.pillow_simd"
PREPROCESSOR = "inference_models.models.rfdetr.pre_processing"
PATH_ENV = "INFERENCE_MODELS_PILLOW_SIMD_PATH"
ALIAS = "PILSIMD"


def _forget_loaded_build():
sys.modules.pop(LOADER, None)
for name in [m for m in sys.modules if m.split(".")[0] == ALIAS]:
del sys.modules[name]


def _import_loader_fresh():
_forget_loaded_build()
return importlib.import_module(LOADER)


def _reload_preprocessor():
_forget_loaded_build()
return importlib.reload(importlib.import_module(PREPROCESSOR))


@pytest.fixture(autouse=True)
def _restore_modules(monkeypatch):
yield
# Leave the preprocessor bound to standard Pillow for the rest of the session.
monkeypatch.setenv(PATH_ENV, "")
_reload_preprocessor()


def _write_fake_pillow(root, version="12.2.0.post0", broken=False):
package_dir = os.path.join(root, "PIL")
os.makedirs(package_dir)
with open(os.path.join(package_dir, "__init__.py"), "w") as f:
f.write(f'__version__ = "{version}"\n')
with open(os.path.join(package_dir, "Image.py"), "w") as f:
body = "raise ImportError('no _imaging')\n" if broken else textwrap.dedent("""
import PIL.Image as _std
BILINEAR = _std.BILINEAR
FROMARRAY_CALLS = []

def fromarray(array):
FROMARRAY_CALLS.append(array.shape)
return _std.fromarray(array)
""")
f.write(body)


def test_import_raises_module_not_found_when_path_is_absent(monkeypatch, tmp_path):
monkeypatch.setenv(PATH_ENV, str(tmp_path / "missing"))

with pytest.raises(ModuleNotFoundError):
_import_loader_fresh()
assert ALIAS not in sys.modules


def test_import_raises_module_not_found_when_path_is_empty(monkeypatch):
monkeypatch.setenv(PATH_ENV, "")

with pytest.raises(ModuleNotFoundError):
_import_loader_fresh()


def test_import_raises_import_error_when_the_build_does_not_load(monkeypatch, tmp_path):
_write_fake_pillow(tmp_path, broken=True)
monkeypatch.setenv(PATH_ENV, str(tmp_path))

with pytest.raises(ImportError):
_import_loader_fresh()
assert ALIAS not in sys.modules


def test_import_loads_the_alias_beside_standard_pillow(monkeypatch, tmp_path):
_write_fake_pillow(tmp_path)
monkeypatch.setenv(PATH_ENV, str(tmp_path))

loader = _import_loader_fresh()

assert loader.Image is not PIL.Image
assert loader.Image.__name__ == f"{ALIAS}.Image"
assert sys.modules[ALIAS].__version__ == "12.2.0.post0"
assert PIL.Image.__name__ == "PIL.Image"


def test_rfdetr_preprocessor_binds_to_the_build_and_falls_back(monkeypatch, tmp_path):
network_input = NetworkInputDefinition(
training_input_size=TrainingInputSize(height=64, width=64),
dataset_version_resize_dimensions=None,
dynamic_spatial_size_supported=False,
color_mode=ColorMode.RGB,
resize_mode=ResizeMode.STRETCH_TO,
input_channels=3,
scaling_factor=255,
normalization=[[0.485, 0.456, 0.406], [0.229, 0.224, 0.225]],
)
rgb = np.random.default_rng(3).integers(0, 256, (160, 200, 3), dtype=np.uint8)

def run(preprocessor):
tensor, _ = preprocessor.pre_process_network_input(
images=rgb[:, :, ::-1].copy(),
image_pre_processing=ImagePreProcessing(),
network_input=network_input,
target_device=torch.device("cpu"),
input_color_format="bgr",
)
return tensor

_write_fake_pillow(tmp_path)
monkeypatch.setenv(PATH_ENV, str(tmp_path))
with_build = _reload_preprocessor()
assert with_build.Image.__name__ == f"{ALIAS}.Image"
tensor_with_build = run(with_build)
assert with_build.Image.FROMARRAY_CALLS == [(160, 200, 3)]

monkeypatch.setenv(PATH_ENV, "")
with_pillow = _reload_preprocessor()
assert with_pillow.Image is PIL.Image
torch.testing.assert_close(tensor_with_build, run(with_pillow), atol=0, rtol=0)
Loading