test_main.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import pickle
  2. from io import BytesIO
  3. from typing import TypeAlias
  4. from unittest import mock
  5. import cv2
  6. import numpy as np
  7. import onnxruntime as ort
  8. import pytest
  9. from fastapi.testclient import TestClient
  10. from PIL import Image
  11. from pytest_mock import MockerFixture
  12. from .config import settings
  13. from .models.base import PicklableSessionOptions
  14. from .models.cache import ModelCache
  15. from .models.clip import CLIPEncoder
  16. from .models.facial_recognition import FaceRecognizer
  17. from .models.image_classification import ImageClassifier
  18. from .schemas import ModelType
  19. ndarray: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
  20. class TestImageClassifier:
  21. classifier_preds = [
  22. {"label": "that's an image alright", "score": 0.8},
  23. {"label": "well it ends with .jpg", "score": 0.1},
  24. {"label": "idk, im just seeing bytes", "score": 0.05},
  25. {"label": "not sure", "score": 0.04},
  26. {"label": "probably a virus", "score": 0.01},
  27. ]
  28. def test_eager_init(self, mocker: MockerFixture) -> None:
  29. mocker.patch.object(ImageClassifier, "download")
  30. mock_load = mocker.patch.object(ImageClassifier, "load")
  31. classifier = ImageClassifier("test_model_name", cache_dir="test_cache", eager=True, test_arg="test_arg")
  32. assert classifier.model_name == "test_model_name"
  33. mock_load.assert_called_once_with(test_arg="test_arg")
  34. def test_lazy_init(self, mocker: MockerFixture) -> None:
  35. mock_download = mocker.patch.object(ImageClassifier, "download")
  36. mock_load = mocker.patch.object(ImageClassifier, "load")
  37. face_model = ImageClassifier("test_model_name", cache_dir="test_cache", eager=False, test_arg="test_arg")
  38. assert face_model.model_name == "test_model_name"
  39. mock_download.assert_called_once_with(test_arg="test_arg")
  40. mock_load.assert_not_called()
  41. def test_min_score(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
  42. mocker.patch.object(ImageClassifier, "load")
  43. classifier = ImageClassifier("test_model_name", min_score=0.0)
  44. assert classifier.min_score == 0.0
  45. classifier.model = mock.Mock()
  46. classifier.model.return_value = self.classifier_preds
  47. all_labels = classifier.predict(pil_image)
  48. classifier.min_score = 0.5
  49. filtered_labels = classifier.predict(pil_image)
  50. assert all_labels == [
  51. "that's an image alright",
  52. "well it ends with .jpg",
  53. "idk",
  54. "im just seeing bytes",
  55. "not sure",
  56. "probably a virus",
  57. ]
  58. assert filtered_labels == ["that's an image alright"]
  59. class TestCLIP:
  60. embedding = np.random.rand(512).astype(np.float32)
  61. def test_eager_init(self, mocker: MockerFixture) -> None:
  62. mocker.patch.object(CLIPEncoder, "download")
  63. mock_load = mocker.patch.object(CLIPEncoder, "load")
  64. clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=True, test_arg="test_arg")
  65. assert clip_model.model_name == "ViT-B-32::openai"
  66. mock_load.assert_called_once_with(test_arg="test_arg")
  67. def test_lazy_init(self, mocker: MockerFixture) -> None:
  68. mock_download = mocker.patch.object(CLIPEncoder, "download")
  69. mock_load = mocker.patch.object(CLIPEncoder, "load")
  70. clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=False, test_arg="test_arg")
  71. assert clip_model.model_name == "ViT-B-32::openai"
  72. mock_download.assert_called_once_with(test_arg="test_arg")
  73. mock_load.assert_not_called()
  74. def test_basic_image(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
  75. mocker.patch.object(CLIPEncoder, "download")
  76. mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
  77. mocked.return_value.run.return_value = [[self.embedding]]
  78. clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="vision")
  79. assert clip_encoder.mode == "vision"
  80. embedding = clip_encoder.predict(pil_image)
  81. assert isinstance(embedding, list)
  82. assert len(embedding) == 512
  83. assert all([isinstance(num, float) for num in embedding])
  84. clip_encoder.vision_model.run.assert_called_once()
  85. def test_basic_text(self, mocker: MockerFixture) -> None:
  86. mocker.patch.object(CLIPEncoder, "download")
  87. mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
  88. mocked.return_value.run.return_value = [[self.embedding]]
  89. clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="text")
  90. assert clip_encoder.mode == "text"
  91. embedding = clip_encoder.predict("test search query")
  92. assert isinstance(embedding, list)
  93. assert len(embedding) == 512
  94. assert all([isinstance(num, float) for num in embedding])
  95. clip_encoder.text_model.run.assert_called_once()
  96. class TestFaceRecognition:
  97. def test_eager_init(self, mocker: MockerFixture) -> None:
  98. mocker.patch.object(FaceRecognizer, "download")
  99. mock_load = mocker.patch.object(FaceRecognizer, "load")
  100. face_model = FaceRecognizer("test_model_name", cache_dir="test_cache", eager=True, test_arg="test_arg")
  101. assert face_model.model_name == "test_model_name"
  102. mock_load.assert_called_once_with(test_arg="test_arg")
  103. def test_lazy_init(self, mocker: MockerFixture) -> None:
  104. mock_download = mocker.patch.object(FaceRecognizer, "download")
  105. mock_load = mocker.patch.object(FaceRecognizer, "load")
  106. face_model = FaceRecognizer("test_model_name", cache_dir="test_cache", eager=False, test_arg="test_arg")
  107. assert face_model.model_name == "test_model_name"
  108. mock_download.assert_called_once_with(test_arg="test_arg")
  109. mock_load.assert_not_called()
  110. def test_set_min_score(self, mocker: MockerFixture) -> None:
  111. mocker.patch.object(FaceRecognizer, "load")
  112. face_recognizer = FaceRecognizer("test_model_name", cache_dir="test_cache", min_score=0.5)
  113. assert face_recognizer.min_score == 0.5
  114. def test_basic(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None:
  115. mocker.patch.object(FaceRecognizer, "load")
  116. face_recognizer = FaceRecognizer("test_model_name", min_score=0.0, cache_dir="test_cache")
  117. det_model = mock.Mock()
  118. num_faces = 2
  119. bbox = np.random.rand(num_faces, 4).astype(np.float32)
  120. score = np.array([[0.67]] * num_faces).astype(np.float32)
  121. kpss = np.random.rand(num_faces, 5, 2).astype(np.float32)
  122. det_model.detect.return_value = (np.concatenate([bbox, score], axis=-1), kpss)
  123. face_recognizer.det_model = det_model
  124. rec_model = mock.Mock()
  125. embedding = np.random.rand(num_faces, 512).astype(np.float32)
  126. rec_model.get_feat.return_value = embedding
  127. face_recognizer.rec_model = rec_model
  128. faces = face_recognizer.predict(cv_image)
  129. assert len(faces) == num_faces
  130. for face in faces:
  131. assert face["imageHeight"] == 800
  132. assert face["imageWidth"] == 600
  133. assert isinstance(face["embedding"], list)
  134. assert len(face["embedding"]) == 512
  135. assert all([isinstance(num, float) for num in face["embedding"]])
  136. det_model.detect.assert_called_once()
  137. assert rec_model.get_feat.call_count == num_faces
  138. @pytest.mark.asyncio
  139. class TestCache:
  140. async def test_caches(self, mock_get_model: mock.Mock) -> None:
  141. model_cache = ModelCache()
  142. await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
  143. await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
  144. assert len(model_cache.cache._cache) == 1
  145. mock_get_model.assert_called_once()
  146. async def test_kwargs_used(self, mock_get_model: mock.Mock) -> None:
  147. model_cache = ModelCache()
  148. await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION, cache_dir="test_cache")
  149. mock_get_model.assert_called_once_with(
  150. ModelType.IMAGE_CLASSIFICATION, "test_model_name", cache_dir="test_cache"
  151. )
  152. async def test_different_clip(self, mock_get_model: mock.Mock) -> None:
  153. model_cache = ModelCache()
  154. await model_cache.get("test_image_model_name", ModelType.CLIP)
  155. await model_cache.get("test_text_model_name", ModelType.CLIP)
  156. mock_get_model.assert_has_calls(
  157. [
  158. mock.call(ModelType.CLIP, "test_image_model_name"),
  159. mock.call(ModelType.CLIP, "test_text_model_name"),
  160. ]
  161. )
  162. assert len(model_cache.cache._cache) == 2
  163. @mock.patch("app.models.cache.OptimisticLock", autospec=True)
  164. async def test_model_ttl(self, mock_lock_cls: mock.Mock, mock_get_model: mock.Mock) -> None:
  165. model_cache = ModelCache(ttl=100)
  166. await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
  167. mock_lock_cls.return_value.__aenter__.return_value.cas.assert_called_with(mock.ANY, ttl=100)
  168. @mock.patch("app.models.cache.SimpleMemoryCache.expire")
  169. async def test_revalidate(self, mock_cache_expire: mock.Mock, mock_get_model: mock.Mock) -> None:
  170. model_cache = ModelCache(ttl=100, revalidate=True)
  171. await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
  172. await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
  173. mock_cache_expire.assert_called_once_with(mock.ANY, 100)
  174. @pytest.mark.skipif(
  175. not settings.test_full,
  176. reason="More time-consuming since it deploys the app and loads models.",
  177. )
  178. class TestEndpoints:
  179. def test_tagging_endpoint(self, pil_image: Image.Image, deployed_app: TestClient) -> None:
  180. byte_image = BytesIO()
  181. pil_image.save(byte_image, format="jpeg")
  182. headers = {"Content-Type": "image/jpg"}
  183. response = deployed_app.post(
  184. "http://localhost:3003/image-classifier/tag-image",
  185. content=byte_image.getvalue(),
  186. headers=headers,
  187. )
  188. assert response.status_code == 200
  189. def test_clip_image_endpoint(self, pil_image: Image.Image, deployed_app: TestClient) -> None:
  190. byte_image = BytesIO()
  191. pil_image.save(byte_image, format="jpeg")
  192. headers = {"Content-Type": "image/jpg"}
  193. response = deployed_app.post(
  194. "http://localhost:3003/sentence-transformer/encode-image",
  195. content=byte_image.getvalue(),
  196. headers=headers,
  197. )
  198. assert response.status_code == 200
  199. def test_clip_text_endpoint(self, deployed_app: TestClient) -> None:
  200. response = deployed_app.post(
  201. "http://localhost:3003/sentence-transformer/encode-text",
  202. json={"text": "test search query"},
  203. )
  204. assert response.status_code == 200
  205. def test_face_endpoint(self, pil_image: Image.Image, deployed_app: TestClient) -> None:
  206. byte_image = BytesIO()
  207. pil_image.save(byte_image, format="jpeg")
  208. headers = {"Content-Type": "image/jpg"}
  209. response = deployed_app.post(
  210. "http://localhost:3003/facial-recognition/detect-faces",
  211. content=byte_image.getvalue(),
  212. headers=headers,
  213. )
  214. assert response.status_code == 200
  215. def test_sess_options() -> None:
  216. sess_options = PicklableSessionOptions()
  217. sess_options.intra_op_num_threads = 1
  218. sess_options.inter_op_num_threads = 1
  219. pickled = pickle.dumps(sess_options)
  220. unpickled = pickle.loads(pickled)
  221. assert unpickled.intra_op_num_threads == 1
  222. assert unpickled.inter_op_num_threads == 1