From 87238705dda58a691644327bfc3376cf0a796822 Mon Sep 17 00:00:00 2001 From: kinaryml Date: Thu, 17 Nov 2022 14:03:07 -0800 Subject: [PATCH 001/136] Updated cosine similarity utility --- .../components/utils/cosine_similarity.py | 15 +- .../python/test/vision/image_embedder_test.py | 454 +++++++++--------- 2 files changed, 235 insertions(+), 234 deletions(-) diff --git a/mediapipe/tasks/python/components/utils/cosine_similarity.py b/mediapipe/tasks/python/components/utils/cosine_similarity.py index 486c02ece..735b32910 100644 --- a/mediapipe/tasks/python/components/utils/cosine_similarity.py +++ b/mediapipe/tasks/python/components/utils/cosine_similarity.py @@ -22,20 +22,20 @@ _Embedding = embedding_result.Embedding _EmbedderOptions = embedder_options.EmbedderOptions -def _compute_cosine_similarity(u, v): +def _compute_cosine_similarity(u: np.ndarray, v: np.ndarray): """Computes cosine similarity between two embeddings.""" - if len(u.embedding) <= 0: + if len(u) <= 0: raise ValueError("Cannot compute cosing similarity on empty embeddings.") - norm_u = np.linalg.norm(u.embedding) - norm_v = np.linalg.norm(v.embedding) + norm_u = np.linalg.norm(u) + norm_v = np.linalg.norm(v) if norm_u <= 0 or norm_v <= 0: raise ValueError( "Cannot compute cosine similarity on embedding with 0 norm.") - return np.dot(u.embedding, v.embedding.T) / (norm_u * norm_v) + return u.dot(v) / (norm_u * norm_v) def cosine_similarity(u: _Embedding, v: _Embedding) -> float: @@ -58,10 +58,11 @@ def cosine_similarity(u: _Embedding, v: _Embedding) -> float: f"({len(u.embedding)} vs. {len(v.embedding)}).") if u.embedding.dtype == float and v.embedding.dtype == float: - return _compute_cosine_similarity(u, v) + return _compute_cosine_similarity(u.embedding, v.embedding) if u.embedding.dtype == np.uint8 and v.embedding.dtype == np.uint8: - return _compute_cosine_similarity(u, v) + return _compute_cosine_similarity(u.embedding.astype('float'), + v.embedding.astype('float')) raise ValueError("Cannot compute cosine similarity between quantized and " "float embeddings.") diff --git a/mediapipe/tasks/python/test/vision/image_embedder_test.py b/mediapipe/tasks/python/test/vision/image_embedder_test.py index 4bb96bad6..8664ed305 100644 --- a/mediapipe/tasks/python/test/vision/image_embedder_test.py +++ b/mediapipe/tasks/python/test/vision/image_embedder_test.py @@ -125,8 +125,8 @@ class ImageEmbedderTest(parameterized.TestCase): (-0.2101883, -0.193027)), (True, False, False, ModelFileType.FILE_NAME, 0.925519, 1024, (-0.0142344, -0.0131606)), - # (False, True, False, ModelFileType.FILE_NAME, - # 0.926791, 1024, (229, 231)), + (False, True, False, ModelFileType.FILE_NAME, + 0.906201, 1024, (229, 231)), (False, False, True, ModelFileType.FILE_CONTENT, 0.999931, 1024, (-0.195062, -0.193027))) def test_embed(self, l2_normalize, quantize, with_roi, model_file_type, @@ -169,231 +169,231 @@ class ImageEmbedderTest(parameterized.TestCase): # Closes the embedder explicitly when the embedder is not used in # a context. embedder.close() - - @parameterized.parameters( - (False, False, ModelFileType.FILE_NAME, 0.925519), - (False, False, ModelFileType.FILE_CONTENT, 0.925519)) - def test_embed_in_context(self, l2_normalize, quantize, model_file_type, - expected_similarity): - # Creates embedder. - if model_file_type is ModelFileType.FILE_NAME: - base_options = _BaseOptions(model_asset_path=self.model_path) - elif model_file_type is ModelFileType.FILE_CONTENT: - with open(self.model_path, 'rb') as f: - model_content = f.read() - base_options = _BaseOptions(model_asset_buffer=model_content) - else: - # Should never happen - raise ValueError('model_file_type is invalid.') - - embedder_options = _EmbedderOptions( - l2_normalize=l2_normalize, quantize=quantize) - options = _ImageEmbedderOptions( - base_options=base_options, embedder_options=embedder_options) - - with _ImageEmbedder.create_from_options(options) as embedder: - # Extracts both embeddings. - image_result = embedder.embed(self.test_image) - crop_result = embedder.embed(self.test_cropped_image) - - # Checks cosine similarity. - self._check_cosine_similarity(image_result, crop_result, - expected_similarity) - - def test_missing_result_callback(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.LIVE_STREAM) - with self.assertRaisesRegex(ValueError, - r'result callback must be provided'): - with _ImageEmbedder.create_from_options(options) as unused_embedder: - pass - - @parameterized.parameters((_RUNNING_MODE.IMAGE), (_RUNNING_MODE.VIDEO)) - def test_illegal_result_callback(self, running_mode): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=running_mode, - result_callback=mock.MagicMock()) - with self.assertRaisesRegex(ValueError, - r'result callback should not be provided'): - with _ImageEmbedder.create_from_options(options) as unused_embedder: - pass - - def test_calling_embed_for_video_in_image_mode(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.IMAGE) - with _ImageEmbedder.create_from_options(options) as embedder: - with self.assertRaisesRegex(ValueError, - r'not initialized with the video mode'): - embedder.embed_for_video(self.test_image, 0) - - def test_calling_embed_async_in_image_mode(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.IMAGE) - with _ImageEmbedder.create_from_options(options) as embedder: - with self.assertRaisesRegex(ValueError, - r'not initialized with the live stream mode'): - embedder.embed_async(self.test_image, 0) - - def test_calling_embed_in_video_mode(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.VIDEO) - with _ImageEmbedder.create_from_options(options) as embedder: - with self.assertRaisesRegex(ValueError, - r'not initialized with the image mode'): - embedder.embed(self.test_image) - - def test_calling_embed_async_in_video_mode(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.VIDEO) - with _ImageEmbedder.create_from_options(options) as embedder: - with self.assertRaisesRegex(ValueError, - r'not initialized with the live stream mode'): - embedder.embed_async(self.test_image, 0) - - def test_embed_for_video_with_out_of_order_timestamp(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.VIDEO) - with _ImageEmbedder.create_from_options(options) as embedder: - unused_result = embedder.embed_for_video(self.test_image, 1) - with self.assertRaisesRegex( - ValueError, r'Input timestamp must be monotonically increasing'): - embedder.embed_for_video(self.test_image, 0) - - def test_embed_for_video(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.VIDEO) - with _ImageEmbedder.create_from_options(options) as embedder0, \ - _ImageEmbedder.create_from_options(options) as embedder1: - for timestamp in range(0, 300, 30): - # Extracts both embeddings. - image_result = embedder0.embed_for_video(self.test_image, timestamp) - crop_result = embedder1.embed_for_video(self.test_cropped_image, - timestamp) - # Checks cosine similarity. - self._check_cosine_similarity( - image_result, crop_result, expected_similarity=0.925519) - - def test_embed_for_video_succeeds_with_region_of_interest(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.VIDEO) - with _ImageEmbedder.create_from_options(options) as embedder0, \ - _ImageEmbedder.create_from_options(options) as embedder1: - # Region-of-interest in "burger.jpg" corresponding to "burger_crop.jpg". - roi = _Rect(left=0, top=0, right=0.833333, bottom=1) - image_processing_options = _ImageProcessingOptions(roi) - - for timestamp in range(0, 300, 30): - # Extracts both embeddings. - image_result = embedder0.embed_for_video(self.test_image, timestamp, - image_processing_options) - crop_result = embedder1.embed_for_video(self.test_cropped_image, - timestamp) - - # Checks cosine similarity. - self._check_cosine_similarity( - image_result, crop_result, expected_similarity=0.999931) - - def test_calling_embed_in_live_stream_mode(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.LIVE_STREAM, - result_callback=mock.MagicMock()) - with _ImageEmbedder.create_from_options(options) as embedder: - with self.assertRaisesRegex(ValueError, - r'not initialized with the image mode'): - embedder.embed(self.test_image) - - def test_calling_embed_for_video_in_live_stream_mode(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.LIVE_STREAM, - result_callback=mock.MagicMock()) - with _ImageEmbedder.create_from_options(options) as embedder: - with self.assertRaisesRegex(ValueError, - r'not initialized with the video mode'): - embedder.embed_for_video(self.test_image, 0) - - def test_embed_async_calls_with_illegal_timestamp(self): - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.LIVE_STREAM, - result_callback=mock.MagicMock()) - with _ImageEmbedder.create_from_options(options) as embedder: - embedder.embed_async(self.test_image, 100) - with self.assertRaisesRegex( - ValueError, r'Input timestamp must be monotonically increasing'): - embedder.embed_async(self.test_image, 0) - - def test_embed_async_calls(self): - # Get the embedding result for the cropped image. - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.IMAGE) - with _ImageEmbedder.create_from_options(options) as embedder: - crop_result = embedder.embed(self.test_cropped_image) - - observed_timestamp_ms = -1 - - def check_result(result: _ImageEmbedderResult, output_image: _Image, - timestamp_ms: int): - # Checks cosine similarity. - self._check_cosine_similarity( - result, crop_result, expected_similarity=0.925519) - self.assertTrue( - np.array_equal(output_image.numpy_view(), - self.test_image.numpy_view())) - self.assertLess(observed_timestamp_ms, timestamp_ms) - self.observed_timestamp_ms = timestamp_ms - - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.LIVE_STREAM, - result_callback=check_result) - with _ImageEmbedder.create_from_options(options) as embedder: - for timestamp in range(0, 300, 30): - embedder.embed_async(self.test_image, timestamp) - - def test_embed_async_succeeds_with_region_of_interest(self): - # Get the embedding result for the cropped image. - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.IMAGE) - with _ImageEmbedder.create_from_options(options) as embedder: - crop_result = embedder.embed(self.test_cropped_image) - - # Region-of-interest in "burger.jpg" corresponding to "burger_crop.jpg". - roi = _Rect(left=0, top=0, right=0.833333, bottom=1) - image_processing_options = _ImageProcessingOptions(roi) - observed_timestamp_ms = -1 - - def check_result(result: _ImageEmbedderResult, output_image: _Image, - timestamp_ms: int): - # Checks cosine similarity. - self._check_cosine_similarity( - result, crop_result, expected_similarity=0.999931) - self.assertTrue( - np.array_equal(output_image.numpy_view(), - self.test_image.numpy_view())) - self.assertLess(observed_timestamp_ms, timestamp_ms) - self.observed_timestamp_ms = timestamp_ms - - options = _ImageEmbedderOptions( - base_options=_BaseOptions(model_asset_path=self.model_path), - running_mode=_RUNNING_MODE.LIVE_STREAM, - result_callback=check_result) - with _ImageEmbedder.create_from_options(options) as embedder: - for timestamp in range(0, 300, 30): - embedder.embed_async(self.test_image, timestamp, - image_processing_options) + # + # @parameterized.parameters( + # (False, False, ModelFileType.FILE_NAME, 0.925519), + # (False, False, ModelFileType.FILE_CONTENT, 0.925519)) + # def test_embed_in_context(self, l2_normalize, quantize, model_file_type, + # expected_similarity): + # # Creates embedder. + # if model_file_type is ModelFileType.FILE_NAME: + # base_options = _BaseOptions(model_asset_path=self.model_path) + # elif model_file_type is ModelFileType.FILE_CONTENT: + # with open(self.model_path, 'rb') as f: + # model_content = f.read() + # base_options = _BaseOptions(model_asset_buffer=model_content) + # else: + # # Should never happen + # raise ValueError('model_file_type is invalid.') + # + # embedder_options = _EmbedderOptions( + # l2_normalize=l2_normalize, quantize=quantize) + # options = _ImageEmbedderOptions( + # base_options=base_options, embedder_options=embedder_options) + # + # with _ImageEmbedder.create_from_options(options) as embedder: + # # Extracts both embeddings. + # image_result = embedder.embed(self.test_image) + # crop_result = embedder.embed(self.test_cropped_image) + # + # # Checks cosine similarity. + # self._check_cosine_similarity(image_result, crop_result, + # expected_similarity) + # + # def test_missing_result_callback(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.LIVE_STREAM) + # with self.assertRaisesRegex(ValueError, + # r'result callback must be provided'): + # with _ImageEmbedder.create_from_options(options) as unused_embedder: + # pass + # + # @parameterized.parameters((_RUNNING_MODE.IMAGE), (_RUNNING_MODE.VIDEO)) + # def test_illegal_result_callback(self, running_mode): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=running_mode, + # result_callback=mock.MagicMock()) + # with self.assertRaisesRegex(ValueError, + # r'result callback should not be provided'): + # with _ImageEmbedder.create_from_options(options) as unused_embedder: + # pass + # + # def test_calling_embed_for_video_in_image_mode(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.IMAGE) + # with _ImageEmbedder.create_from_options(options) as embedder: + # with self.assertRaisesRegex(ValueError, + # r'not initialized with the video mode'): + # embedder.embed_for_video(self.test_image, 0) + # + # def test_calling_embed_async_in_image_mode(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.IMAGE) + # with _ImageEmbedder.create_from_options(options) as embedder: + # with self.assertRaisesRegex(ValueError, + # r'not initialized with the live stream mode'): + # embedder.embed_async(self.test_image, 0) + # + # def test_calling_embed_in_video_mode(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.VIDEO) + # with _ImageEmbedder.create_from_options(options) as embedder: + # with self.assertRaisesRegex(ValueError, + # r'not initialized with the image mode'): + # embedder.embed(self.test_image) + # + # def test_calling_embed_async_in_video_mode(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.VIDEO) + # with _ImageEmbedder.create_from_options(options) as embedder: + # with self.assertRaisesRegex(ValueError, + # r'not initialized with the live stream mode'): + # embedder.embed_async(self.test_image, 0) + # + # def test_embed_for_video_with_out_of_order_timestamp(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.VIDEO) + # with _ImageEmbedder.create_from_options(options) as embedder: + # unused_result = embedder.embed_for_video(self.test_image, 1) + # with self.assertRaisesRegex( + # ValueError, r'Input timestamp must be monotonically increasing'): + # embedder.embed_for_video(self.test_image, 0) + # + # def test_embed_for_video(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.VIDEO) + # with _ImageEmbedder.create_from_options(options) as embedder0, \ + # _ImageEmbedder.create_from_options(options) as embedder1: + # for timestamp in range(0, 300, 30): + # # Extracts both embeddings. + # image_result = embedder0.embed_for_video(self.test_image, timestamp) + # crop_result = embedder1.embed_for_video(self.test_cropped_image, + # timestamp) + # # Checks cosine similarity. + # self._check_cosine_similarity( + # image_result, crop_result, expected_similarity=0.925519) + # + # def test_embed_for_video_succeeds_with_region_of_interest(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.VIDEO) + # with _ImageEmbedder.create_from_options(options) as embedder0, \ + # _ImageEmbedder.create_from_options(options) as embedder1: + # # Region-of-interest in "burger.jpg" corresponding to "burger_crop.jpg". + # roi = _Rect(left=0, top=0, right=0.833333, bottom=1) + # image_processing_options = _ImageProcessingOptions(roi) + # + # for timestamp in range(0, 300, 30): + # # Extracts both embeddings. + # image_result = embedder0.embed_for_video(self.test_image, timestamp, + # image_processing_options) + # crop_result = embedder1.embed_for_video(self.test_cropped_image, + # timestamp) + # + # # Checks cosine similarity. + # self._check_cosine_similarity( + # image_result, crop_result, expected_similarity=0.999931) + # + # def test_calling_embed_in_live_stream_mode(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.LIVE_STREAM, + # result_callback=mock.MagicMock()) + # with _ImageEmbedder.create_from_options(options) as embedder: + # with self.assertRaisesRegex(ValueError, + # r'not initialized with the image mode'): + # embedder.embed(self.test_image) + # + # def test_calling_embed_for_video_in_live_stream_mode(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.LIVE_STREAM, + # result_callback=mock.MagicMock()) + # with _ImageEmbedder.create_from_options(options) as embedder: + # with self.assertRaisesRegex(ValueError, + # r'not initialized with the video mode'): + # embedder.embed_for_video(self.test_image, 0) + # + # def test_embed_async_calls_with_illegal_timestamp(self): + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.LIVE_STREAM, + # result_callback=mock.MagicMock()) + # with _ImageEmbedder.create_from_options(options) as embedder: + # embedder.embed_async(self.test_image, 100) + # with self.assertRaisesRegex( + # ValueError, r'Input timestamp must be monotonically increasing'): + # embedder.embed_async(self.test_image, 0) + # + # def test_embed_async_calls(self): + # # Get the embedding result for the cropped image. + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.IMAGE) + # with _ImageEmbedder.create_from_options(options) as embedder: + # crop_result = embedder.embed(self.test_cropped_image) + # + # observed_timestamp_ms = -1 + # + # def check_result(result: _ImageEmbedderResult, output_image: _Image, + # timestamp_ms: int): + # # Checks cosine similarity. + # self._check_cosine_similarity( + # result, crop_result, expected_similarity=0.925519) + # self.assertTrue( + # np.array_equal(output_image.numpy_view(), + # self.test_image.numpy_view())) + # self.assertLess(observed_timestamp_ms, timestamp_ms) + # self.observed_timestamp_ms = timestamp_ms + # + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.LIVE_STREAM, + # result_callback=check_result) + # with _ImageEmbedder.create_from_options(options) as embedder: + # for timestamp in range(0, 300, 30): + # embedder.embed_async(self.test_image, timestamp) + # + # def test_embed_async_succeeds_with_region_of_interest(self): + # # Get the embedding result for the cropped image. + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.IMAGE) + # with _ImageEmbedder.create_from_options(options) as embedder: + # crop_result = embedder.embed(self.test_cropped_image) + # + # # Region-of-interest in "burger.jpg" corresponding to "burger_crop.jpg". + # roi = _Rect(left=0, top=0, right=0.833333, bottom=1) + # image_processing_options = _ImageProcessingOptions(roi) + # observed_timestamp_ms = -1 + # + # def check_result(result: _ImageEmbedderResult, output_image: _Image, + # timestamp_ms: int): + # # Checks cosine similarity. + # self._check_cosine_similarity( + # result, crop_result, expected_similarity=0.999931) + # self.assertTrue( + # np.array_equal(output_image.numpy_view(), + # self.test_image.numpy_view())) + # self.assertLess(observed_timestamp_ms, timestamp_ms) + # self.observed_timestamp_ms = timestamp_ms + # + # options = _ImageEmbedderOptions( + # base_options=_BaseOptions(model_asset_path=self.model_path), + # running_mode=_RUNNING_MODE.LIVE_STREAM, + # result_callback=check_result) + # with _ImageEmbedder.create_from_options(options) as embedder: + # for timestamp in range(0, 300, 30): + # embedder.embed_async(self.test_image, timestamp, + # image_processing_options) if __name__ == '__main__': From ea77a7c25d372fcd992c77b0decec4b48a829b7d Mon Sep 17 00:00:00 2001 From: kinaryml Date: Thu, 17 Nov 2022 14:06:30 -0800 Subject: [PATCH 002/136] Undo commenting out remaining tests --- .../python/test/vision/image_embedder_test.py | 453 +++++++++--------- 1 file changed, 226 insertions(+), 227 deletions(-) diff --git a/mediapipe/tasks/python/test/vision/image_embedder_test.py b/mediapipe/tasks/python/test/vision/image_embedder_test.py index 8664ed305..3d92699d7 100644 --- a/mediapipe/tasks/python/test/vision/image_embedder_test.py +++ b/mediapipe/tasks/python/test/vision/image_embedder_test.py @@ -125,8 +125,7 @@ class ImageEmbedderTest(parameterized.TestCase): (-0.2101883, -0.193027)), (True, False, False, ModelFileType.FILE_NAME, 0.925519, 1024, (-0.0142344, -0.0131606)), - (False, True, False, ModelFileType.FILE_NAME, - 0.906201, 1024, (229, 231)), + (False, True, False, ModelFileType.FILE_NAME, 0.906201, 1024, (229, 231)), (False, False, True, ModelFileType.FILE_CONTENT, 0.999931, 1024, (-0.195062, -0.193027))) def test_embed(self, l2_normalize, quantize, with_roi, model_file_type, @@ -169,231 +168,231 @@ class ImageEmbedderTest(parameterized.TestCase): # Closes the embedder explicitly when the embedder is not used in # a context. embedder.close() - # - # @parameterized.parameters( - # (False, False, ModelFileType.FILE_NAME, 0.925519), - # (False, False, ModelFileType.FILE_CONTENT, 0.925519)) - # def test_embed_in_context(self, l2_normalize, quantize, model_file_type, - # expected_similarity): - # # Creates embedder. - # if model_file_type is ModelFileType.FILE_NAME: - # base_options = _BaseOptions(model_asset_path=self.model_path) - # elif model_file_type is ModelFileType.FILE_CONTENT: - # with open(self.model_path, 'rb') as f: - # model_content = f.read() - # base_options = _BaseOptions(model_asset_buffer=model_content) - # else: - # # Should never happen - # raise ValueError('model_file_type is invalid.') - # - # embedder_options = _EmbedderOptions( - # l2_normalize=l2_normalize, quantize=quantize) - # options = _ImageEmbedderOptions( - # base_options=base_options, embedder_options=embedder_options) - # - # with _ImageEmbedder.create_from_options(options) as embedder: - # # Extracts both embeddings. - # image_result = embedder.embed(self.test_image) - # crop_result = embedder.embed(self.test_cropped_image) - # - # # Checks cosine similarity. - # self._check_cosine_similarity(image_result, crop_result, - # expected_similarity) - # - # def test_missing_result_callback(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.LIVE_STREAM) - # with self.assertRaisesRegex(ValueError, - # r'result callback must be provided'): - # with _ImageEmbedder.create_from_options(options) as unused_embedder: - # pass - # - # @parameterized.parameters((_RUNNING_MODE.IMAGE), (_RUNNING_MODE.VIDEO)) - # def test_illegal_result_callback(self, running_mode): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=running_mode, - # result_callback=mock.MagicMock()) - # with self.assertRaisesRegex(ValueError, - # r'result callback should not be provided'): - # with _ImageEmbedder.create_from_options(options) as unused_embedder: - # pass - # - # def test_calling_embed_for_video_in_image_mode(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.IMAGE) - # with _ImageEmbedder.create_from_options(options) as embedder: - # with self.assertRaisesRegex(ValueError, - # r'not initialized with the video mode'): - # embedder.embed_for_video(self.test_image, 0) - # - # def test_calling_embed_async_in_image_mode(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.IMAGE) - # with _ImageEmbedder.create_from_options(options) as embedder: - # with self.assertRaisesRegex(ValueError, - # r'not initialized with the live stream mode'): - # embedder.embed_async(self.test_image, 0) - # - # def test_calling_embed_in_video_mode(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.VIDEO) - # with _ImageEmbedder.create_from_options(options) as embedder: - # with self.assertRaisesRegex(ValueError, - # r'not initialized with the image mode'): - # embedder.embed(self.test_image) - # - # def test_calling_embed_async_in_video_mode(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.VIDEO) - # with _ImageEmbedder.create_from_options(options) as embedder: - # with self.assertRaisesRegex(ValueError, - # r'not initialized with the live stream mode'): - # embedder.embed_async(self.test_image, 0) - # - # def test_embed_for_video_with_out_of_order_timestamp(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.VIDEO) - # with _ImageEmbedder.create_from_options(options) as embedder: - # unused_result = embedder.embed_for_video(self.test_image, 1) - # with self.assertRaisesRegex( - # ValueError, r'Input timestamp must be monotonically increasing'): - # embedder.embed_for_video(self.test_image, 0) - # - # def test_embed_for_video(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.VIDEO) - # with _ImageEmbedder.create_from_options(options) as embedder0, \ - # _ImageEmbedder.create_from_options(options) as embedder1: - # for timestamp in range(0, 300, 30): - # # Extracts both embeddings. - # image_result = embedder0.embed_for_video(self.test_image, timestamp) - # crop_result = embedder1.embed_for_video(self.test_cropped_image, - # timestamp) - # # Checks cosine similarity. - # self._check_cosine_similarity( - # image_result, crop_result, expected_similarity=0.925519) - # - # def test_embed_for_video_succeeds_with_region_of_interest(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.VIDEO) - # with _ImageEmbedder.create_from_options(options) as embedder0, \ - # _ImageEmbedder.create_from_options(options) as embedder1: - # # Region-of-interest in "burger.jpg" corresponding to "burger_crop.jpg". - # roi = _Rect(left=0, top=0, right=0.833333, bottom=1) - # image_processing_options = _ImageProcessingOptions(roi) - # - # for timestamp in range(0, 300, 30): - # # Extracts both embeddings. - # image_result = embedder0.embed_for_video(self.test_image, timestamp, - # image_processing_options) - # crop_result = embedder1.embed_for_video(self.test_cropped_image, - # timestamp) - # - # # Checks cosine similarity. - # self._check_cosine_similarity( - # image_result, crop_result, expected_similarity=0.999931) - # - # def test_calling_embed_in_live_stream_mode(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.LIVE_STREAM, - # result_callback=mock.MagicMock()) - # with _ImageEmbedder.create_from_options(options) as embedder: - # with self.assertRaisesRegex(ValueError, - # r'not initialized with the image mode'): - # embedder.embed(self.test_image) - # - # def test_calling_embed_for_video_in_live_stream_mode(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.LIVE_STREAM, - # result_callback=mock.MagicMock()) - # with _ImageEmbedder.create_from_options(options) as embedder: - # with self.assertRaisesRegex(ValueError, - # r'not initialized with the video mode'): - # embedder.embed_for_video(self.test_image, 0) - # - # def test_embed_async_calls_with_illegal_timestamp(self): - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.LIVE_STREAM, - # result_callback=mock.MagicMock()) - # with _ImageEmbedder.create_from_options(options) as embedder: - # embedder.embed_async(self.test_image, 100) - # with self.assertRaisesRegex( - # ValueError, r'Input timestamp must be monotonically increasing'): - # embedder.embed_async(self.test_image, 0) - # - # def test_embed_async_calls(self): - # # Get the embedding result for the cropped image. - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.IMAGE) - # with _ImageEmbedder.create_from_options(options) as embedder: - # crop_result = embedder.embed(self.test_cropped_image) - # - # observed_timestamp_ms = -1 - # - # def check_result(result: _ImageEmbedderResult, output_image: _Image, - # timestamp_ms: int): - # # Checks cosine similarity. - # self._check_cosine_similarity( - # result, crop_result, expected_similarity=0.925519) - # self.assertTrue( - # np.array_equal(output_image.numpy_view(), - # self.test_image.numpy_view())) - # self.assertLess(observed_timestamp_ms, timestamp_ms) - # self.observed_timestamp_ms = timestamp_ms - # - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.LIVE_STREAM, - # result_callback=check_result) - # with _ImageEmbedder.create_from_options(options) as embedder: - # for timestamp in range(0, 300, 30): - # embedder.embed_async(self.test_image, timestamp) - # - # def test_embed_async_succeeds_with_region_of_interest(self): - # # Get the embedding result for the cropped image. - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.IMAGE) - # with _ImageEmbedder.create_from_options(options) as embedder: - # crop_result = embedder.embed(self.test_cropped_image) - # - # # Region-of-interest in "burger.jpg" corresponding to "burger_crop.jpg". - # roi = _Rect(left=0, top=0, right=0.833333, bottom=1) - # image_processing_options = _ImageProcessingOptions(roi) - # observed_timestamp_ms = -1 - # - # def check_result(result: _ImageEmbedderResult, output_image: _Image, - # timestamp_ms: int): - # # Checks cosine similarity. - # self._check_cosine_similarity( - # result, crop_result, expected_similarity=0.999931) - # self.assertTrue( - # np.array_equal(output_image.numpy_view(), - # self.test_image.numpy_view())) - # self.assertLess(observed_timestamp_ms, timestamp_ms) - # self.observed_timestamp_ms = timestamp_ms - # - # options = _ImageEmbedderOptions( - # base_options=_BaseOptions(model_asset_path=self.model_path), - # running_mode=_RUNNING_MODE.LIVE_STREAM, - # result_callback=check_result) - # with _ImageEmbedder.create_from_options(options) as embedder: - # for timestamp in range(0, 300, 30): - # embedder.embed_async(self.test_image, timestamp, - # image_processing_options) + + @parameterized.parameters( + (False, False, ModelFileType.FILE_NAME, 0.925519), + (False, False, ModelFileType.FILE_CONTENT, 0.925519)) + def test_embed_in_context(self, l2_normalize, quantize, model_file_type, + expected_similarity): + # Creates embedder. + if model_file_type is ModelFileType.FILE_NAME: + base_options = _BaseOptions(model_asset_path=self.model_path) + elif model_file_type is ModelFileType.FILE_CONTENT: + with open(self.model_path, 'rb') as f: + model_content = f.read() + base_options = _BaseOptions(model_asset_buffer=model_content) + else: + # Should never happen + raise ValueError('model_file_type is invalid.') + + embedder_options = _EmbedderOptions( + l2_normalize=l2_normalize, quantize=quantize) + options = _ImageEmbedderOptions( + base_options=base_options, embedder_options=embedder_options) + + with _ImageEmbedder.create_from_options(options) as embedder: + # Extracts both embeddings. + image_result = embedder.embed(self.test_image) + crop_result = embedder.embed(self.test_cropped_image) + + # Checks cosine similarity. + self._check_cosine_similarity(image_result, crop_result, + expected_similarity) + + def test_missing_result_callback(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM) + with self.assertRaisesRegex(ValueError, + r'result callback must be provided'): + with _ImageEmbedder.create_from_options(options) as unused_embedder: + pass + + @parameterized.parameters((_RUNNING_MODE.IMAGE), (_RUNNING_MODE.VIDEO)) + def test_illegal_result_callback(self, running_mode): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=running_mode, + result_callback=mock.MagicMock()) + with self.assertRaisesRegex(ValueError, + r'result callback should not be provided'): + with _ImageEmbedder.create_from_options(options) as unused_embedder: + pass + + def test_calling_embed_for_video_in_image_mode(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.IMAGE) + with _ImageEmbedder.create_from_options(options) as embedder: + with self.assertRaisesRegex(ValueError, + r'not initialized with the video mode'): + embedder.embed_for_video(self.test_image, 0) + + def test_calling_embed_async_in_image_mode(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.IMAGE) + with _ImageEmbedder.create_from_options(options) as embedder: + with self.assertRaisesRegex(ValueError, + r'not initialized with the live stream mode'): + embedder.embed_async(self.test_image, 0) + + def test_calling_embed_in_video_mode(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _ImageEmbedder.create_from_options(options) as embedder: + with self.assertRaisesRegex(ValueError, + r'not initialized with the image mode'): + embedder.embed(self.test_image) + + def test_calling_embed_async_in_video_mode(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _ImageEmbedder.create_from_options(options) as embedder: + with self.assertRaisesRegex(ValueError, + r'not initialized with the live stream mode'): + embedder.embed_async(self.test_image, 0) + + def test_embed_for_video_with_out_of_order_timestamp(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _ImageEmbedder.create_from_options(options) as embedder: + unused_result = embedder.embed_for_video(self.test_image, 1) + with self.assertRaisesRegex( + ValueError, r'Input timestamp must be monotonically increasing'): + embedder.embed_for_video(self.test_image, 0) + + def test_embed_for_video(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _ImageEmbedder.create_from_options(options) as embedder0, \ + _ImageEmbedder.create_from_options(options) as embedder1: + for timestamp in range(0, 300, 30): + # Extracts both embeddings. + image_result = embedder0.embed_for_video(self.test_image, timestamp) + crop_result = embedder1.embed_for_video(self.test_cropped_image, + timestamp) + # Checks cosine similarity. + self._check_cosine_similarity( + image_result, crop_result, expected_similarity=0.925519) + + def test_embed_for_video_succeeds_with_region_of_interest(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _ImageEmbedder.create_from_options(options) as embedder0, \ + _ImageEmbedder.create_from_options(options) as embedder1: + # Region-of-interest in "burger.jpg" corresponding to "burger_crop.jpg". + roi = _Rect(left=0, top=0, right=0.833333, bottom=1) + image_processing_options = _ImageProcessingOptions(roi) + + for timestamp in range(0, 300, 30): + # Extracts both embeddings. + image_result = embedder0.embed_for_video(self.test_image, timestamp, + image_processing_options) + crop_result = embedder1.embed_for_video(self.test_cropped_image, + timestamp) + + # Checks cosine similarity. + self._check_cosine_similarity( + image_result, crop_result, expected_similarity=0.999931) + + def test_calling_embed_in_live_stream_mode(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=mock.MagicMock()) + with _ImageEmbedder.create_from_options(options) as embedder: + with self.assertRaisesRegex(ValueError, + r'not initialized with the image mode'): + embedder.embed(self.test_image) + + def test_calling_embed_for_video_in_live_stream_mode(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=mock.MagicMock()) + with _ImageEmbedder.create_from_options(options) as embedder: + with self.assertRaisesRegex(ValueError, + r'not initialized with the video mode'): + embedder.embed_for_video(self.test_image, 0) + + def test_embed_async_calls_with_illegal_timestamp(self): + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=mock.MagicMock()) + with _ImageEmbedder.create_from_options(options) as embedder: + embedder.embed_async(self.test_image, 100) + with self.assertRaisesRegex( + ValueError, r'Input timestamp must be monotonically increasing'): + embedder.embed_async(self.test_image, 0) + + def test_embed_async_calls(self): + # Get the embedding result for the cropped image. + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.IMAGE) + with _ImageEmbedder.create_from_options(options) as embedder: + crop_result = embedder.embed(self.test_cropped_image) + + observed_timestamp_ms = -1 + + def check_result(result: _ImageEmbedderResult, output_image: _Image, + timestamp_ms: int): + # Checks cosine similarity. + self._check_cosine_similarity( + result, crop_result, expected_similarity=0.925519) + self.assertTrue( + np.array_equal(output_image.numpy_view(), + self.test_image.numpy_view())) + self.assertLess(observed_timestamp_ms, timestamp_ms) + self.observed_timestamp_ms = timestamp_ms + + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=check_result) + with _ImageEmbedder.create_from_options(options) as embedder: + for timestamp in range(0, 300, 30): + embedder.embed_async(self.test_image, timestamp) + + def test_embed_async_succeeds_with_region_of_interest(self): + # Get the embedding result for the cropped image. + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.IMAGE) + with _ImageEmbedder.create_from_options(options) as embedder: + crop_result = embedder.embed(self.test_cropped_image) + + # Region-of-interest in "burger.jpg" corresponding to "burger_crop.jpg". + roi = _Rect(left=0, top=0, right=0.833333, bottom=1) + image_processing_options = _ImageProcessingOptions(roi) + observed_timestamp_ms = -1 + + def check_result(result: _ImageEmbedderResult, output_image: _Image, + timestamp_ms: int): + # Checks cosine similarity. + self._check_cosine_similarity( + result, crop_result, expected_similarity=0.999931) + self.assertTrue( + np.array_equal(output_image.numpy_view(), + self.test_image.numpy_view())) + self.assertLess(observed_timestamp_ms, timestamp_ms) + self.observed_timestamp_ms = timestamp_ms + + options = _ImageEmbedderOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=check_result) + with _ImageEmbedder.create_from_options(options) as embedder: + for timestamp in range(0, 300, 30): + embedder.embed_async(self.test_image, timestamp, + image_processing_options) if __name__ == '__main__': From c73ff261adbdcf2dace2fffc2202c3eae22f942f Mon Sep 17 00:00:00 2001 From: Tarun Jain Date: Fri, 24 Feb 2023 19:37:30 +0530 Subject: [PATCH 003/136] Fix Broken Link in Object Detection solutions --- docs/solutions/object_detection.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/solutions/object_detection.md b/docs/solutions/object_detection.md index 7ae5e9aff..71d6063bc 100644 --- a/docs/solutions/object_detection.md +++ b/docs/solutions/object_detection.md @@ -108,9 +108,9 @@ on how to build MediaPipe examples. * With a TensorFlow Model This uses the - [TensorFlow model](https://github.com/google/mediapipe/tree/master/mediapipe/models/object_detection_saved_model) + [TensorFlow model](https://github.com/google/mediapipe/tree/master/mediapipe/graphs/object_detection) ( see also - [model info](https://github.com/google/mediapipe/tree/master/mediapipe/models/object_detection_saved_model/README.md)), + [model info](https://github.com/google/mediapipe/tree/master/mediapipe/modules/objectron)), and the pipeline is implemented in this [graph](https://github.com/google/mediapipe/tree/master/mediapipe/graphs/object_detection/object_detection_mobile_cpu.pbtxt). From 87ba86ace2ccf0a74ae2629a373fb52f6e96787e Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:34:44 +0530 Subject: [PATCH 004/136] Added method to send packet map to C++ task runner --- mediapipe/tasks/ios/core/sources/MPPTaskRunner.h | 12 ++++++++++++ mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm | 3 +++ 2 files changed, 15 insertions(+) diff --git a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h index 704fc453f..fbb12295f 100644 --- a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h +++ b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h @@ -74,6 +74,18 @@ NS_ASSUME_NONNULL_BEGIN */ - (absl::StatusOr)process: (const mediapipe::tasks::core::PacketMap &)packetMap; +- (std::optional) + processPacketMap:(const mediapipe::tasks::core::PacketMap &)packetMap + error:(NSError **)error; + +/** + * An asynchronous method that is designed for handling live streaming data such as live camera. A + * user-defined PacketsCallback function must be provided in the constructor to receive the output + * packets. The caller must ensure that the input packet timestamps are monotonically increasing. + * This method is thread-unsafe and it is the caller's responsibility to synchronize access to this + * method across multiple threads and to ensure that the input packet timestamps are in order. + */ +- (BOOL)sendPacketMap:(const mediapipe::tasks::core::PacketMap &)packetMap error:(NSError **)error; /** * Shuts down the C++ task runner. After the runner is closed, any calls that send input data to the diff --git a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm index eb777679a..f7db95536 100644 --- a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm +++ b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm @@ -52,6 +52,9 @@ using TaskRunnerCpp = ::mediapipe::tasks::core::TaskRunner; - (absl::StatusOr)process:(const PacketMap &)packetMap { return _cppTaskRunner->Process(packetMap); +- (BOOL)sendPacketMap:(const PacketMap &)packetMap error:(NSError **)error { + absl::Status sendStatus = _cppTaskRunner->Send(packetMap); + return [MPPCommonUtils checkCppError:sendStatus toError:error]; } - (absl::Status)close { From 6d7f172e9fdd77b44db0a1228c32cf95fa9d693d Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:35:05 +0530 Subject: [PATCH 005/136] Changed return type of process method in MPPTaskRunner --- mediapipe/tasks/ios/core/sources/MPPTaskRunner.h | 4 ++-- mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h index fbb12295f..b2881ac06 100644 --- a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h +++ b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h @@ -17,6 +17,8 @@ #include "mediapipe/framework/calculator.pb.h" #include "mediapipe/tasks/cc/core/task_runner.h" +#include + NS_ASSUME_NONNULL_BEGIN /** @@ -72,8 +74,6 @@ NS_ASSUME_NONNULL_BEGIN * caller's responsibility to synchronize access to this method across multiple threads and to * ensure that the input packet timestamps are in order. */ -- (absl::StatusOr)process: - (const mediapipe::tasks::core::PacketMap &)packetMap; - (std::optional) processPacketMap:(const mediapipe::tasks::core::PacketMap &)packetMap error:(NSError **)error; diff --git a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm index f7db95536..a57846338 100644 --- a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm +++ b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm @@ -50,8 +50,14 @@ using TaskRunnerCpp = ::mediapipe::tasks::core::TaskRunner; return self; } -- (absl::StatusOr)process:(const PacketMap &)packetMap { - return _cppTaskRunner->Process(packetMap); +- (std::optional)processPacketMap:(const PacketMap &)packetMap error:(NSError **)error { + absl::StatusOr resultPacketMap = _cppTaskRunner->Process(packetMap); + if (![MPPCommonUtils checkCppError:resultPacketMap.status() toError:error]) { + return std::nullopt; + } + return resultPacketMap.value(); +} + - (BOOL)sendPacketMap:(const PacketMap &)packetMap error:(NSError **)error { absl::Status sendStatus = _cppTaskRunner->Send(packetMap); return [MPPCommonUtils checkCppError:sendStatus toError:error]; From 045050fc85d11ee3b9f35591708dee83060822fb Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:36:34 +0530 Subject: [PATCH 006/136] Changed method Updated method calls to process packet map in iOS text tasks --- .../text/text_classifier/sources/MPPTextClassifier.mm | 11 ++++++----- .../ios/text/text_embedder/sources/MPPTextEmbedder.mm | 9 +++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/mediapipe/tasks/ios/text/text_classifier/sources/MPPTextClassifier.mm b/mediapipe/tasks/ios/text/text_classifier/sources/MPPTextClassifier.mm index 52e4d92ac..871df0f86 100644 --- a/mediapipe/tasks/ios/text/text_classifier/sources/MPPTextClassifier.mm +++ b/mediapipe/tasks/ios/text/text_classifier/sources/MPPTextClassifier.mm @@ -83,15 +83,16 @@ static NSString *const kTaskGraphName = @"mediapipe.tasks.text.text_classifier.T Packet packet = [MPPTextPacketCreator createWithText:text]; std::map packetMap = {{kTextInStreamName.cppString, packet}}; - absl::StatusOr statusOrOutputPacketMap = [_textTaskRunner process:packetMap]; + std::optional outputPacketMap = [_textTaskRunner processPacketMap:packetMap + error:error]; - if (![MPPCommonUtils checkCppError:statusOrOutputPacketMap.status() toError:error]) { + if (!outputPacketMap.has_value()) { return nil; } - return [MPPTextClassifierResult - textClassifierResultWithClassificationsPacket:statusOrOutputPacketMap.value() - [kClassificationsStreamName.cppString]]; + return + [MPPTextClassifierResult textClassifierResultWithClassificationsPacket: + outputPacketMap.value()[kClassificationsStreamName.cppString]]; } @end diff --git a/mediapipe/tasks/ios/text/text_embedder/sources/MPPTextEmbedder.mm b/mediapipe/tasks/ios/text/text_embedder/sources/MPPTextEmbedder.mm index 62eb882d3..80a34f0c5 100644 --- a/mediapipe/tasks/ios/text/text_embedder/sources/MPPTextEmbedder.mm +++ b/mediapipe/tasks/ios/text/text_embedder/sources/MPPTextEmbedder.mm @@ -83,14 +83,15 @@ static NSString *const kTaskGraphName = @"mediapipe.tasks.text.text_embedder.Tex Packet packet = [MPPTextPacketCreator createWithText:text]; std::map packetMap = {{kTextInStreamName.cppString, packet}}; - absl::StatusOr statusOrOutputPacketMap = [_textTaskRunner process:packetMap]; - if (![MPPCommonUtils checkCppError:statusOrOutputPacketMap.status() toError:error]) { + std::optional outputPacketMap = [_textTaskRunner processPacketMap:packetMap + error:error]; + + if (!outputPacketMap.has_value()) { return nil; } - return [MPPTextEmbedderResult - textEmbedderResultWithOutputPacket:statusOrOutputPacketMap + textEmbedderResultWithOutputPacket:outputPacketMap .value()[kEmbeddingsOutStreamName.cppString]]; } From b76ab3739482244e41e9757467fa5277efbd622e Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:37:24 +0530 Subject: [PATCH 007/136] Removed unwanted imports --- mediapipe/tasks/ios/text/text_classifier/BUILD | 1 - .../tasks/ios/text/text_classifier/sources/MPPTextClassifier.mm | 1 - mediapipe/tasks/ios/text/text_embedder/BUILD | 1 - .../tasks/ios/text/text_embedder/sources/MPPTextEmbedder.mm | 2 -- 4 files changed, 5 deletions(-) diff --git a/mediapipe/tasks/ios/text/text_classifier/BUILD b/mediapipe/tasks/ios/text/text_classifier/BUILD index c56d51e5f..7913340ac 100644 --- a/mediapipe/tasks/ios/text/text_classifier/BUILD +++ b/mediapipe/tasks/ios/text/text_classifier/BUILD @@ -58,6 +58,5 @@ objc_library( "//mediapipe/tasks/ios/text/core:MPPTextTaskRunner", "//mediapipe/tasks/ios/text/text_classifier/utils:MPPTextClassifierOptionsHelpers", "//mediapipe/tasks/ios/text/text_classifier/utils:MPPTextClassifierResultHelpers", - "@com_google_absl//absl/status:statusor", ], ) diff --git a/mediapipe/tasks/ios/text/text_classifier/sources/MPPTextClassifier.mm b/mediapipe/tasks/ios/text/text_classifier/sources/MPPTextClassifier.mm index 871df0f86..f0e1e4152 100644 --- a/mediapipe/tasks/ios/text/text_classifier/sources/MPPTextClassifier.mm +++ b/mediapipe/tasks/ios/text/text_classifier/sources/MPPTextClassifier.mm @@ -22,7 +22,6 @@ #import "mediapipe/tasks/ios/text/text_classifier/utils/sources/MPPTextClassifierOptions+Helpers.h" #import "mediapipe/tasks/ios/text/text_classifier/utils/sources/MPPTextClassifierResult+Helpers.h" -#include "absl/status/statusor.h" #include "mediapipe/tasks/cc/components/containers/proto/classifications.pb.h" namespace { diff --git a/mediapipe/tasks/ios/text/text_embedder/BUILD b/mediapipe/tasks/ios/text/text_embedder/BUILD index 74aefdf77..a600d5366 100644 --- a/mediapipe/tasks/ios/text/text_embedder/BUILD +++ b/mediapipe/tasks/ios/text/text_embedder/BUILD @@ -58,6 +58,5 @@ objc_library( "//mediapipe/tasks/ios/text/core:MPPTextTaskRunner", "//mediapipe/tasks/ios/text/text_embedder/utils:MPPTextEmbedderOptionsHelpers", "//mediapipe/tasks/ios/text/text_embedder/utils:MPPTextEmbedderResultHelpers", - "@com_google_absl//absl/status:statusor", ], ) diff --git a/mediapipe/tasks/ios/text/text_embedder/sources/MPPTextEmbedder.mm b/mediapipe/tasks/ios/text/text_embedder/sources/MPPTextEmbedder.mm index 80a34f0c5..e0f0d549d 100644 --- a/mediapipe/tasks/ios/text/text_embedder/sources/MPPTextEmbedder.mm +++ b/mediapipe/tasks/ios/text/text_embedder/sources/MPPTextEmbedder.mm @@ -23,8 +23,6 @@ #import "mediapipe/tasks/ios/text/text_embedder/utils/sources/MPPTextEmbedderOptions+Helpers.h" #import "mediapipe/tasks/ios/text/text_embedder/utils/sources/MPPTextEmbedderResult+Helpers.h" -#include "absl/status/statusor.h" - namespace { using ::mediapipe::Packet; using ::mediapipe::tasks::core::PacketMap; From dc393b0bd42316926712d662589942d2b629be7c Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:43:01 +0530 Subject: [PATCH 008/136] Added methods to MPPVisionTaskRunner --- .../vision/core/sources/MPPVisionTaskRunner.h | 77 ++++++++++++++- .../core/sources/MPPVisionTaskRunner.mm | 93 +++++++++++++++++++ 2 files changed, 166 insertions(+), 4 deletions(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h index 84b657305..5212f65fe 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h @@ -13,10 +13,13 @@ // limitations under the License. #import +#import #import "mediapipe/tasks/ios/core/sources/MPPTaskRunner.h" #import "mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h" +#include "mediapipe/framework/formats/rect.pb.h" + NS_ASSUME_NONNULL_BEGIN /** @@ -54,10 +57,76 @@ NS_ASSUME_NONNULL_BEGIN (mediapipe::tasks::core::PacketsCallback)packetsCallback error:(NSError **)error NS_DESIGNATED_INITIALIZER; -- (instancetype)initWithCalculatorGraphConfig:(mediapipe::CalculatorGraphConfig)graphConfig - packetsCallback: - (mediapipe::tasks::core::PacketsCallback)packetsCallback - error:(NSError **)error NS_UNAVAILABLE; +/** + * Creates a `NormalizedRect` from region of interest and image orientation, performing + * sanity checks on-the-fly. If the input region of interest equals `CGRectZero`, returns a default + * `NormalizedRect` covering the whole image with rotation set according `imageOrientation`. If + * `roiAllowed` is NO, an error will be returned if the input region of interest is not equal to + * `CGRectZero`. + * + * @param roi A `CGRect` specifying the region of interest. If the input region of interest equals + * `CGRectZero`, the returned `NormalizedRect` covers the whole image. Make sure that `roi` equals + * `CGRectZero` if `roiAllowed` is NO. Otherwise an error will be returned. + * @param imageOrientation A `UIImageOrientation` indicating the rotation to be applied to the + * image. The resulting `NormalizedRect` will convert the `imageOrientation` to degrees clockwise. + * @param roiAllowed Indicates if the `roi` field is allowed to be a value other than `CGRectZero`. + * + * @param error Pointer to the memory location where errors if any should be + * saved. If @c NULL, no error will be saved. + * + * @return An optional `NormalizedRect` from the given region of interest and image orientation. + */ +- (std::optional) + normalizedRectFromRegionOfInterest:(CGRect)roi + imageOrientation:(UIImageOrientation)imageOrientation + roiAllowed:(BOOL)roiAllowed + error:(NSError **)error; + +/** + * A synchronous method to invoke the C++ task runner to process single image inputs. The call + * blocks the current thread until a failure status or a successful result is returned. + * + * @param packetMap A `PackeMap` containing pairs of input stream name and data packet. + * @param error Pointer to the memory location where errors if any should be + * saved. If @c NULL, no error will be saved. + * + * @return An optional `PacketMap` containing pairs of output stream name and data packet. + */ +- (std::optional) + processImagePacketMap:(const mediapipe::tasks::core::PacketMap &)packetMap + error:(NSError **)error; + +/** + * A synchronous method to invoke the C++ task runner to process continuous video frames. The call + * blocks the current thread until a failure status or a successful result is returned. + * + * @param packetMap A `PackeMap` containing pairs of input stream name and data packet. + * @param error Pointer to the memory location where errors if any should be + * saved. If @c NULL, no error will be saved. + * + * @return An optional `PacketMap` containing pairs of output stream name and data packet. + */ +- (std::optional) + processVideoFramePacketMap:(const mediapipe::tasks::core::PacketMap &)packetMap + error:(NSError **)error; + +/** + * An asynchronous method to send live stream data to the C++ task runner. The call blocks the + * current thread until a failure status or a successful result is returned. The results will be + * available in the user-defined `packetsCallback` that was provided during initialization of the + * `MPPVisionTaskRunner`. + * + * @param packetMap A `PackeMap` containing pairs of input stream name and data packet. + * @param error Pointer to the memory location where errors if any should be + * saved. If @c NULL, no error will be saved. + * + * @return A `BOOL` indicating if the live stream data was sent to the C++ task runner successfully. + * Please note that any errors during processing of the live stream packet will only be available in + * the user-defined `packetsCallback` that was provided during initialization of the + * `MPPVisionTaskRunner`. + */ +- (BOOL)processLiveStreamPacketMap:(const mediapipe::tasks::core::PacketMap &)packetMap + error:(NSError **)error; - (instancetype)init NS_UNAVAILABLE; diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm index bfa9e34e5..3c115f1c6 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm @@ -17,8 +17,14 @@ #import "mediapipe/tasks/ios/common/sources/MPPCommon.h" #import "mediapipe/tasks/ios/common/utils/sources/MPPCommonUtils.h" +#include "absl/status/statusor.h" + +#include + namespace { using ::mediapipe::CalculatorGraphConfig; +using ::mediapipe::NormalizedRect; +using ::mediapipe::tasks::core::PacketMap; using ::mediapipe::tasks::core::PacketsCallback; } // namespace @@ -70,4 +76,91 @@ using ::mediapipe::tasks::core::PacketsCallback; return self; } +- (std::optional)normalizedRectFromRegionOfInterest:(CGRect)roi + imageOrientation: + (UIImageOrientation)imageOrientation + roiAllowed:(BOOL)roiAllowed + error:(NSError **)error { + if (CGRectEqualToRect(roi, CGRectZero) && !roiAllowed) { + [MPPCommonUtils createCustomError:error + withCode:MPPTasksErrorCodeInvalidArgumentError + description:@"This task doesn't support region-of-interest."]; + return std::nullopt; + } + + CGRect calculatedRoi = CGRectEqualToRect(roi, CGRectZero) ? roi : CGRectMake(0.0, 0.0, 1.0, 1.0); + + NormalizedRect normalizedRect; + normalizedRect.set_x_center(CGRectGetMidX(calculatedRoi)); + normalizedRect.set_y_center(CGRectGetMidY(calculatedRoi)); + normalizedRect.set_width(CGRectGetWidth(calculatedRoi)); + normalizedRect.set_height(CGRectGetHeight(calculatedRoi)); + + int rotationDegrees = 0; + switch (imageOrientation) { + case UIImageOrientationUp: + break; + case UIImageOrientationRight: { + rotationDegrees = -90; + break; + } + case UIImageOrientationDown: { + rotationDegrees = -180; + break; + } + case UIImageOrientationLeft: { + rotationDegrees = -270; + break; + } + default: + [MPPCommonUtils createCustomError:error + withCode:MPPTasksErrorCodeInvalidArgumentError + description:@"Unsupported UIImageOrientation."]; + } + + normalizedRect.set_rotation(-rotationDegrees * M_PI / 180.0); + + return normalizedRect; +} + +- (std::optional)processImagePacketMap:(PacketMap &)packetMap error:(NSError **)error { + if (_runningMode != MPPRunningModeImage) { + [MPPCommonUtils + createCustomError:error + withCode:MPPTasksErrorCodeInvalidArgumentError + description: + @"The vision task is not initialized with image mode. Current Running Mode:"]; + return std::nullopt; + } + + return [self processPacketMap:packetMap error:error]; +} + +- (std::optional)processVideoFramePacketMap:(PacketMap &)packetMap + error:(NSError **)error { + if (_runningMode != MPPRunningModeVideo) { + [MPPCommonUtils + createCustomError:error + withCode:MPPTasksErrorCodeInvalidArgumentError + description: + @"The vision task is not initialized with image mode. Current Running Mode:"]; + return std::nullopt; + } + + return [self processPacketMap:packetMap error:error]; +} + +- (BOOL)processLiveStreamPacketMap:(PacketMap &)packetMap error:(NSError **)error { + if (_runningMode != MPPRunningModeLiveStream) { + [MPPCommonUtils + createCustomError:error + withCode:MPPTasksErrorCodeInvalidArgumentError + description: + @"The vision task is not initialized with image mode. Current Running Mode:"]; + return NO; + } + + return [self sendPacketMap:packetMap error:error]; +} + @end From c625d6afdc2fde7cfa85d053596d9ca01d551836 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:43:22 +0530 Subject: [PATCH 009/136] Added methods to MPPVisionPacketCreator --- .../core/sources/MPPVisionPacketCreator.h | 13 +++++++- .../core/sources/MPPVisionPacketCreator.mm | 32 ++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionPacketCreator.h b/mediapipe/tasks/ios/vision/core/sources/MPPVisionPacketCreator.h index cf597ec24..77dcd1978 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionPacketCreator.h +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionPacketCreator.h @@ -14,9 +14,11 @@ #import -#include "mediapipe/framework/packet.h" #import "mediapipe/tasks/ios/vision/core/sources/MPPImage.h" +#include "mediapipe/framework/packet.h" +#include "mediapipe/framework/formats/rect.pb.h" + /** * This class helps create various kinds of packets for Mediapipe Vision Tasks. */ @@ -24,4 +26,13 @@ + (mediapipe::Packet)createPacketWithMPPImage:(MPPImage *)image error:(NSError **)error; ++ (mediapipe::Packet)createPacketWithMPPImage:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + error:(NSError **)error; + ++ (mediapipe::Packet)createPacketWithNormalizedRect:(mediapipe::NormalizedRect &)normalizedRect; + ++ (mediapipe::Packet)createPacketWithNormalizedRect:(mediapipe::NormalizedRect &)normalizedRect + timestampMs:(NSInteger)timestampMs; + @end diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionPacketCreator.mm b/mediapipe/tasks/ios/vision/core/sources/MPPVisionPacketCreator.mm index 01e583e62..bf136a759 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionPacketCreator.mm +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionPacketCreator.mm @@ -16,18 +16,19 @@ #import "mediapipe/tasks/ios/vision/core/utils/sources/MPPImage+Utils.h" #include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/timestamp.h" + +static const NSUInteger kMicroSecondsPerMilliSecond = 1000; namespace { using ::mediapipe::Image; using ::mediapipe::ImageFrame; using ::mediapipe::MakePacket; +using ::mediapipe::NormalizedRect; using ::mediapipe::Packet; +using ::mediapipe::Timestamp; } // namespace -struct freeDeleter { - void operator()(void *ptr) { free(ptr); } -}; - @implementation MPPVisionPacketCreator + (Packet)createPacketWithMPPImage:(MPPImage *)image error:(NSError **)error { @@ -40,4 +41,27 @@ struct freeDeleter { return MakePacket(std::move(imageFrame)); } ++ (Packet)createPacketWithMPPImage:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + error:(NSError **)error { + std::unique_ptr imageFrame = [image imageFrameWithError:error]; + + if (!imageFrame) { + return Packet(); + } + + return MakePacket(std::move(imageFrame)) + .At(Timestamp(int64(timestampMs * kMicroSecondsPerMilliSecond))); +} + ++ (Packet)createPacketWithNormalizedRect:(NormalizedRect &)normalizedRect { + return MakePacket(std::move(normalizedRect)); +} + ++ (Packet)createPacketWithNormalizedRect:(NormalizedRect &)normalizedRect + timestampMs:(NSInteger)timestampMs { + return MakePacket(std::move(normalizedRect)) + .At(Timestamp(int64(timestampMs * kMicroSecondsPerMilliSecond))); +} + @end From c871fc58ec44b6f776e637dd50f6e60121adf026 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:43:46 +0530 Subject: [PATCH 010/136] Updated build targets of vision packet creator and task runner --- mediapipe/tasks/ios/vision/core/BUILD | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/mediapipe/tasks/ios/vision/core/BUILD b/mediapipe/tasks/ios/vision/core/BUILD index 1961ca6b0..600ef4304 100644 --- a/mediapipe/tasks/ios/vision/core/BUILD +++ b/mediapipe/tasks/ios/vision/core/BUILD @@ -26,6 +26,24 @@ objc_library( module_name = "MPPRunningMode", ) +objc_library( + name = "MPPVisionPacketCreator", + srcs = ["sources/MPPVisionPacketCreator.mm"], + hdrs = ["sources/MPPVisionPacketCreator.h"], + sdk_frameworks = ["UIKit"], + copts = [ + "-ObjC++", + "-std=c++17", + ], + deps = [ + "//mediapipe/framework:packet", + "//mediapipe/framework:timestamp", + "//mediapipe/tasks/ios/vision/core:MPPImage", + "//mediapipe/tasks/ios/vision/core/utils:MPPImageUtils", + "//mediapipe/framework/formats:rect_cc_proto", + ], +) + objc_library( name = "MPPVisionTaskRunner", srcs = ["sources/MPPVisionTaskRunner.mm"], @@ -39,5 +57,6 @@ objc_library( "//mediapipe/tasks/ios/common:MPPCommon", "//mediapipe/tasks/ios/common/utils:MPPCommonUtils", "//mediapipe/tasks/ios/core:MPPTaskRunner", + "//mediapipe/tasks/ios/vision/core:MPPVisionPacketCreator", ], ) From abb140ed2ea174c3caa000fb71400f9acbd47ed1 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:45:58 +0530 Subject: [PATCH 011/136] Added MPPImageClassifierResultHelpers --- .../ios/vision/image_classifier/utils/BUILD | 29 +++++++++++++ .../MPPImageClassifierResult+Helpers.h | 28 ++++++++++++ .../MPPImageClassifierResult+Helpers.mm | 43 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 mediapipe/tasks/ios/vision/image_classifier/utils/BUILD create mode 100644 mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.h create mode 100644 mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.mm diff --git a/mediapipe/tasks/ios/vision/image_classifier/utils/BUILD b/mediapipe/tasks/ios/vision/image_classifier/utils/BUILD new file mode 100644 index 000000000..5effa46ff --- /dev/null +++ b/mediapipe/tasks/ios/vision/image_classifier/utils/BUILD @@ -0,0 +1,29 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package(default_visibility = ["//mediapipe/tasks:internal"]) + +licenses(["notice"]) + +objc_library( + name = "MPPImageClassifierResultHelpers", + srcs = ["sources/MPPImageClassifierResult+Helpers.mm"], + hdrs = ["sources/MPPImageClassifierResult+Helpers.h"], + deps = [ + "//mediapipe/framework:packet", + "//mediapipe/tasks/cc/components/containers/proto:classifications_cc_proto", + "//mediapipe/tasks/ios/components/containers/utils:MPPClassificationResultHelpers", + "//mediapipe/tasks/ios/vision/image_classifier:MPPImageClassifierResult", + ], +) diff --git a/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.h b/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.h new file mode 100644 index 000000000..2e6921c98 --- /dev/null +++ b/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.h @@ -0,0 +1,28 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import "mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifierResult.h" + +#include "mediapipe/framework/packet.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MPPImageClassifierResult (Helpers) + ++ (MPPImageClassifierResult *)imageClassifierResultWithClassificationsPacket: + (const mediapipe::Packet &)packet; + +@end + +NS_ASSUME_NONNULL_END diff --git a/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.mm b/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.mm new file mode 100644 index 000000000..e634569bb --- /dev/null +++ b/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.mm @@ -0,0 +1,43 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import "mediapipe/tasks/ios/components/containers/utils/sources/MPPClassificationResult+Helpers.h" +#import "mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.h" + +#include "mediapipe/tasks/cc/components/containers/proto/classifications.pb.h" + +static const int kMicroSecondsPerMilliSecond = 1000; + +namespace { +using ClassificationResultProto = + ::mediapipe::tasks::components::containers::proto::ClassificationResult; +using ::mediapipe::Packet; +} // namespace + +#define int kMicroSecondsPerMilliSecond = 1000; + +@implementation MPPImageClassifierResult (Helpers) + ++ (MPPImageClassifierResult *)imageClassifierResultWithClassificationsPacket: + (const Packet &)packet { + MPPClassificationResult *classificationResult = [MPPClassificationResult + classificationResultWithProto:packet.Get()]; + + return [[MPPImageClassifierResult alloc] + initWithClassificationResult:classificationResult + timestampMs:(NSInteger)(packet.Timestamp().Value() / + kMicroSecondsPerMilliSecond)]; +} + +@end From ee6171c8336f631b9e4b0409d8ae31fe8687b31d Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:46:11 +0530 Subject: [PATCH 012/136] Added MPPImageClassifierOptionsHelpers --- .../ios/vision/image_classifier/utils/BUILD | 15 +++++ .../MPPImageClassifierOptions+Helpers.h | 27 +++++++++ .../MPPImageClassifierOptions+Helpers.mm | 56 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.h create mode 100644 mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.mm diff --git a/mediapipe/tasks/ios/vision/image_classifier/utils/BUILD b/mediapipe/tasks/ios/vision/image_classifier/utils/BUILD index 5effa46ff..c1928b6ff 100644 --- a/mediapipe/tasks/ios/vision/image_classifier/utils/BUILD +++ b/mediapipe/tasks/ios/vision/image_classifier/utils/BUILD @@ -16,6 +16,21 @@ package(default_visibility = ["//mediapipe/tasks:internal"]) licenses(["notice"]) +objc_library( + name = "MPPImageClassifierOptionsHelpers", + srcs = ["sources/MPPImageClassifierOptions+Helpers.mm"], + hdrs = ["sources/MPPImageClassifierOptions+Helpers.h"], + deps = [ + "//mediapipe/framework:calculator_options_cc_proto", + "//mediapipe/tasks/cc/components/processors/proto:classifier_options_cc_proto", + "//mediapipe/tasks/cc/vision/image_classifier/proto:image_classifier_graph_options_cc_proto", + "//mediapipe/tasks/ios/common/utils:NSStringHelpers", + "//mediapipe/tasks/ios/core:MPPTaskOptionsProtocol", + "//mediapipe/tasks/ios/core/utils:MPPBaseOptionsHelpers", + "//mediapipe/tasks/ios/vision/image_classifier:MPPImageClassifierOptions", + ], +) + objc_library( name = "MPPImageClassifierResultHelpers", srcs = ["sources/MPPImageClassifierResult+Helpers.mm"], diff --git a/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.h b/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.h new file mode 100644 index 000000000..316ca215b --- /dev/null +++ b/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.h @@ -0,0 +1,27 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/framework/calculator_options.pb.h" +#import "mediapipe/tasks/ios/core/sources/MPPTaskOptionsProtocol.h" +#import "mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifierOptions.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MPPImageClassifierOptions (Helpers) + +- (void)copyToProto:(::mediapipe::CalculatorOptions *)optionsProto; + +@end + +NS_ASSUME_NONNULL_END diff --git a/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.mm b/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.mm new file mode 100644 index 000000000..36ecf9093 --- /dev/null +++ b/mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.mm @@ -0,0 +1,56 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import "mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.h" + +#import "mediapipe/tasks/ios/common/utils/sources/NSString+Helpers.h" +#import "mediapipe/tasks/ios/core/utils/sources/MPPBaseOptions+Helpers.h" + +#include "mediapipe/tasks/cc/components/processors/proto/classifier_options.pb.h" +#include "mediapipe/tasks/cc/vision/image_classifier/proto/image_classifier_graph_options.pb.h" + +namespace { +using CalculatorOptionsProto = ::mediapipe::CalculatorOptions; +using ImageClassifierGraphOptionsProto = + ::mediapipe::tasks::vision::image_classifier::proto::ImageClassifierGraphOptions; +using ClassifierOptionsProto = ::mediapipe::tasks::components::processors::proto::ClassifierOptions; +} // namespace + +@implementation MPPImageClassifierOptions (Helpers) + +- (void)copyToProto:(CalculatorOptionsProto *)optionsProto { + ImageClassifierGraphOptionsProto *graphOptions = + optionsProto->MutableExtension(ImageClassifierGraphOptionsProto::ext); + [self.baseOptions copyToProto:graphOptions->mutable_base_options()]; + + ClassifierOptionsProto *classifierOptionsProto = graphOptions->mutable_classifier_options(); + classifierOptionsProto->Clear(); + + if (self.displayNamesLocale) { + classifierOptionsProto->set_display_names_locale(self.displayNamesLocale.cppString); + } + + classifierOptionsProto->set_max_results((int)self.maxResults); + classifierOptionsProto->set_score_threshold(self.scoreThreshold); + + for (NSString *category in self.categoryAllowlist) { + classifierOptionsProto->add_category_allowlist(category.cppString); + } + + for (NSString *category in self.categoryDenylist) { + classifierOptionsProto->add_category_denylist(category.cppString); + } +} + +@end From 6eafddb8e232610b24ef366c64402fc615eebac2 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:46:20 +0530 Subject: [PATCH 013/136] Added MPPImageClassifier --- .../tasks/ios/vision/image_classifier/BUILD | 26 ++ .../sources/MPPImageClassifier.h | 217 +++++++++++++++++ .../sources/MPPImageClassifier.mm | 228 ++++++++++++++++++ 3 files changed, 471 insertions(+) create mode 100644 mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.h create mode 100644 mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm diff --git a/mediapipe/tasks/ios/vision/image_classifier/BUILD b/mediapipe/tasks/ios/vision/image_classifier/BUILD index 45e6e2156..130e5fe7d 100644 --- a/mediapipe/tasks/ios/vision/image_classifier/BUILD +++ b/mediapipe/tasks/ios/vision/image_classifier/BUILD @@ -36,3 +36,29 @@ objc_library( "//mediapipe/tasks/ios/vision/core:MPPRunningMode", ], ) + +objc_library( + name = "MPPImageClassifier", + srcs = ["sources/MPPImageClassifier.mm"], + hdrs = ["sources/MPPImageClassifier.h"], + copts = [ + "-ObjC++", + "-std=c++17", + "-x objective-c++", + ], + module_name = "MPPImageClassifier", + deps = [ + ":MPPImageClassifierOptions", + ":MPPImageClassifierResult", + "//mediapipe/tasks/cc/components/containers/proto:classifications_cc_proto", + "//mediapipe/tasks/cc/vision/image_classifier:image_classifier_graph", + "//mediapipe/tasks/ios/common/utils:MPPCommonUtils", + "//mediapipe/tasks/ios/common/utils:NSStringHelpers", + "//mediapipe/tasks/ios/core:MPPTaskInfo", + "//mediapipe/tasks/ios/core:MPPTaskOptions", + "//mediapipe/tasks/ios/vision/core:MPPVisionPacketCreator", + "//mediapipe/tasks/ios/vision/core:MPPVisionTaskRunner", + "//mediapipe/tasks/ios/vision/image_classifier/utils:MPPImageClassifierOptionsHelpers", + "//mediapipe/tasks/ios/vision/image_classifier/utils:MPPImageClassifierResultHelpers", + ], +) diff --git a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.h b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.h new file mode 100644 index 000000000..1914f9aea --- /dev/null +++ b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.h @@ -0,0 +1,217 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import + +#import "mediapipe/tasks/ios/core/sources/MPPTaskOptions.h" +#import "mediapipe/tasks/ios/vision/core/sources/MPPImage.h" +#import "mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifierOptions.h" +#import "mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifierResult.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * @brief Performs classification on images. + * + * The API expects a TFLite model with optional, but strongly recommended, + * [TFLite Model Metadata.](https://www.tensorflow.org/lite/convert/metadata"). + * + * The API supports models with one image input tensor and one or more output tensors. To be more + * specific, here are the requirements. + * + * Input tensor + * (kTfLiteUInt8/kTfLiteFloat32) + * - image input of size `[batch x height x width x channels]`. + * - batch inference is not supported (`batch` is required to be 1). + * - only RGB inputs are supported (`channels` is required to be 3). + * - if type is kTfLiteFloat32, NormalizationOptions are required to be attached to the metadata + * for input normalization. + * + * At least one output tensor with: + * (kTfLiteUInt8/kTfLiteFloat32) + * - `N `classes and either 2 or 4 dimensions, i.e. `[1 x N]` or `[1 x 1 x 1 x N]` + * - optional (but recommended) label map(s) as AssociatedFiles with type TENSOR_AXIS_LABELS, + * containing one label per line. The first such AssociatedFile (if any) is used to fill the + * `class_name` field of the results. The `display_name` field is filled from the AssociatedFile + * (if any) whose locale matches the `display_names_locale` field of the `ImageClassifierOptions` + * used at creation time ("en" by default, i.e. English). If none of these are available, only + * the `index` field of the results will be filled. + * - optional score calibration can be attached using ScoreCalibrationOptions and an AssociatedFile + * with type TENSOR_AXIS_SCORE_CALIBRATION. See metadata_schema.fbs [1] for more details. + */ +NS_SWIFT_NAME(ImageClassifier) +@interface MPPImageClassifier : NSObject + +/** + * Creates a new instance of `MPPImageClassifier` from an absolute path to a TensorFlow Lite + * model file stored locally on the device and the default `MPPImageClassifierOptions`. + * + * @param modelPath An absolute path to a TensorFlow Lite model file stored locally on the device. + * @param error An optional error parameter populated when there is an error in initializing the + * image classifier. + * + * @return A new instance of `MPPImageClassifier` with the given model path. `nil` if there is an + * error in initializing the image classifier. + */ +- (nullable instancetype)initWithModelPath:(NSString *)modelPath error:(NSError **)error; + +/** + * Creates a new instance of `MPPImageClassifier` from the given `MPPImageClassifierOptions`. + * + * @param options The options of type `MPPImageClassifierOptions` to use for configuring the + * `MPPImageClassifier`. + * @param error An optional error parameter populated when there is an error in initializing the + * image classifier. + * + * @return A new instance of `MPPImageClassifier` with the given options. `nil` if there is an + * error in initializing the image classifier. + */ +- (nullable instancetype)initWithOptions:(MPPImageClassifierOptions *)options + error:(NSError **)error NS_DESIGNATED_INITIALIZER; + +/** + * Performs image classification on the provided MPPImage using the whole image as region of + * interest. Rotation will be applied according to the `orientation` property of the provided + * `MPPImage`. Only use this method when the `MPPImageClassifier` is created with + * `MPPRunningModeImage`. + * + * @param image The `MPPImage` on which image classification is to be performed. + * @param error An optional error parameter populated when there is an error in performing + * image classification on the input image. + * + * @return A `MPPImageClassifierResult` object that contains a list of image classifications. + */ +- (nullable MPPImageClassifierResult *)classifyImage:(MPPImage *)image + error:(NSError **)error + NS_SWIFT_NAME(classify(image:)); + +/** + * Performs image classification on the provided `MPPImage` cropped to the specified region of + * interest. Rotation will be applied on the cropped image according to the `orientation` property + * of the provided `MPPImage`. Only use this method when the `MPPImageClassifier` is created with + * `MPPRunningModeImage`. + * + * @param image The `MPPImage` on which image classification is to be performed. + * @param roi A `CGRect` specifying the region of interest within the given `MPPImage`, on which + * image classification should be performed. + * @param error An optional error parameter populated when there is an error in performing + * image classification on the input image. + * + * @return A `MPPImageClassifierResult` object that contains a list of image classifications. + */ +- (nullable MPPImageClassifierResult *)classifyImage:(MPPImage *)image + regionOfInterest:(CGRect)roi + error:(NSError **)error + NS_SWIFT_NAME(classify(image:regionOfInterest:)); + +/** + * Performs image classification on the provided video frame of type `MPPImage` using the whole + * image as region of interest. Rotation will be applied according to the `orientation` property of + * the provided `MPPImage`. Only use this method when the `MPPImageClassifier` is created with + * `MPPRunningModeImage`. + * + * @param image The `MPPImage` on which image classification is to be performed. + * @param timeStampMs The video frame's timestamp (in milliseconds). The input timestamps must be + * monotonically increasing. + * @param error An optional error parameter populated when there is an error in performing + * image classification on the input video frame. + * + * @return A `MPPImageClassifierResult` object that contains a list of image classifications. + */ +- (nullable MPPImageClassifierResult *)classifyVideoFrame:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + error:(NSError **)error + NS_SWIFT_NAME(classify(videoFrame:timeStampMs:)); + +/** + * Performs image classification on the provided video frame of type `MPPImage` cropped to the + * specified region of interest. Rotation will be applied according to the `orientation` property of + * the provided `MPPImage`. Only use this method when the `MPPImageClassifier` is created with + * `MPPRunningModeVideo`. + * + * It's required to provide the video frame's timestamp (in milliseconds). The input timestamps must + * be monotonically increasing. + * + * @param image A live stream image data of type `MPPImage` on which image classification is to be + * performed. + * @param timeStampMs The video frame's timestamp (in milliseconds). The input timestamps must be + * monotonically increasing. + * @param roi A `CGRect` specifying the region of interest within the video frame of type + * `MPPImage`, on which image classification should be performed. + * @param error An optional error parameter populated when there is an error in performing + * image classification on the input video frame. + * + * @return A `MPPImageClassifierResult` object that contains a list of image classifications. + */ +- (nullable MPPImageClassifierResult *)classifyVideoFrame:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + regionOfInterest:(CGRect)roi + error:(NSError **)error + NS_SWIFT_NAME(classify(videoFrame:timeStampMs:regionOfInterest:)); + +/** + * Sends live stream image data of type `MPPImage` to perform image classification using the whole + * image as region of interest. Rotation will be applied according to the `orientation` property of + * the provided `MPPImage`. Only use this method when the `MPPImageClassifier` is created with + * `MPPRunningModeLiveStream`. + * + * It's required to provide a timestamp (in milliseconds) to indicate when the input image is sent + * to the image classifier. The input timestamps must be monotonically increasing. + * + * @param image A live stream image data of type `MPPImage` on which image classification is to be + * performed. + * @param timeStampMs The timestamp (in milliseconds) which indicates when the input image is sent + * to the image classifier. The input timestamps must be monotonically increasing. + * @param error An optional error parameter populated when there is an error in performing + * image classification on the input live stream image data. + * + * @return A `MPPImageClassifierResult` object that contains a list of image classifications. + */ +- (BOOL)classifyAsyncImage:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + error:(NSError **)error NS_SWIFT_NAME(classifyAsync(image:timeStampMs:)); + +/** + * Sends live stream image data of type `MPPImage` to perform image classification, cropped to the + * specified region of interest.. Rotation will be applied according to the `orientation` property + * of the provided `MPPImage`. Only use this method when the `MPPImageClassifier` is created with + * `MPPRunningModeLiveStream`. + * + * It's required to provide a timestamp (in milliseconds) to indicate when the input image is sent + * to the image classifier. The input timestamps must be monotonically increasing. + * + * @param image A live stream image data of type `MPPImage` on which image classification is to be + * performed. + * @param timeStampMs The timestamp (in milliseconds) which indicates when the input image is sent + * to the image classifier. The input timestamps must be monotonically increasing. + * @param roi A `CGRect` specifying the region of interest within the given live stream image data + * of type `MPPImage`, on which image classification should be performed. + * @param error An optional error parameter populated when there is an error in performing + * image classification on the input live stream image data. + * + * @return A `MPPImageClassifierResult` object that contains a list of image classifications. + */ +- (BOOL)classifyAsyncImage:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + regionOfInterest:(CGRect)roi + error:(NSError **)error + NS_SWIFT_NAME(classifyAsync(image:timeStampMs:regionOfInterest:)); + +- (instancetype)init NS_UNAVAILABLE; + ++ (instancetype)new NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm new file mode 100644 index 000000000..f4e13717b --- /dev/null +++ b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm @@ -0,0 +1,228 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import "mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.h" + +#import "mediapipe/tasks/ios/common/utils/sources/MPPCommonUtils.h" +#import "mediapipe/tasks/ios/common/utils/sources/NSString+Helpers.h" +#import "mediapipe/tasks/ios/core/sources/MPPTaskInfo.h" +#import "mediapipe/tasks/ios/vision/core/sources/MPPVisionPacketCreator.h" +#import "mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h" +#import "mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierOptions+Helpers.h" +#import "mediapipe/tasks/ios/vision/image_classifier/utils/sources/MPPImageClassifierResult+Helpers.h" + +#include "mediapipe/tasks/cc/components/containers/proto/classifications.pb.h" + +namespace { +using ::mediapipe::NormalizedRect; +using ::mediapipe::Packet; +using ::mediapipe::tasks::core::PacketMap; +using ::mediapipe::tasks::core::PacketsCallback; +} // namespace + +static NSString *const kClassificationsStreamName = @"classifications_out"; +static NSString *const kClassificationsTag = @"CLASSIFICATIONS"; +static NSString *const kImageInStreamName = @"image_in"; +static NSString *const kImageOutStreamName = @"image_out"; +static NSString *const kImageTag = @"IMAGE"; +static NSString *const kNormRectName = @"norm_rect_in"; +static NSString *const kNormRectTag = @"NORM_RECT"; + +static NSString *const kTaskGraphName = + @"mediapipe.tasks.vision.image_classifier.ImageClassifierGraph"; + +@interface MPPImageClassifier () { + /** iOS Text Task Runner */ + MPPVisionTaskRunner *_visionTaskRunner; +} +@end + +@implementation MPPImageClassifier + +- (instancetype)initWithOptions:(MPPImageClassifierOptions *)options error:(NSError **)error { + self = [super init]; + if (self) { + MPPTaskInfo *taskInfo = [[MPPTaskInfo alloc] + initWithTaskGraphName:kTaskGraphName + inputStreams:@[ [NSString + stringWithFormat:@"%@:%@", kImageTag, kImageInStreamName] ] + outputStreams:@[ [NSString stringWithFormat:@"%@:%@", kClassificationsTag, + kClassificationsStreamName] ] + taskOptions:options + enableFlowLimiting:NO + error:error]; + + if (!taskInfo) { + return nil; + } + + PacketsCallback packetsCallback = nullptr; + + if (options.completion) { + packetsCallback = [=](absl::StatusOr status_or_packets) { + NSError *callbackError = nil; + MPPImageClassifierResult *result; + if ([MPPCommonUtils checkCppError:status_or_packets.status() toError:&callbackError]) { + result = [MPPImageClassifierResult + imageClassifierResultWithClassificationsPacket: + status_or_packets.value()[kClassificationsStreamName.cppString]]; + } + options.completion(result, callbackError); + }; + } + + _visionTaskRunner = + [[MPPVisionTaskRunner alloc] initWithCalculatorGraphConfig:[taskInfo generateGraphConfig] + runningMode:options.runningMode + packetsCallback:std::move(packetsCallback) + error:error]; + + if (!_visionTaskRunner) { + return nil; + } + } + return self; +} + +- (instancetype)initWithModelPath:(NSString *)modelPath error:(NSError **)error { + MPPImageClassifierOptions *options = [[MPPImageClassifierOptions alloc] init]; + + options.baseOptions.modelAssetPath = modelPath; + + return [self initWithOptions:options error:error]; +} + +- (nullable MPPImageClassifierResult *)classifyImage:(MPPImage *)image + regionOfInterest:(CGRect)roi + error:(NSError **)error { + std::optional rect = + [_visionTaskRunner normalizedRectFromRegionOfInterest:roi + imageOrientation:image.orientation + roiAllowed:YES + error:error]; + if (!rect.has_value()) { + return nil; + } + + Packet imagePacket = [MPPVisionPacketCreator createPacketWithMPPImage:image error:error]; + if (imagePacket.IsEmpty()) { + return nil; + } + + Packet normalizedRectPacket = + [MPPVisionPacketCreator createPacketWithNormalizedRect:rect.value()]; + + PacketMap inputPacketMap = {{kImageInStreamName.cppString, imagePacket}, + {kNormRectName.cppString, normalizedRectPacket}}; + + std::optional outputPacketMap = [_visionTaskRunner processPacketMap:inputPacketMap + error:error]; + if (!outputPacketMap.has_value()) { + return nil; + } + + return + [MPPImageClassifierResult imageClassifierResultWithClassificationsPacket: + outputPacketMap.value()[kClassificationsStreamName.cppString]]; +} + +- (nullable MPPImageClassifierResult *)classifyImage:(MPPImage *)image error:(NSError **)error { + return [self classifyImage:image regionOfInterest:CGRectZero error:error]; +} + +- (nullable MPPImageClassifierResult *)classifyVideoFrame:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + regionOfInterest:(CGRect)roi + error:(NSError **)error { + std::optional rect = + [_visionTaskRunner normalizedRectFromRegionOfInterest:roi + imageOrientation:image.orientation + roiAllowed:YES + error:error]; + if (!rect.has_value()) { + return nil; + } + + Packet imagePacket = [MPPVisionPacketCreator createPacketWithMPPImage:image + timestampMs:timestampMs + error:error]; + if (imagePacket.IsEmpty()) { + return nil; + } + + Packet normalizedRectPacket = [MPPVisionPacketCreator createPacketWithNormalizedRect:rect.value() + timestampMs:timestampMs]; + + PacketMap inputPacketMap = {{kImageInStreamName.cppString, imagePacket}, + {kNormRectName.cppString, normalizedRectPacket}}; + + std::optional outputPacketMap = + [_visionTaskRunner processVideoFramePacketMap:inputPacketMap error:error]; + if (!outputPacketMap.has_value()) { + return nil; + } + + return + [MPPImageClassifierResult imageClassifierResultWithClassificationsPacket: + outputPacketMap.value()[kClassificationsStreamName.cppString]]; +} + +- (nullable MPPImageClassifierResult *)classifyVideoFrame:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + error:(NSError **)error { + return [self classifyVideoFrame:image + timestampMs:timestampMs + regionOfInterest:CGRectZero + error:error]; +} + +- (BOOL)classifyAsyncImage:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + regionOfInterest:(CGRect)roi + error:(NSError **)error { + std::optional rect = + [_visionTaskRunner normalizedRectFromRegionOfInterest:roi + imageOrientation:image.orientation + roiAllowed:YES + error:error]; + if (!rect.has_value()) { + return NO; + } + + Packet imagePacket = [MPPVisionPacketCreator createPacketWithMPPImage:image + timestampMs:timestampMs + error:error]; + if (imagePacket.IsEmpty()) { + return NO; + } + + Packet normalizedRectPacket = [MPPVisionPacketCreator createPacketWithNormalizedRect:rect.value() + timestampMs:timestampMs]; + + PacketMap inputPacketMap = {{kImageInStreamName.cppString, imagePacket}, + {kNormRectName.cppString, normalizedRectPacket}}; + + return [_visionTaskRunner processLiveStreamPacketMap:inputPacketMap error:error]; +} + +- (BOOL)classifyAsyncImage:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + error:(NSError **)error { + return [self classifyAsyncImage:image + timestampMs:timestampMs + regionOfInterest:CGRectZero + error:error]; +} + +@end From fe7bb92859cb6754686986dddc12b333c74d0e3d Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:49:42 +0530 Subject: [PATCH 014/136] Added dependency for image format --- mediapipe/tasks/ios/vision/core/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/mediapipe/tasks/ios/vision/core/BUILD b/mediapipe/tasks/ios/vision/core/BUILD index 600ef4304..bbcf0dca8 100644 --- a/mediapipe/tasks/ios/vision/core/BUILD +++ b/mediapipe/tasks/ios/vision/core/BUILD @@ -38,6 +38,7 @@ objc_library( deps = [ "//mediapipe/framework:packet", "//mediapipe/framework:timestamp", + "//mediapipe/framework/formats:image", "//mediapipe/tasks/ios/vision/core:MPPImage", "//mediapipe/tasks/ios/vision/core/utils:MPPImageUtils", "//mediapipe/framework/formats:rect_cc_proto", From 33a34de03bcae1fa05583c383f2b0cc308a35f57 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 19:50:00 +0530 Subject: [PATCH 015/136] Updated method signature in MPPTaskRunner --- mediapipe/tasks/ios/core/sources/MPPTaskRunner.h | 2 +- mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h index b2881ac06..20720e723 100644 --- a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h +++ b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h @@ -91,7 +91,7 @@ NS_ASSUME_NONNULL_BEGIN * Shuts down the C++ task runner. After the runner is closed, any calls that send input data to the * runner are illegal and will receive errors. */ -- (absl::Status)close; +- (BOOL)closeWithError:(NSError **)error; - (instancetype)init NS_UNAVAILABLE; diff --git a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm index a57846338..0813760c2 100644 --- a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm +++ b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.mm @@ -63,8 +63,9 @@ using TaskRunnerCpp = ::mediapipe::tasks::core::TaskRunner; return [MPPCommonUtils checkCppError:sendStatus toError:error]; } -- (absl::Status)close { - return _cppTaskRunner->Close(); +- (BOOL)closeWithError:(NSError **)error { + absl::Status closeStatus = _cppTaskRunner->Close(); + return [MPPCommonUtils checkCppError:closeStatus toError:error]; } @end From 09fa23088a5a16ec09a344fd85aeaf2ace673843 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Thu, 2 Mar 2023 20:01:48 +0530 Subject: [PATCH 016/136] Added TODO --- .../vision/image_classifier/sources/MPPImageClassifierOptions.h | 1 + 1 file changed, 1 insertion(+) diff --git a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifierOptions.h b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifierOptions.h index f7e9a6297..2e6022041 100644 --- a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifierOptions.h +++ b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifierOptions.h @@ -31,6 +31,7 @@ NS_SWIFT_NAME(ImageClassifierOptions) /** * The user-defined result callback for processing live stream data. The result callback should only * be specified when the running mode is set to the live stream mode. + * TODO: Add parameter `MPPImage` in the callback. */ @property(nonatomic, copy) void (^completion)(MPPImageClassifierResult *result, NSError *error); From 38ec8ae842ceb4083129a4da325a56e41799bd08 Mon Sep 17 00:00:00 2001 From: Tarun Jain Date: Thu, 2 Mar 2023 21:04:53 +0530 Subject: [PATCH 017/136] link updated --- docs/solutions/object_detection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/solutions/object_detection.md b/docs/solutions/object_detection.md index 7b18c0b08..09c178447 100644 --- a/docs/solutions/object_detection.md +++ b/docs/solutions/object_detection.md @@ -118,7 +118,7 @@ on how to build MediaPipe examples. * With a TensorFlow Model This uses the - [TensorFlow model](https://github.com/google/mediapipe/tree/master/mediapipe/graphs/object_detection) + [TensorFlow model](https://github.com/google/mediapipe/tree/v0.8.10/mediapipe/models/object_detection_saved_model) ( see also [model info](https://github.com/google/mediapipe/tree/master/mediapipe/modules/objectron)), and the pipeline is implemented in this From 4b54f7f45fec949841de904dc1b146b59352bb32 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 10:57:39 +0530 Subject: [PATCH 018/136] Fixed comments in MPPVisionTaskRunner --- .../tasks/ios/vision/core/sources/MPPVisionTaskRunner.h | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h index 5212f65fe..99787222e 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h @@ -58,7 +58,7 @@ NS_ASSUME_NONNULL_BEGIN error:(NSError **)error NS_DESIGNATED_INITIALIZER; /** - * Creates a `NormalizedRect` from region of interest and image orientation, performing + * Creates a `NormalizedRect` from a region of interest and an image orientation, performing * sanity checks on-the-fly. If the input region of interest equals `CGRectZero`, returns a default * `NormalizedRect` covering the whole image with rotation set according `imageOrientation`. If * `roiAllowed` is NO, an error will be returned if the input region of interest is not equal to @@ -66,11 +66,10 @@ NS_ASSUME_NONNULL_BEGIN * * @param roi A `CGRect` specifying the region of interest. If the input region of interest equals * `CGRectZero`, the returned `NormalizedRect` covers the whole image. Make sure that `roi` equals - * `CGRectZero` if `roiAllowed` is NO. Otherwise an error will be returned. + * `CGRectZero` if `roiAllowed` is NO. Otherwise, an error will be returned. * @param imageOrientation A `UIImageOrientation` indicating the rotation to be applied to the * image. The resulting `NormalizedRect` will convert the `imageOrientation` to degrees clockwise. * @param roiAllowed Indicates if the `roi` field is allowed to be a value other than `CGRectZero`. - * * @param error Pointer to the memory location where errors if any should be * saved. If @c NULL, no error will be saved. * @@ -83,7 +82,7 @@ NS_ASSUME_NONNULL_BEGIN error:(NSError **)error; /** - * A synchronous method to invoke the C++ task runner to process single image inputs. The call + * A synchronous method to invoke the C++ task runner to process single image inputs. The call * blocks the current thread until a failure status or a successful result is returned. * * @param packetMap A `PackeMap` containing pairs of input stream name and data packet. @@ -97,7 +96,7 @@ NS_ASSUME_NONNULL_BEGIN error:(NSError **)error; /** - * A synchronous method to invoke the C++ task runner to process continuous video frames. The call + * A synchronous method to invoke the C++ task runner to process continuous video frames. The call * blocks the current thread until a failure status or a successful result is returned. * * @param packetMap A `PackeMap` containing pairs of input stream name and data packet. From af82dc5e175b4e377c2728e0faf37c950bfbe229 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 11:10:20 +0530 Subject: [PATCH 019/136] Updated comments in MPPImageClassifier --- .../sources/MPPImageClassifier.h | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.h b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.h index 1914f9aea..0e42ec203 100644 --- a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.h +++ b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.h @@ -54,8 +54,8 @@ NS_SWIFT_NAME(ImageClassifier) @interface MPPImageClassifier : NSObject /** - * Creates a new instance of `MPPImageClassifier` from an absolute path to a TensorFlow Lite - * model file stored locally on the device and the default `MPPImageClassifierOptions`. + * Creates a new instance of `MPPImageClassifier` from an absolute path to a TensorFlow Lite model + * file stored locally on the device and the default `MPPImageClassifierOptions`. * * @param modelPath An absolute path to a TensorFlow Lite model file stored locally on the device. * @param error An optional error parameter populated when there is an error in initializing the @@ -74,8 +74,8 @@ NS_SWIFT_NAME(ImageClassifier) * @param error An optional error parameter populated when there is an error in initializing the * image classifier. * - * @return A new instance of `MPPImageClassifier` with the given options. `nil` if there is an - * error in initializing the image classifier. + * @return A new instance of `MPPImageClassifier` with the given options. `nil` if there is an error + * in initializing the image classifier. */ - (nullable instancetype)initWithOptions:(MPPImageClassifierOptions *)options error:(NSError **)error NS_DESIGNATED_INITIALIZER; @@ -87,8 +87,8 @@ NS_SWIFT_NAME(ImageClassifier) * `MPPRunningModeImage`. * * @param image The `MPPImage` on which image classification is to be performed. - * @param error An optional error parameter populated when there is an error in performing - * image classification on the input image. + * @param error An optional error parameter populated when there is an error in performing image + * classification on the input image. * * @return A `MPPImageClassifierResult` object that contains a list of image classifications. */ @@ -105,8 +105,8 @@ NS_SWIFT_NAME(ImageClassifier) * @param image The `MPPImage` on which image classification is to be performed. * @param roi A `CGRect` specifying the region of interest within the given `MPPImage`, on which * image classification should be performed. - * @param error An optional error parameter populated when there is an error in performing - * image classification on the input image. + * @param error An optional error parameter populated when there is an error in performing image + * classification on the input image. * * @return A `MPPImageClassifierResult` object that contains a list of image classifications. */ @@ -119,20 +119,20 @@ NS_SWIFT_NAME(ImageClassifier) * Performs image classification on the provided video frame of type `MPPImage` using the whole * image as region of interest. Rotation will be applied according to the `orientation` property of * the provided `MPPImage`. Only use this method when the `MPPImageClassifier` is created with - * `MPPRunningModeImage`. + * `MPPRunningModeVideo`. * * @param image The `MPPImage` on which image classification is to be performed. - * @param timeStampMs The video frame's timestamp (in milliseconds). The input timestamps must be + * @param timestampMs The video frame's timestamp (in milliseconds). The input timestamps must be * monotonically increasing. - * @param error An optional error parameter populated when there is an error in performing - * image classification on the input video frame. + * @param error An optional error parameter populated when there is an error in performing image + * classification on the input video frame. * * @return A `MPPImageClassifierResult` object that contains a list of image classifications. */ - (nullable MPPImageClassifierResult *)classifyVideoFrame:(MPPImage *)image timestampMs:(NSInteger)timestampMs error:(NSError **)error - NS_SWIFT_NAME(classify(videoFrame:timeStampMs:)); + NS_SWIFT_NAME(classify(videoFrame:timestampMs:)); /** * Performs image classification on the provided video frame of type `MPPImage` cropped to the @@ -145,12 +145,12 @@ NS_SWIFT_NAME(ImageClassifier) * * @param image A live stream image data of type `MPPImage` on which image classification is to be * performed. - * @param timeStampMs The video frame's timestamp (in milliseconds). The input timestamps must be + * @param timestampMs The video frame's timestamp (in milliseconds). The input timestamps must be * monotonically increasing. * @param roi A `CGRect` specifying the region of interest within the video frame of type * `MPPImage`, on which image classification should be performed. - * @param error An optional error parameter populated when there is an error in performing - * image classification on the input video frame. + * @param error An optional error parameter populated when there is an error in performing image + * classification on the input video frame. * * @return A `MPPImageClassifierResult` object that contains a list of image classifications. */ @@ -158,7 +158,7 @@ NS_SWIFT_NAME(ImageClassifier) timestampMs:(NSInteger)timestampMs regionOfInterest:(CGRect)roi error:(NSError **)error - NS_SWIFT_NAME(classify(videoFrame:timeStampMs:regionOfInterest:)); + NS_SWIFT_NAME(classify(videoFrame:timestampMs:regionOfInterest:)); /** * Sends live stream image data of type `MPPImage` to perform image classification using the whole @@ -171,16 +171,16 @@ NS_SWIFT_NAME(ImageClassifier) * * @param image A live stream image data of type `MPPImage` on which image classification is to be * performed. - * @param timeStampMs The timestamp (in milliseconds) which indicates when the input image is sent + * @param timestampMs The timestamp (in milliseconds) which indicates when the input image is sent * to the image classifier. The input timestamps must be monotonically increasing. - * @param error An optional error parameter populated when there is an error in performing - * image classification on the input live stream image data. + * @param error An optional error parameter populated when there is an error in performing image + * classification on the input live stream image data. * * @return A `MPPImageClassifierResult` object that contains a list of image classifications. */ - (BOOL)classifyAsyncImage:(MPPImage *)image timestampMs:(NSInteger)timestampMs - error:(NSError **)error NS_SWIFT_NAME(classifyAsync(image:timeStampMs:)); + error:(NSError **)error NS_SWIFT_NAME(classifyAsync(image:timestampMs:)); /** * Sends live stream image data of type `MPPImage` to perform image classification, cropped to the @@ -193,12 +193,12 @@ NS_SWIFT_NAME(ImageClassifier) * * @param image A live stream image data of type `MPPImage` on which image classification is to be * performed. - * @param timeStampMs The timestamp (in milliseconds) which indicates when the input image is sent + * @param timestampMs The timestamp (in milliseconds) which indicates when the input image is sent * to the image classifier. The input timestamps must be monotonically increasing. * @param roi A `CGRect` specifying the region of interest within the given live stream image data * of type `MPPImage`, on which image classification should be performed. - * @param error An optional error parameter populated when there is an error in performing - * image classification on the input live stream image data. + * @param error An optional error parameter populated when there is an error in performing image + * classification on the input live stream image data. * * @return A `MPPImageClassifierResult` object that contains a list of image classifications. */ @@ -206,7 +206,7 @@ NS_SWIFT_NAME(ImageClassifier) timestampMs:(NSInteger)timestampMs regionOfInterest:(CGRect)roi error:(NSError **)error - NS_SWIFT_NAME(classifyAsync(image:timeStampMs:regionOfInterest:)); + NS_SWIFT_NAME(classifyAsync(image:timestampMs:regionOfInterest:)); - (instancetype)init NS_UNAVAILABLE; From d577727698e80c0903dbe350a4c2194241fb6b97 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 11:11:07 +0530 Subject: [PATCH 020/136] Updated MPPImageClassifier --- .../ios/vision/image_classifier/sources/MPPImageClassifier.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm index f4e13717b..6db7d06c5 100644 --- a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm +++ b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm @@ -43,7 +43,7 @@ static NSString *const kTaskGraphName = @"mediapipe.tasks.vision.image_classifier.ImageClassifierGraph"; @interface MPPImageClassifier () { - /** iOS Text Task Runner */ + /** iOS Vision Task Runner */ MPPVisionTaskRunner *_visionTaskRunner; } @end From 8aaabe4a022f1d0c7fae4d13d06c80ab18400b41 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 11:18:36 +0530 Subject: [PATCH 021/136] Updated comments in MPPVisionTaskRunner --- .../ios/vision/core/sources/MPPVisionTaskRunner.h | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h index 99787222e..007d4f648 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h @@ -70,8 +70,8 @@ NS_ASSUME_NONNULL_BEGIN * @param imageOrientation A `UIImageOrientation` indicating the rotation to be applied to the * image. The resulting `NormalizedRect` will convert the `imageOrientation` to degrees clockwise. * @param roiAllowed Indicates if the `roi` field is allowed to be a value other than `CGRectZero`. - * @param error Pointer to the memory location where errors if any should be - * saved. If @c NULL, no error will be saved. + * @param error Pointer to the memory location where errors if any should be saved. If @c NULL, no + * error will be saved. * * @return An optional `NormalizedRect` from the given region of interest and image orientation. */ @@ -100,8 +100,8 @@ NS_ASSUME_NONNULL_BEGIN * blocks the current thread until a failure status or a successful result is returned. * * @param packetMap A `PackeMap` containing pairs of input stream name and data packet. - * @param error Pointer to the memory location where errors if any should be - * saved. If @c NULL, no error will be saved. + * @param error Pointer to the memory location where errors if any should be saved. If @c NULL, no + * error will be saved. * * @return An optional `PacketMap` containing pairs of output stream name and data packet. */ @@ -116,8 +116,8 @@ NS_ASSUME_NONNULL_BEGIN * `MPPVisionTaskRunner`. * * @param packetMap A `PackeMap` containing pairs of input stream name and data packet. - * @param error Pointer to the memory location where errors if any should be - * saved. If @c NULL, no error will be saved. + * @param error Pointer to the memory location where errors if any should be saved. If @c NULL, no + * error will be saved. * * @return A `BOOL` indicating if the live stream data was sent to the C++ task runner successfully. * Please note that any errors during processing of the live stream packet will only be available in From 289b3b20de9a66d89e58e95d132ad0115360bcf8 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 12:01:45 +0530 Subject: [PATCH 022/136] Added methods for common functionality in MPPImageClassifier --- .../sources/MPPImageClassifier.mm | 88 ++++++++++--------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm index 6db7d06c5..564aede88 100644 --- a/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm +++ b/mediapipe/tasks/ios/vision/image_classifier/sources/MPPImageClassifier.mm @@ -42,6 +42,11 @@ static NSString *const kNormRectTag = @"NORM_RECT"; static NSString *const kTaskGraphName = @"mediapipe.tasks.vision.image_classifier.ImageClassifierGraph"; +#define InputPacketMap(imagePacket, normalizedRectPacket) \ + { \ + {kImageInStreamName.cppString, imagePacket}, { kNormRectName.cppString, normalizedRectPacket } \ + } + @interface MPPImageClassifier () { /** iOS Vision Task Runner */ MPPVisionTaskRunner *_visionTaskRunner; @@ -123,8 +128,7 @@ static NSString *const kTaskGraphName = Packet normalizedRectPacket = [MPPVisionPacketCreator createPacketWithNormalizedRect:rect.value()]; - PacketMap inputPacketMap = {{kImageInStreamName.cppString, imagePacket}, - {kNormRectName.cppString, normalizedRectPacket}}; + PacketMap inputPacketMap = InputPacketMap(imagePacket, normalizedRectPacket); std::optional outputPacketMap = [_visionTaskRunner processPacketMap:inputPacketMap error:error]; @@ -137,6 +141,33 @@ static NSString *const kTaskGraphName = outputPacketMap.value()[kClassificationsStreamName.cppString]]; } +- (std::optional)inputPacketMapWithMPPImage:(MPPImage *)image + timestampMs:(NSInteger)timestampMs + regionOfInterest:(CGRect)roi + error:(NSError **)error { + std::optional rect = + [_visionTaskRunner normalizedRectFromRegionOfInterest:roi + imageOrientation:image.orientation + roiAllowed:YES + error:error]; + if (!rect.has_value()) { + return std::nullopt; + } + + Packet imagePacket = [MPPVisionPacketCreator createPacketWithMPPImage:image + timestampMs:timestampMs + error:error]; + if (imagePacket.IsEmpty()) { + return std::nullopt; + } + + Packet normalizedRectPacket = [MPPVisionPacketCreator createPacketWithNormalizedRect:rect.value() + timestampMs:timestampMs]; + + PacketMap inputPacketMap = InputPacketMap(imagePacket, normalizedRectPacket); + return inputPacketMap; +} + - (nullable MPPImageClassifierResult *)classifyImage:(MPPImage *)image error:(NSError **)error { return [self classifyImage:image regionOfInterest:CGRectZero error:error]; } @@ -145,30 +176,17 @@ static NSString *const kTaskGraphName = timestampMs:(NSInteger)timestampMs regionOfInterest:(CGRect)roi error:(NSError **)error { - std::optional rect = - [_visionTaskRunner normalizedRectFromRegionOfInterest:roi - imageOrientation:image.orientation - roiAllowed:YES - error:error]; - if (!rect.has_value()) { + std::optional inputPacketMap = [self inputPacketMapWithMPPImage:image + timestampMs:timestampMs + regionOfInterest:roi + error:error]; + if (!inputPacketMap.has_value()) { return nil; } - Packet imagePacket = [MPPVisionPacketCreator createPacketWithMPPImage:image - timestampMs:timestampMs - error:error]; - if (imagePacket.IsEmpty()) { - return nil; - } - - Packet normalizedRectPacket = [MPPVisionPacketCreator createPacketWithNormalizedRect:rect.value() - timestampMs:timestampMs]; - - PacketMap inputPacketMap = {{kImageInStreamName.cppString, imagePacket}, - {kNormRectName.cppString, normalizedRectPacket}}; - std::optional outputPacketMap = - [_visionTaskRunner processVideoFramePacketMap:inputPacketMap error:error]; + [_visionTaskRunner processVideoFramePacketMap:inputPacketMap.value() error:error]; + if (!outputPacketMap.has_value()) { return nil; } @@ -191,29 +209,15 @@ static NSString *const kTaskGraphName = timestampMs:(NSInteger)timestampMs regionOfInterest:(CGRect)roi error:(NSError **)error { - std::optional rect = - [_visionTaskRunner normalizedRectFromRegionOfInterest:roi - imageOrientation:image.orientation - roiAllowed:YES - error:error]; - if (!rect.has_value()) { + std::optional inputPacketMap = [self inputPacketMapWithMPPImage:image + timestampMs:timestampMs + regionOfInterest:roi + error:error]; + if (!inputPacketMap.has_value()) { return NO; } - Packet imagePacket = [MPPVisionPacketCreator createPacketWithMPPImage:image - timestampMs:timestampMs - error:error]; - if (imagePacket.IsEmpty()) { - return NO; - } - - Packet normalizedRectPacket = [MPPVisionPacketCreator createPacketWithNormalizedRect:rect.value() - timestampMs:timestampMs]; - - PacketMap inputPacketMap = {{kImageInStreamName.cppString, imagePacket}, - {kNormRectName.cppString, normalizedRectPacket}}; - - return [_visionTaskRunner processLiveStreamPacketMap:inputPacketMap error:error]; + return [_visionTaskRunner processLiveStreamPacketMap:inputPacketMap.value() error:error]; } - (BOOL)classifyAsyncImage:(MPPImage *)image From 6253966901930f2bf45912330ae8098a34e1a733 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 12:15:25 +0530 Subject: [PATCH 023/136] Updated comments in MPPTaskRunner to include note about mirrored orientations --- .../ios/vision/core/sources/MPPVisionTaskRunner.h | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h index 007d4f648..ced188e3c 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h @@ -59,16 +59,23 @@ NS_ASSUME_NONNULL_BEGIN /** * Creates a `NormalizedRect` from a region of interest and an image orientation, performing - * sanity checks on-the-fly. If the input region of interest equals `CGRectZero`, returns a default - * `NormalizedRect` covering the whole image with rotation set according `imageOrientation`. If - * `roiAllowed` is NO, an error will be returned if the input region of interest is not equal to + * sanity checks on-the-fly. + * If the input region of interest equals `CGRectZero`, returns a default `NormalizedRect` covering + * the whole image with rotation set according `imageOrientation`. + * If `roiAllowed` is NO, an error will be returned if the input region of interest is not equal to * `CGRectZero`. + * Mirrored orientations (`UIImageOrientationUpMirrored`,`UIImageOrientationDownMirrored`, + * `UIImageOrientationLeftMirrored`,`UIImageOrientationRightMirrored`) are not supported. An error + * will be returned if `imageOrientation` is equal to any one of them. * * @param roi A `CGRect` specifying the region of interest. If the input region of interest equals * `CGRectZero`, the returned `NormalizedRect` covers the whole image. Make sure that `roi` equals * `CGRectZero` if `roiAllowed` is NO. Otherwise, an error will be returned. * @param imageOrientation A `UIImageOrientation` indicating the rotation to be applied to the * image. The resulting `NormalizedRect` will convert the `imageOrientation` to degrees clockwise. + * Mirrored orientations (`UIImageOrientationUpMirrored`, `UIImageOrientationDownMirrored`, + * `UIImageOrientationLeftMirrored`, `UIImageOrientationRightMirrored`) are not supported. An error + * will be returned if `imageOrientation` is equal to any one of them. * @param roiAllowed Indicates if the `roi` field is allowed to be a value other than `CGRectZero`. * @param error Pointer to the memory location where errors if any should be saved. If @c NULL, no * error will be saved. From fee66069acad274efb98a64da939e0d1264925b1 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 12:19:14 +0530 Subject: [PATCH 024/136] Updated error with info about unsupported mirrored orientations in MPPVisionTaskRunner --- .../ios/vision/core/sources/MPPVisionTaskRunner.mm | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm index 3c115f1c6..fff14a978 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm @@ -113,9 +113,13 @@ using ::mediapipe::tasks::core::PacketsCallback; break; } default: - [MPPCommonUtils createCustomError:error - withCode:MPPTasksErrorCodeInvalidArgumentError - description:@"Unsupported UIImageOrientation."]; + [MPPCommonUtils + createCustomError:error + withCode:MPPTasksErrorCodeInvalidArgumentError + description:@"Unsupported UIImageOrientation. `imageOrientation` cannot be equal to " + @"any of the mirrored orientations " + @"(`UIImageOrientationUpMirrored`,`UIImageOrientationDownMirrored`,`" + @"UIImageOrientationLeftMirrored`,`UIImageOrientationRightMirrored`)"]; } normalizedRect.set_rotation(-rotationDegrees * M_PI / 180.0); From 160fa424b536f0d6648f0839facd950df3462682 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 12:33:17 +0530 Subject: [PATCH 025/136] Updated documentation of MPPTaskRunner --- .../tasks/ios/core/sources/MPPTaskRunner.h | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h index 20720e723..df6292989 100644 --- a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h +++ b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h @@ -64,15 +64,23 @@ NS_ASSUME_NONNULL_BEGIN error:(NSError **)error NS_DESIGNATED_INITIALIZER; /** - * A synchronous method for processing batch data or offline streaming data. This method is designed - * for processing either batch data such as unrelated images and texts or offline streaming data - * such as the decoded frames from a video file or audio file. The call blocks the current - * thread until a failure status or a successful result is returned. If the input packets have no - * timestamp, an internal timestamp will be assigend per invocation. Otherwise, when the timestamp - * is set in the input packets, the caller must ensure that the input packet timestamps are greater - * than the timestamps of the previous invocation. This method is thread-unsafe and it is the - * caller's responsibility to synchronize access to this method across multiple threads and to - * ensure that the input packet timestamps are in order. + * A synchronous method for invoking the C++ task runner for processing batch data or offline + * streaming data. This method is designed for processing either batch data such as unrelated images + * and texts or offline streaming data such as the decoded frames from a video file or audio file. + * The call blocks the current thread until a failure status or a successful result is returned. If + * the input packets have no timestamp, an internal timestamp will be assigend per invocation. + * Otherwise, when the timestamp is set in the input packets, the caller must ensure that the input + * packet timestamps are greater than the timestamps of the previous invocation. This method is + * thread-unsafe and it is the caller's responsibility to synchronize access to this method across + * multiple threads and to ensure that the input packet timestamps are in order. + * + * @param packetMap A `PacketMap` containing pairs of input stream name and data packet which are to + * be sent to the C++ task runner for processing synchronously. + * @param error Pointer to the memory location where errors if any should be saved. If @c NULL, no + * error will be saved. + * + * @return An optional output `PacketMap` containing pairs of output stream name and data packet + * which holds the results of processing the input packet map, if there are no errors. */ - (std::optional) processPacketMap:(const mediapipe::tasks::core::PacketMap &)packetMap @@ -84,12 +92,27 @@ NS_ASSUME_NONNULL_BEGIN * packets. The caller must ensure that the input packet timestamps are monotonically increasing. * This method is thread-unsafe and it is the caller's responsibility to synchronize access to this * method across multiple threads and to ensure that the input packet timestamps are in order. + * + * @param packetMap A `PacketMap` containing pairs of input stream name and data packet that are to + * be sent to the C++ task runner for processing asynchronously. + * @param error Pointer to the memory location where errors if any should be saved. If @c NULL, no + * error will be saved. + * + * @return A `BOOL` indicating if the live stream data was sent to the C++ task runner successfully. + * Please note that any errors during processing of the live stream packet map will only be + * available in the user-defined `packetsCallback` that was provided during initialization of the + * `MPPVisionTaskRunner`. */ - (BOOL)sendPacketMap:(const mediapipe::tasks::core::PacketMap &)packetMap error:(NSError **)error; /** * Shuts down the C++ task runner. After the runner is closed, any calls that send input data to the * runner are illegal and will receive errors. + * + * @param error Pointer to the memory location where errors if any should be saved. If @c NULL, no + * error will be saved. + * + * @return A `BOOL` indicating if the C++ task runner was shutdown successfully. */ - (BOOL)closeWithError:(NSError **)error; From f2dfa7f474d467b57719fe01b99539c4ff2fa4ae Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 12:33:31 +0530 Subject: [PATCH 026/136] Updated documentation of MPPVisionTaskRunner --- mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h index ced188e3c..6e4078f32 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h @@ -127,7 +127,7 @@ NS_ASSUME_NONNULL_BEGIN * error will be saved. * * @return A `BOOL` indicating if the live stream data was sent to the C++ task runner successfully. - * Please note that any errors during processing of the live stream packet will only be available in + * Please note that any errors during processing of the live stream packet map will only be available in * the user-defined `packetsCallback` that was provided during initialization of the * `MPPVisionTaskRunner`. */ From 412476eba11d292b0380c88a22b8a93b02f2cc3e Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 14:39:03 +0530 Subject: [PATCH 027/136] Added inline function to return display name of MPPRunningMode --- .../ios/vision/core/sources/MPPRunningMode.h | 19 +++++++++++++++ .../core/sources/MPPVisionTaskRunner.mm | 24 +++++++++++-------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h b/mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h index 5cc57b88a..8818d9b15 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h +++ b/mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h @@ -38,4 +38,23 @@ typedef NS_ENUM(NSUInteger, MPPRunningMode) { } NS_SWIFT_NAME(RunningMode); +NS_INLINE NSString *MPPRunningModeDisplayName(MPPRunningMode runningMode) { + + if (runningMode > MPPRunningModeLiveStream) { + return nil; + } + + #define MPPRunningModeDisplayNameMap(mode) [mode] = @#mode + + NSString *displayNameMap[MPPRunningModeLiveStream + 1] = { + MPPRunningModeDisplayNameMap(MPPRunningModeImage), + MPPRunningModeDisplayNameMap(MPPRunningModeVideo), + MPPRunningModeDisplayNameMap(MPPRunningModeLiveStream), + }; + + #undef MPPRunningModeDisplayNameMap + + return displayNameMap[runningMode]; +} + NS_ASSUME_NONNULL_END diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm index fff14a978..43ef8ce9b 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.mm @@ -116,10 +116,11 @@ using ::mediapipe::tasks::core::PacketsCallback; [MPPCommonUtils createCustomError:error withCode:MPPTasksErrorCodeInvalidArgumentError - description:@"Unsupported UIImageOrientation. `imageOrientation` cannot be equal to " - @"any of the mirrored orientations " - @"(`UIImageOrientationUpMirrored`,`UIImageOrientationDownMirrored`,`" - @"UIImageOrientationLeftMirrored`,`UIImageOrientationRightMirrored`)"]; + description: + @"Unsupported UIImageOrientation. `imageOrientation` cannot be equal to " + @"any of the mirrored orientations " + @"(`UIImageOrientationUpMirrored`,`UIImageOrientationDownMirrored`,`" + @"UIImageOrientationLeftMirrored`,`UIImageOrientationRightMirrored`)"]; } normalizedRect.set_rotation(-rotationDegrees * M_PI / 180.0); @@ -132,8 +133,9 @@ using ::mediapipe::tasks::core::PacketsCallback; [MPPCommonUtils createCustomError:error withCode:MPPTasksErrorCodeInvalidArgumentError - description: - @"The vision task is not initialized with image mode. Current Running Mode:"]; + description:[NSString stringWithFormat:@"The vision task is not initialized with " + @"image mode. Current Running Mode: %@", + MPPRunningModeDisplayName(_runningMode)]]; return std::nullopt; } @@ -146,8 +148,9 @@ using ::mediapipe::tasks::core::PacketsCallback; [MPPCommonUtils createCustomError:error withCode:MPPTasksErrorCodeInvalidArgumentError - description: - @"The vision task is not initialized with image mode. Current Running Mode:"]; + description:[NSString stringWithFormat:@"The vision task is not initialized with " + @"video mode. Current Running Mode: %@", + MPPRunningModeDisplayName(_runningMode)]]; return std::nullopt; } @@ -159,8 +162,9 @@ using ::mediapipe::tasks::core::PacketsCallback; [MPPCommonUtils createCustomError:error withCode:MPPTasksErrorCodeInvalidArgumentError - description: - @"The vision task is not initialized with image mode. Current Running Mode:"]; + description:[NSString stringWithFormat:@"The vision task is not initialized with " + @"live stream mode. Current Running Mode: %@", + MPPRunningModeDisplayName(_runningMode)]]; return NO; } From 7b8d92ba47199921a47f99e565c8b2c17d3bc63d Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 14:39:22 +0530 Subject: [PATCH 028/136] Updated formatting in MPPVisionTaskRunner --- mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h index 6e4078f32..52a9e6d34 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h +++ b/mediapipe/tasks/ios/vision/core/sources/MPPVisionTaskRunner.h @@ -127,8 +127,8 @@ NS_ASSUME_NONNULL_BEGIN * error will be saved. * * @return A `BOOL` indicating if the live stream data was sent to the C++ task runner successfully. - * Please note that any errors during processing of the live stream packet map will only be available in - * the user-defined `packetsCallback` that was provided during initialization of the + * Please note that any errors during processing of the live stream packet map will only be + * available in the user-defined `packetsCallback` that was provided during initialization of the * `MPPVisionTaskRunner`. */ - (BOOL)processLiveStreamPacketMap:(const mediapipe::tasks::core::PacketMap &)packetMap From 87df23f0fa51072ee1cd9e757bb72ba11704c5f4 Mon Sep 17 00:00:00 2001 From: Prianka Liz Kariat Date: Fri, 3 Mar 2023 14:40:00 +0530 Subject: [PATCH 029/136] Updated formatting of MPPRunningMode.h --- mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h b/mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h index 8818d9b15..4abacc1a0 100644 --- a/mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h +++ b/mediapipe/tasks/ios/vision/core/sources/MPPRunningMode.h @@ -39,20 +39,19 @@ typedef NS_ENUM(NSUInteger, MPPRunningMode) { } NS_SWIFT_NAME(RunningMode); NS_INLINE NSString *MPPRunningModeDisplayName(MPPRunningMode runningMode) { - if (runningMode > MPPRunningModeLiveStream) { - return nil; + return nil; } - #define MPPRunningModeDisplayNameMap(mode) [mode] = @#mode - +#define MPPRunningModeDisplayNameMap(mode) [mode] = @ #mode + NSString *displayNameMap[MPPRunningModeLiveStream + 1] = { MPPRunningModeDisplayNameMap(MPPRunningModeImage), MPPRunningModeDisplayNameMap(MPPRunningModeVideo), MPPRunningModeDisplayNameMap(MPPRunningModeLiveStream), }; - #undef MPPRunningModeDisplayNameMap +#undef MPPRunningModeDisplayNameMap return displayNameMap[runningMode]; } From 54e597216b08f43a74c542347301d791415b8a9c Mon Sep 17 00:00:00 2001 From: Tarun Jain Date: Sat, 4 Mar 2023 22:58:12 +0530 Subject: [PATCH 030/136] np used in the program but not imported --- docs/solutions/pose.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/solutions/pose.md b/docs/solutions/pose.md index 3226ddc2f..41dad28e1 100644 --- a/docs/solutions/pose.md +++ b/docs/solutions/pose.md @@ -268,6 +268,7 @@ Supported configuration options: ```python import cv2 +import numpy as np import mediapipe as mp mp_drawing = mp.solutions.drawing_utils mp_drawing_styles = mp.solutions.drawing_styles From 022838a7f378d199876a9ab1c1c6b9ace03c8b29 Mon Sep 17 00:00:00 2001 From: kinaryml Date: Thu, 9 Mar 2023 01:36:39 -0800 Subject: [PATCH 031/136] Added Face Detector implementation and tests --- mediapipe/python/BUILD | 1 + .../tasks/python/components/containers/BUILD | 10 + .../components/containers/detections.py | 40 +- .../python/components/containers/keypoint.py | 78 ++++ mediapipe/tasks/python/test/vision/BUILD | 22 + .../python/test/vision/face_detector_test.py | 407 ++++++++++++++++++ mediapipe/tasks/python/vision/BUILD | 20 + .../tasks/python/vision/face_detector.py | 308 +++++++++++++ 8 files changed, 882 insertions(+), 4 deletions(-) create mode 100644 mediapipe/tasks/python/components/containers/keypoint.py create mode 100644 mediapipe/tasks/python/test/vision/face_detector_test.py create mode 100644 mediapipe/tasks/python/vision/face_detector.py diff --git a/mediapipe/python/BUILD b/mediapipe/python/BUILD index f56e5b3d4..141b59d71 100644 --- a/mediapipe/python/BUILD +++ b/mediapipe/python/BUILD @@ -94,6 +94,7 @@ cc_library( "//mediapipe/tasks/cc/vision/image_embedder:image_embedder_graph", "//mediapipe/tasks/cc/vision/image_segmenter:image_segmenter_graph", "//mediapipe/tasks/cc/vision/object_detector:object_detector_graph", + "//mediapipe/tasks/cc/vision/face_detector:face_detector_graph", ] + select({ # TODO: Build text_classifier_graph and text_embedder_graph on Windows. "//mediapipe:windows": [], diff --git a/mediapipe/tasks/python/components/containers/BUILD b/mediapipe/tasks/python/components/containers/BUILD index 7108617ff..b84ab744d 100644 --- a/mediapipe/tasks/python/components/containers/BUILD +++ b/mediapipe/tasks/python/components/containers/BUILD @@ -73,12 +73,22 @@ py_library( ], ) +py_library( + name = "keypoint", + srcs = ["keypoint.py"], + deps = [ + "//mediapipe/framework/formats:location_data_py_pb2", + "//mediapipe/tasks/python/core:optional_dependencies", + ], +) + py_library( name = "detections", srcs = ["detections.py"], deps = [ ":bounding_box", ":category", + ":keypoint", "//mediapipe/framework/formats:detection_py_pb2", "//mediapipe/framework/formats:location_data_py_pb2", "//mediapipe/tasks/python/core:optional_dependencies", diff --git a/mediapipe/tasks/python/components/containers/detections.py b/mediapipe/tasks/python/components/containers/detections.py index b4d550633..94fe16096 100644 --- a/mediapipe/tasks/python/components/containers/detections.py +++ b/mediapipe/tasks/python/components/containers/detections.py @@ -20,6 +20,7 @@ from mediapipe.framework.formats import detection_pb2 from mediapipe.framework.formats import location_data_pb2 from mediapipe.tasks.python.components.containers import bounding_box as bounding_box_module from mediapipe.tasks.python.components.containers import category as category_module +from mediapipe.tasks.python.components.containers import keypoint as keypoint_module from mediapipe.tasks.python.core.optional_dependencies import doc_controls _DetectionListProto = detection_pb2.DetectionList @@ -34,10 +35,12 @@ class Detection: Attributes: bounding_box: A BoundingBox object. categories: A list of Category objects. + keypoints: A list of NormalizedKeypoint objects. """ - bounding_box: bounding_box_module.BoundingBox - categories: List[category_module.Category] + bounding_box: bounding_box_module.BoundingBox = None + categories: List[category_module.Category] = None + keypoints: List[keypoint_module.NormalizedKeypoint] = None @doc_controls.do_not_generate_docs def to_pb2(self) -> _DetectionProto: @@ -46,6 +49,8 @@ class Detection: label_ids = [] scores = [] display_names = [] + relative_keypoints = [] + for category in self.categories: scores.append(category.score) if category.index: @@ -54,6 +59,20 @@ class Detection: labels.append(category.category_name) if category.display_name: display_names.append(category.display_name) + + if self.keypoints: + for keypoint in self.keypoints: + relative_keypoint_proto = _LocationDataProto.RelativeKeypoint() + if keypoint.x: + relative_keypoint_proto.x = keypoint.x + if keypoint.y: + relative_keypoint_proto.y = keypoint.y + if keypoint.label: + relative_keypoint_proto.keypoint_label = keypoint.label + if keypoint.score: + relative_keypoint_proto.score = keypoint.score + relative_keypoints.append(relative_keypoint_proto) + return _DetectionProto( label=labels, label_id=label_ids, @@ -61,13 +80,16 @@ class Detection: display_name=display_names, location_data=_LocationDataProto( format=_LocationDataProto.Format.BOUNDING_BOX, - bounding_box=self.bounding_box.to_pb2())) + bounding_box=self.bounding_box.to_pb2(), + relative_keypoints=relative_keypoints)) @classmethod @doc_controls.do_not_generate_docs def create_from_pb2(cls, pb2_obj: _DetectionProto) -> 'Detection': """Creates a `Detection` object from the given protobuf object.""" categories = [] + keypoints = [] + for idx, score in enumerate(pb2_obj.score): categories.append( category_module.Category( @@ -79,10 +101,20 @@ class Detection: display_name=pb2_obj.display_name[idx] if idx < len(pb2_obj.display_name) else None)) + if pb2_obj.location_data.relative_keypoints: + for idx in range(len(pb2_obj.location_data.relative_keypoints)): + keypoints.append( + keypoint_module.NormalizedKeypoint( + x=pb2_obj.location_data.relative_keypoints[idx].x, + y=pb2_obj.location_data.relative_keypoints[idx].y, + label=pb2_obj.location_data.relative_keypoints[idx].keypoint_label, + score=pb2_obj.location_data.relative_keypoints[idx].score)) + return Detection( bounding_box=bounding_box_module.BoundingBox.create_from_pb2( pb2_obj.location_data.bounding_box), - categories=categories) + categories=categories, + keypoints=keypoints) def __eq__(self, other: Any) -> bool: """Checks if this object is equal to the given object. diff --git a/mediapipe/tasks/python/components/containers/keypoint.py b/mediapipe/tasks/python/components/containers/keypoint.py new file mode 100644 index 000000000..ef70c00b9 --- /dev/null +++ b/mediapipe/tasks/python/components/containers/keypoint.py @@ -0,0 +1,78 @@ +# Copyright 2022 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Keypoint data class.""" + +import dataclasses +from typing import Any, Optional + +from mediapipe.framework.formats import location_data_pb2 +from mediapipe.tasks.python.core.optional_dependencies import doc_controls + +_RelativeKeypointProto = location_data_pb2.LocationData.RelativeKeypoint + + +@dataclasses.dataclass +class NormalizedKeypoint: + """A normalized keypoint. + + Normalized keypoint represents a point in 2D space with x, y coordinates. + x and y are normalized to [0.0, 1.0] by the image width and height + respectively. + + Attributes: + x: The x coordinates of the normalized keypoint. + y: The y coordinates of the normalized keypoint. + label: The optional label of the keypoint. + score: The score of the keypoint. + """ + + x: Optional[float] = None + y: Optional[float] = None + label: Optional[str] = None + score: Optional[str] = None + + @doc_controls.do_not_generate_docs + def to_pb2(self) -> _RelativeKeypointProto: + """Generates a RelativeKeypoint protobuf object.""" + return _RelativeKeypointProto( + x=self.x, + y=self.y, + keypoint_label=self.label, + score=self.score + ) + + @classmethod + @doc_controls.do_not_generate_docs + def create_from_pb2(cls, + pb2_obj: _RelativeKeypointProto) -> 'NormalizedKeypoint': + """Creates a `NormalizedKeypoint` object from the given protobuf object.""" + return NormalizedKeypoint( + x=pb2_obj.x, + y=pb2_obj.y, + label=pb2_obj.keypoint_label, + score=pb2_obj.score) + + def __eq__(self, other: Any) -> bool: + """Checks if this object is equal to the given object. + + Args: + other: The object to be compared with. + + Returns: + True if the objects are equal. + """ + if not isinstance(other, NormalizedKeypoint): + return False + + return self.to_pb2().__eq__(other.to_pb2()) diff --git a/mediapipe/tasks/python/test/vision/BUILD b/mediapipe/tasks/python/test/vision/BUILD index 48ecc30b3..813f76bdb 100644 --- a/mediapipe/tasks/python/test/vision/BUILD +++ b/mediapipe/tasks/python/test/vision/BUILD @@ -114,3 +114,25 @@ py_test( "@com_google_protobuf//:protobuf_python", ], ) + +py_test( + name = "face_detector_test", + srcs = ["face_detector_test.py"], + data = [ + "//mediapipe/tasks/testdata/vision:test_images", + "//mediapipe/tasks/testdata/vision:test_models", + "//mediapipe/tasks/testdata/vision:test_protos", + ], + deps = [ + "//mediapipe/python:_framework_bindings", + "//mediapipe/tasks/python/components/containers:bounding_box", + "//mediapipe/tasks/python/components/containers:category", + "//mediapipe/tasks/python/components/containers:detections", + "//mediapipe/tasks/python/core:base_options", + "//mediapipe/tasks/python/test:test_utils", + "//mediapipe/tasks/python/vision:face_detector", + "//mediapipe/tasks/python/vision/core:image_processing_options", + "//mediapipe/tasks/python/vision/core:vision_task_running_mode", + "@com_google_protobuf//:protobuf_python", + ], +) diff --git a/mediapipe/tasks/python/test/vision/face_detector_test.py b/mediapipe/tasks/python/test/vision/face_detector_test.py new file mode 100644 index 000000000..90a52d110 --- /dev/null +++ b/mediapipe/tasks/python/test/vision/face_detector_test.py @@ -0,0 +1,407 @@ +# Copyright 2022 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for face detector.""" + +import enum +import os +from unittest import mock + +from absl.testing import absltest +from absl.testing import parameterized +import numpy as np +from google.protobuf import text_format + +from mediapipe.framework.formats import detection_pb2 +from mediapipe.python._framework_bindings import image as image_module +from mediapipe.tasks.python.components.containers import bounding_box as bounding_box_module +from mediapipe.tasks.python.components.containers import category as category_module +from mediapipe.tasks.python.components.containers import detections as detections_module +from mediapipe.tasks.python.core import base_options as base_options_module +from mediapipe.tasks.python.test import test_utils +from mediapipe.tasks.python.vision import face_detector +from mediapipe.tasks.python.vision.core import vision_task_running_mode as running_mode_module +from mediapipe.tasks.python.vision.core import image_processing_options as image_processing_options_module + + +FaceDetectorResult = detections_module.DetectionResult +_BaseOptions = base_options_module.BaseOptions +_Category = category_module.Category +_BoundingBox = bounding_box_module.BoundingBox +_Detection = detections_module.Detection +_Image = image_module.Image +_FaceDetector = face_detector.FaceDetector +_FaceDetectorOptions = face_detector.FaceDetectorOptions +_RUNNING_MODE = running_mode_module.VisionTaskRunningMode +_ImageProcessingOptions = image_processing_options_module.ImageProcessingOptions + +_SHORT_RANGE_BLAZE_FACE_MODEL = 'face_detection_short_range.tflite' +_PORTRAIT_IMAGE = 'portrait.jpg' +_PORTRAIT_EXPECTED_DETECTION = 'portrait_expected_detection.pbtxt' +_PORTRAIT_ROTATED_IMAGE = 'portrait_rotated.jpg' +_PORTRAIT_ROTATED_EXPECTED_DETECTION = 'portrait_rotated_expected_detection.pbtxt' +_CAT_IMAGE = 'cat.jpg' +_KEYPOINT_ERROR_THRESHOLD = 1e-2 +_TEST_DATA_DIR = 'mediapipe/tasks/testdata/vision' + + +def _get_expected_face_detector_result(file_name: str) -> FaceDetectorResult: + face_detection_result_file_path = test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, file_name)) + with open(face_detection_result_file_path, "rb") as f: + face_detection_proto = detection_pb2.Detection() + text_format.Parse(f.read(), face_detection_proto) + face_detection = detections_module.Detection.create_from_pb2(face_detection_proto) + return FaceDetectorResult(detections=[face_detection]) + + +class ModelFileType(enum.Enum): + FILE_CONTENT = 1 + FILE_NAME = 2 + + +class FaceDetectorTest(parameterized.TestCase): + + def setUp(self): + super().setUp() + self.test_image = _Image.create_from_file( + test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, _PORTRAIT_IMAGE))) + self.model_path = test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, _SHORT_RANGE_BLAZE_FACE_MODEL)) + + def test_create_from_file_succeeds_with_valid_model_path(self): + # Creates with default option and valid model file successfully. + with _FaceDetector.create_from_model_path(self.model_path) as detector: + self.assertIsInstance(detector, _FaceDetector) + + def test_create_from_options_succeeds_with_valid_model_path(self): + # Creates with options containing model file successfully. + base_options = _BaseOptions(model_asset_path=self.model_path) + options = _FaceDetectorOptions(base_options=base_options) + with _FaceDetector.create_from_options(options) as detector: + self.assertIsInstance(detector, _FaceDetector) + + def test_create_from_options_fails_with_invalid_model_path(self): + with self.assertRaisesRegex( + RuntimeError, 'Unable to open file at /path/to/invalid/model.tflite'): + base_options = _BaseOptions( + model_asset_path='/path/to/invalid/model.tflite') + options = _FaceDetectorOptions(base_options=base_options) + _FaceDetector.create_from_options(options) + + def test_create_from_options_succeeds_with_valid_model_content(self): + # Creates with options containing model content successfully. + with open(self.model_path, 'rb') as f: + base_options = _BaseOptions(model_asset_buffer=f.read()) + options = _FaceDetectorOptions(base_options=base_options) + detector = _FaceDetector.create_from_options(options) + self.assertIsInstance(detector, _FaceDetector) + + def _expect_keypoints_correct(self, actual_keypoints, expected_keypoints): + self.assertLen(actual_keypoints, len(expected_keypoints)) + for i in range(len(actual_keypoints)): + self.assertAlmostEqual( + actual_keypoints[i].x, expected_keypoints[i].x, + delta=_KEYPOINT_ERROR_THRESHOLD) + self.assertAlmostEqual( + actual_keypoints[i].y, expected_keypoints[i].y, + delta=_KEYPOINT_ERROR_THRESHOLD) + + def _expect_face_detector_results_correct(self, actual_results, expected_results): + self.assertLen(actual_results.detections, len(expected_results.detections)) + for i in range(len(actual_results.detections)): + actual_bbox = actual_results.detections[i].bounding_box + expected_bbox = expected_results.detections[i].bounding_box + self.assertEqual(actual_bbox, expected_bbox) + self.assertNotEmpty(actual_results.detections[i].keypoints) + self._expect_keypoints_correct(actual_results.detections[i].keypoints, + expected_results.detections[i].keypoints) + + @parameterized.parameters( + (ModelFileType.FILE_NAME, _PORTRAIT_EXPECTED_DETECTION), + (ModelFileType.FILE_CONTENT, _PORTRAIT_EXPECTED_DETECTION)) + def test_detect(self, model_file_type, expected_detection_result_file): + # Creates detector. + if model_file_type is ModelFileType.FILE_NAME: + base_options = _BaseOptions(model_asset_path=self.model_path) + elif model_file_type is ModelFileType.FILE_CONTENT: + with open(self.model_path, 'rb') as f: + model_content = f.read() + base_options = _BaseOptions(model_asset_buffer=model_content) + else: + # Should never happen + raise ValueError('model_file_type is invalid.') + + options = _FaceDetectorOptions(base_options=base_options) + detector = _FaceDetector.create_from_options(options) + + # Performs face detection on the input. + detection_result = detector.detect(self.test_image) + # Comparing results. + expected_detection_result = _get_expected_face_detector_result( + expected_detection_result_file) + self._expect_face_detector_results_correct(detection_result, + expected_detection_result) + # Closes the detector explicitly when the detector is not used in + # a context. + detector.close() + + @parameterized.parameters( + (ModelFileType.FILE_NAME, _PORTRAIT_EXPECTED_DETECTION), + (ModelFileType.FILE_CONTENT, _PORTRAIT_EXPECTED_DETECTION)) + def test_detect_in_context(self, model_file_type, expected_detection_result_file): + # Creates detector. + if model_file_type is ModelFileType.FILE_NAME: + base_options = _BaseOptions(model_asset_path=self.model_path) + elif model_file_type is ModelFileType.FILE_CONTENT: + with open(self.model_path, 'rb') as f: + model_content = f.read() + base_options = _BaseOptions(model_asset_buffer=model_content) + else: + # Should never happen + raise ValueError('model_file_type is invalid.') + + options = _FaceDetectorOptions(base_options=base_options) + + with _FaceDetector.create_from_options(options) as detector: + # Performs face detection on the input. + detection_result = detector.detect(self.test_image) + # Comparing results. + expected_detection_result = _get_expected_face_detector_result( + expected_detection_result_file) + self._expect_face_detector_results_correct(detection_result, + expected_detection_result) + + def test_detect_succeeds_with_rotated_image(self): + base_options = _BaseOptions(model_asset_path=self.model_path) + options = _FaceDetectorOptions(base_options=base_options) + with _FaceDetector.create_from_options(options) as detector: + # Load the test image. + test_image = _Image.create_from_file( + test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, _PORTRAIT_ROTATED_IMAGE))) + # Rotated input image. + image_processing_options = _ImageProcessingOptions(rotation_degrees=-90) + # Performs face detection on the input. + detection_result = detector.detect(test_image, image_processing_options) + # Comparing results. + expected_detection_result = _get_expected_face_detector_result( + _PORTRAIT_ROTATED_EXPECTED_DETECTION) + self._expect_face_detector_results_correct(detection_result, + expected_detection_result) + + def test_empty_detection_outputs(self): + # Load a test image with no faces. + test_image = _Image.create_from_file( + test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, _CAT_IMAGE))) + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path)) + with _FaceDetector.create_from_options(options) as detector: + # Performs object detection on the input. + detection_result = detector.detect(test_image) + self.assertEmpty(detection_result.detections) + + def test_missing_result_callback(self): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM) + with self.assertRaisesRegex(ValueError, + r'result callback must be provided'): + with _FaceDetector.create_from_options(options) as unused_detector: + pass + + @parameterized.parameters((_RUNNING_MODE.IMAGE), (_RUNNING_MODE.VIDEO)) + def test_illegal_result_callback(self, running_mode): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=running_mode, + result_callback=mock.MagicMock()) + with self.assertRaisesRegex(ValueError, + r'result callback should not be provided'): + with _FaceDetector.create_from_options(options) as unused_detector: + pass + + def test_calling_detect_for_video_in_image_mode(self): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.IMAGE) + with _FaceDetector.create_from_options(options) as detector: + with self.assertRaisesRegex(ValueError, + r'not initialized with the video mode'): + detector.detect_for_video(self.test_image, 0) + + def test_calling_detect_async_in_image_mode(self): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.IMAGE) + with _FaceDetector.create_from_options(options) as detector: + with self.assertRaisesRegex(ValueError, + r'not initialized with the live stream mode'): + detector.detect_async(self.test_image, 0) + + def test_calling_detect_in_video_mode(self): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _FaceDetector.create_from_options(options) as detector: + with self.assertRaisesRegex(ValueError, + r'not initialized with the image mode'): + detector.detect(self.test_image) + + def test_calling_detect_async_in_video_mode(self): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _FaceDetector.create_from_options(options) as detector: + with self.assertRaisesRegex(ValueError, + r'not initialized with the live stream mode'): + detector.detect_async(self.test_image, 0) + + def test_detect_for_video_with_out_of_order_timestamp(self): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _FaceDetector.create_from_options(options) as detector: + unused_result = detector.detect_for_video(self.test_image, 1) + with self.assertRaisesRegex( + ValueError, r'Input timestamp must be monotonically increasing'): + detector.detect_for_video(self.test_image, 0) + + @parameterized.parameters( + (ModelFileType.FILE_NAME, _PORTRAIT_IMAGE, 0, + _get_expected_face_detector_result(_PORTRAIT_EXPECTED_DETECTION)), + (ModelFileType.FILE_CONTENT, _PORTRAIT_IMAGE, 0, + _get_expected_face_detector_result(_PORTRAIT_EXPECTED_DETECTION)), + (ModelFileType.FILE_NAME, _PORTRAIT_ROTATED_IMAGE, -90, + _get_expected_face_detector_result(_PORTRAIT_ROTATED_EXPECTED_DETECTION)), + (ModelFileType.FILE_CONTENT, _PORTRAIT_ROTATED_IMAGE, -90, + _get_expected_face_detector_result(_PORTRAIT_ROTATED_EXPECTED_DETECTION)), + (ModelFileType.FILE_NAME, _CAT_IMAGE, 0, FaceDetectorResult([])), + (ModelFileType.FILE_CONTENT, _CAT_IMAGE, 0, FaceDetectorResult([]))) + def test_detect_for_video(self, model_file_type, test_image_file_name, + rotation_degrees, expected_detection_result): + # Creates detector. + if model_file_type is ModelFileType.FILE_NAME: + base_options = _BaseOptions(model_asset_path=self.model_path) + elif model_file_type is ModelFileType.FILE_CONTENT: + with open(self.model_path, 'rb') as f: + model_content = f.read() + base_options = _BaseOptions(model_asset_buffer=model_content) + else: + # Should never happen + raise ValueError('model_file_type is invalid.') + + options = _FaceDetectorOptions(base_options=base_options, + running_mode=_RUNNING_MODE.VIDEO) + + with _FaceDetector.create_from_options(options) as detector: + for timestamp in range(0, 300, 30): + # Load the test image. + test_image = _Image.create_from_file( + test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, test_image_file_name))) + # Set the image processing options. + image_processing_options = _ImageProcessingOptions( + rotation_degrees=rotation_degrees) + # Performs face detection on the input. + detection_result = detector.detect_for_video(test_image, timestamp, + image_processing_options) + # Comparing results. + self._expect_face_detector_results_correct(detection_result, + expected_detection_result) + + def test_calling_detect_in_live_stream_mode(self): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=mock.MagicMock()) + with _FaceDetector.create_from_options(options) as detector: + with self.assertRaisesRegex(ValueError, + r'not initialized with the image mode'): + detector.detect(self.test_image) + + def test_calling_detect_for_video_in_live_stream_mode(self): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=mock.MagicMock()) + with _FaceDetector.create_from_options(options) as detector: + with self.assertRaisesRegex(ValueError, + r'not initialized with the video mode'): + detector.detect_for_video(self.test_image, 0) + + def test_detect_async_calls_with_illegal_timestamp(self): + options = _FaceDetectorOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=mock.MagicMock()) + with _FaceDetector.create_from_options(options) as detector: + detector.detect_async(self.test_image, 100) + with self.assertRaisesRegex( + ValueError, r'Input timestamp must be monotonically increasing'): + detector.detect_async(self.test_image, 0) + + @parameterized.parameters( + (ModelFileType.FILE_NAME, _PORTRAIT_IMAGE, 0, + _get_expected_face_detector_result(_PORTRAIT_EXPECTED_DETECTION)), + (ModelFileType.FILE_CONTENT, _PORTRAIT_IMAGE, 0, + _get_expected_face_detector_result(_PORTRAIT_EXPECTED_DETECTION)), + (ModelFileType.FILE_NAME, _PORTRAIT_ROTATED_IMAGE, -90, + _get_expected_face_detector_result(_PORTRAIT_ROTATED_EXPECTED_DETECTION)), + (ModelFileType.FILE_CONTENT, _PORTRAIT_ROTATED_IMAGE, -90, + _get_expected_face_detector_result(_PORTRAIT_ROTATED_EXPECTED_DETECTION)), + (ModelFileType.FILE_NAME, _CAT_IMAGE, 0, FaceDetectorResult([])), + (ModelFileType.FILE_CONTENT, _CAT_IMAGE, 0, FaceDetectorResult([]))) + def test_detect_async_calls(self, model_file_type, test_image_file_name, + rotation_degrees, expected_detection_result): + # Creates detector. + if model_file_type is ModelFileType.FILE_NAME: + base_options = _BaseOptions(model_asset_path=self.model_path) + elif model_file_type is ModelFileType.FILE_CONTENT: + with open(self.model_path, 'rb') as f: + model_content = f.read() + base_options = _BaseOptions(model_asset_buffer=model_content) + else: + # Should never happen + raise ValueError('model_file_type is invalid.') + + observed_timestamp_ms = -1 + + def check_result(result: FaceDetectorResult, output_image: _Image, + timestamp_ms: int): + self._expect_face_detector_results_correct(result, + expected_detection_result) + self.assertLess(observed_timestamp_ms, timestamp_ms) + self.observed_timestamp_ms = timestamp_ms + + options = _FaceDetectorOptions(base_options=base_options, + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=check_result) + + # Load the test image. + test_image = _Image.create_from_file( + test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, test_image_file_name))) + + with _FaceDetector.create_from_options(options) as detector: + for timestamp in range(0, 300, 30): + # Set the image processing options. + image_processing_options = _ImageProcessingOptions( + rotation_degrees=rotation_degrees) + detector.detect_async(test_image, timestamp, image_processing_options) + + +if __name__ == '__main__': + absltest.main() diff --git a/mediapipe/tasks/python/vision/BUILD b/mediapipe/tasks/python/vision/BUILD index eda8e290d..891286641 100644 --- a/mediapipe/tasks/python/vision/BUILD +++ b/mediapipe/tasks/python/vision/BUILD @@ -152,3 +152,23 @@ py_library( "//mediapipe/tasks/python/vision/core:vision_task_running_mode", ], ) + +py_library( + name = "face_detector", + srcs = [ + "face_detector.py", + ], + deps = [ + "//mediapipe/python:_framework_bindings", + "//mediapipe/python:packet_creator", + "//mediapipe/python:packet_getter", + "//mediapipe/tasks/cc/vision/face_detector/proto:face_detector_graph_options_py_pb2", + "//mediapipe/tasks/python/components/containers:detections", + "//mediapipe/tasks/python/core:base_options", + "//mediapipe/tasks/python/core:optional_dependencies", + "//mediapipe/tasks/python/core:task_info", + "//mediapipe/tasks/python/vision/core:base_vision_task_api", + "//mediapipe/tasks/python/vision/core:image_processing_options", + "//mediapipe/tasks/python/vision/core:vision_task_running_mode", + ], +) diff --git a/mediapipe/tasks/python/vision/face_detector.py b/mediapipe/tasks/python/vision/face_detector.py new file mode 100644 index 000000000..91baecff4 --- /dev/null +++ b/mediapipe/tasks/python/vision/face_detector.py @@ -0,0 +1,308 @@ +# Copyright 2022 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""MediaPipe face detector task.""" + +import dataclasses +from typing import Callable, Mapping, Optional + +from mediapipe.python import packet_creator +from mediapipe.python import packet_getter +from mediapipe.python._framework_bindings import image as image_module +from mediapipe.python._framework_bindings import packet as packet_module +from mediapipe.tasks.cc.vision.face_detector.proto import face_detector_graph_options_pb2 +from mediapipe.tasks.python.components.containers import detections as detections_module +from mediapipe.tasks.python.core import base_options as base_options_module +from mediapipe.tasks.python.core import task_info as task_info_module +from mediapipe.tasks.python.core.optional_dependencies import doc_controls +from mediapipe.tasks.python.vision.core import base_vision_task_api +from mediapipe.tasks.python.vision.core import image_processing_options as image_processing_options_module +from mediapipe.tasks.python.vision.core import vision_task_running_mode as running_mode_module + +FaceDetectorResult = detections_module.DetectionResult +_BaseOptions = base_options_module.BaseOptions +_FaceDetectorGraphOptionsProto = face_detector_graph_options_pb2.FaceDetectorGraphOptions +_RunningMode = running_mode_module.VisionTaskRunningMode +_ImageProcessingOptions = image_processing_options_module.ImageProcessingOptions +_TaskInfo = task_info_module.TaskInfo + +_DETECTIONS_OUT_STREAM_NAME = 'detections' +_DETECTIONS_TAG = 'DETECTIONS' +_NORM_RECT_STREAM_NAME = 'norm_rect_in' +_NORM_RECT_TAG = 'NORM_RECT' +_IMAGE_IN_STREAM_NAME = 'image_in' +_IMAGE_OUT_STREAM_NAME = 'image_out' +_IMAGE_TAG = 'IMAGE' +_TASK_GRAPH_NAME = 'mediapipe.tasks.vision.face_detector.FaceDetectorGraph' +_MICRO_SECONDS_PER_MILLISECOND = 1000 + + +@dataclasses.dataclass +class FaceDetectorOptions: + """Options for the face detector task. + + Attributes: + base_options: Base options for the face detector task. + running_mode: The running mode of the task. Default to the image mode. + Face detector task has three running modes: + 1) The image mode for detecting faces on single image inputs. + 2) The video mode for detecting faces on the decoded frames of a video. + 3) The live stream mode for detecting faces on a live stream of input + data, such as from camera. + min_detection_confidence: The minimum confidence score for the face + detection to be considered successful. + min_suppression_threshold: The minimum non-maximum-suppression threshold + for face detection to be considered overlapped. + num_faces: Maximum number of faces to detect in the image. + result_callback: The user-defined result callback for processing live stream + data. The result callback should only be specified when the running mode + is set to the live stream mode. + """ + base_options: _BaseOptions + running_mode: _RunningMode = _RunningMode.IMAGE + min_detection_confidence: Optional[float] = None + min_suppression_threshold: Optional[float] = None + num_faces: Optional[int] = None + result_callback: Optional[ + Callable[[detections_module.DetectionResult, image_module.Image, int], + None]] = None + + @doc_controls.do_not_generate_docs + def to_pb2(self) -> _FaceDetectorGraphOptionsProto: + """Generates an FaceDetectorOptions protobuf object.""" + base_options_proto = self.base_options.to_pb2() + base_options_proto.use_stream_mode = False if self.running_mode == _RunningMode.IMAGE else True + return _FaceDetectorGraphOptionsProto( + base_options=base_options_proto, + min_detection_confidence=self.min_detection_confidence, + min_suppression_threshold=self.min_suppression_threshold, + num_faces=self.num_faces + ) + + +class FaceDetector(base_vision_task_api.BaseVisionTaskApi): + """Class that performs face detection on images.""" + + @classmethod + def create_from_model_path(cls, model_path: str) -> 'FaceDetector': + """Creates an `FaceDetector` object from a TensorFlow Lite model and the default `FaceDetectorOptions`. + + Note that the created `FaceDetector` instance is in image mode, for + detecting faces on single image inputs. + + Args: + model_path: Path to the model. + + Returns: + `FaceDetector` object that's created from the model file and the default + `FaceDetectorOptions`. + + Raises: + ValueError: If failed to create `FaceDetector` object from the provided + file such as invalid file path. + RuntimeError: If other types of error occurred. + """ + base_options = _BaseOptions(model_asset_path=model_path) + options = FaceDetectorOptions( + base_options=base_options, running_mode=_RunningMode.IMAGE) + return cls.create_from_options(options) + + @classmethod + def create_from_options(cls, + options: FaceDetectorOptions) -> 'FaceDetector': + """Creates the `FaceDetector` object from face detector options. + + Args: + options: Options for the face detector task. + + Returns: + `FaceDetector` object that's created from `options`. + + Raises: + ValueError: If failed to create `FaceDetector` object from + `FaceDetectorOptions` such as missing the model. + RuntimeError: If other types of error occurred. + """ + + def packets_callback(output_packets: Mapping[str, packet_module.Packet]): + if output_packets[_IMAGE_OUT_STREAM_NAME].is_empty(): + return + image = packet_getter.get_image(output_packets[_IMAGE_OUT_STREAM_NAME]) + if output_packets[_DETECTIONS_OUT_STREAM_NAME].is_empty(): + empty_packet = output_packets[_DETECTIONS_OUT_STREAM_NAME] + options.result_callback( + FaceDetectorResult([]), image, + empty_packet.timestamp.value // _MICRO_SECONDS_PER_MILLISECOND) + return + detection_proto_list = packet_getter.get_proto_list( + output_packets[_DETECTIONS_OUT_STREAM_NAME]) + detection_result = detections_module.DetectionResult([ + detections_module.Detection.create_from_pb2(result) + for result in detection_proto_list + ]) + + timestamp = output_packets[_IMAGE_OUT_STREAM_NAME].timestamp + options.result_callback(detection_result, image, + timestamp.value // _MICRO_SECONDS_PER_MILLISECOND) + + task_info = _TaskInfo( + task_graph=_TASK_GRAPH_NAME, + input_streams=[ + ':'.join([_IMAGE_TAG, _IMAGE_IN_STREAM_NAME]), + ':'.join([_NORM_RECT_TAG, _NORM_RECT_STREAM_NAME]), + ], + output_streams=[ + ':'.join([_DETECTIONS_TAG, _DETECTIONS_OUT_STREAM_NAME]), + ':'.join([_IMAGE_TAG, _IMAGE_OUT_STREAM_NAME]) + ], + task_options=options) + return cls( + task_info.generate_graph_config( + enable_flow_limiting=options.running_mode == + _RunningMode.LIVE_STREAM), options.running_mode, + packets_callback if options.result_callback else None) + + def detect( + self, + image: image_module.Image, + image_processing_options: Optional[_ImageProcessingOptions] = None + ) -> FaceDetectorResult: + """Performs face detection on the provided MediaPipe Image. + + Only use this method when the FaceDetector is created with the image + running mode. + + Args: + image: MediaPipe Image. + image_processing_options: Options for image processing. + + Returns: + A face detection result object that contains a list of face detections, + each detection has a bounding box that is expressed in the unrotated input + frame of reference coordinates system, i.e. in `[0,image_width) x [0, + image_height)`, which are the dimensions of the underlying image data. + + Raises: + ValueError: If any of the input arguments is invalid. + RuntimeError: If face detection failed to run. + """ + normalized_rect = self.convert_to_normalized_rect(image_processing_options, + roi_allowed=False) + output_packets = self._process_image_data({ + _IMAGE_IN_STREAM_NAME: + packet_creator.create_image(image), + _NORM_RECT_STREAM_NAME: + packet_creator.create_proto(normalized_rect.to_pb2()) + }) + if output_packets[_DETECTIONS_OUT_STREAM_NAME].is_empty(): + return FaceDetectorResult([]) + detection_proto_list = packet_getter.get_proto_list( + output_packets[_DETECTIONS_OUT_STREAM_NAME]) + return detections_module.DetectionResult([ + detections_module.Detection.create_from_pb2(result) + for result in detection_proto_list + ]) + + def detect_for_video( + self, + image: image_module.Image, + timestamp_ms: int, + image_processing_options: Optional[_ImageProcessingOptions] = None + ) -> detections_module.DetectionResult: + """Performs face detection on the provided video frames. + + Only use this method when the FaceDetector is created with the video + running mode. It's required to provide the video frame's timestamp (in + milliseconds) along with the video frame. The input timestamps should be + monotonically increasing for adjacent calls of this method. + + Args: + image: MediaPipe Image. + timestamp_ms: The timestamp of the input video frame in milliseconds. + image_processing_options: Options for image processing. + + Returns: + A face detection result object that contains a list of face detections, + each detection has a bounding box that is expressed in the unrotated input + frame of reference coordinates system, i.e. in `[0,image_width) x [0, + image_height)`, which are the dimensions of the underlying image data. + + Raises: + ValueError: If any of the input arguments is invalid. + RuntimeError: If face detection failed to run. + """ + normalized_rect = self.convert_to_normalized_rect(image_processing_options, + roi_allowed=False) + output_packets = self._process_video_data({ + _IMAGE_IN_STREAM_NAME: + packet_creator.create_image(image).at( + timestamp_ms * _MICRO_SECONDS_PER_MILLISECOND), + _NORM_RECT_STREAM_NAME: + packet_creator.create_proto(normalized_rect.to_pb2()).at( + timestamp_ms * _MICRO_SECONDS_PER_MILLISECOND) + }) + if output_packets[_DETECTIONS_OUT_STREAM_NAME].is_empty(): + return FaceDetectorResult([]) + detection_proto_list = packet_getter.get_proto_list( + output_packets[_DETECTIONS_OUT_STREAM_NAME]) + return detections_module.DetectionResult([ + detections_module.Detection.create_from_pb2(result) + for result in detection_proto_list + ]) + + def detect_async( + self, + image: image_module.Image, + timestamp_ms: int, + image_processing_options: Optional[_ImageProcessingOptions] = None + ) -> None: + """Sends live image data (an Image with a unique timestamp) to perform face detection. + + Only use this method when the FaceDetector is created with the live stream + running mode. The input timestamps should be monotonically increasing for + adjacent calls of this method. This method will return immediately after the + input image is accepted. The results will be available via the + `result_callback` provided in the `FaceDetectorOptions`. The + `detect_async` method is designed to process live stream data such as camera + input. To lower the overall latency, face detector may drop the input + images if needed. In other words, it's not guaranteed to have output per + input image. + + The `result_callback` provides: + - A face detection result object that contains a list of face detections, + each detection has a bounding box that is expressed in the unrotated + input frame of reference coordinates system, + i.e. in `[0,image_width) x [0,image_height)`, which are the dimensions + of the underlying image data. + - The input image that the face detector runs on. + - The input timestamp in milliseconds. + + Args: + image: MediaPipe Image. + timestamp_ms: The timestamp of the input image in milliseconds. + image_processing_options: Options for image processing. + + Raises: + ValueError: If the current input timestamp is smaller than what the face + detector has already processed. + """ + normalized_rect = self.convert_to_normalized_rect(image_processing_options, + roi_allowed=False) + self._send_live_stream_data({ + _IMAGE_IN_STREAM_NAME: + packet_creator.create_image(image).at( + timestamp_ms * _MICRO_SECONDS_PER_MILLISECOND), + _NORM_RECT_STREAM_NAME: + packet_creator.create_proto(normalized_rect.to_pb2()).at( + timestamp_ms * _MICRO_SECONDS_PER_MILLISECOND) + }) From 24114ec2fec7a216903cb3b8fb08b657569f8648 Mon Sep 17 00:00:00 2001 From: kinaryml Date: Thu, 9 Mar 2023 01:41:42 -0800 Subject: [PATCH 032/136] Updated comment in test --- mediapipe/tasks/python/test/vision/face_detector_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/tasks/python/test/vision/face_detector_test.py b/mediapipe/tasks/python/test/vision/face_detector_test.py index 90a52d110..f78c9c94e 100644 --- a/mediapipe/tasks/python/test/vision/face_detector_test.py +++ b/mediapipe/tasks/python/test/vision/face_detector_test.py @@ -209,7 +209,7 @@ class FaceDetectorTest(parameterized.TestCase): options = _FaceDetectorOptions( base_options=_BaseOptions(model_asset_path=self.model_path)) with _FaceDetector.create_from_options(options) as detector: - # Performs object detection on the input. + # Performs face detection on the input. detection_result = detector.detect(test_image) self.assertEmpty(detection_result.detections) From f48909cab63243a477207e65f0ad08c079613baa Mon Sep 17 00:00:00 2001 From: kinaryml Date: Thu, 9 Mar 2023 02:13:34 -0800 Subject: [PATCH 033/136] Fixed score's data type --- mediapipe/tasks/python/components/containers/keypoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/tasks/python/components/containers/keypoint.py b/mediapipe/tasks/python/components/containers/keypoint.py index ef70c00b9..ef91d0950 100644 --- a/mediapipe/tasks/python/components/containers/keypoint.py +++ b/mediapipe/tasks/python/components/containers/keypoint.py @@ -40,7 +40,7 @@ class NormalizedKeypoint: x: Optional[float] = None y: Optional[float] = None label: Optional[str] = None - score: Optional[str] = None + score: Optional[float] = None @doc_controls.do_not_generate_docs def to_pb2(self) -> _RelativeKeypointProto: From 39e2c8351fc6cb6609ca5e023980c36a9d98a6c9 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 9 Mar 2023 05:04:18 -0800 Subject: [PATCH 034/136] Add build system for Halide and expose FrameBufferUtils. PiperOrigin-RevId: 515304264 --- WORKSPACE | 32 + mediapipe/util/frame_buffer/BUILD | 106 ++ mediapipe/util/frame_buffer/buffer_common.cc | 40 + mediapipe/util/frame_buffer/buffer_common.h | 32 + .../util/frame_buffer/frame_buffer_util.cc | 794 +++++++++++ .../util/frame_buffer/frame_buffer_util.h | 131 ++ .../frame_buffer/frame_buffer_util_test.cc | 1176 +++++++++++++++++ mediapipe/util/frame_buffer/gray_buffer.cc | 95 ++ mediapipe/util/frame_buffer/gray_buffer.h | 137 ++ .../util/frame_buffer/gray_buffer_test.cc | 223 ++++ mediapipe/util/frame_buffer/halide/BUILD | 118 ++ mediapipe/util/frame_buffer/halide/common.cc | 89 ++ mediapipe/util/frame_buffer/halide/common.h | 61 + .../halide/gray_flip_generator.cc | 61 + .../halide/gray_resize_generator.cc | 60 + .../halide/gray_rotate_generator.cc | 63 + .../frame_buffer/halide/rgb_flip_generator.cc | 84 ++ .../frame_buffer/halide/rgb_gray_generator.cc | 65 + .../halide/rgb_resize_generator.cc | 85 ++ .../frame_buffer/halide/rgb_rgb_generator.cc | 64 + .../halide/rgb_rotate_generator.cc | 76 ++ .../frame_buffer/halide/rgb_yuv_generator.cc | 101 ++ .../frame_buffer/halide/yuv_flip_generator.cc | 90 ++ .../halide/yuv_resize_generator.cc | 91 ++ .../frame_buffer/halide/yuv_rgb_generator.cc | 110 ++ .../halide/yuv_rotate_generator.cc | 91 ++ mediapipe/util/frame_buffer/rgb_buffer.cc | 132 ++ mediapipe/util/frame_buffer/rgb_buffer.h | 139 ++ .../util/frame_buffer/rgb_buffer_test.cc | 606 +++++++++ mediapipe/util/frame_buffer/yuv_buffer.cc | 152 +++ mediapipe/util/frame_buffer/yuv_buffer.h | 160 +++ .../util/frame_buffer/yuv_buffer_test.cc | 251 ++++ third_party/halide.BUILD | 70 + third_party/halide/BUILD | 1 + third_party/halide/BUILD.bazel | 40 + third_party/halide/halide.bzl | 875 ++++++++++++ 36 files changed, 6501 insertions(+) create mode 100644 mediapipe/util/frame_buffer/BUILD create mode 100644 mediapipe/util/frame_buffer/buffer_common.cc create mode 100644 mediapipe/util/frame_buffer/buffer_common.h create mode 100644 mediapipe/util/frame_buffer/frame_buffer_util.cc create mode 100644 mediapipe/util/frame_buffer/frame_buffer_util.h create mode 100644 mediapipe/util/frame_buffer/frame_buffer_util_test.cc create mode 100644 mediapipe/util/frame_buffer/gray_buffer.cc create mode 100644 mediapipe/util/frame_buffer/gray_buffer.h create mode 100644 mediapipe/util/frame_buffer/gray_buffer_test.cc create mode 100644 mediapipe/util/frame_buffer/halide/BUILD create mode 100644 mediapipe/util/frame_buffer/halide/common.cc create mode 100644 mediapipe/util/frame_buffer/halide/common.h create mode 100644 mediapipe/util/frame_buffer/halide/gray_flip_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/gray_resize_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/gray_rotate_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/rgb_flip_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/rgb_gray_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/rgb_resize_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/rgb_rgb_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/rgb_rotate_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/rgb_yuv_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/yuv_flip_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/yuv_resize_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/yuv_rgb_generator.cc create mode 100644 mediapipe/util/frame_buffer/halide/yuv_rotate_generator.cc create mode 100644 mediapipe/util/frame_buffer/rgb_buffer.cc create mode 100644 mediapipe/util/frame_buffer/rgb_buffer.h create mode 100644 mediapipe/util/frame_buffer/rgb_buffer_test.cc create mode 100644 mediapipe/util/frame_buffer/yuv_buffer.cc create mode 100644 mediapipe/util/frame_buffer/yuv_buffer.h create mode 100644 mediapipe/util/frame_buffer/yuv_buffer_test.cc create mode 100644 third_party/halide.BUILD create mode 100644 third_party/halide/BUILD create mode 100644 third_party/halide/BUILD.bazel create mode 100644 third_party/halide/halide.bzl diff --git a/WORKSPACE b/WORKSPACE index 10f0c1ac5..2f2bc3d9d 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -543,3 +543,35 @@ external_files() load("@//third_party:wasm_files.bzl", "wasm_files") wasm_files() + +# Halide + +new_local_repository( + name = "halide", + build_file = "@//third_party/halide:BUILD.bazel", + path = "third_party/halide" +) + +http_archive( + name = "linux_halide", + sha256 = "be3bdd067acb9ee0d37d0830821113cd69174bee46da466a836d8829fef7cf91", + strip_prefix = "Halide-14.0.0-x86-64-linux", + urls = ["https://github.com/halide/Halide/releases/download/v14.0.0/Halide-14.0.0-x86-64-linux-6b9ed2afd1d6d0badf04986602c943e287d44e46.tar.gz"], + build_file = "@//third_party:halide.BUILD", +) + +http_archive( + name = "macos_halide", + sha256 = "569b1cda858d278d9dd68e072768d8347775e419f2ff39ff34d001ceb299e3c4", + strip_prefix = "Halide-14.0.0-x86-64-osx", + urls = ["https://github.com/halide/Halide/releases/download/v14.0.0/Halide-14.0.0-x86-64-osx-6b9ed2afd1d6d0badf04986602c943e287d44e46.tar.gz"], + build_file = "@//third_party:halide.BUILD", +) + +http_archive( + name = "windows_halide", + sha256 = "a7a0481af2691ec436d79c20ca441e9a701bfce409f4f763dab75a8f1d740179", + strip_prefix = "Halide-14.0.0-x86-64-windows", + urls = ["https://github.com/halide/Halide/releases/download/v14.0.0/Halide-14.0.0-x86-64-windows-6b9ed2afd1d6d0badf04986602c943e287d44e46.zip"], + build_file = "@//third_party:halide.BUILD", +) diff --git a/mediapipe/util/frame_buffer/BUILD b/mediapipe/util/frame_buffer/BUILD new file mode 100644 index 000000000..27343d6df --- /dev/null +++ b/mediapipe/util/frame_buffer/BUILD @@ -0,0 +1,106 @@ +# Copyright 2023 The MediaPipe Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package(default_visibility = ["//mediapipe/util/frame_buffer:__subpackages__"]) + +cc_library( + name = "frame_buffer_util", + srcs = ["frame_buffer_util.cc"], + hdrs = ["frame_buffer_util.h"], + visibility = ["//visibility:public"], + deps = [ + ":buffer", + "//mediapipe/framework/formats:frame_buffer", + "//mediapipe/framework/port:status", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings:str_format", + ], +) + +cc_test( + name = "frame_buffer_util_test", + srcs = [ + "frame_buffer_util_test.cc", + ], + deps = [ + ":frame_buffer_util", + "//mediapipe/framework/formats:frame_buffer", + "//mediapipe/framework/port:gtest_main", + "//mediapipe/framework/port:status", + ], +) + +cc_library( + name = "buffer", + srcs = [ + "buffer_common.cc", + "gray_buffer.cc", + "rgb_buffer.cc", + "yuv_buffer.cc", + ], + hdrs = [ + "buffer_common.h", + "gray_buffer.h", + "rgb_buffer.h", + "yuv_buffer.h", + ], + deps = [ + "//mediapipe/util/frame_buffer/halide:gray_flip_halide", + "//mediapipe/util/frame_buffer/halide:gray_resize_halide", + "//mediapipe/util/frame_buffer/halide:gray_rotate_halide", + "//mediapipe/util/frame_buffer/halide:rgb_flip_halide", + "//mediapipe/util/frame_buffer/halide:rgb_gray_halide", + "//mediapipe/util/frame_buffer/halide:rgb_resize_halide", + "//mediapipe/util/frame_buffer/halide:rgb_rgb_halide", + "//mediapipe/util/frame_buffer/halide:rgb_rotate_halide", + "//mediapipe/util/frame_buffer/halide:rgb_yuv_halide", + "//mediapipe/util/frame_buffer/halide:yuv_flip_halide", + "//mediapipe/util/frame_buffer/halide:yuv_resize_halide", + "//mediapipe/util/frame_buffer/halide:yuv_rgb_halide", + "//mediapipe/util/frame_buffer/halide:yuv_rotate_halide", + "@halide//:runtime", + ], +) + +# Tests: +cc_test( + name = "rgb_buffer_test", + srcs = ["rgb_buffer_test.cc"], + deps = [ + ":buffer", + "//mediapipe/framework/port:gtest_main", + "@com_google_absl//absl/log", + ], +) + +cc_test( + name = "yuv_buffer_test", + srcs = ["yuv_buffer_test.cc"], + deps = [ + ":buffer", + "//mediapipe/framework/port:gtest_main", + "@com_google_absl//absl/log", + ], +) + +cc_test( + name = "gray_buffer_test", + srcs = ["gray_buffer_test.cc"], + deps = [ + ":buffer", + "//mediapipe/framework/port:gtest_main", + "@com_google_absl//absl/log", + ], +) diff --git a/mediapipe/util/frame_buffer/buffer_common.cc b/mediapipe/util/frame_buffer/buffer_common.cc new file mode 100644 index 000000000..180f52c03 --- /dev/null +++ b/mediapipe/util/frame_buffer/buffer_common.cc @@ -0,0 +1,40 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/buffer_common.h" + +namespace mediapipe { +namespace frame_buffer { +namespace common { + +bool crop_buffer(int x0, int y0, int x1, int y1, halide_buffer_t* buffer) { + if (x0 < 0 || x1 >= buffer->dim[0].extent) { + return false; + } + if (y0 < 0 || y1 >= buffer->dim[1].extent) { + return false; + } + + // Move the start pointer so that it points at (x0, y0) and set the new + // extents. Leave the strides unchanged; we simply skip over the cropped + // image data. + buffer->host += y0 * buffer->dim[1].stride + x0 * buffer->dim[0].stride; + buffer->dim[0].extent = x1 - x0 + 1; + buffer->dim[1].extent = y1 - y0 + 1; + return true; +} + +} // namespace common +} // namespace frame_buffer +} // namespace mediapipe diff --git a/mediapipe/util/frame_buffer/buffer_common.h b/mediapipe/util/frame_buffer/buffer_common.h new file mode 100644 index 000000000..9e0e891d3 --- /dev/null +++ b/mediapipe/util/frame_buffer/buffer_common.h @@ -0,0 +1,32 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef MEDIAPIPE_UTIL_FRAME_BUFFER_BUFFER_COMMON_H_ +#define MEDIAPIPE_UTIL_FRAME_BUFFER_BUFFER_COMMON_H_ + +#include "HalideRuntime.h" + +namespace mediapipe { +namespace frame_buffer { +namespace common { + +// Performs in-place cropping on the given buffer; the provided rectangle +// becomes the full extent of the buffer upon success. Returns false on error. +bool crop_buffer(int x0, int y0, int x1, int y1, halide_buffer_t* buffer); + +} // namespace common +} // namespace frame_buffer +} // namespace mediapipe + +#endif // MEDIAPIPE_UTIL_FRAME_BUFFER_BUFFER_COMMON_H_ diff --git a/mediapipe/util/frame_buffer/frame_buffer_util.cc b/mediapipe/util/frame_buffer/frame_buffer_util.cc new file mode 100644 index 000000000..b18e8cb13 --- /dev/null +++ b/mediapipe/util/frame_buffer/frame_buffer_util.cc @@ -0,0 +1,794 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/frame_buffer_util.h" + +#include +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_format.h" +#include "mediapipe/framework/formats/frame_buffer.h" +#include "mediapipe/framework/port/status_macros.h" +#include "mediapipe/util/frame_buffer/gray_buffer.h" +#include "mediapipe/util/frame_buffer/rgb_buffer.h" +#include "mediapipe/util/frame_buffer/yuv_buffer.h" + +namespace mediapipe { +namespace frame_buffer { + +namespace { + +constexpr int kRgbaChannels = 4; +constexpr int kRgbaPixelBytes = 4; +constexpr int kRgbChannels = 3; +constexpr int kRgbPixelBytes = 3; +constexpr int kGrayChannel = 1; +constexpr int kGrayPixelBytes = 1; + +// YUV helpers. +//------------------------------------------------------------------------------ + +// Returns whether the buffer is part of the supported Yuv format. +bool IsSupportedYuvBuffer(const FrameBuffer& buffer) { + return buffer.format() == FrameBuffer::Format::kNV21 || + buffer.format() == FrameBuffer::Format::kNV12 || + buffer.format() == FrameBuffer::Format::kYV12 || + buffer.format() == FrameBuffer::Format::kYV21; +} + +// Shared validation functions. +//------------------------------------------------------------------------------ + +// Indicates whether the given buffers have the same dimensions. +bool AreBufferDimsEqual(const FrameBuffer& buffer1, + const FrameBuffer& buffer2) { + return buffer1.dimension() == buffer2.dimension(); +} + +// Indicates whether the given buffers formats are compatible. Same formats are +// compatible and all YUV family formats (e.g. NV21, NV12, YV12, YV21, etc) are +// compatible. +bool AreBufferFormatsCompatible(const FrameBuffer& buffer1, + const FrameBuffer& buffer2) { + switch (buffer1.format()) { + case FrameBuffer::Format::kRGBA: + case FrameBuffer::Format::kRGB: + return (buffer2.format() == FrameBuffer::Format::kRGBA || + buffer2.format() == FrameBuffer::Format::kRGB); + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return (buffer2.format() == FrameBuffer::Format::kNV12 || + buffer2.format() == FrameBuffer::Format::kNV21 || + buffer2.format() == FrameBuffer::Format::kYV12 || + buffer2.format() == FrameBuffer::Format::kYV21); + case FrameBuffer::Format::kGRAY: + default: + return buffer1.format() == buffer2.format(); + } +} + +absl::Status ValidateBufferFormat(const FrameBuffer& buffer) { + switch (buffer.format()) { + case FrameBuffer::Format::kGRAY: + case FrameBuffer::Format::kRGB: + case FrameBuffer::Format::kRGBA: + if (buffer.plane_count() == 1) return absl::OkStatus(); + return absl::InvalidArgumentError( + "Plane count must be 1 for grayscale and RGB[a] buffers."); + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kYV21: + case FrameBuffer::Format::kYV12: + return absl::OkStatus(); + default: + return absl::InternalError( + absl::StrFormat("Unsupported buffer format: %i.", buffer.format())); + } +} + +absl::Status ValidateBufferFormats(const FrameBuffer& buffer1, + const FrameBuffer& buffer2) { + MP_RETURN_IF_ERROR(ValidateBufferFormat(buffer1)); + MP_RETURN_IF_ERROR(ValidateBufferFormat(buffer2)); + return absl::OkStatus(); +} + +absl::Status ValidateResizeBufferInputs(const FrameBuffer& buffer, + const FrameBuffer& output_buffer) { + bool valid_format = false; + switch (buffer.format()) { + case FrameBuffer::Format::kGRAY: + case FrameBuffer::Format::kRGB: + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + valid_format = (buffer.format() == output_buffer.format()); + break; + case FrameBuffer::Format::kRGBA: + valid_format = (output_buffer.format() == FrameBuffer::Format::kRGBA || + output_buffer.format() == FrameBuffer::Format::kRGB); + break; + default: + return absl::InternalError( + absl::StrFormat("Unsupported buffer format: %i.", buffer.format())); + } + if (!valid_format) { + return absl::InvalidArgumentError( + "Input and output buffer formats must match."); + } + return ValidateBufferFormats(buffer, output_buffer); +} + +absl::Status ValidateRotateBufferInputs(const FrameBuffer& buffer, + const FrameBuffer& output_buffer, + int angle_deg) { + if (!AreBufferFormatsCompatible(buffer, output_buffer)) { + return absl::InvalidArgumentError( + "Input and output buffer formats must match."); + } + + const bool is_dimension_change = (angle_deg / 90) % 2 == 1; + const bool are_dimensions_rotated = + (buffer.dimension().width == output_buffer.dimension().height) && + (buffer.dimension().height == output_buffer.dimension().width); + const bool are_dimensions_equal = + buffer.dimension() == output_buffer.dimension(); + + if (angle_deg >= 360 || angle_deg <= 0 || angle_deg % 90 != 0) { + return absl::InvalidArgumentError( + "Rotation angle must be between 0 and 360, in multiples of 90 " + "degrees."); + } else if ((is_dimension_change && !are_dimensions_rotated) || + (!is_dimension_change && !are_dimensions_equal)) { + return absl::InvalidArgumentError( + "Output buffer has invalid dimensions for rotation."); + } + return absl::OkStatus(); +} + +absl::Status ValidateCropBufferInputs(const FrameBuffer& buffer, + const FrameBuffer& output_buffer, int x0, + int y0, int x1, int y1) { + if (!AreBufferFormatsCompatible(buffer, output_buffer)) { + return absl::InvalidArgumentError( + "Input and output buffer formats must match."); + } + + bool is_buffer_size_valid = + ((x1 < buffer.dimension().width) && y1 < buffer.dimension().height); + bool are_points_valid = (x0 >= 0) && (y0 >= 0) && (x1 >= x0) && (y1 >= y0); + + if (!is_buffer_size_valid || !are_points_valid) { + return absl::InvalidArgumentError("Invalid crop coordinates."); + } + return absl::OkStatus(); +} + +absl::Status ValidateFlipBufferInputs(const FrameBuffer& buffer, + const FrameBuffer& output_buffer) { + if (!AreBufferFormatsCompatible(buffer, output_buffer)) { + return absl::InvalidArgumentError( + "Input and output buffer formats must match."); + } + return AreBufferDimsEqual(buffer, output_buffer) + ? absl::OkStatus() + : absl::InvalidArgumentError( + "Input and output buffers must have the same dimensions."); +} + +absl::Status ValidateConvertFormats(FrameBuffer::Format from_format, + FrameBuffer::Format to_format) { + if (from_format == to_format) { + return absl::InvalidArgumentError("Formats must be different."); + } + + switch (from_format) { + case FrameBuffer::Format::kGRAY: + return absl::InvalidArgumentError( + "Grayscale format does not convert to other formats."); + case FrameBuffer::Format::kRGB: + case FrameBuffer::Format::kRGBA: + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return absl::OkStatus(); + default: + return absl::InternalError( + absl::StrFormat("Unsupported buffer format: %i.", from_format)); + } +} + +// Construct buffer helper functions. +//------------------------------------------------------------------------------ + +// Creates NV12 / NV21 / YV12 / YV21 YuvBuffer from the input `buffer`. The +// output YuvBuffer is agnostic to the YUV format since the YUV buffers are +// managed individually. +absl::StatusOr CreateYuvBuffer(const FrameBuffer& buffer) { + ASSIGN_OR_RETURN(FrameBuffer::YuvData yuv_data, + FrameBuffer::GetYuvDataFromFrameBuffer(buffer)); + return YuvBuffer(const_cast(yuv_data.y_buffer), + const_cast(yuv_data.u_buffer), + const_cast(yuv_data.v_buffer), + buffer.dimension().width, buffer.dimension().height, + yuv_data.y_row_stride, yuv_data.uv_row_stride, + yuv_data.uv_pixel_stride); +} + +absl::StatusOr CreateGrayBuffer(const FrameBuffer& buffer) { + if (buffer.plane_count() != 1) { + return absl::InternalError("Unsupported grayscale planar format."); + } + return GrayBuffer(const_cast(buffer.plane(0).buffer()), + buffer.dimension().width, buffer.dimension().height); +} + +absl::StatusOr CreateRgbBuffer(const FrameBuffer& buffer) { + if (buffer.plane_count() != 1) { + return absl::InternalError("Unsupported rgb[a] planar format."); + } + bool alpha = buffer.format() == FrameBuffer::Format::kRGBA ? true : false; + return RgbBuffer(const_cast(buffer.plane(0).buffer()), + buffer.dimension().width, buffer.dimension().height, + buffer.plane(0).stride().row_stride_bytes, alpha); +} + +// Grayscale transformation functions. +//------------------------------------------------------------------------------ + +absl::Status CropGrayscale(const FrameBuffer& buffer, int x0, int y0, int x1, + int y1, FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateGrayBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateGrayBuffer(*output_buffer)); + bool success_crop = input.Crop(x0, y0, x1, y1); + if (!success_crop) { + return absl::UnknownError("Halide grayscale crop operation failed."); + } + bool success_resize = input.Resize(&output); + if (!success_resize) { + return absl::UnknownError("Halide grayscale resize operation failed."); + } + return absl::OkStatus(); +} + +absl::Status ResizeGrayscale(const FrameBuffer& buffer, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateGrayBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateGrayBuffer(*output_buffer)); + return input.Resize(&output) + ? absl::OkStatus() + : absl::UnknownError("Halide grayscale resize operation failed."); +} + +absl::Status RotateGrayscale(const FrameBuffer& buffer, int angle_deg, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateGrayBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateGrayBuffer(*output_buffer)); + return input.Rotate(angle_deg % 360, &output) + ? absl::OkStatus() + : absl::UnknownError("Halide grayscale rotate operation failed."); +} + +absl::Status FlipHorizontallyGrayscale(const FrameBuffer& buffer, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateGrayBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateGrayBuffer(*output_buffer)); + return input.FlipHorizontally(&output) + ? absl::OkStatus() + : absl::UnknownError( + "Halide grayscale horizontal flip operation failed."); +} + +absl::Status FlipVerticallyGrayscale(const FrameBuffer& buffer, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateGrayBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateGrayBuffer(*output_buffer)); + return input.FlipVertically(&output) + ? absl::OkStatus() + : absl::UnknownError( + "Halide grayscale vertical flip operation failed."); +} + +// Rgb transformation functions. +//------------------------------------------------------------------------------ + +absl::Status ResizeRgb(const FrameBuffer& buffer, FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateRgbBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateRgbBuffer(*output_buffer)); + return input.Resize(&output) + ? absl::OkStatus() + : absl::UnknownError("Halide rgb[a] resize operation failed."); +} + +absl::Status ConvertRgb(const FrameBuffer& buffer, FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateRgbBuffer(buffer)); + bool result = false; + if (output_buffer->format() == FrameBuffer::Format::kGRAY) { + ASSIGN_OR_RETURN(auto output, CreateGrayBuffer(*output_buffer)); + result = input.Convert(&output); + } else if (IsSupportedYuvBuffer(*output_buffer)) { + ASSIGN_OR_RETURN(auto output, CreateYuvBuffer(*output_buffer)); + result = input.Convert(&output); + } else if (output_buffer->format() == FrameBuffer::Format::kRGBA || + output_buffer->format() == FrameBuffer::Format::kRGB) { + ASSIGN_OR_RETURN(auto output, CreateRgbBuffer(*output_buffer)); + result = input.Convert(&output); + } + return result ? absl::OkStatus() + : absl::UnknownError("Halide rgb[a] convert operation failed."); +} + +absl::Status CropRgb(const FrameBuffer& buffer, int x0, int y0, int x1, int y1, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateRgbBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateRgbBuffer(*output_buffer)); + bool success_crop = input.Crop(x0, y0, x1, y1); + if (!success_crop) { + return absl::UnknownError("Halide rgb[a] crop operation failed."); + } + bool success_resize = input.Resize(&output); + if (!success_resize) { + return absl::UnknownError("Halide rgb resize operation failed."); + } + return absl::OkStatus(); +} + +absl::Status FlipHorizontallyRgb(const FrameBuffer& buffer, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateRgbBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateRgbBuffer(*output_buffer)); + return input.FlipHorizontally(&output) + ? absl::OkStatus() + : absl::UnknownError( + "Halide rgb[a] horizontal flip operation failed."); +} + +absl::Status FlipVerticallyRgb(const FrameBuffer& buffer, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateRgbBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateRgbBuffer(*output_buffer)); + return input.FlipVertically(&output) + ? absl::OkStatus() + : absl::UnknownError( + "Halide rgb[a] vertical flip operation failed."); +} + +absl::Status RotateRgb(const FrameBuffer& buffer, int angle, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateRgbBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateRgbBuffer(*output_buffer)); + return input.Rotate(angle % 360, &output) + ? absl::OkStatus() + : absl::UnknownError("Halide rgb[a] rotate operation failed."); +} + +// Yuv transformation functions. +//------------------------------------------------------------------------------ + +absl::Status CropYuv(const FrameBuffer& buffer, int x0, int y0, int x1, int y1, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateYuvBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateYuvBuffer(*output_buffer)); + bool success_crop = input.Crop(x0, y0, x1, y1); + if (!success_crop) { + return absl::UnknownError("Halide YUV crop operation failed."); + } + bool success_resize = input.Resize(&output); + if (!success_resize) { + return absl::UnknownError("Halide YUV resize operation failed."); + } + return absl::OkStatus(); +} + +absl::Status ResizeYuv(const FrameBuffer& buffer, FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateYuvBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateYuvBuffer(*output_buffer)); + return input.Resize(&output) + ? absl::OkStatus() + : absl::UnknownError("Halide YUV resize operation failed."); +} + +absl::Status RotateYuv(const FrameBuffer& buffer, int angle_deg, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateYuvBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateYuvBuffer(*output_buffer)); + return input.Rotate(angle_deg % 360, &output) + ? absl::OkStatus() + : absl::UnknownError("Halide YUV rotate operation failed."); +} + +absl::Status FlipHorizontallyYuv(const FrameBuffer& buffer, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateYuvBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateYuvBuffer(*output_buffer)); + return input.FlipHorizontally(&output) + ? absl::OkStatus() + : absl::UnknownError( + "Halide YUV horizontal flip operation failed."); +} + +absl::Status FlipVerticallyYuv(const FrameBuffer& buffer, + FrameBuffer* output_buffer) { + ASSIGN_OR_RETURN(auto input, CreateYuvBuffer(buffer)); + ASSIGN_OR_RETURN(auto output, CreateYuvBuffer(*output_buffer)); + return input.FlipVertically(&output) + ? absl::OkStatus() + : absl::UnknownError("Halide YUV vertical flip operation failed."); +} + +// Converts input YUV `buffer` into the `output_buffer` in RGB, RGBA or gray +// scale format. +absl::Status ConvertYuv(const FrameBuffer& buffer, FrameBuffer* output_buffer) { + bool success_convert = false; + ASSIGN_OR_RETURN(auto input, CreateYuvBuffer(buffer)); + if (output_buffer->format() == FrameBuffer::Format::kRGBA || + output_buffer->format() == FrameBuffer::Format::kRGB) { + ASSIGN_OR_RETURN(auto output, CreateRgbBuffer(*output_buffer)); + bool half_sampling = false; + if (buffer.dimension().width / 2 == output_buffer->dimension().width && + buffer.dimension().height / 2 == output_buffer->dimension().height) { + half_sampling = true; + } + success_convert = input.Convert(half_sampling, &output); + } else if (output_buffer->format() == FrameBuffer::Format::kGRAY) { + if (buffer.plane(0).stride().row_stride_bytes == buffer.dimension().width) { + std::copy(input.y_buffer()->host, + input.y_buffer()->host + buffer.dimension().Size(), + const_cast(output_buffer->plane(0).buffer())); + } else { + // The y_buffer is padded. The conversion removes the padding. + uint8_t* gray_buffer = + const_cast(output_buffer->plane(0).buffer()); + for (int i = 0; i < buffer.dimension().height; i++) { + int src_address = i * buffer.plane(0).stride().row_stride_bytes; + int dest_address = i * buffer.dimension().width; + std::memcpy(&gray_buffer[dest_address], + &buffer.plane(0).buffer()[src_address], + buffer.dimension().width); + } + } + success_convert = true; + } else if (IsSupportedYuvBuffer(*output_buffer)) { + ASSIGN_OR_RETURN(auto output, CreateYuvBuffer(*output_buffer)); + success_convert = input.Resize(&output); + } + return success_convert + ? absl::OkStatus() + : absl::UnknownError("Halide YUV convert operation failed."); +} + +} // namespace + +// Public methods. +//------------------------------------------------------------------------------ + +std::shared_ptr CreateFromRgbaRawBuffer( + uint8_t* input, FrameBuffer::Dimension dimension, + FrameBuffer::Stride stride) { + if (stride == kDefaultStride) { + stride.row_stride_bytes = dimension.width * kRgbaChannels; + stride.pixel_stride_bytes = kRgbaChannels; + } + FrameBuffer::Plane input_plane(/*buffer=*/input, + /*stride=*/stride); + std::vector planes{input_plane}; + return std::make_shared(planes, dimension, + FrameBuffer::Format::kRGBA); +} + +std::shared_ptr CreateFromRgbRawBuffer( + uint8_t* input, FrameBuffer::Dimension dimension, + FrameBuffer::Stride stride) { + if (stride == kDefaultStride) { + stride.row_stride_bytes = dimension.width * kRgbChannels; + stride.pixel_stride_bytes = kRgbChannels; + } + FrameBuffer::Plane input_plane(/*buffer=*/input, + /*stride=*/stride); + std::vector planes{input_plane}; + return std::make_shared(planes, dimension, + FrameBuffer::Format::kRGB); +} + +std::shared_ptr CreateFromGrayRawBuffer( + uint8_t* input, FrameBuffer::Dimension dimension, + FrameBuffer::Stride stride) { + if (stride == kDefaultStride) { + stride.row_stride_bytes = dimension.width * kGrayChannel; + stride.pixel_stride_bytes = kGrayChannel; + } + FrameBuffer::Plane input_plane(/*buffer=*/input, + /*stride=*/stride); + std::vector planes{input_plane}; + return std::make_shared(planes, dimension, + FrameBuffer::Format::kGRAY); +} + +absl::StatusOr> CreateFromYuvRawBuffer( + uint8_t* y_plane, uint8_t* u_plane, uint8_t* v_plane, + FrameBuffer::Format format, FrameBuffer::Dimension dimension, + int row_stride_y, int row_stride_uv, int pixel_stride_uv) { + const int pixel_stride_y = 1; + std::vector planes; + if (format == FrameBuffer::Format::kNV21 || + format == FrameBuffer::Format::kYV12) { + planes = {{y_plane, /*stride=*/{row_stride_y, pixel_stride_y}}, + {v_plane, /*stride=*/{row_stride_uv, pixel_stride_uv}}, + {u_plane, /*stride=*/{row_stride_uv, pixel_stride_uv}}}; + } else if (format == FrameBuffer::Format::kNV12 || + format == FrameBuffer::Format::kYV21) { + planes = {{y_plane, /*stride=*/{row_stride_y, pixel_stride_y}}, + {u_plane, /*stride=*/{row_stride_uv, pixel_stride_uv}}, + {v_plane, /*stride=*/{row_stride_uv, pixel_stride_uv}}}; + } else { + return absl::InvalidArgumentError( + absl::StrFormat("Input format is not YUV-like: %i.", format)); + } + return std::make_shared(planes, dimension, format); +} + +absl::StatusOr> CreateFromRawBuffer( + uint8_t* buffer, FrameBuffer::Dimension dimension, + const FrameBuffer::Format target_format) { + switch (target_format) { + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: { + FrameBuffer::Plane plane(/*buffer=*/buffer, + /*stride=*/{dimension.width, kGrayChannel}); + std::vector planes{plane}; + return std::make_shared(planes, dimension, target_format); + } + case FrameBuffer::Format::kYV12: { + ASSIGN_OR_RETURN(const FrameBuffer::Dimension uv_dimension, + GetUvPlaneDimension(dimension, target_format)); + return CreateFromYuvRawBuffer( + /*y_plane=*/buffer, + /*u_plane=*/buffer + dimension.Size() + uv_dimension.Size(), + /*v_plane=*/buffer + dimension.Size(), target_format, dimension, + /*row_stride_y=*/dimension.width, uv_dimension.width, + /*pixel_stride_uv=*/1); + } + case FrameBuffer::Format::kYV21: { + ASSIGN_OR_RETURN(const FrameBuffer::Dimension uv_dimension, + GetUvPlaneDimension(dimension, target_format)); + return CreateFromYuvRawBuffer( + /*y_plane=*/buffer, /*u_plane=*/buffer + dimension.Size(), + /*v_plane=*/buffer + dimension.Size() + uv_dimension.Size(), + target_format, dimension, /*row_stride_y=*/dimension.width, + uv_dimension.width, + /*pixel_stride_uv=*/1); + } + case FrameBuffer::Format::kRGBA: + return CreateFromRgbaRawBuffer(buffer, dimension); + case FrameBuffer::Format::kRGB: + return CreateFromRgbRawBuffer(buffer, dimension); + case FrameBuffer::Format::kGRAY: + return CreateFromGrayRawBuffer(buffer, dimension); + default: + return absl::InternalError( + absl::StrFormat("Unsupported buffer format: %i.", target_format)); + } +} + +absl::Status Crop(const FrameBuffer& buffer, int x0, int y0, int x1, int y1, + FrameBuffer* output_buffer) { + MP_RETURN_IF_ERROR( + ValidateCropBufferInputs(buffer, *output_buffer, x0, y0, x1, y1)); + MP_RETURN_IF_ERROR(ValidateBufferFormats(buffer, *output_buffer)); + + switch (buffer.format()) { + case FrameBuffer::Format::kGRAY: + return CropGrayscale(buffer, x0, y0, x1, y1, output_buffer); + case FrameBuffer::Format::kRGBA: + case FrameBuffer::Format::kRGB: + return CropRgb(buffer, x0, y0, x1, y1, output_buffer); + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return CropYuv(buffer, x0, y0, x1, y1, output_buffer); + default: + return absl::InternalError( + absl::StrFormat("Format %i is not supported.", buffer.format())); + } +} + +absl::Status Resize(const FrameBuffer& buffer, FrameBuffer* output_buffer) { + MP_RETURN_IF_ERROR(ValidateResizeBufferInputs(buffer, *output_buffer)); + + switch (buffer.format()) { + case FrameBuffer::Format::kGRAY: + return ResizeGrayscale(buffer, output_buffer); + case FrameBuffer::Format::kRGBA: + case FrameBuffer::Format::kRGB: + return ResizeRgb(buffer, output_buffer); + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return ResizeYuv(buffer, output_buffer); + default: + return absl::InternalError( + absl::StrFormat("Format %i is not supported.", buffer.format())); + } +} + +absl::Status Rotate(const FrameBuffer& buffer, int angle_deg, + FrameBuffer* output_buffer) { + MP_RETURN_IF_ERROR( + ValidateRotateBufferInputs(buffer, *output_buffer, angle_deg)); + MP_RETURN_IF_ERROR(ValidateBufferFormats(buffer, *output_buffer)); + + switch (buffer.format()) { + case FrameBuffer::Format::kGRAY: + return RotateGrayscale(buffer, angle_deg, output_buffer); + case FrameBuffer::Format::kRGBA: + case FrameBuffer::Format::kRGB: + return RotateRgb(buffer, angle_deg, output_buffer); + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return RotateYuv(buffer, angle_deg, output_buffer); + default: + return absl::InternalError( + absl::StrFormat("Format %i is not supported.", buffer.format())); + } +} + +absl::Status FlipHorizontally(const FrameBuffer& buffer, + FrameBuffer* output_buffer) { + MP_RETURN_IF_ERROR(ValidateFlipBufferInputs(buffer, *output_buffer)); + MP_RETURN_IF_ERROR(ValidateBufferFormats(buffer, *output_buffer)); + + switch (buffer.format()) { + case FrameBuffer::Format::kGRAY: + return FlipHorizontallyGrayscale(buffer, output_buffer); + case FrameBuffer::Format::kRGBA: + case FrameBuffer::Format::kRGB: + return FlipHorizontallyRgb(buffer, output_buffer); + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return FlipHorizontallyYuv(buffer, output_buffer); + default: + return absl::InternalError( + absl::StrFormat("Format %i is not supported.", buffer.format())); + } +} + +absl::Status FlipVertically(const FrameBuffer& buffer, + FrameBuffer* output_buffer) { + MP_RETURN_IF_ERROR(ValidateFlipBufferInputs(buffer, *output_buffer)); + MP_RETURN_IF_ERROR(ValidateBufferFormats(buffer, *output_buffer)); + + switch (buffer.format()) { + case FrameBuffer::Format::kGRAY: + return FlipVerticallyGrayscale(buffer, output_buffer); + case FrameBuffer::Format::kRGBA: + case FrameBuffer::Format::kRGB: + return FlipVerticallyRgb(buffer, output_buffer); + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return FlipVerticallyYuv(buffer, output_buffer); + default: + return absl::InternalError( + absl::StrFormat("Format %i is not supported.", buffer.format())); + } +} + +absl::Status Convert(const FrameBuffer& buffer, FrameBuffer* output_buffer) { + MP_RETURN_IF_ERROR( + ValidateConvertFormats(buffer.format(), output_buffer->format())); + + switch (buffer.format()) { + case FrameBuffer::Format::kRGBA: + case FrameBuffer::Format::kRGB: + return ConvertRgb(buffer, output_buffer); + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return ConvertYuv(buffer, output_buffer); + default: + return absl::InternalError( + absl::StrFormat("Format %i is not supported.", buffer.format())); + } +} + +int GetFrameBufferByteSize(FrameBuffer::Dimension dimension, + FrameBuffer::Format format) { + switch (format) { + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return /*y plane*/ dimension.Size() + + /*uv plane*/ (dimension.width + 1) / 2 * (dimension.height + 1) / + 2 * 2; + case FrameBuffer::Format::kRGB: + return dimension.Size() * kRgbPixelBytes; + case FrameBuffer::Format::kRGBA: + return dimension.Size() * kRgbaPixelBytes; + case FrameBuffer::Format::kGRAY: + return dimension.Size(); + default: + return 0; + } +} + +absl::StatusOr GetPixelStrides(FrameBuffer::Format format) { + switch (format) { + case FrameBuffer::Format::kGRAY: + return kGrayPixelBytes; + case FrameBuffer::Format::kRGB: + return kRgbPixelBytes; + case FrameBuffer::Format::kRGBA: + return kRgbaPixelBytes; + default: + return absl::InvalidArgumentError(absl::StrFormat( + "GetPixelStrides does not support format: %i.", format)); + } +} + +absl::StatusOr GetUvRawBuffer(const FrameBuffer& buffer) { + if (buffer.format() != FrameBuffer::Format::kNV12 && + buffer.format() != FrameBuffer::Format::kNV21) { + return absl::InvalidArgumentError( + "Only support getting biplanar UV buffer from NV12/NV21 frame buffer."); + } + ASSIGN_OR_RETURN(FrameBuffer::YuvData yuv_data, + FrameBuffer::GetYuvDataFromFrameBuffer(buffer)); + const uint8_t* uv_buffer = buffer.format() == FrameBuffer::Format::kNV12 + ? yuv_data.u_buffer + : yuv_data.v_buffer; + return uv_buffer; +} + +absl::StatusOr GetUvPlaneDimension( + FrameBuffer::Dimension dimension, FrameBuffer::Format format) { + if (dimension.width <= 0 || dimension.height <= 0) { + return absl::InvalidArgumentError( + absl::StrFormat("Invalid input dimension: {%d, %d}.", dimension.width, + dimension.height)); + } + switch (format) { + case FrameBuffer::Format::kNV12: + case FrameBuffer::Format::kNV21: + case FrameBuffer::Format::kYV12: + case FrameBuffer::Format::kYV21: + return FrameBuffer::Dimension{(dimension.width + 1) / 2, + (dimension.height + 1) / 2}; + default: + return absl::InvalidArgumentError( + absl::StrFormat("Input format is not YUV-like: %i.", format)); + } +} + +FrameBuffer::Dimension GetCropDimension(int x0, int x1, int y0, int y1) { + return {x1 - x0 + 1, y1 - y0 + 1}; +} + +} // namespace frame_buffer +} // namespace mediapipe diff --git a/mediapipe/util/frame_buffer/frame_buffer_util.h b/mediapipe/util/frame_buffer/frame_buffer_util.h new file mode 100644 index 000000000..eb17e0094 --- /dev/null +++ b/mediapipe/util/frame_buffer/frame_buffer_util.h @@ -0,0 +1,131 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef MEDIAPIPE_UTIL_FRAME_BUFFER_FRAME_BUFFER_UTIL_H_ +#define MEDIAPIPE_UTIL_FRAME_BUFFER_FRAME_BUFFER_UTIL_H_ + +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "mediapipe/framework/formats/frame_buffer.h" + +namespace mediapipe { +namespace frame_buffer { + +// Creation helpers. +//------------------------------------------------------------------------------ + +// Default stride value for creating frame buffer from raw buffer. When using +// this default value, the default row stride and pixel stride values will be +// applied. e.g. for an RGB image: +// row_stride = width * 3 +// pixel_stride = 3. +inline constexpr FrameBuffer::Stride kDefaultStride = {0, 0}; + +// Creates a FrameBuffer from raw RGBA buffer and passing arguments. +std::shared_ptr CreateFromRgbaRawBuffer( + uint8_t* input, FrameBuffer::Dimension dimension, + FrameBuffer::Stride stride = kDefaultStride); + +// Creates a FrameBuffer from raw RGB buffer and passing arguments. +std::shared_ptr CreateFromRgbRawBuffer( + uint8_t* input, FrameBuffer::Dimension dimension, + FrameBuffer::Stride stride = kDefaultStride); + +// Creates a FrameBuffer from raw grayscale buffer and passing arguments. +std::shared_ptr CreateFromGrayRawBuffer( + uint8_t* input, FrameBuffer::Dimension dimension, + FrameBuffer::Stride stride = kDefaultStride); + +// Creates a FrameBuffer from raw YUV buffer and passing arguments. +absl::StatusOr> CreateFromYuvRawBuffer( + uint8_t* y_plane, uint8_t* u_plane, uint8_t* v_plane, + FrameBuffer::Format format, FrameBuffer::Dimension dimension, + int row_stride_y, int row_stride_uv, int pixel_stride_uv); + +// Creates an instance of FrameBuffer from raw buffer and passing arguments. +absl::StatusOr> CreateFromRawBuffer( + uint8_t* buffer, FrameBuffer::Dimension dimension, + FrameBuffer::Format target_format); + +// Transformations. +//------------------------------------------------------------------------------ + +// Crops `buffer` to the specified points. +// +// (x0, y0) represents the top-left point of the buffer. +// (x1, y1) represents the bottom-right point of the buffer. +// +// The implementation performs origin moving and resizing operations. +absl::Status Crop(const FrameBuffer& buffer, int x0, int y0, int x1, int y1, + FrameBuffer* output_buffer); + +// Resizes `buffer` to the size of the given `output_buffer` using bilinear +// interpolation. +absl::Status Resize(const FrameBuffer& buffer, FrameBuffer* output_buffer); + +// Rotates `buffer` counter-clockwise by the given `angle_deg` (in degrees). +// +// The given angle must be a multiple of 90 degrees. +absl::Status Rotate(const FrameBuffer& buffer, int angle_deg, + FrameBuffer* output_buffer); + +// Flips `buffer` horizontally. +absl::Status FlipHorizontally(const FrameBuffer& buffer, + FrameBuffer* output_buffer); + +// Flips `buffer` vertically. +absl::Status FlipVertically(const FrameBuffer& buffer, + FrameBuffer* output_buffer); + +// Converts `buffer`'s format to the format of the given `output_buffer`. +// +// Note that grayscale format does not convert to other formats. +// Note the NV21 to RGB/RGBA conversion may downsample by factor of 2 based +// on the buffer and output_buffer dimensions. +absl::Status Convert(const FrameBuffer& buffer, FrameBuffer* output_buffer); + +// Miscellaneous Methods +// ----------------------------------------------------------------- + +// Returns the frame buffer size in bytes based on the input format and +// dimensions. GRAY, YV12/YV21 are in the planar formats, NV12/NV21 are in the +// semi-planar formats with the interleaved UV planes. RGB/RGBA are in the +// interleaved format. +int GetFrameBufferByteSize(FrameBuffer::Dimension dimension, + FrameBuffer::Format format); + +// Returns pixel stride info for kGRAY, kRGB, kRGBA formats. +absl::StatusOr GetPixelStrides(FrameBuffer::Format format); + +// Returns the biplanar UV raw buffer for NV12/NV21 frame buffer. +absl::StatusOr GetUvRawBuffer(const FrameBuffer& buffer); + +// Returns U or V plane dimension with the given buffer `dimension` and +// `format`. Only supports NV12/NV21/YV12/YV21 formats. Returns +// InvalidArgumentError if 'dimension' is invalid or 'format' is other than the +// supported formats. This method assums the UV plane share the same dimension, +// especially for the YV12 / YV21 formats. +absl::StatusOr GetUvPlaneDimension( + FrameBuffer::Dimension dimension, FrameBuffer::Format format); + +// Returns crop dimension based on crop start and end points. +FrameBuffer::Dimension GetCropDimension(int x0, int x1, int y0, int y1); + +} // namespace frame_buffer +} // namespace mediapipe + +#endif // MEDIAPIPE_UTIL_FRAME_BUFFER_FRAME_BUFFER_UTIL_H_ diff --git a/mediapipe/util/frame_buffer/frame_buffer_util_test.cc b/mediapipe/util/frame_buffer/frame_buffer_util_test.cc new file mode 100644 index 000000000..d92eb3f53 --- /dev/null +++ b/mediapipe/util/frame_buffer/frame_buffer_util_test.cc @@ -0,0 +1,1176 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/frame_buffer_util.h" + +#include +#include +#include + +#include "mediapipe/framework/formats/frame_buffer.h" +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/framework/port/status_macros.h" +#include "mediapipe/framework/port/status_matchers.h" + +namespace mediapipe { +namespace frame_buffer { +namespace { + +// Grayscale unit tests. +//------------------------------------------------------------------------------ + +TEST(FrameBufferUtil, GrayCrop) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}, + kOutputDimension = {.width = 1, .height = 1}; + uint8_t data[6] = {1, 2, 3, 4, 5, 6}; + uint8_t output_data[2]; + auto input = CreateFromGrayRawBuffer(data, kBufferDimension); + auto output = CreateFromGrayRawBuffer(output_data, kOutputDimension); + + MP_ASSERT_OK(Crop(*input, 0, 1, 0, 1, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 4); +} + +TEST(FrameBufferUtil, GrayResize) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 2, .height = 2}, + kOutputDimension = {.width = 3, .height = 2}; + uint8_t data[4] = {1, 2, 3, 4}; + uint8_t output_data[6]; + auto input = CreateFromGrayRawBuffer(data, kBufferDimension); + auto output = CreateFromGrayRawBuffer(output_data, kOutputDimension); + + MP_ASSERT_OK(Resize(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 1); + EXPECT_EQ(output->plane(0).buffer()[1], 2); + EXPECT_EQ(output->plane(0).buffer()[2], 2); + EXPECT_EQ(output->plane(0).buffer()[3], 3); + EXPECT_EQ(output->plane(0).buffer()[4], 4); + EXPECT_EQ(output->plane(0).buffer()[5], 4); +} + +TEST(FrameBufferUtil, GrayRotate) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}, + kOutputDimension = {.width = 2, .height = 3}; + uint8_t data[6] = {1, 2, 3, 4, 5, 6}; + uint8_t output_data[6]; + auto input = CreateFromGrayRawBuffer(data, kBufferDimension); + auto output = CreateFromGrayRawBuffer(output_data, kOutputDimension); + + MP_ASSERT_OK(Rotate(*input, 90, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 3); + EXPECT_EQ(output->plane(0).buffer()[1], 6); + EXPECT_EQ(output->plane(0).buffer()[2], 2); + EXPECT_EQ(output->plane(0).buffer()[3], 5); + EXPECT_EQ(output->plane(0).buffer()[4], 1); + EXPECT_EQ(output->plane(0).buffer()[5], 4); +} + +TEST(FrameBufferUtil, GrayFlipHorizontally) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}; + uint8_t data[6] = {1, 2, 3, 4, 5, 6}; + uint8_t output_data[6]; + auto input = CreateFromGrayRawBuffer(data, kBufferDimension); + auto output = CreateFromGrayRawBuffer(output_data, kBufferDimension); + + MP_ASSERT_OK(FlipHorizontally(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 3); + EXPECT_EQ(output->plane(0).buffer()[1], 2); + EXPECT_EQ(output->plane(0).buffer()[2], 1); + EXPECT_EQ(output->plane(0).buffer()[3], 6); + EXPECT_EQ(output->plane(0).buffer()[4], 5); + EXPECT_EQ(output->plane(0).buffer()[5], 4); +} + +TEST(FrameBufferUtil, GrayFlipVertically) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}; + uint8_t data[6] = {1, 2, 3, 4, 5, 6}; + uint8_t output_data[6]; + auto input = CreateFromGrayRawBuffer(data, kBufferDimension); + auto output = CreateFromGrayRawBuffer(output_data, kBufferDimension); + + MP_ASSERT_OK(FlipVertically(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 4); + EXPECT_EQ(output->plane(0).buffer()[1], 5); + EXPECT_EQ(output->plane(0).buffer()[2], 6); + EXPECT_EQ(output->plane(0).buffer()[3], 1); + EXPECT_EQ(output->plane(0).buffer()[4], 2); + EXPECT_EQ(output->plane(0).buffer()[5], 3); +} + +// Grayscale EndToEnd tests. +//------------------------------------------------------------------------------ + +struct GrayInputTestParam { + FrameBuffer::Dimension input_dimension; + FrameBuffer::Format input_format; + FrameBuffer::Dimension output_dimension; + FrameBuffer::Format output_format; + int rotation_angle; + int x0; + int y0; + int x1; + int y1; +}; + +enum Operation { + kRotate = 1, + kCrop = 2, + kResize = 3, + kHorizontalFlip = 4, + kVerticalFlip = 5, + kConvert = 6 +}; + +class GrayInputTest : public ::testing::TestWithParam< + std::tuple> {}; + +TEST_P(GrayInputTest, ValidateInputs) { + GrayInputTestParam inputs; + bool is_valid; + Operation operation; + std::tie(operation, inputs, is_valid) = GetParam(); + MP_ASSERT_OK_AND_ASSIGN( + auto input, + CreateFromRawBuffer(/*buffer=*/nullptr, inputs.input_dimension, + inputs.input_format)); + MP_ASSERT_OK_AND_ASSIGN(auto output, + CreateFromRawBuffer(nullptr, inputs.output_dimension, + inputs.output_format)); + + absl::Status status; + switch (operation) { + case kRotate: + status = Rotate(*input, inputs.rotation_angle, output.get()); + break; + case kResize: + status = Resize(*input, output.get()); + break; + case kCrop: { + status = Crop(*input, inputs.x0, inputs.y0, inputs.x1, inputs.y1, + output.get()); + break; + } + case kHorizontalFlip: + status = FlipHorizontally(*input, output.get()); + break; + case kVerticalFlip: + status = FlipVertically(*input, output.get()); + break; + case kConvert: + status = Convert(*input, output.get()); + break; + } + + if (is_valid) { + MP_EXPECT_OK(status); + } else { + EXPECT_THAT(status, StatusIs(absl::StatusCode::kInvalidArgument)); + } +} + +std::tuple CreateGrayRotateInputTestParam( + int in_width, int in_height, FrameBuffer::Format in_format, int out_width, + int out_height, FrameBuffer::Format out_format, int angle, bool is_valid) { + GrayInputTestParam param = { + .input_dimension = FrameBuffer::Dimension{in_width, in_height}, + .input_format = in_format, + .output_dimension = FrameBuffer::Dimension{out_width, out_height}, + .output_format = out_format, + .rotation_angle = angle}; + return std::make_tuple(kRotate, param, is_valid); +} + +INSTANTIATE_TEST_SUITE_P( + ValidateRotateInputs, GrayInputTest, + testing::Values( + CreateGrayRotateInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 2, 3, + FrameBuffer::Format::kGRAY, 30, false), + CreateGrayRotateInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kRGB, 180, false), + CreateGrayRotateInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kGRAY, 90, false), + CreateGrayRotateInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kGRAY, 0, false), + CreateGrayRotateInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 2, 3, + FrameBuffer::Format::kGRAY, -90, false), + CreateGrayRotateInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 2, 3, + FrameBuffer::Format::kGRAY, 90, true), + CreateGrayRotateInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kGRAY, 180, true), + CreateGrayRotateInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 2, 3, + FrameBuffer::Format::kGRAY, 270, true), + CreateGrayRotateInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 2, 3, + FrameBuffer::Format::kGRAY, 450, + false))); + +std::tuple CreateGrayCropInputTestParam( + int in_width, int in_height, FrameBuffer::Format in_format, int out_width, + int out_height, FrameBuffer::Format out_format, int x0, int y0, int x1, + int y1, bool is_valid) { + GrayInputTestParam param = { + .input_dimension = FrameBuffer::Dimension{in_width, in_height}, + .input_format = in_format, + .output_dimension = FrameBuffer::Dimension{out_width, out_height}, + .output_format = out_format, + .x0 = x0, + .y0 = y0, + .x1 = x1, + .y1 = y1}; + return std::make_tuple(kCrop, param, is_valid); +} + +INSTANTIATE_TEST_SUITE_P( + ValidateCropInputs, GrayInputTest, + ::testing::Values( + CreateGrayCropInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kRGB, 0, 0, 3, 2, + false), + CreateGrayCropInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kGRAY, 1, 1, 1, 4, + false), + CreateGrayCropInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 2, 1, + FrameBuffer::Format::kGRAY, -1, 0, 1, 1, + false), + CreateGrayCropInputTestParam(5, 5, FrameBuffer::Format::kGRAY, 3, 3, + FrameBuffer::Format::kGRAY, 0, 0, 2, 2, + true), + CreateGrayCropInputTestParam(5, 5, FrameBuffer::Format::kGRAY, 2, 2, + FrameBuffer::Format::kGRAY, 1, 2, 2, 3, + true), + CreateGrayCropInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 1, 1, + FrameBuffer::Format::kGRAY, 0, 0, 0, 0, + true))); + +std::tuple CreateGrayResizeInputTestParam( + int in_width, int in_height, FrameBuffer::Format in_format, int out_width, + int out_height, FrameBuffer::Format out_format, bool is_valid) { + GrayInputTestParam param = { + .input_dimension = FrameBuffer::Dimension{in_width, in_height}, + .input_format = in_format, + .output_dimension = FrameBuffer::Dimension{out_width, out_height}, + .output_format = out_format}; + return std::make_tuple(kResize, param, is_valid); +} + +INSTANTIATE_TEST_SUITE_P( + ValidateResizeInputs, GrayInputTest, + ::testing::Values( + CreateGrayResizeInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 1, 1, + FrameBuffer::Format::kRGB, false), + CreateGrayResizeInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 5, 5, + FrameBuffer::Format::kRGB, false), + CreateGrayResizeInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 2, 1, + FrameBuffer::Format::kGRAY, true), + CreateGrayResizeInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 7, 9, + FrameBuffer::Format::kGRAY, true))); + +std::tuple CreateGrayFlipInputTestParam( + int in_width, int in_height, FrameBuffer::Format in_format, int out_width, + int out_height, FrameBuffer::Format out_format, bool horizontal_flip, + bool is_valid) { + GrayInputTestParam param = { + .input_dimension = FrameBuffer::Dimension{in_width, in_height}, + .input_format = in_format, + .output_dimension = FrameBuffer::Dimension{out_width, out_height}, + .output_format = out_format}; + return std::make_tuple(horizontal_flip ? kHorizontalFlip : kVerticalFlip, + param, is_valid); +} + +INSTANTIATE_TEST_SUITE_P( + ValidateFlipInputs, GrayInputTest, + ::testing::Values( + CreateGrayFlipInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kRGB, true, false), + CreateGrayFlipInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 3, + FrameBuffer::Format::kGRAY, true, false), + CreateGrayFlipInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kGRAY, true, true), + CreateGrayFlipInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kRGB, false, false), + CreateGrayFlipInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 3, + FrameBuffer::Format::kGRAY, false, false), + CreateGrayFlipInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kGRAY, false, true))); + +std::tuple CreateGrayConvertInputTestParam( + int in_width, int in_height, FrameBuffer::Format in_format, int out_width, + int out_height, FrameBuffer::Format out_format, bool is_valid) { + GrayInputTestParam param = { + .input_dimension = FrameBuffer::Dimension{in_width, in_height}, + .input_format = in_format, + .output_dimension = FrameBuffer::Dimension{out_width, out_height}, + .output_format = out_format}; + return std::make_tuple(kConvert, param, is_valid); +} + +INSTANTIATE_TEST_SUITE_P( + ValidateConvertInputs, GrayInputTest, + ::testing::Values( + CreateGrayConvertInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kRGB, false), + CreateGrayConvertInputTestParam(3, 2, FrameBuffer::Format::kGRAY, 3, 2, + FrameBuffer::Format::kGRAY, false))); + +// Rgb unit tests. +//------------------------------------------------------------------------------ + +struct FrameBufferPlanarFormat { + FrameBufferPlanarFormat() + : format(FrameBuffer::Format::kGRAY), plane_count(0) {} + FrameBufferPlanarFormat(FrameBuffer::Format format, int plane_count) + : format(format), plane_count(plane_count) {} + + FrameBuffer::Format format; + int plane_count; +}; + +class RgbaConvertTest + : public testing::TestWithParam< + std::tuple> { + public: + void SetUp() override { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 2, + .height = 1}; + constexpr int kBufferSize = 20; + std::tie(input_format_, output_planar_format_) = GetParam(); + + // Setup input frame buffer + input_data_ = std::make_unique(kBufferSize); + FrameBuffer::Stride input_stride; + if (input_format_ == FrameBuffer::Format::kRGBA) { + uint8_t data[] = {200, 100, 0, 1, 0, 200, 100, 50}; + std::copy(data, data + 8, input_data_.get()); + input_stride = {/*row_stride_bytes=*/8, /*pixel_stride_bytes=*/4}; + } else { + uint8_t data[] = {200, 100, 0, 0, 200, 100}; + std::copy(data, data + 6, input_data_.get()); + input_stride = {/*row_stride_bytes=*/6, /*pixel_stride_bytes=*/3}; + } + FrameBuffer::Plane input_plane(/*buffer=*/input_data_.get(), input_stride); + std::vector input_planes = {input_plane}; + input_frame_buffer_ = std::make_shared( + input_planes, kBufferDimension, input_format_); + + // Setup output frame buffer + if (output_planar_format_.format == FrameBuffer::Format::kRGBA) { + output_data_1_ = std::make_unique(kBufferSize); + FrameBuffer::Plane output_plane_1( + /*buffer=*/output_data_1_.get(), + /*stride=*/{/*row_stride_bytes=*/8, /*pixel_stride_bytes=*/4}); + std::vector output_planes = {output_plane_1}; + output_frame_buffer_ = std::make_shared( + output_planes, kBufferDimension, output_planar_format_.format); + } else if (output_planar_format_.format == FrameBuffer::Format::kRGB) { + output_data_1_ = std::make_unique(kBufferSize); + FrameBuffer::Plane output_plane_1( + /*buffer=*/output_data_1_.get(), + /*stride=*/{/*row_stride_bytes=*/6, /*pixel_stride_bytes=*/3}); + std::vector output_planes = {output_plane_1}; + output_frame_buffer_ = std::make_shared( + output_planes, kBufferDimension, output_planar_format_.format); + } else if (output_planar_format_.plane_count == 1) { + output_data_1_ = std::make_unique(kBufferSize); + FrameBuffer::Plane output_plane_1( + /*buffer=*/output_data_1_.get(), + /*stride=*/{/*row_stride_bytes=*/2, + /*pixel_stride_bytes=*/1}); + std::vector output_planes = {output_plane_1}; + output_frame_buffer_ = std::make_shared( + output_planes, kBufferDimension, output_planar_format_.format); + } else if (output_planar_format_.plane_count == 2) { + output_data_1_ = std::make_unique(kBufferSize); + FrameBuffer::Plane output_plane_1( + /*buffer=*/output_data_1_.get(), + /*stride=*/{/*row_stride_bytes=*/2, /*pixel_stride_bytes=*/1}); + output_data_2_ = std::make_unique(kBufferSize); + FrameBuffer::Plane output_plane_2( + /*buffer=*/output_data_2_.get(), + /*stride=*/{/*row_stride_bytes=*/1, /*pixel_stride_bytes=*/2}); + std::vector planes = {output_plane_1, output_plane_2}; + output_frame_buffer_ = std::make_shared( + planes, kBufferDimension, output_planar_format_.format); + } else { + output_data_1_ = std::make_unique(kBufferSize); + output_data_2_ = std::make_unique(kBufferSize); + output_data_3_ = std::make_unique(kBufferSize); + FrameBuffer::Plane output_plane_1( + /*buffer=*/output_data_1_.get(), + /*stride=*/{/*row_stride_bytes=*/2, /*pixel_stride_bytes=*/1}); + FrameBuffer::Plane output_plane_2( + /*buffer=*/output_data_2_.get(), + /*stride=*/{/*row_stride_bytes=*/1, /*pixel_stride_bytes=*/1}); + FrameBuffer::Plane output_plane_3( + /*buffer=*/output_data_3_.get(), + /*stride=*/{/*row_stride_bytes=*/1, /*pixel_stride_bytes=*/1}); + std::vector planes = {output_plane_1, output_plane_2, + output_plane_3}; + output_frame_buffer_ = std::make_shared( + planes, kBufferDimension, output_planar_format_.format); + } + } + + protected: + FrameBuffer::Format input_format_; + FrameBufferPlanarFormat output_planar_format_; + + std::unique_ptr output_data_1_; + std::unique_ptr output_data_2_; + std::unique_ptr output_data_3_; + std::unique_ptr input_data_; + + std::shared_ptr input_frame_buffer_; + std::shared_ptr output_frame_buffer_; +}; + +TEST_P(RgbaConvertTest, RgbaToOtherFormatConversion) { + absl::Status status = + Convert(*input_frame_buffer_, output_frame_buffer_.get()); + if (output_planar_format_.format == FrameBuffer::Format::kGRAY) { + MP_ASSERT_OK(status); + EXPECT_EQ(output_data_1_[0], 118); + EXPECT_EQ(output_data_1_[1], 129); + } else if (output_frame_buffer_->format() == FrameBuffer::Format::kNV12 || + output_frame_buffer_->format() == FrameBuffer::Format::kNV21 || + output_frame_buffer_->format() == FrameBuffer::Format::kYV12 || + output_frame_buffer_->format() == FrameBuffer::Format::kYV21) { + MP_ASSERT_OK(status); + MP_ASSERT_OK_AND_ASSIGN( + FrameBuffer::YuvData yuv_data, + FrameBuffer::GetYuvDataFromFrameBuffer(*output_frame_buffer_)); + EXPECT_EQ(yuv_data.y_buffer[0], 118); + EXPECT_EQ(yuv_data.y_buffer[1], 129); + EXPECT_EQ(yuv_data.u_buffer[0], 61); + EXPECT_EQ(yuv_data.v_buffer[0], 186); + } else if (input_format_ == FrameBuffer::Format::kRGBA && + output_frame_buffer_->format() == FrameBuffer::Format::kRGB) { + EXPECT_EQ(output_data_1_[0], 200); + EXPECT_EQ(output_data_1_[1], 100); + EXPECT_EQ(output_data_1_[2], 0); + EXPECT_EQ(output_data_1_[3], 0); + MP_ASSERT_OK(status); + } else if (input_format_ == FrameBuffer::Format::kRGB && + output_frame_buffer_->format() == FrameBuffer::Format::kRGBA) { + MP_ASSERT_OK(status); + EXPECT_EQ(output_data_1_[0], 200); + EXPECT_EQ(output_data_1_[1], 100); + EXPECT_EQ(output_data_1_[2], 0); + EXPECT_EQ(output_data_1_[3], 255); + } else { + ASSERT_FALSE(status.ok()); + } +} + +INSTANTIATE_TEST_SUITE_P( + RgbaToOtherFormatConversion, RgbaConvertTest, + testing::Combine( + testing::Values(FrameBuffer::Format::kRGBA, FrameBuffer::Format::kRGB), + testing::Values(FrameBufferPlanarFormat(FrameBuffer::Format::kGRAY, + /*plane_count=*/1), + FrameBufferPlanarFormat(FrameBuffer::Format::kRGBA, + /*plane_count=*/1), + FrameBufferPlanarFormat(FrameBuffer::Format::kRGB, + /*plane_count=*/1), + FrameBufferPlanarFormat(FrameBuffer::Format::kNV21, + /*plane_count=*/1), + FrameBufferPlanarFormat(FrameBuffer::Format::kNV21, + /*plane_count=*/2), + FrameBufferPlanarFormat(FrameBuffer::Format::kNV21, + /*plane_count=*/3), + FrameBufferPlanarFormat(FrameBuffer::Format::kNV12, + /*plane_count=*/1), + FrameBufferPlanarFormat(FrameBuffer::Format::kNV12, + /*plane_count=*/2), + FrameBufferPlanarFormat(FrameBuffer::Format::kNV12, + /*plane_count=*/3), + FrameBufferPlanarFormat(FrameBuffer::Format::kYV21, + /*plane_count=*/1), + FrameBufferPlanarFormat(FrameBuffer::Format::kYV21, + /*plane_count=*/3), + FrameBufferPlanarFormat(FrameBuffer::Format::kYV12, + /*plane_count=*/1), + FrameBufferPlanarFormat(FrameBuffer::Format::kYV12, + /*plane_count=*/3)))); + +TEST(FrameBufferUtil, RgbaToRgbConversion) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 2, .height = 1}; + uint8_t data[] = {200, 100, 0, 1, 0, 200, 100, 50}; + auto input = CreateFromRgbaRawBuffer(data, kBufferDimension); + uint8_t output_data[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + auto output = CreateFromRgbRawBuffer(output_data, kBufferDimension); + + MP_ASSERT_OK(Convert(*input, output.get())); + EXPECT_EQ(output_data[0], 200); + EXPECT_EQ(output_data[1], 100); + EXPECT_EQ(output_data[2], 0); + EXPECT_EQ(output_data[3], 0); + EXPECT_EQ(output_data[4], 200); + EXPECT_EQ(output_data[5], 100); +} + +TEST(FrameBufferUtil, RgbaCrop) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}, + kOutputDimension = {.width = 1, .height = 1}; + uint8_t kRgbaTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24}; + uint8_t output_data[4]; + auto input = CreateFromRgbaRawBuffer(kRgbaTestData, kBufferDimension); + auto output = CreateFromRgbaRawBuffer(output_data, kOutputDimension); + + MP_ASSERT_OK(Crop(*input, 0, 1, 0, 1, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 13); + EXPECT_EQ(output->plane(0).buffer()[1], 14); + EXPECT_EQ(output->plane(0).buffer()[2], 15); + EXPECT_EQ(output->plane(0).buffer()[3], 16); +} + +TEST(FrameBufferUtil, RgbCrop) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}, + kOutputDimension = {.width = 1, .height = 1}; + uint8_t kRgbTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + uint8_t output_data[3]; + auto input = CreateFromRgbRawBuffer(kRgbTestData, kBufferDimension); + auto output = CreateFromRgbRawBuffer(output_data, kOutputDimension); + + MP_ASSERT_OK(Crop(*input, 0, 1, 0, 1, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 10); + EXPECT_EQ(output->plane(0).buffer()[1], 11); + EXPECT_EQ(output->plane(0).buffer()[2], 12); +} + +TEST(FrameBufferUtil, RgbaFlipHorizontally) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 1}; + uint8_t kRgbaTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24}; + uint8_t output_data[sizeof(kRgbaTestData) / 2]; + auto input = CreateFromRgbaRawBuffer(kRgbaTestData, kBufferDimension); + auto output = CreateFromRgbaRawBuffer(output_data, kBufferDimension); + + MP_ASSERT_OK(FlipHorizontally(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 9); + EXPECT_EQ(output->plane(0).buffer()[1], 10); + EXPECT_EQ(output->plane(0).buffer()[2], 11); + EXPECT_EQ(output->plane(0).buffer()[3], 12); + EXPECT_EQ(output->plane(0).buffer()[4], 5); + EXPECT_EQ(output->plane(0).buffer()[5], 6); + EXPECT_EQ(output->plane(0).buffer()[6], 7); + EXPECT_EQ(output->plane(0).buffer()[7], 8); + EXPECT_EQ(output->plane(0).buffer()[8], 1); + EXPECT_EQ(output->plane(0).buffer()[9], 2); + EXPECT_EQ(output->plane(0).buffer()[10], 3); + EXPECT_EQ(output->plane(0).buffer()[11], 4); +} + +TEST(FrameBufferUtil, RgbFlipHorizontally) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 1}; + uint8_t kRgbTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + uint8_t output_data[sizeof(kRgbTestData) / 2]; + auto input = CreateFromRgbRawBuffer(kRgbTestData, kBufferDimension); + auto output = CreateFromRgbRawBuffer(output_data, kBufferDimension); + + MP_ASSERT_OK(FlipHorizontally(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 7); + EXPECT_EQ(output->plane(0).buffer()[1], 8); + EXPECT_EQ(output->plane(0).buffer()[2], 9); + EXPECT_EQ(output->plane(0).buffer()[3], 4); + EXPECT_EQ(output->plane(0).buffer()[4], 5); + EXPECT_EQ(output->plane(0).buffer()[5], 6); + EXPECT_EQ(output->plane(0).buffer()[6], 1); + EXPECT_EQ(output->plane(0).buffer()[7], 2); + EXPECT_EQ(output->plane(0).buffer()[8], 3); +} + +TEST(FrameBufferUtil, RgbaFlipVertically) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}; + uint8_t kRgbaTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24}; + uint8_t output_data[sizeof(kRgbaTestData)]; + auto input = CreateFromRgbaRawBuffer(kRgbaTestData, kBufferDimension); + auto output = CreateFromRgbaRawBuffer(output_data, kBufferDimension); + + MP_ASSERT_OK(FlipVertically(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 13); + EXPECT_EQ(output->plane(0).buffer()[1], 14); + EXPECT_EQ(output->plane(0).buffer()[2], 15); + EXPECT_EQ(output->plane(0).buffer()[3], 16); + EXPECT_EQ(output->plane(0).buffer()[12], 1); + EXPECT_EQ(output->plane(0).buffer()[13], 2); + EXPECT_EQ(output->plane(0).buffer()[14], 3); + EXPECT_EQ(output->plane(0).buffer()[15], 4); +} + +TEST(FrameBufferUtil, RgbFlipVertically) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}; + uint8_t kRgbTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + uint8_t output_data[sizeof(kRgbTestData)]; + auto input = CreateFromRgbRawBuffer(kRgbTestData, kBufferDimension); + auto output = CreateFromRgbRawBuffer(output_data, kBufferDimension); + + MP_ASSERT_OK(FlipVertically(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 10); + EXPECT_EQ(output->plane(0).buffer()[1], 11); + EXPECT_EQ(output->plane(0).buffer()[2], 12); + EXPECT_EQ(output->plane(0).buffer()[9], 1); + EXPECT_EQ(output->plane(0).buffer()[10], 2); + EXPECT_EQ(output->plane(0).buffer()[11], 3); +} + +TEST(FrameBufferUtil, RgbaResize) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}, + kResizeUpDimension = {.width = 4, + .height = 2}, + kResizeDownDimension = {.width = 2, + .height = 2}; + uint8_t kRgbaTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24}; + uint8_t output_data_up[32]; + auto input = CreateFromRgbaRawBuffer(kRgbaTestData, kBufferDimension); + auto output = CreateFromRgbaRawBuffer(output_data_up, kResizeUpDimension); + + // Test increasing the size. + MP_ASSERT_OK(Resize(*input, output.get())); + uint8_t resize_result_size_increase[] = { + 1, 2, 3, 4, 4, 5, 6, 7, 7, 8, 9, 10, 9, 10, 11, 12, + 13, 14, 15, 16, 16, 17, 18, 19, 19, 20, 21, 22, 21, 22, 23, 24}; + for (int i = 0; i < sizeof(output_data_up); i++) { + EXPECT_EQ(output->plane(0).buffer()[i], resize_result_size_increase[i]); + } + + // Test shrinking the image by half. + uint8_t output_data_down[16]; + output = CreateFromRgbaRawBuffer(output_data_down, kResizeDownDimension); + + MP_ASSERT_OK(Resize(*input, output.get())); + uint8_t resize_result_size_decrease[] = {1, 2, 3, 4, 7, 8, 9, 10, + 13, 14, 15, 16, 19, 20, 21, 22}; + for (int i = 0; i < sizeof(output_data_down); i++) { + EXPECT_EQ(output->plane(0).buffer()[i], resize_result_size_decrease[i]); + } +} + +TEST(FrameBufferUtil, RgbResize) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}, + kResizeUpDimension = {.width = 4, + .height = 3}, + kResizeDownDimension = {.width = 2, + .height = 2}; + uint8_t kRgbTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + auto input = CreateFromRgbRawBuffer(kRgbTestData, kBufferDimension); + + // Test increasing the size. + uint8_t output_data_up[36]; + auto output = CreateFromRgbRawBuffer(output_data_up, kResizeUpDimension); + MP_ASSERT_OK(Resize(*input, output.get())); + uint8_t resize_result_size_increase[] = { + 1, 2, 3, 3, 4, 5, 5, 6, 7, 7, 8, 9, 7, 8, 9, 9, 10, 11, + 11, 12, 13, 13, 14, 15, 10, 11, 12, 12, 13, 14, 14, 15, 16, 16, 17, 18}; + for (int i = 0; i < sizeof(output_data_up); i++) { + EXPECT_EQ(output_data_up[i], resize_result_size_increase[i]); + } + + // Test decreasing the size. + uint8_t output_data_down[12]; + output = CreateFromRgbRawBuffer(output_data_down, kResizeDownDimension); + MP_ASSERT_OK(Resize(*input, output.get())); + + uint8_t resize_result_size_decrease[] = {1, 2, 3, 5, 6, 7, + 10, 11, 12, 14, 15, 16}; + for (int i = 0; i < sizeof(resize_result_size_decrease); i++) { + EXPECT_EQ(output->plane(0).buffer()[i], resize_result_size_decrease[i]); + } +} + +TEST(FrameBufferUtil, RgbaRotate) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}, + kRotatedDimension = {.width = 2, + .height = 3}; + uint8_t kRgbaTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24}; + uint8_t output_data[sizeof(kRgbaTestData)]; + auto input = CreateFromRgbaRawBuffer(kRgbaTestData, kBufferDimension); + const std::array kAnglesToTest = {90, 180, 270}; + std::map> kOutputBuffers; + kOutputBuffers[90] = CreateFromRgbaRawBuffer(output_data, kRotatedDimension); + kOutputBuffers[180] = CreateFromRgbaRawBuffer(output_data, kBufferDimension); + kOutputBuffers[270] = CreateFromRgbaRawBuffer(output_data, kRotatedDimension); + const std::map> kRotationResults{ + {90, {9, 10, 11, 12, 21, 22, 23, 24, 5, 6, 7, 8, + 17, 18, 19, 20, 1, 2, 3, 4, 13, 14, 15, 16}}, + {180, {21, 22, 23, 24, 17, 18, 19, 20, 13, 14, 15, 16, + 9, 10, 11, 12, 5, 6, 7, 8, 1, 2, 3, 4}}, + {270, {13, 14, 15, 16, 1, 2, 3, 4, 17, 18, 19, 20, + 5, 6, 7, 8, 21, 22, 23, 24, 9, 10, 11, 12}}}; + + for (auto angle : kAnglesToTest) { + auto output = kOutputBuffers.at(angle).get(); + MP_ASSERT_OK(Rotate(*input, angle, output)); + auto results = kRotationResults.at(angle); + for (int i = 0; i < results.size(); i++) { + EXPECT_EQ(output->plane(0).buffer()[i], results[i]); + } + } +} + +TEST(FrameBufferUtil, RgbRotate) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 3, .height = 2}, + kRotatedDimension = {.width = 2, + .height = 3}; + uint8_t kRgbTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + uint8_t output_data[sizeof(kRgbTestData)]; + auto input = CreateFromRgbRawBuffer(kRgbTestData, kBufferDimension); + const std::array kAnglesToTest = {90, 180, 270}; + std::map> kOutputBuffers; + kOutputBuffers[90] = CreateFromRgbRawBuffer(output_data, kRotatedDimension); + kOutputBuffers[180] = CreateFromRgbRawBuffer(output_data, kBufferDimension); + kOutputBuffers[270] = CreateFromRgbRawBuffer(output_data, kRotatedDimension); + const std::map> kRotationResults{ + {90, {7, 8, 9, 16, 17, 18, 4, 5, 6, 13, 14, 15, 1, 2, 3, 10, 11, 12}}, + {180, {16, 17, 18, 13, 14, 15, 10, 11, 12, 7, 8, 9, 4, 5, 6, 1, 2, 3}}, + {270, {10, 11, 12, 1, 2, 3, 13, 14, 15, 4, 5, 6, 16, 17, 18, 7, 8, 9}}}; + + for (auto angle : kAnglesToTest) { + auto output = kOutputBuffers.at(angle).get(); + MP_ASSERT_OK(Rotate(*input, angle, output)); + auto results = kRotationResults.at(angle); + for (int i = 0; i < results.size(); i++) { + EXPECT_EQ(output->plane(0).buffer()[i], results[i]); + } + } +} + +// Nv21 unit tests. +//------------------------------------------------------------------------------ + +// Helper function to create YUV buffer. +absl::StatusOr> CreateYuvBuffer( + uint8_t* buffer, FrameBuffer::Dimension dimension, int plane_count, + FrameBuffer::Format format) { + DCHECK(plane_count > 0 && plane_count < 4); + ASSIGN_OR_RETURN(auto uv_dimension, GetUvPlaneDimension(dimension, format)); + + if (plane_count == 1) { + const std::vector planes = { + {buffer, /*stride=*/{/*row_stride_bytes=*/dimension.width, + /*pixel_stride_bytes=*/1}}}; + return std::make_shared(planes, dimension, format); + } else if (plane_count == 2) { + CHECK(format == FrameBuffer::Format::kNV12 || + format == FrameBuffer::Format::kNV21); + const std::vector planes = { + {buffer, + /*stride=*/{/*row_stride_bytes=*/dimension.width, + /*pixel_stride_bytes=*/1}}, + {buffer + dimension.Size(), + /*stride=*/{/*row_stride_bytes=*/uv_dimension.width * 2, + /*pixel_stride_bytes=*/2}}}; + return std::make_shared(planes, dimension, format); + } else if (plane_count == 3) { + std::vector planes = { + {buffer, + /*stride=*/{/*row_stride_bytes=*/dimension.width, + /*pixel_stride_bytes=*/1}}, + {buffer + dimension.Size(), + /*stride=*/{/*row_stride_bytes=*/uv_dimension.width, + /*pixel_stride_bytes=*/1}}, + {buffer + dimension.Size() + uv_dimension.Size(), + /*stride=*/{/*row_stride_bytes=*/uv_dimension.width, + /*pixel_stride_bytes=*/1}}}; + return std::make_shared(planes, dimension, format); + } + + return absl::InvalidArgumentError("The plane_count must between 1 and 3."); +} + +TEST(FrameBufferUtil, NV21CreatePlanarYuvBuffer) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}, + kOutputDimension = {.width = 4, .height = 2}; + uint8_t kYTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}; + uint8_t kUTestData[] = {13, 15, 17, 0, 0, 0}; + uint8_t kVTestData[] = {14, 16, 18, 0, 0, 0}; + uint8_t kNV21VUTestData[] = {14, 13, 16, 15, 18, 17}; + const std::vector three_input_planes = { + {kYTestData, /*stride=*/{6, 1}}, + {kUTestData, /*stride=*/{3, 1}}, + {kVTestData, /*stride=*/{3, 1}}}; + FrameBuffer three_planar_input(three_input_planes, kBufferDimension, + FrameBuffer::Format::kYV21); + + const std::vector two_input_planes = { + {kYTestData, /*stride=*/{6, 1}}, {kNV21VUTestData, /*stride=*/{6, 2}}}; + FrameBuffer two_planar_input(two_input_planes, kBufferDimension, + FrameBuffer::Format::kNV21); + + uint8_t output_y[8], output_u[2], output_v[2]; + const std::vector output_planes = { + {output_y, /*stride=*/{4, 1}}, + {output_u, /*stride=*/{2, 1}}, + {output_v, /*stride=*/{2, 1}}}; + FrameBuffer output(output_planes, kOutputDimension, + FrameBuffer::Format::kYV12); + + MP_ASSERT_OK(Crop(three_planar_input, 2, 0, 5, 1, &output)); + EXPECT_EQ(output.plane(0).buffer()[0], 3); + EXPECT_EQ(output.plane(0).buffer()[1], 4); + EXPECT_EQ(output.plane(0).buffer()[2], 5); + EXPECT_EQ(output.plane(1).buffer()[0], 16); + EXPECT_EQ(output.plane(2).buffer()[0], 15); + + memset(output_y, 0, sizeof(output_y)); + memset(output_u, 0, sizeof(output_u)); + memset(output_v, 0, sizeof(output_v)); + MP_ASSERT_OK(Crop(two_planar_input, 2, 0, 5, 1, &output)); + EXPECT_EQ(output.plane(0).buffer()[0], 3); + EXPECT_EQ(output.plane(0).buffer()[1], 4); + EXPECT_EQ(output.plane(0).buffer()[2], 5); + EXPECT_EQ(output.plane(1).buffer()[0], 16); + EXPECT_EQ(output.plane(2).buffer()[0], 15); +} + +TEST(FrameBufferUtil, NV21Crop) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}, + kOutputDimension = {.width = 4, .height = 2}; + uint8_t kNV21TestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + MP_ASSERT_OK_AND_ASSIGN(auto input, + CreateFromRawBuffer(kNV21TestData, kBufferDimension, + FrameBuffer::Format::kNV21)); + uint8_t output_data[12]; + MP_ASSERT_OK_AND_ASSIGN(auto output, + CreateFromRawBuffer(output_data, kOutputDimension, + FrameBuffer::Format::kNV21)); + + MP_ASSERT_OK(Crop(*input, 2, 0, 5, 1, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 3); + EXPECT_EQ(output->plane(0).buffer()[1], 4); + EXPECT_EQ(output->plane(0).buffer()[2], 5); + EXPECT_EQ(output->plane(0).buffer()[8], 15); + EXPECT_EQ(output->plane(0).buffer()[9], 16); +} + +TEST(FrameBufferUtil, YV21Crop) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}, + kOutputDimension = {.width = 4, .height = 2}; + uint8_t kYV21TestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 15, 17, 14, 16, 18}; + MP_ASSERT_OK_AND_ASSIGN( + auto input, + CreateYuvBuffer(kYV21TestData, kBufferDimension, /*plane_count=*/3, + FrameBuffer::Format::kYV21)); + uint8_t output_data[12]{}; + MP_ASSERT_OK_AND_ASSIGN( + auto output, + CreateYuvBuffer(output_data, kOutputDimension, /*plane_count=*/3, + FrameBuffer::Format::kYV21)); + + MP_ASSERT_OK( + Crop(*input, /*x0=*/2, /*y0=*/0, /*x1=*/5, /*y1=*/1, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 3); + EXPECT_EQ(output->plane(0).buffer()[1], 4); + EXPECT_EQ(output->plane(0).buffer()[2], 5); + EXPECT_EQ(output->plane(1).buffer()[0], 15); + EXPECT_EQ(output->plane(2).buffer()[0], 16); +} + +TEST(FrameBufferUtil, NV21HorizontalFlip) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}; + uint8_t kNV21TestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + MP_ASSERT_OK_AND_ASSIGN(auto input, + CreateFromRawBuffer(kNV21TestData, kBufferDimension, + FrameBuffer::Format::kNV21)); + uint8_t output_data[18]; + MP_ASSERT_OK_AND_ASSIGN(auto output, + CreateFromRawBuffer(output_data, kBufferDimension, + FrameBuffer::Format::kNV21)); + + MP_ASSERT_OK(FlipHorizontally(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 6); + EXPECT_EQ(output->plane(0).buffer()[1], 5); + EXPECT_EQ(output->plane(0).buffer()[2], 4); + EXPECT_EQ(output->plane(0).buffer()[12], 17); + EXPECT_EQ(output->plane(0).buffer()[13], 18); +} + +TEST(FrameBufferUtil, NV21VerticalFlip) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}; + uint8_t kNV21TestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + MP_ASSERT_OK_AND_ASSIGN(auto input, + CreateFromRawBuffer(kNV21TestData, kBufferDimension, + FrameBuffer::Format::kNV21)); + uint8_t output_data[18]; + MP_ASSERT_OK_AND_ASSIGN(auto output, + CreateFromRawBuffer(output_data, kBufferDimension, + FrameBuffer::Format::kNV21)); + + MP_ASSERT_OK(FlipVertically(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 7); + EXPECT_EQ(output->plane(0).buffer()[1], 8); + EXPECT_EQ(output->plane(0).buffer()[2], 9); + EXPECT_EQ(output->plane(0).buffer()[12], 13); + EXPECT_EQ(output->plane(0).buffer()[13], 14); +} + +TEST(FrameBufferUtil, NV21Rotate) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}, + kRotatedDimension = {.width = 2, + .height = 6}; + uint8_t kNV21TestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + MP_ASSERT_OK_AND_ASSIGN(auto input, + CreateFromRawBuffer(kNV21TestData, kBufferDimension, + FrameBuffer::Format::kNV21)); + uint8_t output_data[18]; + MP_ASSERT_OK_AND_ASSIGN(auto output, + CreateFromRawBuffer(output_data, kRotatedDimension, + FrameBuffer::Format::kNV21)); + + MP_ASSERT_OK(Rotate(*input, 90, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 6); + EXPECT_EQ(output->plane(0).buffer()[1], 12); + EXPECT_EQ(output->plane(0).buffer()[2], 5); + EXPECT_EQ(output->plane(0).buffer()[12], 17); + EXPECT_EQ(output->plane(0).buffer()[13], 18); +} + +TEST(FrameBufferUtil, NV21Resize) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}, + kOutputDimension = {.width = 1, .height = 1}; + uint8_t kNV21TestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + MP_ASSERT_OK_AND_ASSIGN(auto input, + CreateFromRawBuffer(kNV21TestData, kBufferDimension, + FrameBuffer::Format::kNV21)); + uint8_t output_data[6]; + MP_ASSERT_OK_AND_ASSIGN(auto output, + CreateFromRawBuffer(output_data, kOutputDimension, + FrameBuffer::Format::kNV21)); + + MP_ASSERT_OK(Resize(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 1); + EXPECT_EQ(output->plane(0).buffer()[1], 13); +} + +TEST(FrameBufferUtil, NV21ConvertGray) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}; + uint8_t kNV21TestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + MP_ASSERT_OK_AND_ASSIGN(auto input, + CreateFromRawBuffer(kNV21TestData, kBufferDimension, + FrameBuffer::Format::kNV21)); + const int kOutputSize = + GetFrameBufferByteSize(kBufferDimension, FrameBuffer::Format::kGRAY); + std::vector output_data(kOutputSize); + auto output = CreateFromGrayRawBuffer(output_data.data(), kBufferDimension); + + MP_ASSERT_OK(Convert(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 1); + EXPECT_EQ(output->plane(0).buffer()[1], 2); + EXPECT_EQ(output->plane(0).buffer()[11], 12); +} + +TEST(FrameBufferUtil, PaddedYuvConvertGray) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}; + uint8_t kNV21PaddedTestData[] = {1, 2, 3, 4, 5, 6, 100, 100, + 7, 8, 9, 10, 11, 12, 100, 100, + 13, 14, 15, 16, 17, 18, 100, 100}; + constexpr int row_stride_y = 8; + const std::vector planes = { + {kNV21PaddedTestData, /*stride=*/{row_stride_y, 1}}, + {kNV21PaddedTestData + (row_stride_y * kBufferDimension.width), + /*stride=*/{row_stride_y, 2}}}; + auto input = std::make_shared(planes, kBufferDimension, + FrameBuffer::Format::kNV21); + const int kOutputSize = + GetFrameBufferByteSize(kBufferDimension, FrameBuffer::Format::kGRAY); + std::vector output_data(kOutputSize); + auto output = CreateFromGrayRawBuffer(output_data.data(), kBufferDimension); + + MP_ASSERT_OK(Convert(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 1); + EXPECT_EQ(output->plane(0).buffer()[1], 2); + EXPECT_EQ(output->plane(0).buffer()[6], 7); + EXPECT_EQ(output->plane(0).buffer()[7], 8); + EXPECT_EQ(output->plane(0).buffer()[11], 12); +} + +TEST(FrameBufferUtil, NV21ConvertRgb) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 32, + .height = 8}; + // Note that RGB conversion expects at images width at least width >= 32 + // because the implementation is vectorized. + const int kInputSize = + GetFrameBufferByteSize(kBufferDimension, FrameBuffer::Format::kNV21); + std::vector input_data(kInputSize); + input_data.data()[0] = 1; + input_data.data()[1] = 2; + input_data.data()[32] = 7; + input_data.data()[33] = 8; + input_data.data()[256] = 13; + input_data.data()[257] = 14; + MP_ASSERT_OK_AND_ASSIGN( + auto input, CreateFromRawBuffer(input_data.data(), kBufferDimension, + FrameBuffer::Format::kNV21)); + const int kOutputSize = + GetFrameBufferByteSize(kBufferDimension, FrameBuffer::Format::kRGB); + std::vector output_data(kOutputSize); + auto output = CreateFromRgbRawBuffer(output_data.data(), kBufferDimension); + + MP_ASSERT_OK(Convert(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 0); + EXPECT_EQ(output->plane(0).buffer()[1], 122); +} + +TEST(FrameBufferUtil, NV21ConvertHalfRgb) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 64, + .height = 16}, + kOutputDimension = {.width = 32, + .height = 8}; + // Note that RGB conversion expects at images width at least width >= 32 + // because the implementation is vectorized. + uint8_t data[1576]; + for (int i = 0; i < sizeof(data); i++) { + data[i] = (i + 1); + } + MP_ASSERT_OK_AND_ASSIGN( + auto input, + CreateFromRawBuffer(data, kBufferDimension, FrameBuffer::Format::kNV21)); + uint8_t output_data[768]; + auto output = CreateFromRgbRawBuffer(output_data, kOutputDimension); + + MP_ASSERT_OK(Convert(*input, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 0); + EXPECT_EQ(output->plane(0).buffer()[1], 135); +} + +TEST(FrameBufferUtil, NV12ConvertGray) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}; + uint8_t kYTestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}; + uint8_t kNV12UVTestData[] = {13, 14, 15, 16, 17, 18}; + const std::vector planes_nv12 = { + {kYTestData, /*stride=*/{kBufferDimension.width, 1}}, + {kNV12UVTestData, /*stride=*/{kBufferDimension.width, 2}}}; + auto buffer_nv12 = std::make_shared( + planes_nv12, kBufferDimension, FrameBuffer::Format::kNV12); + const int kOutputSize = + GetFrameBufferByteSize(kBufferDimension, FrameBuffer::Format::kGRAY); + std::vector output_data(kOutputSize); + auto output = CreateFromGrayRawBuffer(output_data.data(), kBufferDimension); + + MP_ASSERT_OK(Convert(*buffer_nv12, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], kYTestData[0]); + EXPECT_EQ(output->plane(0).buffer()[1], kYTestData[1]); + EXPECT_EQ(output->plane(0).buffer()[11], kYTestData[11]); +} + +TEST(FrameBufferUtil, NV12ConvertRgb) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 32, + .height = 8}; + MP_ASSERT_OK_AND_ASSIGN( + FrameBuffer::Dimension uv_dimension, + GetUvPlaneDimension(kBufferDimension, FrameBuffer::Format::kNV12)); + // Halide RGB converter expects at images width at least width >= 32 because + // the implementation is vectorized. + auto y_data = std::make_unique(kBufferDimension.Size()); + auto uv_data = std::make_unique(uv_dimension.Size() * 2); + y_data[0] = 1; + y_data[1] = 2; + y_data[32] = 7; + y_data[33] = 8; + uv_data[0] = 13; + uv_data[1] = 14; + const std::vector planes_nv12 = { + {y_data.get(), /*stride=*/{kBufferDimension.width, 1}}, + {uv_data.get(), /*stride=*/{kBufferDimension.width, 2}}}; + auto buffer_nv12 = std::make_shared( + planes_nv12, kBufferDimension, FrameBuffer::Format::kNV12); + const int kOutputSize = + GetFrameBufferByteSize(kBufferDimension, FrameBuffer::Format::kRGB); + std::vector output_data(kOutputSize); + auto output = CreateFromRgbRawBuffer(output_data.data(), kBufferDimension); + + MP_ASSERT_OK(Convert(*buffer_nv12, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 0); + EXPECT_EQ(output->plane(0).buffer()[1], 122); +} + +TEST(FrameBufferUtil, NV12ConvertHalfRgb) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 64, + .height = 16}; + MP_ASSERT_OK_AND_ASSIGN( + FrameBuffer::Dimension uv_dimension, + GetUvPlaneDimension(kBufferDimension, FrameBuffer::Format::kNV12)); + // Halide RGB converter expects at images width at least width >= 32 because + // the implementation is vectorized. + auto y_data = std::make_unique(kBufferDimension.Size()); + auto uv_data = std::make_unique(uv_dimension.Size() * 2); + for (int i = 0; i < kBufferDimension.Size(); i++) { + y_data[i] = (i + 1) % 256; + } + for (int i = 0; i < uv_dimension.Size() * 2; i++) { + uv_data[i] = (i + 1) % 256; + } + const std::vector planes_nv12 = { + {y_data.get(), /*stride=*/{kBufferDimension.width, 1}}, + {uv_data.get(), /*stride=*/{kBufferDimension.width, 2}}}; + auto buffer_nv12 = std::make_shared( + planes_nv12, kBufferDimension, FrameBuffer::Format::kNV12); + constexpr FrameBuffer::Dimension kOutputDimension = { + .width = kBufferDimension.width / 2, + .height = kBufferDimension.height / 2}; + const int kOutputSize = + GetFrameBufferByteSize(kOutputDimension, FrameBuffer::Format::kRGB); + std::vector output_data(kOutputSize); + auto output = CreateFromRgbRawBuffer(output_data.data(), kOutputDimension); + + MP_ASSERT_OK(Convert(*buffer_nv12, output.get())); + EXPECT_EQ(output->plane(0).buffer()[0], 0); + EXPECT_EQ(output->plane(0).buffer()[1], 135); +} + +TEST(FrameBufferUtil, NV21ConvertYV12) { + constexpr FrameBuffer::Dimension kBufferDimension = {.width = 6, .height = 2}; + uint8_t kNV21TestData[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18}; + MP_ASSERT_OK_AND_ASSIGN( + auto nv21, + CreateYuvBuffer(kNV21TestData, kBufferDimension, /*plane_count=*/2, + FrameBuffer::Format::kNV21)); + MP_ASSERT_OK_AND_ASSIGN(FrameBuffer::YuvData nv21_data, + FrameBuffer::GetYuvDataFromFrameBuffer(*nv21)); + const int kOutputSize = + GetFrameBufferByteSize(kBufferDimension, FrameBuffer::Format::kYV12); + std::vector output_data(kOutputSize); + MP_ASSERT_OK_AND_ASSIGN( + auto yv12, + CreateYuvBuffer(output_data.data(), kBufferDimension, /*plane_count=*/3, + FrameBuffer::Format::kYV12)); + MP_ASSERT_OK_AND_ASSIGN(FrameBuffer::YuvData yv12_data, + FrameBuffer::GetYuvDataFromFrameBuffer(*yv12)); + + MP_ASSERT_OK(Convert(*nv21, yv12.get())); + EXPECT_EQ(nv21_data.y_buffer[0], yv12_data.y_buffer[0]); + EXPECT_EQ(nv21_data.u_buffer[0], yv12_data.u_buffer[0]); + EXPECT_EQ(nv21_data.v_buffer[0], yv12_data.v_buffer[0]); +} + +} // namespace +} // namespace frame_buffer +} // namespace mediapipe diff --git a/mediapipe/util/frame_buffer/gray_buffer.cc b/mediapipe/util/frame_buffer/gray_buffer.cc new file mode 100644 index 000000000..51f7b09e2 --- /dev/null +++ b/mediapipe/util/frame_buffer/gray_buffer.cc @@ -0,0 +1,95 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/gray_buffer.h" + +#include + +#include "mediapipe/util/frame_buffer/buffer_common.h" +#include "mediapipe/util/frame_buffer/halide/gray_flip_halide.h" +#include "mediapipe/util/frame_buffer/halide/gray_resize_halide.h" +#include "mediapipe/util/frame_buffer/halide/gray_rotate_halide.h" +#include "mediapipe/util/frame_buffer/yuv_buffer.h" + +namespace mediapipe { +namespace frame_buffer { + +GrayBuffer::GrayBuffer(uint8_t* buffer, int width, int height) + : owned_buffer_(nullptr) { + Initialize(buffer, width, height); +} + +GrayBuffer::GrayBuffer(int width, int height) + : owned_buffer_(new uint8_t[ByteSize(width, height)]) { + Initialize(owned_buffer_.get(), width, height); +} + +GrayBuffer::GrayBuffer(const GrayBuffer& other) : buffer_(other.buffer_) {} + +GrayBuffer::GrayBuffer(GrayBuffer&& other) { *this = std::move(other); } + +GrayBuffer& GrayBuffer::operator=(const GrayBuffer& other) { + if (this != &other) { + buffer_ = other.buffer_; + } + return *this; +} + +GrayBuffer& GrayBuffer::operator=(GrayBuffer&& other) { + if (this != &other) { + owned_buffer_ = std::move(other.owned_buffer_); + buffer_ = other.buffer_; + } + return *this; +} + +GrayBuffer::~GrayBuffer() {} + +void GrayBuffer::Initialize(uint8_t* data, int width, int height) { + buffer_ = Halide::Runtime::Buffer(data, width, height); +} + +bool GrayBuffer::Crop(int x0, int y0, int x1, int y1) { + // Twiddle the buffer start and extents to crop images. + return common::crop_buffer(x0, y0, x1, y1, buffer()); +} + +bool GrayBuffer::Resize(GrayBuffer* output) { + const int result = gray_resize_halide( + buffer(), static_cast(width()) / output->width(), + static_cast(height()) / output->height(), output->buffer()); + return result == 0; +} + +bool GrayBuffer::Rotate(int angle, GrayBuffer* output) { + const int result = gray_rotate_halide(buffer(), angle, output->buffer()); + return result == 0; +} + +bool GrayBuffer::FlipHorizontally(GrayBuffer* output) { + const int result = gray_flip_halide(buffer(), + false, // horizontal + output->buffer()); + return result == 0; +} + +bool GrayBuffer::FlipVertically(GrayBuffer* output) { + const int result = gray_flip_halide(buffer(), + true, // vertical + output->buffer()); + return result == 0; +} + +} // namespace frame_buffer +} // namespace mediapipe diff --git a/mediapipe/util/frame_buffer/gray_buffer.h b/mediapipe/util/frame_buffer/gray_buffer.h new file mode 100644 index 000000000..fa181acec --- /dev/null +++ b/mediapipe/util/frame_buffer/gray_buffer.h @@ -0,0 +1,137 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef MEDIAPIPE_UTIL_FRAME_BUFFER_GRAY_BUFFER_H_ +#define MEDIAPIPE_UTIL_FRAME_BUFFER_GRAY_BUFFER_H_ + +#include + +#include "HalideBuffer.h" +#include "HalideRuntime.h" + +namespace mediapipe { +namespace frame_buffer { + +// GrayBuffer represents a view over a grayscale (i.e. luminance, +// or Y-only) buffer. +// GrayBuffer may be copied and moved efficiently; their backing buffers are +// shared and never deep copied. +// GrayBuffer requires a minimum image width depending on the natural vector +// size of the platform, e.g., 16px. This is not validated by GrayBuffer. +class GrayBuffer { + public: + // Returns the size (in bytes) of a grayscale image of the given + // dimensions. The given dimensions contain padding. + static int ByteSize(int buffer_width, int buffer_height) { + const int size = buffer_width * buffer_height; + return size; + } + + // Builds a grayscale buffer with size as width * height. The buffer should + // be in row-major order with no padding. + // + // Does not take ownership of any backing buffers, which must be large + // enough to fit their contents. + GrayBuffer(uint8_t* buffer, int width, int height); + + // Builds a grayscale buffer with size as width * height. + // + // The underlying backing buffer is allocated and owned by this + // GrayBuffer. + GrayBuffer(int width, int height); + + // GrayBuffer is copyable. The source retains ownership of its backing + // buffers. + // + // Since the source retains ownership of its backing buffer, the source needs + // to outlive this instance's lifetime when the backing buffer is owned by + // the source. Otherwise, the passing in backing buffer should outlive this + // instance. + GrayBuffer(const GrayBuffer& other); + // GrayBuffer is moveable. The source loses ownership of any backing buffers. + // Specifically, if the source owns its backing buffer, after the move, + // Release() will return nullptr. + GrayBuffer(GrayBuffer&& other); + + // GrayBuffer is assignable. The source retains ownership of its backing + // buffers. + // + // Since the source retains ownership of its backing buffer, the source needs + // to outlive this instance's lifetime when the backing buffer is owned by the + // source. Otherwise, the passing in backing buffer should outlive this + // instance. + GrayBuffer& operator=(const GrayBuffer& other); + GrayBuffer& operator=(GrayBuffer&& other); + + ~GrayBuffer(); + + // Performs an in-place crop. Modifies this buffer so that the new extent + // matches that of the given crop rectangle -- (x0, y0) becomes (0, 0) and + // the new width and height are x1 - x0 + 1 and y1 - y0 + 1, respectively. + bool Crop(int x0, int y0, int x1, int y1); + + // Resizes this image to match the dimensions of the given output GrayBuffer + // and places the result into output's backing buffer. + // + // Note, if the output backing buffer is shared with multiple instances, by + // calling this method, all the instances' backing buffers will change. + bool Resize(GrayBuffer* output); + + // Rotates this image into the given buffer by the given angle (90, 180, 270). + // + // Rotation is specified in degrees counter-clockwise such that when rotating + // by 90 degrees, the top-right corner of the source becomes the top-left of + // the output. The output buffer must have its height and width swapped when + // rotating by 90 or 270. + // + // Any angle values other than (90, 180, 270) are invalid. + // + // Note, if the output backing buffer is shared with multiple instances, by + // calling this method, all the instances' backing buffers will change. + bool Rotate(int angle, GrayBuffer* output); + + // Flips this image horizontally/vertically into the given buffer. Both buffer + // dimensions must match. + // + // Note, if the output backing buffer is shared with multiple instances, by + // calling this method, all the instances' backing buffers will change. + bool FlipHorizontally(GrayBuffer* output); + bool FlipVertically(GrayBuffer* output); + + // Releases ownership of the owned backing buffer. + uint8_t* Release() { return owned_buffer_.release(); } + + // Returns the halide_buffer_t* for the image. + halide_buffer_t* buffer() { return buffer_.raw_buffer(); } + + // Returns the image width. + const int width() const { return buffer_.dim(0).extent(); } + // Returns the image height. + const int height() const { return buffer_.dim(1).extent(); } + + private: + void Initialize(uint8_t* data, int width, int height); + + // Non-NULL iff this GrayBuffer owns its buffer. + std::unique_ptr owned_buffer_; + + // Backing buffer: layout is always width x height. The backing buffer binds + // to either "owned_buffer_" or an external buffer. + Halide::Runtime::Buffer buffer_; +}; + +} // namespace frame_buffer +} // namespace mediapipe + +#endif // MEDIAPIPE_UTIL_FRAME_BUFFER_GRAY_BUFFER_H_ diff --git a/mediapipe/util/frame_buffer/gray_buffer_test.cc b/mediapipe/util/frame_buffer/gray_buffer_test.cc new file mode 100644 index 000000000..f6f9e9e34 --- /dev/null +++ b/mediapipe/util/frame_buffer/gray_buffer_test.cc @@ -0,0 +1,223 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/gray_buffer.h" + +#include + +#include "absl/log/log.h" +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" + +// The default implementation of halide_error calls abort(), which we don't +// want. Instead, log the error and let the filter invocation fail. +extern "C" void halide_error(void*, const char* message) { + LOG(ERROR) << "Halide Error: " << message; +} + +namespace mediapipe { +namespace frame_buffer { +namespace { + +// Fill a GrayBuffer with zeroes. +void Fill(GrayBuffer* gray_buffer) { + halide_buffer_t* buffer = gray_buffer->buffer(); + for (int y = 0; y < buffer->dim[1].extent; ++y) { + for (int x = 0; x < buffer->dim[0].extent; ++x) { + buffer->host[buffer->dim[1].stride * y + buffer->dim[0].stride * x] = 0; + } + } +} + +TEST(GrayBufferTest, Properties) { + GrayBuffer buffer(5, 4); + EXPECT_EQ(5, buffer.width()); + EXPECT_EQ(4, buffer.height()); +} + +TEST(GrayBufferTest, Release) { + GrayBuffer buffer(4, 4); + delete[] buffer.Release(); +} + +TEST(GrayBufferTest, Assign) { + GrayBuffer buffer(3, 2); + GrayBuffer sink(nullptr, 0, 0); + Fill(&buffer); + sink = buffer; + EXPECT_EQ(3, sink.width()); + EXPECT_EQ(2, sink.height()); + + sink = GrayBuffer(5, 4); + EXPECT_EQ(5, sink.width()); + EXPECT_EQ(4, sink.height()); +} + +TEST(GrayBufferTest, MoveAssign) { + GrayBuffer buffer(3, 2); + GrayBuffer sink(nullptr, 0, 0); + Fill(&buffer); + sink = std::move(buffer); + EXPECT_EQ(nullptr, buffer.Release()); + EXPECT_EQ(3, sink.width()); + EXPECT_EQ(2, sink.height()); +} + +TEST(GrayBufferTest, MoveConstructor) { + GrayBuffer buffer(5, 4); + GrayBuffer sink(std::move(buffer)); + Fill(&buffer); + EXPECT_EQ(nullptr, buffer.Release()); + EXPECT_EQ(5, sink.width()); + EXPECT_EQ(4, sink.height()); +} + +TEST(GrayBufferTest, Crop) { + GrayBuffer source(8, 8); + EXPECT_TRUE(source.Crop(2, 2, 6, 6)); +} + +TEST(GrayBufferTest, Resize_Even) { + uint8_t* data = new uint8_t[16]; + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 4; ++x) { + data[x + y * 4] = x + y * 4; + } + } + GrayBuffer source(data, 4, 4); + GrayBuffer result(2, 2); + EXPECT_TRUE(source.Resize(&result)); + EXPECT_EQ(0, result.buffer()->host[0]); + EXPECT_EQ(2, result.buffer()->host[1]); + EXPECT_EQ(8, result.buffer()->host[2]); + EXPECT_EQ(10, result.buffer()->host[3]); + delete[] data; +} + +TEST(GrayBufferTest, Resize_Odd) { + uint8_t* data = new uint8_t[16]; + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 4; ++x) { + data[x + y * 4] = x + y * 4; + } + } + GrayBuffer source(data, 4, 4); + GrayBuffer result(1, 3); + EXPECT_TRUE(source.Resize(&result)); + EXPECT_EQ(0, result.buffer()->host[0]); + EXPECT_EQ(5, result.buffer()->host[1]); + EXPECT_EQ(11, result.buffer()->host[2]); + delete[] data; +} + +TEST(GrayBufferTest, Rotate) { + GrayBuffer buffer(5, 4); + GrayBuffer result(4, 5); + Fill(&buffer); + EXPECT_TRUE(buffer.Rotate(90, &result)); +} + +TEST(GrayBufferTest, Rotate_90) { + uint8_t* data = new uint8_t[4]; + data[0] = 1; + data[1] = 2; + data[2] = 3; + data[3] = 4; + GrayBuffer buffer(data, 2, 2); + GrayBuffer result(2, 2); + EXPECT_TRUE(buffer.Rotate(90, &result)); + + EXPECT_EQ(2, result.buffer()->host[0]); + EXPECT_EQ(4, result.buffer()->host[1]); + EXPECT_EQ(1, result.buffer()->host[2]); + EXPECT_EQ(3, result.buffer()->host[3]); + + delete[] data; +} + +TEST(GrayBufferTest, Rotate_180) { + uint8_t* data = new uint8_t[4]; + data[0] = 1; + data[1] = 2; + data[2] = 3; + data[3] = 4; + GrayBuffer buffer(data, 2, 2); + GrayBuffer result(2, 2); + EXPECT_TRUE(buffer.Rotate(180, &result)); + EXPECT_EQ(4, result.buffer()->host[0]); + EXPECT_EQ(3, result.buffer()->host[1]); + EXPECT_EQ(2, result.buffer()->host[2]); + EXPECT_EQ(1, result.buffer()->host[3]); + delete[] data; +} + +TEST(GrayBufferTest, Rotate_270) { + uint8_t* data = new uint8_t[4]; + data[0] = 1; + data[1] = 2; + data[2] = 3; + data[3] = 4; + GrayBuffer buffer(data, 2, 2); + GrayBuffer result(2, 2); + EXPECT_TRUE(buffer.Rotate(270, &result)); + EXPECT_EQ(3, result.buffer()->host[0]); + EXPECT_EQ(1, result.buffer()->host[1]); + EXPECT_EQ(4, result.buffer()->host[2]); + EXPECT_EQ(2, result.buffer()->host[3]); + delete[] data; +} + +TEST(GrayBufferTest, Flip) { + GrayBuffer buffer(5, 4); + GrayBuffer result(5, 4); + Fill(&buffer); + EXPECT_TRUE(buffer.FlipHorizontally(&result)); + EXPECT_TRUE(buffer.FlipVertically(&result)); +} + +TEST(GrayBufferTest, Flip_Horizontally) { + uint8_t* data = new uint8_t[4]; + data[0] = 1; + data[1] = 2; + data[2] = 3; + data[3] = 4; + GrayBuffer buffer(data, 2, 2); + GrayBuffer result(2, 2); + EXPECT_TRUE(buffer.FlipHorizontally(&result)); + EXPECT_EQ(2, result.buffer()->host[0]); + EXPECT_EQ(1, result.buffer()->host[1]); + EXPECT_EQ(4, result.buffer()->host[2]); + EXPECT_EQ(3, result.buffer()->host[3]); + delete[] data; +} + +TEST(GrayBufferTest, Flip_Vertically) { + uint8_t* data = new uint8_t[4]; + data[0] = 1; + data[1] = 2; + data[2] = 3; + data[3] = 4; + GrayBuffer buffer(data, 2, 2); + GrayBuffer result(2, 2); + EXPECT_TRUE(buffer.FlipVertically(&result)); + EXPECT_EQ(3, result.buffer()->host[0]); + EXPECT_EQ(4, result.buffer()->host[1]); + EXPECT_EQ(1, result.buffer()->host[2]); + EXPECT_EQ(2, result.buffer()->host[3]); + delete[] data; +} + +} // namespace +} // namespace frame_buffer +} // namespace mediapipe diff --git a/mediapipe/util/frame_buffer/halide/BUILD b/mediapipe/util/frame_buffer/halide/BUILD new file mode 100644 index 000000000..619a16d26 --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/BUILD @@ -0,0 +1,118 @@ +# Copyright 2023 The MediaPipe Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@halide//:halide.bzl", "halide_library") + +package(default_visibility = ["//mediapipe/util/frame_buffer:__subpackages__"]) + +# Common Halide library: +cc_library( + name = "common", + srcs = ["common.cc"], + hdrs = ["common.h"], + deps = ["@halide//:language"], +) + +# Enable Halide's built-in profiler with: +# bazel ... --define halide_target_features=profile +# and HTML output of its intermediate representation with: +# bazel ... --define halide_extra_outputs=html + +# RGB operations: +halide_library( + name = "rgb_flip_halide", + srcs = ["rgb_flip_generator.cc"], + generator_name = "rgb_flip_generator", +) + +halide_library( + name = "rgb_resize_halide", + srcs = ["rgb_resize_generator.cc"], + generator_deps = [":common"], + generator_name = "rgb_resize_generator", +) + +halide_library( + name = "rgb_rotate_halide", + srcs = ["rgb_rotate_generator.cc"], + generator_deps = [":common"], + generator_name = "rgb_rotate_generator", +) + +halide_library( + name = "rgb_yuv_halide", + srcs = ["rgb_yuv_generator.cc"], + generator_name = "rgb_yuv_generator", +) + +halide_library( + name = "rgb_rgb_halide", + srcs = ["rgb_rgb_generator.cc"], + generator_name = "rgb_rgb_generator", +) + +# YUV operations: +halide_library( + name = "yuv_flip_halide", + srcs = ["yuv_flip_generator.cc"], + generator_name = "yuv_flip_generator", +) + +halide_library( + name = "yuv_rgb_halide", + srcs = ["yuv_rgb_generator.cc"], + generator_name = "yuv_rgb_generator", +) + +halide_library( + name = "yuv_resize_halide", + srcs = ["yuv_resize_generator.cc"], + generator_deps = [":common"], + generator_name = "yuv_resize_generator", +) + +halide_library( + name = "yuv_rotate_halide", + srcs = ["yuv_rotate_generator.cc"], + generator_deps = [":common"], + generator_name = "yuv_rotate_generator", +) + +# Grayscale operations: + +halide_library( + name = "rgb_gray_halide", + srcs = ["rgb_gray_generator.cc"], + generator_name = "rgb_gray_generator", +) + +halide_library( + name = "gray_rotate_halide", + srcs = ["gray_rotate_generator.cc"], + generator_deps = [":common"], + generator_name = "gray_rotate_generator", +) + +halide_library( + name = "gray_flip_halide", + srcs = ["gray_flip_generator.cc"], + generator_name = "gray_flip_generator", +) + +halide_library( + name = "gray_resize_halide", + srcs = ["gray_resize_generator.cc"], + generator_deps = [":common"], + generator_name = "gray_resize_generator", +) diff --git a/mediapipe/util/frame_buffer/halide/common.cc b/mediapipe/util/frame_buffer/halide/common.cc new file mode 100644 index 000000000..8142c82f5 --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/common.cc @@ -0,0 +1,89 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/halide/common.h" + +namespace mediapipe { +namespace frame_buffer { +namespace halide { +namespace common { + +namespace { +using ::Halide::_; +} + +void resize_nn(Halide::Func input, Halide::Func result, Halide::Expr fx, + Halide::Expr fy) { + Halide::Var x{"x"}, y{"y"}; + result(x, y, _) = input(Halide::cast((x + 0.5f) * fx), + Halide::cast((y + 0.5f) * fy), _); +} + +// Borrowed from photos/editing/halide/src/resize_image_bilinear_generator.cc: +void resize_bilinear(Halide::Func input, Halide::Func result, Halide::Expr fx, + Halide::Expr fy) { + Halide::Var x{"x"}, y{"y"}; + Halide::Func x_interpolated("x_interpolated"); + + Halide::Expr xi = Halide::cast(x * fx); + Halide::Expr xr = x * fx - xi; + Halide::Expr x0 = input(xi + 0, y, _); + Halide::Expr x1 = input(xi + 1, y, _); + x_interpolated(x, y, _) = lerp(x0, x1, xr); + + Halide::Expr yi = Halide::cast(y * fy); + Halide::Expr yr = y * fy - yi; + Halide::Expr y0 = x_interpolated(x, yi + 0, _); + Halide::Expr y1 = x_interpolated(x, yi + 1, _); + result(x, y, _) = lerp(y0, y1, yr); +} + +void resize_bilinear_int(Halide::Func input, Halide::Func result, + Halide::Expr fx, Halide::Expr fy) { + Halide::Var x{"x"}, y{"y"}; + Halide::Func x_interpolated("x_interpolated"); + + fx = Halide::cast(fx * 65536); + Halide::Expr xi = Halide::cast(x * fx / 65536); + Halide::Expr xr = Halide::cast(x * fx % 65536); + Halide::Expr x0 = input(xi + 0, y, _); + Halide::Expr x1 = input(xi + 1, y, _); + x_interpolated(x, y, _) = lerp(x0, x1, xr); + + fy = Halide::cast(fy * 65536); + Halide::Expr yi = Halide::cast(y * fy / 65536); + Halide::Expr yr = Halide::cast(y * fy % 65536); + Halide::Expr y0 = x_interpolated(x, yi + 0, _); + Halide::Expr y1 = x_interpolated(x, yi + 1, _); + result(x, y, _) = lerp(y0, y1, yr); +} + +void rotate(Halide::Func input, Halide::Func result, Halide::Expr width, + Halide::Expr height, Halide::Expr angle) { + Halide::Var x{"x"}, y{"y"}; + Halide::Func result_90_degrees, result_180_degrees, result_270_degrees; + result_90_degrees(x, y, _) = input(width - 1 - y, x, _); + result_180_degrees(x, y, _) = input(width - 1 - x, height - 1 - y, _); + result_270_degrees(x, y, _) = input(y, height - 1 - x, _); + + result(x, y, _) = + select(angle == 90, result_90_degrees(x, y, _), angle == 180, + result_180_degrees(x, y, _), angle == 270, + result_270_degrees(x, y, _), input(x, y, _)); +} + +} // namespace common +} // namespace halide +} // namespace frame_buffer +} // namespace mediapipe diff --git a/mediapipe/util/frame_buffer/halide/common.h b/mediapipe/util/frame_buffer/halide/common.h new file mode 100644 index 000000000..4b7023a9f --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/common.h @@ -0,0 +1,61 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef MEDIAPIPE_UTIL_FRAME_BUFFER_HALIDE_COMMON_H_ +#define MEDIAPIPE_UTIL_FRAME_BUFFER_HALIDE_COMMON_H_ + +#include "Halide.h" + +namespace mediapipe { +namespace frame_buffer { +namespace halide { +namespace common { + +template +Halide::Expr is_planar(const T& buffer) { + return buffer.dim(0).stride() == 1; +} + +template +Halide::Expr is_interleaved(const T& buffer) { + return buffer.dim(0).stride() == buffer.dim(2).extent() && + buffer.dim(2).stride() == 1; +} + +// Resize scale parameters (fx, fy) are the ratio of source size to output +// size; thus if you want to produce an image half as wide and twice as tall +// as the input, (fx, fy) should be (2, 0.5). + +// Nearest-neighbor resize: fast, but low-quality (prone to aliasing). +void resize_nn(Halide::Func input, Halide::Func result, Halide::Expr fx, + Halide::Expr fy); + +// Resize with bilinear interpolation: slower but higher-quality. +void resize_bilinear(Halide::Func input, Halide::Func result, Halide::Expr fx, + Halide::Expr fy); +// Identical to the above, except that it uses fixed point integer math. +void resize_bilinear_int(Halide::Func input, Halide::Func result, + Halide::Expr fx, Halide::Expr fy); + +// Note: width and height are the source image dimensions; angle must be one +// of [0, 90, 180, 270] or the result is undefined. +void rotate(Halide::Func input, Halide::Func result, Halide::Expr width, + Halide::Expr height, Halide::Expr angle); + +} // namespace common +} // namespace halide +} // namespace frame_buffer +} // namespace mediapipe + +#endif // MEDIAPIPE_UTIL_FRAME_BUFFER_HALIDE_COMMON_H_ diff --git a/mediapipe/util/frame_buffer/halide/gray_flip_generator.cc b/mediapipe/util/frame_buffer/halide/gray_flip_generator.cc new file mode 100644 index 000000000..528489c84 --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/gray_flip_generator.cc @@ -0,0 +1,61 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" + +namespace { + +using ::Halide::_; + +class GrayFlip : public Halide::Generator { + public: + Var x{"x"}, y{"y"}; + + // Input because that allows us to apply constraints on stride, etc. + Input> src_y{"src_y"}; + + // Flip vertically if true; flips horizontally (mirroring) otherwise. + Input flip_vertical{"flip_vertical", false}; + + Output dst_y{"dst_y", UInt(8), 2}; + + void generate(); + void schedule(); + + private: + void flip(Func input, Func result, Expr width, Expr height, Expr vertical); +}; + +void GrayFlip::generate() { + Halide::Func flip_x, flip_y; + flip_x(x, y, _) = src_y(src_y.dim(0).extent() - x - 1, y, _); + flip_y(x, y, _) = src_y(x, src_y.dim(1).extent() - y - 1, _); + + dst_y(x, y, _) = select(flip_vertical, flip_y(x, y, _), flip_x(x, y, _)); +} + +void GrayFlip::schedule() { + Halide::Func dst_y_func = dst_y; + + // Y plane dimensions start at zero and destination bounds must match. + Halide::OutputImageParam dst_y_output = dst_y_func.output_buffer(); + src_y.dim(0).set_min(0); + src_y.dim(1).set_min(0); + dst_y_output.dim(0).set_bounds(0, src_y.dim(0).extent()); + dst_y_output.dim(1).set_bounds(0, src_y.dim(1).extent()); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(GrayFlip, gray_flip_generator) diff --git a/mediapipe/util/frame_buffer/halide/gray_resize_generator.cc b/mediapipe/util/frame_buffer/halide/gray_resize_generator.cc new file mode 100644 index 000000000..adda9c8d5 --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/gray_resize_generator.cc @@ -0,0 +1,60 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" +#include "mediapipe/util/frame_buffer/halide/common.h" + +namespace { + +using ::Halide::BoundaryConditions::repeat_edge; +using ::mediapipe::frame_buffer::halide::common::resize_bilinear_int; + +class GrayResize : public Halide::Generator { + public: + Var x{"x"}, y{"y"}; + + Input> src_y{"src_y"}; + Input scale_x{"scale_x", 1.0f, 0.0f, 1024.0f}; + Input scale_y{"scale_y", 1.0f, 0.0f, 1024.0f}; + + Output dst_y{"dst_y", UInt(8), 2}; + + void generate(); + void schedule(); +}; + +void GrayResize::generate() { + resize_bilinear_int(repeat_edge(src_y), dst_y, scale_x, scale_y); +} + +void GrayResize::schedule() { + // Grayscale image dimensions start at zero. + Halide::Func dst_y_func = dst_y; + Halide::OutputImageParam dst_y_output = dst_y_func.output_buffer(); + src_y.dim(0).set_min(0); + src_y.dim(1).set_min(0); + dst_y_output.dim(0).set_min(0); + dst_y_output.dim(1).set_min(0); + + // We must ensure that the image is wide enough to support vector + // operations. + const int vector_size = natural_vector_size(); + Halide::Expr min_y_width = + Halide::min(src_y.dim(0).extent(), dst_y_output.dim(0).extent()); + dst_y_func.specialize(min_y_width >= vector_size).vectorize(x, vector_size); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(GrayResize, gray_resize_generator) diff --git a/mediapipe/util/frame_buffer/halide/gray_rotate_generator.cc b/mediapipe/util/frame_buffer/halide/gray_rotate_generator.cc new file mode 100644 index 000000000..825741a5f --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/gray_rotate_generator.cc @@ -0,0 +1,63 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" +#include "mediapipe/util/frame_buffer/halide/common.h" + +namespace { + +using ::mediapipe::frame_buffer::halide::common::rotate; + +class GrayRotate : public Halide::Generator { + public: + Var x{"x"}, y{"y"}; + + // Input because that allows us to apply constraints on stride, etc. + Input> src_y{"src_y"}; + + // Rotation angle in degrees counter-clockwise. Must be in {0, 90, 180, 270}. + Input rotation_angle{"rotation_angle", 0}; + + Output dst_y{"dst_y", UInt(8), 2}; + + void generate(); + void schedule(); +}; + +void GrayRotate::generate() { + const Halide::Expr width = src_y.dim(0).extent(); + const Halide::Expr height = src_y.dim(1).extent(); + + rotate(src_y, dst_y, width, height, rotation_angle); +} + +void GrayRotate::schedule() { + Halide::Func dst_y_func = dst_y; + dst_y_func.specialize(rotation_angle == 0).reorder(x, y); + dst_y_func.specialize(rotation_angle == 90).reorder(y, x); + dst_y_func.specialize(rotation_angle == 180).reorder(x, y); + dst_y_func.specialize(rotation_angle == 270).reorder(y, x); + + // Y plane dimensions start at zero. We could additionally constrain the + // extent to be even, but that doesn't seem to have any benefit. + Halide::OutputImageParam dst_y_output = dst_y_func.output_buffer(); + src_y.dim(0).set_min(0); + src_y.dim(1).set_min(0); + dst_y_output.dim(0).set_min(0); + dst_y_output.dim(1).set_min(0); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(GrayRotate, gray_rotate_generator) diff --git a/mediapipe/util/frame_buffer/halide/rgb_flip_generator.cc b/mediapipe/util/frame_buffer/halide/rgb_flip_generator.cc new file mode 100644 index 000000000..2b2723a68 --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/rgb_flip_generator.cc @@ -0,0 +1,84 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" + +namespace { + +using ::Halide::_; + +class RgbFlip : public Halide::Generator { + public: + Var x{"x"}, y{"y"}; + + // Input because that allows us to apply constraints on stride, etc. + Input> src_rgb{"src_rgb"}; + // Flip vertically if true; flips horizontally (mirroring) otherwise. + Input flip_vertical{"flip_vertical", false}; + + Output dst_rgb{"dst_rgb", UInt(8), 3}; + + void generate(); + void schedule(); + + private: + void flip(Func input, Func result, Expr width, Expr height, Expr vertical); +}; + +void RgbFlip::flip(Halide::Func input, Halide::Func result, Halide::Expr width, + Halide::Expr height, Halide::Expr vertical) { + Halide::Func flip_x, flip_y; + flip_x(x, y, _) = input(width - x - 1, y, _); + flip_y(x, y, _) = input(x, height - y - 1, _); + + result(x, y, _) = select(vertical, flip_y(x, y, _), flip_x(x, y, _)); +} + +void RgbFlip::generate() { + const Halide::Expr width = src_rgb.dim(0).extent(); + const Halide::Expr height = src_rgb.dim(1).extent(); + + // Flip each of the RGB planes independently. + flip(src_rgb, dst_rgb, width, height, flip_vertical); +} + +void RgbFlip::schedule() { + Halide::Func dst_rgb_func = dst_rgb; + Halide::Var c = dst_rgb_func.args()[2]; + Halide::OutputImageParam rgb_output = dst_rgb_func.output_buffer(); + + // Iterate over channel in the innermost loop, then x, then y. + dst_rgb_func.reorder(c, x, y); + + // RGB planes starts at index zero in every dimension and destination bounds + // must match. + src_rgb.dim(0).set_min(0); + src_rgb.dim(1).set_min(0); + src_rgb.dim(2).set_min(0); + rgb_output.dim(0).set_bounds(0, src_rgb.dim(0).extent()); + rgb_output.dim(1).set_bounds(0, src_rgb.dim(1).extent()); + rgb_output.dim(2).set_bounds(0, src_rgb.dim(2).extent()); + + // Require that the input/output buffer be interleaved and tightly- + // packed; that is, either RGBRGBRGB[...] or RGBARGBARGBA[...], + // without gaps between pixels. + src_rgb.dim(0).set_stride(src_rgb.dim(2).extent()); + src_rgb.dim(2).set_stride(1); + rgb_output.dim(0).set_stride(rgb_output.dim(2).extent()); + rgb_output.dim(2).set_stride(1); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(RgbFlip, rgb_flip_generator) diff --git a/mediapipe/util/frame_buffer/halide/rgb_gray_generator.cc b/mediapipe/util/frame_buffer/halide/rgb_gray_generator.cc new file mode 100644 index 000000000..1df96540a --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/rgb_gray_generator.cc @@ -0,0 +1,65 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" + +namespace { + +class RgbGray : public Halide::Generator { + public: + Var x{"x"}, y{"y"}, c{"c"}; + + Input> src_rgb{"rgb"}; + Output> convert{"convert"}; + + void generate(); + void schedule(); +}; + +// Integer math versions of the full-range JFIF RGB-Y coefficients. +// Y = 0.2990*R + 0.5870*G + 0.1140*B +// See https://www.w3.org/Graphics/JPEG/jfif3.pdf. These coefficients are +// similar to, but not identical, to those used in Android. +Halide::Expr rgby(Halide::Expr r, Halide::Expr g, Halide::Expr b) { + r = Halide::cast(r); + g = Halide::cast(g); + b = Halide::cast(b); + return (19595 * r + 38470 * g + 7474 * b + 32768) >> 16; +} + +void RgbGray::generate() { + Halide::Func gray("gray"); + gray(x, y) = rgby(src_rgb(x, y, 0), src_rgb(x, y, 1), src_rgb(x, y, 2)); + convert(x, y) = Halide::saturating_cast(gray(x, y)); +} + +void RgbGray::schedule() { + // RGB images starts at index zero in every dimension. + src_rgb.dim(0).set_min(0); + src_rgb.dim(1).set_min(0); + src_rgb.dim(2).set_min(0); + + // Require that the input buffer be interleaved and tightly-packed; + // with no gaps between pixels. + src_rgb.dim(0).set_stride(src_rgb.dim(2).extent()); + src_rgb.dim(2).set_stride(1); + + // Grayscale images starts at index zero in every dimension. + convert.dim(0).set_min(0); + convert.dim(1).set_min(0); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(RgbGray, rgb_gray_generator) diff --git a/mediapipe/util/frame_buffer/halide/rgb_resize_generator.cc b/mediapipe/util/frame_buffer/halide/rgb_resize_generator.cc new file mode 100644 index 000000000..469120ec3 --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/rgb_resize_generator.cc @@ -0,0 +1,85 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" +#include "mediapipe/util/frame_buffer/halide/common.h" + +namespace { + +using ::Halide::BoundaryConditions::repeat_edge; +using ::mediapipe::frame_buffer::halide::common::resize_bilinear_int; + +class RgbResize : public Halide::Generator { + public: + Var x{"x"}, y{"y"}; + + Input> src_rgb{"src_rgb"}; + Input scale_x{"scale_x", 1.0f, 0.0f, 1024.0f}; + Input scale_y{"scale_y", 1.0f, 0.0f, 1024.0f}; + + Output dst_rgb{"dst_rgb", UInt(8), 3}; + + void generate(); + void schedule(); +}; + +void RgbResize::generate() { + // Resize each of the RGB planes independently. + resize_bilinear_int(repeat_edge(src_rgb), dst_rgb, scale_x, scale_y); +} + +void RgbResize::schedule() { + Halide::Func dst_rgb_func = dst_rgb; + Halide::Var c = dst_rgb_func.args()[2]; + Halide::OutputImageParam rgb_output = dst_rgb_func.output_buffer(); + Halide::Expr input_rgb_channels = src_rgb.dim(2).extent(); + Halide::Expr output_rgb_channels = rgb_output.dim(2).extent(); + Halide::Expr min_width = + Halide::min(src_rgb.dim(0).extent(), rgb_output.dim(0).extent()); + + // Specialize the generated code for RGB and RGBA (input and output channels + // must match); further, specialize the vectorized implementation so it only + // runs on images wide enough to support it. + const int vector_size = natural_vector_size(); + const Expr channel_specializations[] = { + input_rgb_channels == 3 && output_rgb_channels == 3, + input_rgb_channels == 4 && output_rgb_channels == 4, + }; + dst_rgb_func.reorder(c, x, y); + for (const Expr& channel_specialization : channel_specializations) { + dst_rgb_func.specialize(channel_specialization && min_width >= vector_size) + .unroll(c) + .vectorize(x, vector_size); + } + + // Require that the input/output buffer be interleaved and tightly- + // packed; that is, either RGBRGBRGB[...] or RGBARGBARGBA[...], + // without gaps between pixels. + src_rgb.dim(0).set_stride(input_rgb_channels); + src_rgb.dim(2).set_stride(1); + rgb_output.dim(0).set_stride(output_rgb_channels); + rgb_output.dim(2).set_stride(1); + + // RGB planes starts at index zero in every dimension. + src_rgb.dim(0).set_min(0); + src_rgb.dim(1).set_min(0); + src_rgb.dim(2).set_min(0); + rgb_output.dim(0).set_min(0); + rgb_output.dim(1).set_min(0); + rgb_output.dim(2).set_min(0); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(RgbResize, rgb_resize_generator) diff --git a/mediapipe/util/frame_buffer/halide/rgb_rgb_generator.cc b/mediapipe/util/frame_buffer/halide/rgb_rgb_generator.cc new file mode 100644 index 000000000..99a016896 --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/rgb_rgb_generator.cc @@ -0,0 +1,64 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +#include "Halide.h" + +namespace { + +// Convert rgb_buffer between 3 and 4 channels. When converting from 3 channels +// to 4 channels, the alpha value is always 255. +class RgbRgb : public Halide::Generator { + public: + Var x{"x"}, y{"y"}, c{"c"}; + + Input> src_rgb{"src_rgb"}; + Output> dst_rgb{"dst_rgb"}; + + void generate(); + void schedule(); +}; + +void RgbRgb::generate() { + // We use Halide::clamp to avoid evaluating src_rgb(x, y, c) when c == 3 and + // the src_rgb only has c <= 2 (rgb -> rgba conversion case). + dst_rgb(x, y, c) = + Halide::select(c == 3, 255, src_rgb(x, y, Halide::clamp(c, 0, 2))); +} + +void RgbRgb::schedule() { + Halide::Expr input_rgb_channels = src_rgb.dim(2).extent(); + Halide::Expr output_rgb_channels = dst_rgb.dim(2).extent(); + + // The source buffer starts at zero in every dimension and requires an + // interleaved format. + src_rgb.dim(0).set_min(0); + src_rgb.dim(1).set_min(0); + src_rgb.dim(2).set_min(0); + src_rgb.dim(0).set_stride(input_rgb_channels); + src_rgb.dim(2).set_stride(1); + + // The destination buffer starts at zero in every dimension and requires an + // interleaved format. + dst_rgb.dim(0).set_min(0); + dst_rgb.dim(1).set_min(0); + dst_rgb.dim(2).set_min(0); + dst_rgb.dim(0).set_stride(output_rgb_channels); + dst_rgb.dim(2).set_stride(1); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(RgbRgb, rgb_rgb_generator) diff --git a/mediapipe/util/frame_buffer/halide/rgb_rotate_generator.cc b/mediapipe/util/frame_buffer/halide/rgb_rotate_generator.cc new file mode 100644 index 000000000..aa2bb24ec --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/rgb_rotate_generator.cc @@ -0,0 +1,76 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" +#include "mediapipe/util/frame_buffer/halide/common.h" + +namespace { + +using ::mediapipe::frame_buffer::halide::common::rotate; + +class RgbRotate : public Halide::Generator { + public: + Var x{"x"}, y{"y"}; + + // Input because that allows us to apply constraints on stride, etc. + Input> src_rgb{"src_rgb"}; + // Rotation angle in degrees counter-clockwise. Must be in {0, 90, 180, 270}. + Input rotation_angle{"rotation_angle", 0}; + + Output dst_rgb{"dst_rgb", UInt(8), 3}; + + void generate(); + void schedule(); +}; + +void RgbRotate::generate() { + const Halide::Expr width = src_rgb.dim(0).extent(); + const Halide::Expr height = src_rgb.dim(1).extent(); + + // Rotate each of the RGB planes independently. + rotate(src_rgb, dst_rgb, width, height, rotation_angle); +} + +void RgbRotate::schedule() { + // TODO: Remove specialization for (angle == 0) since that is + // a no-op and callers should simply skip rotation. Doing so would cause + // a bounds assertion crash if called with angle=0, however. + Halide::Func dst_rgb_func = dst_rgb; + Halide::Var c = dst_rgb_func.args()[2]; + Halide::OutputImageParam rgb_output = dst_rgb_func.output_buffer(); + dst_rgb_func.specialize(rotation_angle == 0).reorder(c, x, y); + dst_rgb_func.specialize(rotation_angle == 90).reorder(c, y, x); + dst_rgb_func.specialize(rotation_angle == 180).reorder(c, x, y); + dst_rgb_func.specialize(rotation_angle == 270).reorder(c, y, x); + + // RGB planes starts at index zero in every dimension. + src_rgb.dim(0).set_min(0); + src_rgb.dim(1).set_min(0); + src_rgb.dim(2).set_min(0); + rgb_output.dim(0).set_min(0); + rgb_output.dim(1).set_min(0); + rgb_output.dim(2).set_min(0); + + // Require that the input/output buffer be interleaved and tightly- + // packed; that is, either RGBRGBRGB[...] or RGBARGBARGBA[...], + // without gaps between pixels. + src_rgb.dim(0).set_stride(src_rgb.dim(2).extent()); + src_rgb.dim(2).set_stride(1); + rgb_output.dim(0).set_stride(rgb_output.dim(2).extent()); + rgb_output.dim(2).set_stride(1); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(RgbRotate, rgb_rotate_generator) diff --git a/mediapipe/util/frame_buffer/halide/rgb_yuv_generator.cc b/mediapipe/util/frame_buffer/halide/rgb_yuv_generator.cc new file mode 100644 index 000000000..283dcec8a --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/rgb_yuv_generator.cc @@ -0,0 +1,101 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" + +namespace { + +class RgbYuv : public Halide::Generator { + public: + Var x{"x"}, y{"y"}, c{"c"}; + + // Input because that allows us to apply constraints on stride, etc. + Input> src_rgb{"rgb"}; + + Output dst_y{"dst_y", UInt(8), 2}; + Output dst_uv{"dst_uv", UInt(8), 3}; + + void generate(); + void schedule(); +}; + +// Integer math versions of the full-range JFIF RGB-YUV coefficients. +// Y = 0.2990*R + 0.5870*G + 0.1140*B +// U = -0.1687*R - 0.3313*G + 0.5000*B + 128 +// V = 0.5000*R - 0.4187*G - 0.0813*B + 128 +// See https://www.w3.org/Graphics/JPEG/jfif3.pdf. These coefficients are +// similar to, but not identical, to those used in Android. +Halide::Tuple rgbyuv(Halide::Expr r, Halide::Expr g, Halide::Expr b) { + r = Halide::cast(r); + g = Halide::cast(g); + b = Halide::cast(b); + return { + (19595 * r + 38470 * g + 7474 * b + 32768) >> 16, + ((-11056 * r - 21712 * g + 32768 * b + 32768) >> 16) + 128, + ((32768 * r - 27440 * g - 5328 * b + 32768) >> 16) + 128, + }; +} + +void RgbYuv::generate() { + Halide::Func yuv_tuple("yuv_tuple"); + yuv_tuple(x, y) = + rgbyuv(src_rgb(x, y, 0), src_rgb(x, y, 1), src_rgb(x, y, 2)); + + // Y values are copied one-for-one; UV values are sampled 1/4. + // TODO: Take the average UV values across the 2x2 block. + dst_y(x, y) = Halide::saturating_cast(yuv_tuple(x, y)[0]); + dst_uv(x, y, c) = Halide::saturating_cast(Halide::select( + c == 0, yuv_tuple(x * 2, y * 2)[2], yuv_tuple(x * 2, y * 2)[1])); + // NOTE: uv channel indices above assume NV21; this can be abstracted out + // by twiddling strides in calling code. +} + +void RgbYuv::schedule() { + // RGB images starts at index zero in every dimension. + src_rgb.dim(0).set_min(0); + src_rgb.dim(1).set_min(0); + src_rgb.dim(2).set_min(0); + + // Require that the input buffer be interleaved and tightly-packed; + // that is, either RGBRGBRGB[...] or RGBARGBARGBA[...], without gaps + // between pixels. + src_rgb.dim(0).set_stride(src_rgb.dim(2).extent()); + src_rgb.dim(2).set_stride(1); + + // Y plane dimensions start at zero. We could additionally constrain the + // extent to be even, but that doesn't seem to have any benefit. + Halide::Func dst_y_func = dst_y; + Halide::OutputImageParam dst_y_output = dst_y_func.output_buffer(); + dst_y_output.dim(0).set_min(0); + dst_y_output.dim(1).set_min(0); + + // UV plane has two channels and is half the size of the Y plane in X/Y. + Halide::Func dst_uv_func = dst_uv; + Halide::OutputImageParam dst_uv_output = dst_uv_func.output_buffer(); + dst_uv_output.dim(0).set_bounds(0, (dst_y_output.dim(0).extent() + 1) / 2); + dst_uv_output.dim(1).set_bounds(0, (dst_y_output.dim(1).extent() + 1) / 2); + dst_uv_output.dim(2).set_bounds(0, 2); + + // UV channel processing should be loop unrolled. + dst_uv_func.reorder(c, x, y); + dst_uv_func.unroll(c); + + // Remove default memory layout constraints and accept/produce generic UV + // (including semi-planar and planar). + dst_uv_output.dim(0).set_stride(Expr()); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(RgbYuv, rgb_yuv_generator) diff --git a/mediapipe/util/frame_buffer/halide/yuv_flip_generator.cc b/mediapipe/util/frame_buffer/halide/yuv_flip_generator.cc new file mode 100644 index 000000000..83080f3d7 --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/yuv_flip_generator.cc @@ -0,0 +1,90 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" + +namespace { + +using ::Halide::_; + +class YuvFlip : public Halide::Generator { + public: + Var x{"x"}, y{"y"}; + + // Input because that allows us to apply constraints on stride, etc. + Input> src_y{"src_y"}; + Input> src_uv{"src_uv"}; + // Flip vertically if true; flips horizontally (mirroring) otherwise. + Input flip_vertical{"flip_vertical", false}; + + Output dst_y{"dst_y", UInt(8), 2}; + Output dst_uv{"dst_uv", UInt(8), 3}; + + void generate(); + void schedule(); + + private: + void flip(Func input, Func result, Expr width, Expr height, Expr vertical); +}; + +void YuvFlip::flip(Halide::Func input, Halide::Func result, Halide::Expr width, + Halide::Expr height, Halide::Expr vertical) { + Halide::Func flip_x, flip_y; + flip_x(x, y, _) = input(width - x - 1, y, _); + flip_y(x, y, _) = input(x, height - y - 1, _); + + result(x, y, _) = select(vertical, flip_y(x, y, _), flip_x(x, y, _)); +} + +void YuvFlip::generate() { + const Halide::Expr width = src_y.dim(0).extent(); + const Halide::Expr height = src_y.dim(1).extent(); + + // Flip each of the YUV planes independently. + flip(src_y, dst_y, width, height, flip_vertical); + flip(src_uv, dst_uv, (width + 1) / 2, (height + 1) / 2, flip_vertical); +} + +void YuvFlip::schedule() { + Halide::Func dst_y_func = dst_y; + Halide::Func dst_uv_func = dst_uv; + Halide::Var c = dst_uv_func.args()[2]; + dst_uv_func.unroll(c); + dst_uv_func.reorder(c, x, y); + + // Y plane dimensions start at zero and destination bounds must match. + Halide::OutputImageParam dst_y_output = dst_y_func.output_buffer(); + src_y.dim(0).set_min(0); + src_y.dim(1).set_min(0); + dst_y_output.dim(0).set_bounds(0, src_y.dim(0).extent()); + dst_y_output.dim(1).set_bounds(0, src_y.dim(1).extent()); + + // UV plane has two channels and is half the size of the Y plane in X/Y. + Halide::OutputImageParam dst_uv_output = dst_uv_func.output_buffer(); + src_uv.dim(0).set_bounds(0, (src_y.dim(0).extent() + 1) / 2); + src_uv.dim(1).set_bounds(0, (src_y.dim(1).extent() + 1) / 2); + src_uv.dim(2).set_bounds(0, 2); + dst_uv_output.dim(0).set_bounds(0, (dst_y_output.dim(0).extent() + 1) / 2); + dst_uv_output.dim(1).set_bounds(0, (dst_y_output.dim(1).extent() + 1) / 2); + dst_uv_output.dim(2).set_bounds(0, 2); + + // Remove default memory layout constraints and accept/produce generic UV + // (including semi-planar and planar). + src_uv.dim(0).set_stride(Expr()); + dst_uv_output.dim(0).set_stride(Expr()); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(YuvFlip, yuv_flip_generator) diff --git a/mediapipe/util/frame_buffer/halide/yuv_resize_generator.cc b/mediapipe/util/frame_buffer/halide/yuv_resize_generator.cc new file mode 100644 index 000000000..805877fca --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/yuv_resize_generator.cc @@ -0,0 +1,91 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" +#include "mediapipe/util/frame_buffer/halide/common.h" + +namespace { + +using ::Halide::BoundaryConditions::repeat_edge; +using ::mediapipe::frame_buffer::halide::common::is_interleaved; +using ::mediapipe::frame_buffer::halide::common::is_planar; +using ::mediapipe::frame_buffer::halide::common::resize_bilinear_int; + +class YuvResize : public Halide::Generator { + public: + Var x{"x"}, y{"y"}; + + Input> src_y{"src_y"}; + Input> src_uv{"src_uv"}; + Input scale_x{"scale_x", 1.0f, 0.0f, 1024.0f}; + Input scale_y{"scale_y", 1.0f, 0.0f, 1024.0f}; + + Output dst_y{"dst_y", UInt(8), 2}; + Output dst_uv{"dst_uv", UInt(8), 3}; + + void generate(); + void schedule(); +}; + +void YuvResize::generate() { + // Resize each of the YUV planes independently. + resize_bilinear_int(repeat_edge(src_y), dst_y, scale_x, scale_y); + resize_bilinear_int(repeat_edge(src_uv), dst_uv, scale_x, scale_y); +} + +void YuvResize::schedule() { + // Y plane dimensions start at zero. We could additionally constrain the + // extent to be even, but that doesn't seem to have any benefit. + Halide::Func dst_y_func = dst_y; + Halide::OutputImageParam dst_y_output = dst_y_func.output_buffer(); + src_y.dim(0).set_min(0); + src_y.dim(1).set_min(0); + dst_y_output.dim(0).set_min(0); + dst_y_output.dim(1).set_min(0); + + // UV plane has two channels and is half the size of the Y plane in X/Y. + Halide::Func dst_uv_func = dst_uv; + Halide::OutputImageParam dst_uv_output = dst_uv_func.output_buffer(); + src_uv.dim(0).set_bounds(0, (src_y.dim(0).extent() + 1) / 2); + src_uv.dim(1).set_bounds(0, (src_y.dim(1).extent() + 1) / 2); + src_uv.dim(2).set_bounds(0, 2); + dst_uv_output.dim(0).set_bounds(0, (dst_y_output.dim(0).extent() + 1) / 2); + dst_uv_output.dim(1).set_bounds(0, (dst_y_output.dim(1).extent() + 1) / 2); + dst_uv_output.dim(2).set_bounds(0, 2); + + // With bilinear filtering enabled, Y plane resize is profitably vectorizable + // though we must ensure that the image is wide enough to support vector + // operations. + const int vector_size = natural_vector_size(); + Halide::Expr min_y_width = + Halide::min(src_y.dim(0).extent(), dst_y_output.dim(0).extent()); + dst_y_func.specialize(min_y_width >= vector_size).vectorize(x, vector_size); + + // Remove default memory layout constraints and generate specialized + // fast-path implementations when both UV source and output are either + // planar or interleaved. Everything else falls onto a slow path. + src_uv.dim(0).set_stride(Expr()); + dst_uv_output.dim(0).set_stride(Expr()); + + Halide::Var c = dst_uv_func.args()[2]; + dst_uv_func + .specialize(is_interleaved(src_uv) && is_interleaved(dst_uv_output)) + .reorder(c, x, y) + .unroll(c); + dst_uv_func.specialize(is_planar(src_uv) && is_planar(dst_uv_output)); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(YuvResize, yuv_resize_generator) diff --git a/mediapipe/util/frame_buffer/halide/yuv_rgb_generator.cc b/mediapipe/util/frame_buffer/halide/yuv_rgb_generator.cc new file mode 100644 index 000000000..916ea290a --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/yuv_rgb_generator.cc @@ -0,0 +1,110 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" + +namespace { + +class YuvRgb : public Halide::Generator { + public: + Var x{"x"}, y{"y"}, c{"c"}; + + // Input because that allows us to apply constraints on stride, etc. + Input> src_y{"src_y"}; + Input> src_uv{"src_uv"}; + Input halve{"halve", false}; + + Output rgb{"rgb", UInt(8), 3}; + + void generate(); + void schedule(); +}; + +Halide::Expr demux(Halide::Expr c, Halide::Tuple values) { + return select(c == 0, values[0], c == 1, values[1], c == 2, values[2], 255); +} + +// Integer math versions of the full-range JFIF YUV-RGB coefficients. +// R = Y' + 1.40200*(V-128) +// G = Y' - 0.34414*(U-128) - 0.71414*(V-128) +// B = Y' + 1.77200*(U-128) +// See https://www.w3.org/Graphics/JPEG/jfif3.pdf. These coefficients are +// similar to, but not identical, to those used in Android. +Halide::Tuple yuvrgb(Halide::Expr y, Halide::Expr u, Halide::Expr v) { + y = Halide::cast(y); + u = Halide::cast(u) - 128; + v = Halide::cast(v) - 128; + return { + y + ((91881 * v + 32768) >> 16), + y - ((22544 * u + 46802 * v + 32768) >> 16), + y + ((116130 * u + 32768) >> 16), + }; +} + +void YuvRgb::generate() { + // Each 2x2 block of Y pixels shares the same UV values, so UV-coordinates + // advance half as slowly as Y-coordinates. When taking advantage of the + // "free" 2x downsampling, use every UV value but skip every other Y. + Halide::Expr yx = select(halve, 2 * x, x), yy = select(halve, 2 * y, y); + Halide::Expr uvx = select(halve, x, x / 2), uvy = select(halve, y, y / 2); + + rgb(x, y, c) = Halide::saturating_cast(demux( + c, yuvrgb(src_y(yx, yy), src_uv(uvx, uvy, 1), src_uv(uvx, uvy, 0)))); + // NOTE: uv channel indices above assume NV21; this can be abstracted out + // by twiddling strides in calling code. +} + +void YuvRgb::schedule() { + // Y plane dimensions start at zero. We could additionally constrain the + // extent to be even, but that doesn't seem to have any benefit. + src_y.dim(0).set_min(0); + src_y.dim(1).set_min(0); + + // UV plane has two channels and is half the size of the Y plane in X/Y. + src_uv.dim(0).set_bounds(0, (src_y.dim(0).extent() + 1) / 2); + src_uv.dim(1).set_bounds(0, (src_y.dim(1).extent() + 1) / 2); + src_uv.dim(2).set_bounds(0, 2); + + // Remove default memory layout constraints on the UV source so that we + // accept generic UV (including semi-planar and planar). + // + // TODO: Investigate whether it's worth specializing the cross- + // product of [semi-]planar and RGB/RGBA; this would result in 9 codepaths. + src_uv.dim(0).set_stride(Expr()); + + Halide::Func rgb_func = rgb; + Halide::OutputImageParam rgb_output = rgb_func.output_buffer(); + Halide::Expr rgb_channels = rgb_output.dim(2).extent(); + + // Specialize the generated code for RGB and RGBA. + const int vector_size = natural_vector_size(); + rgb_func.reorder(c, x, y); + rgb_func.specialize(rgb_channels == 3).unroll(c).vectorize(x, vector_size); + rgb_func.specialize(rgb_channels == 4).unroll(c).vectorize(x, vector_size); + + // Require that the output buffer be interleaved and tightly-packed; + // that is, either RGBRGBRGB[...] or RGBARGBARGBA[...], without gaps + // between pixels. + rgb_output.dim(0).set_stride(rgb_channels); + rgb_output.dim(2).set_stride(1); + + // RGB output starts at index zero in every dimension. + rgb_output.dim(0).set_min(0); + rgb_output.dim(1).set_min(0); + rgb_output.dim(2).set_min(0); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(YuvRgb, yuv_rgb_generator) diff --git a/mediapipe/util/frame_buffer/halide/yuv_rotate_generator.cc b/mediapipe/util/frame_buffer/halide/yuv_rotate_generator.cc new file mode 100644 index 000000000..46f654a23 --- /dev/null +++ b/mediapipe/util/frame_buffer/halide/yuv_rotate_generator.cc @@ -0,0 +1,91 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Halide.h" +#include "mediapipe/util/frame_buffer/halide/common.h" + +namespace { + +using ::mediapipe::frame_buffer::halide::common::rotate; + +class YuvRotate : public Halide::Generator { + public: + Var x{"x"}, y{"y"}; + + // Input because that allows us to apply constraints on stride, etc. + Input> src_y{"src_y"}; + Input> src_uv{"src_uv"}; + // Rotation angle in degrees counter-clockwise. Must be in {0, 90, 180, 270}. + Input rotation_angle{"rotation_angle", 0}; + + Output dst_y{"dst_y", UInt(8), 2}; + Output dst_uv{"dst_uv", UInt(8), 3}; + + void generate(); + void schedule(); +}; + +void YuvRotate::generate() { + const Halide::Expr width = src_y.dim(0).extent(); + const Halide::Expr height = src_y.dim(1).extent(); + + // Rotate each of the YUV planes independently. + rotate(src_y, dst_y, width, height, rotation_angle); + rotate(src_uv, dst_uv, (width + 1) / 2, (height + 1) / 2, rotation_angle); +} + +void YuvRotate::schedule() { + // TODO: Remove specialization for (angle == 0) since that is + // a no-op and callers should simply skip rotation. Doing so would cause + // a bounds assertion crash if called with angle=0, however. + Halide::Func dst_y_func = dst_y; + dst_y_func.specialize(rotation_angle == 0).reorder(x, y); + dst_y_func.specialize(rotation_angle == 90).reorder(y, x); + dst_y_func.specialize(rotation_angle == 180).reorder(x, y); + dst_y_func.specialize(rotation_angle == 270).reorder(y, x); + + Halide::Func dst_uv_func = dst_uv; + Halide::Var c = dst_uv_func.args()[2]; + dst_uv_func.unroll(c); + dst_uv_func.specialize(rotation_angle == 0).reorder(c, x, y); + dst_uv_func.specialize(rotation_angle == 90).reorder(c, y, x); + dst_uv_func.specialize(rotation_angle == 180).reorder(c, x, y); + dst_uv_func.specialize(rotation_angle == 270).reorder(c, y, x); + + // Y plane dimensions start at zero. We could additionally constrain the + // extent to be even, but that doesn't seem to have any benefit. + Halide::OutputImageParam dst_y_output = dst_y_func.output_buffer(); + src_y.dim(0).set_min(0); + src_y.dim(1).set_min(0); + dst_y_output.dim(0).set_min(0); + dst_y_output.dim(1).set_min(0); + + // UV plane has two channels and is half the size of the Y plane in X/Y. + Halide::OutputImageParam dst_uv_output = dst_uv_func.output_buffer(); + src_uv.dim(0).set_bounds(0, (src_y.dim(0).extent() + 1) / 2); + src_uv.dim(1).set_bounds(0, (src_y.dim(1).extent() + 1) / 2); + src_uv.dim(2).set_bounds(0, 2); + dst_uv_output.dim(0).set_bounds(0, (dst_y_output.dim(0).extent() + 1) / 2); + dst_uv_output.dim(1).set_bounds(0, (dst_y_output.dim(1).extent() + 1) / 2); + dst_uv_output.dim(2).set_bounds(0, 2); + + // Remove default memory layout constraints and accept/produce generic UV + // (including semi-planar and planar). + src_uv.dim(0).set_stride(Expr()); + dst_uv_output.dim(0).set_stride(Expr()); +} + +} // namespace + +HALIDE_REGISTER_GENERATOR(YuvRotate, yuv_rotate_generator) diff --git a/mediapipe/util/frame_buffer/rgb_buffer.cc b/mediapipe/util/frame_buffer/rgb_buffer.cc new file mode 100644 index 000000000..9ae849eab --- /dev/null +++ b/mediapipe/util/frame_buffer/rgb_buffer.cc @@ -0,0 +1,132 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/rgb_buffer.h" + +#include + +#include "mediapipe/util/frame_buffer/buffer_common.h" +#include "mediapipe/util/frame_buffer/gray_buffer.h" +#include "mediapipe/util/frame_buffer/halide/rgb_flip_halide.h" +#include "mediapipe/util/frame_buffer/halide/rgb_gray_halide.h" +#include "mediapipe/util/frame_buffer/halide/rgb_resize_halide.h" +#include "mediapipe/util/frame_buffer/halide/rgb_rgb_halide.h" +#include "mediapipe/util/frame_buffer/halide/rgb_rotate_halide.h" +#include "mediapipe/util/frame_buffer/halide/rgb_yuv_halide.h" +#include "mediapipe/util/frame_buffer/yuv_buffer.h" + +namespace mediapipe { +namespace frame_buffer { + +RgbBuffer::RgbBuffer(uint8_t* data, int width, int height, bool alpha) + : owned_buffer_(nullptr) { + Initialize(data, width, height, alpha); +} + +RgbBuffer::RgbBuffer(uint8_t* data, int width, int height, int row_stride, + bool alpha) { + const int channels = alpha ? 4 : 3; + const halide_dimension_t dimensions[3] = {{/*m=*/0, width, channels}, + {/*m=*/0, height, row_stride}, + {/*m=*/0, channels, 1}}; + buffer_ = Halide::Runtime::Buffer(data, /*d=*/3, dimensions); +} + +RgbBuffer::RgbBuffer(int width, int height, bool alpha) + : owned_buffer_(new uint8_t[ByteSize(width, height, alpha)]) { + Initialize(owned_buffer_.get(), width, height, alpha); +} + +RgbBuffer::RgbBuffer(const RgbBuffer& other) : buffer_(other.buffer_) { + // Never copy owned_buffer; ownership remains with the source of the copy. +} + +RgbBuffer::RgbBuffer(RgbBuffer&& other) { *this = std::move(other); } + +RgbBuffer& RgbBuffer::operator=(const RgbBuffer& other) { + if (this != &other) { + buffer_ = other.buffer_; + } + return *this; +} +RgbBuffer& RgbBuffer::operator=(RgbBuffer&& other) { + if (this != &other) { + owned_buffer_ = std::move(other.owned_buffer_); + buffer_ = other.buffer_; + } + return *this; +} + +RgbBuffer::~RgbBuffer() {} + +bool RgbBuffer::Crop(int x0, int y0, int x1, int y1) { + // Twiddle the buffer start and extents to crop images. + return common::crop_buffer(x0, y0, x1, y1, buffer()); +} + +bool RgbBuffer::Resize(RgbBuffer* output) { + if (output->channels() > channels()) { + // Fail fast; the Halide implementation would otherwise output garbage + // alpha values (i.e. duplicate the blue channel into alpha). + return false; + } + const int result = rgb_resize_halide( + buffer(), static_cast(width()) / output->width(), + static_cast(height()) / output->height(), output->buffer()); + return result == 0; +} + +bool RgbBuffer::Rotate(int angle, RgbBuffer* output) { + const int result = rgb_rotate_halide(buffer(), angle, output->buffer()); + return result == 0; +} + +bool RgbBuffer::FlipHorizontally(RgbBuffer* output) { + const int result = rgb_flip_halide(buffer(), + false, // horizontal + output->buffer()); + return result == 0; +} + +bool RgbBuffer::FlipVertically(RgbBuffer* output) { + const int result = rgb_flip_halide(buffer(), + true, // vertical + output->buffer()); + return result == 0; +} + +bool RgbBuffer::Convert(YuvBuffer* output) { + const int result = + rgb_yuv_halide(buffer(), output->y_buffer(), output->uv_buffer()); + return result == 0; +} + +bool RgbBuffer::Convert(GrayBuffer* output) { + const int result = rgb_gray_halide(buffer(), output->buffer()); + return result == 0; +} + +bool RgbBuffer::Convert(RgbBuffer* output) { + const int result = rgb_rgb_halide(buffer(), output->buffer()); + return result == 0; +} + +void RgbBuffer::Initialize(uint8_t* data, int width, int height, bool alpha) { + const int channels = alpha ? 4 : 3; + buffer_ = Halide::Runtime::Buffer::make_interleaved( + data, width, height, channels); +} + +} // namespace frame_buffer +} // namespace mediapipe diff --git a/mediapipe/util/frame_buffer/rgb_buffer.h b/mediapipe/util/frame_buffer/rgb_buffer.h new file mode 100644 index 000000000..06423c3f6 --- /dev/null +++ b/mediapipe/util/frame_buffer/rgb_buffer.h @@ -0,0 +1,139 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef MEDIAPIPE_UTIL_FRAME_BUFFER_RGB_BUFFER_H_ +#define MEDIAPIPE_UTIL_FRAME_BUFFER_RGB_BUFFER_H_ + +#include + +#include "HalideBuffer.h" +#include "HalideRuntime.h" +#include "mediapipe/util/frame_buffer/gray_buffer.h" +#include "mediapipe/util/frame_buffer/yuv_buffer.h" + +namespace mediapipe { +namespace frame_buffer { + +// RgbBuffer represents a view over an interleaved RGB/RGBA image. +// +// RgbBuffers may be copied and moved efficiently; their backing buffers are +// shared and never deep copied. +// +// RgbBuffer requires a minimum image width depending on the natural vector +// size of the platform, e.g., 16px. This is not validated by RgbBuffer. +class RgbBuffer { + public: + // Returns the size (in bytes) of an RGB/RGBA image of the given dimensions + // without padding. + static int ByteSize(int width, int height, bool alpha) { + return width * height * (alpha ? 4 : 3); + } + + // Builds a RgbBuffer using the given backing buffer and dimensions. + // + // Does not take ownership of the backing buffer (provided in 'data'). + RgbBuffer(uint8_t* data, int width, int height, bool alpha); + + // Builds a RgbBuffer using the given backing buffer and dimensions. + // 'row_stride' must be greater than or equal to 'width'. Padding bytes are at + // the end of each row, following the image bytes. + // + // Does not take ownership of the backing buffer (provided in 'data'). + RgbBuffer(uint8_t* data, int width, int height, int row_stride, bool alpha); + + // Builds a RgbBuffer using the given dimensions. + // + // The underlying backing buffer is allocated and owned by this RgbBuffer. + RgbBuffer(int width, int height, bool alpha); + + // RgbBuffer is copyable. The source retains ownership of its backing buffer. + RgbBuffer(const RgbBuffer& other); + // RgbBuffer is moveable. The source loses ownership of any backing buffers. + RgbBuffer(RgbBuffer&& other); + // RgbBuffer is assignable. + RgbBuffer& operator=(const RgbBuffer& other); + RgbBuffer& operator=(RgbBuffer&& other); + + ~RgbBuffer(); + + // Performs an in-place crop. Modifies this buffer so that the new extent + // matches that of the given crop rectangle -- (x0, y0) becomes (0, 0) and + // the new width and height are x1 - x0 + 1 and y1 - y0 + 1, respectively. + bool Crop(int x0, int y0, int x1, int y1); + + // Resize this image to match the dimensions of the given output RgbBuffer + // and places the result into its backing buffer. + // + // Performs a resize with bilinear interpolation (over four source pixels). + // Resizing with an RGB source buffer and RGBA destination is currently + // unsupported. + bool Resize(RgbBuffer* output); + + // Rotate this image into the given buffer by the given angle (90, 180, 270). + // + // Rotation is specified in degrees counter-clockwise such that when rotating + // by 90 degrees, the top-right corner of the source becomes the top-left of + // the output. The output buffer must have its height and width swapped when + // rotating by 90 or 270. + // + // Any angle values other than (90, 180, 270) are invalid. + bool Rotate(int angle, RgbBuffer* output); + + // Flip this image horizontally/vertically into the given buffer. Both buffer + // dimensions and formats must match (this method does not convert RGB-to-RGBA + // nor RGBA-to-RGB). + bool FlipHorizontally(RgbBuffer* output); + bool FlipVertically(RgbBuffer* output); + + // Performs a RGB-to-YUV color format conversion and places the result + // in the given output YuvBuffer. Both buffer dimensions must match. + bool Convert(YuvBuffer* output); + + // Performs a RGB to grayscale format conversion. + bool Convert(GrayBuffer* output); + + // Performs a rgb to rgba / rgba to rgb format conversion. + bool Convert(RgbBuffer* output); + + // Release ownership of the owned backing buffer. + uint8_t* Release() { return owned_buffer_.release(); } + + // Returns the halide_buffer_t* for the image. + const halide_buffer_t* buffer() const { return buffer_.raw_buffer(); } + // Returns the halide_buffer_t* for the image. + halide_buffer_t* buffer() { return buffer_.raw_buffer(); } + + // Returns the image width. + const int width() const { return buffer_.dim(0).extent(); } + // Returns the image height. + const int height() const { return buffer_.dim(1).extent(); } + // Returns the number of color channels (3, or 4 if RGBA). + const int channels() const { return buffer_.dim(2).extent(); } + // Returns the image row stride. + const int row_stride() const { return buffer_.dim(1).stride(); } + + private: + void Initialize(uint8_t* data, int width, int height, bool alpha); + + // Non-NULL iff this RgbBuffer owns its backing buffer. + std::unique_ptr owned_buffer_; + + // Backing buffer: layout is always width x height x channel (interleaved). + Halide::Runtime::Buffer buffer_; +}; + +} // namespace frame_buffer +} // namespace mediapipe + +#endif // MEDIAPIPE_UTIL_FRAME_BUFFER_RGB_BUFFER_H_ diff --git a/mediapipe/util/frame_buffer/rgb_buffer_test.cc b/mediapipe/util/frame_buffer/rgb_buffer_test.cc new file mode 100644 index 000000000..e5cb39c69 --- /dev/null +++ b/mediapipe/util/frame_buffer/rgb_buffer_test.cc @@ -0,0 +1,606 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/rgb_buffer.h" + +#include + +#include "absl/log/log.h" +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/util/frame_buffer/gray_buffer.h" +#include "mediapipe/util/frame_buffer/yuv_buffer.h" + +// The default implementation of halide_error calls abort(), which we don't +// want. Instead, log the error and let the filter invocation fail. +extern "C" void halide_error(void*, const char* message) { + LOG(ERROR) << "Halide Error: " << message; +} + +namespace mediapipe { +namespace frame_buffer { +namespace { + +// Fill a halide_buffer_t channel with the given value. +void Fill(halide_buffer_t* buffer, int channel, int value) { + for (int y = 0; y < buffer->dim[1].extent; ++y) { + for (int x = 0; x < buffer->dim[0].extent; ++x) { + buffer->host[buffer->dim[1].stride * y + buffer->dim[0].stride * x + + buffer->dim[2].stride * channel] = value; + } + } +} + +// Fill an RgbBuffer with (0, 0, 0). Fills the alpha channel if present. +void Fill(RgbBuffer* buffer) { + for (int c = 0; c < buffer->channels(); ++c) { + Fill(buffer->buffer(), c, 0); + } +} + +// Returns a padded RGB buffer. The metadata are defined as width: 4, height: 2, +// row_stride: 18, channels: 3. +RgbBuffer GetPaddedRgbBuffer() { + static uint8_t rgb_buffer_with_padding[] = { + 10, 20, 30, 20, 30, 40, 30, 40, 50, 40, 50, 60, 0, 0, 0, 0, 0, 0, + 20, 40, 60, 40, 60, 80, 60, 80, 100, 80, 100, 120, 0, 0, 0, 0, 0, 0}; + return RgbBuffer(rgb_buffer_with_padding, + /*width=*/4, /*height=*/2, + /*row_stride=*/18, /*alpha=*/false); +} + +// Returns a padded RGB buffer. The metadata are defined as width: 4, height: 2, +// row_stride: 24, channels: 4. +RgbBuffer GetPaddedRgbaBuffer() { + static uint8_t rgb_buffer_with_padding[] = { + 10, 20, 30, 255, 20, 30, 40, 255, 30, 40, 50, 255, 40, 50, 60, 255, + 0, 0, 0, 0, 0, 0, 0, 0, 20, 40, 60, 255, 40, 60, 80, 255, + 60, 80, 100, 255, 80, 100, 120, 255, 0, 0, 0, 0, 0, 0, 0, 0}; + return RgbBuffer(rgb_buffer_with_padding, + /*width=*/4, /*height=*/2, + /*row_stride=*/24, /*alpha=*/true); +} + +// TODO: Consider move these helper methods into a util class. +// Returns true if the data in the two arrays are the same. Otherwise, return +// false. +bool CompareArray(const uint8_t* lhs_ptr, const uint8_t* rhs_ptr, int width, + int height) { + for (int i = 0; i < height; ++i) { + for (int j = 0; j < width; ++j) { + if (lhs_ptr[i * width + j] != rhs_ptr[i * width + j]) { + return false; + } + } + } + return true; +} + +// Returns true if the halide buffers of two input GrayBuffer are identical. +// Otherwise, returns false; +bool CompareBuffer(const GrayBuffer& lhs, const GrayBuffer& rhs) { + if (lhs.width() != rhs.width() || lhs.height() != rhs.height()) { + return false; + } + const uint8_t* reference_ptr = const_cast(lhs).buffer()->host; + const uint8_t* converted_ptr = const_cast(rhs).buffer()->host; + return CompareArray(reference_ptr, converted_ptr, lhs.width(), lhs.height()); +} + +// Returns true if the halide buffers of two input RgbBuffer are identical. +// Otherwise, returns false; +bool CompareBuffer(const RgbBuffer& lhs, const RgbBuffer& rhs) { + if (lhs.width() != rhs.width() || lhs.height() != rhs.height() || + lhs.row_stride() != rhs.row_stride() || + lhs.channels() != rhs.channels()) { + return false; + } + const uint8_t* reference_ptr = const_cast(lhs).buffer()->host; + const uint8_t* converted_ptr = const_cast(rhs).buffer()->host; + return CompareArray(reference_ptr, converted_ptr, lhs.row_stride(), + lhs.height()); +} + +// Returns true if the halide buffers of two input YuvBuffer are identical. +// Otherwise, returns false; +bool CompareBuffer(const YuvBuffer& lhs, const YuvBuffer& rhs) { + if (lhs.width() != rhs.width() || lhs.height() != rhs.height()) { + return false; + } + const uint8_t* reference_ptr = const_cast(lhs).y_buffer()->host; + const uint8_t* converted_ptr = const_cast(rhs).y_buffer()->host; + if (!CompareArray(reference_ptr, converted_ptr, lhs.width(), lhs.height())) { + return false; + } + reference_ptr = const_cast(lhs).uv_buffer()->host; + converted_ptr = const_cast(rhs).uv_buffer()->host; + return CompareArray(reference_ptr, converted_ptr, lhs.width(), + lhs.height() / 2); +} + +TEST(RgbBufferTest, Properties) { + RgbBuffer rgb(2, 8, false), rgba(2, 8, true); + EXPECT_EQ(2, rgb.width()); + EXPECT_EQ(8, rgb.height()); + EXPECT_EQ(3, rgb.channels()); + + EXPECT_EQ(2, rgba.width()); + EXPECT_EQ(8, rgba.height()); + EXPECT_EQ(4, rgba.channels()); +} + +TEST(RgbBufferTest, PropertiesOfPaddedRgb) { + RgbBuffer rgb_buffer = GetPaddedRgbBuffer(); + EXPECT_EQ(rgb_buffer.width(), 4); + EXPECT_EQ(rgb_buffer.height(), 2); + EXPECT_EQ(rgb_buffer.row_stride(), 18); + EXPECT_EQ(rgb_buffer.channels(), 3); +} + +TEST(RgbBufferTest, PropertiesOfPaddedRgba) { + RgbBuffer rgb_buffer = GetPaddedRgbaBuffer(); + EXPECT_EQ(rgb_buffer.width(), 4); + EXPECT_EQ(rgb_buffer.height(), 2); + EXPECT_EQ(rgb_buffer.row_stride(), 24); + EXPECT_EQ(rgb_buffer.channels(), 4); +} + +TEST(RgbBufferTest, Release) { + RgbBuffer source(8, 8, true); + delete[] source.Release(); +} + +TEST(RgbBufferTest, Assign) { + RgbBuffer source(8, 8, false); + RgbBuffer sink(nullptr, 0, 0, false); + sink = source; + EXPECT_EQ(8, sink.width()); + EXPECT_EQ(8, sink.height()); + EXPECT_EQ(3, sink.channels()); + + sink = RgbBuffer(16, 16, true); + EXPECT_EQ(16, sink.width()); + EXPECT_EQ(16, sink.height()); + EXPECT_EQ(4, sink.channels()); +} + +TEST(RgbBufferTest, MoveAssign) { + RgbBuffer source(8, 8, false); + RgbBuffer sink(nullptr, 0, 0, true); + sink = std::move(source); + EXPECT_EQ(nullptr, source.Release()); + EXPECT_EQ(8, sink.width()); + EXPECT_EQ(8, sink.height()); +} + +TEST(RgbBufferTest, MoveConstructor) { + RgbBuffer source(8, 8, false); + RgbBuffer sink(std::move(source)); + EXPECT_EQ(nullptr, source.Release()); + EXPECT_EQ(8, sink.width()); + EXPECT_EQ(8, sink.height()); +} + +TEST(RgbBufferTest, RgbCrop) { + RgbBuffer source(8, 8, false); + EXPECT_TRUE(source.Crop(2, 2, 6, 6)); +} + +TEST(RgbBufferTest, RgbaCrop) { + RgbBuffer source(8, 8, true); + EXPECT_TRUE(source.Crop(2, 2, 6, 6)); +} + +// Some operations expect images with a platform-dependent minimum width +// because their implementations are vectorized. + +TEST(RgbBufferTest, RgbResize) { + RgbBuffer source(128, 8, false); + RgbBuffer result(32, 4, false); + Fill(&source); + EXPECT_TRUE(source.Resize(&result)); + + // Test odd result sizes too. + source = RgbBuffer(64, 16, false); + result = RgbBuffer(32, 7, false); + Fill(&source); + EXPECT_TRUE(source.Resize(&result)); +} + +TEST(RgbBufferTest, RgbaResize) { + RgbBuffer source(128, 8, true); + RgbBuffer result(32, 4, true); + Fill(&source); + EXPECT_TRUE(source.Resize(&result)); + + // Test odd result sizes too. + source = RgbBuffer(64, 16, true); + result = RgbBuffer(32, 7, true); + Fill(&source); + EXPECT_TRUE(source.Resize(&result)); +} + +// Note: RGB-to-RGBA conversion currently doesn't work. +TEST(RgbBufferTest, RgbResizeDifferentFormat) { + RgbBuffer source(128, 8, false); + RgbBuffer result(16, 4, true); + Fill(&source); + EXPECT_FALSE(source.Resize(&result)); +} + +TEST(RgbBufferTest, RgbaResizeDifferentFormat) { + RgbBuffer source(128, 8, true); + RgbBuffer result(16, 4, false); + Fill(&source); + EXPECT_TRUE(source.Resize(&result)); +} + +TEST(RgbBufferTest, PaddedRgbResize) { + const int target_width = 2; + const int target_height = 1; + RgbBuffer source = GetPaddedRgbBuffer(); + RgbBuffer result(target_width, target_height, /*alpha=*/false); + + ASSERT_TRUE(source.Resize(&result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + EXPECT_EQ(result.channels(), 3); + EXPECT_EQ(result.row_stride(), target_width * /*pixel_stride=*/3); + + uint8_t rgb_data[] = {10, 20, 30, 30, 40, 50}; + RgbBuffer rgb_buffer = + RgbBuffer(rgb_data, target_width, target_height, /*alpha=*/false); + EXPECT_TRUE(CompareBuffer(rgb_buffer, result)); +} + +TEST(RgbBufferTest, PaddedRgbaResize) { + const int target_width = 2; + const int target_height = 1; + RgbBuffer source = GetPaddedRgbaBuffer(); + RgbBuffer result(target_width, target_height, /*alpha=*/true); + + ASSERT_TRUE(source.Resize(&result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + EXPECT_EQ(result.channels(), 4); + EXPECT_EQ(result.row_stride(), target_width * /*pixel_stride=*/4); + + uint8_t rgb_data[] = {10, 20, 30, 255, 30, 40, 50, 255}; + RgbBuffer rgb_buffer = + RgbBuffer(rgb_data, target_width, target_height, /*alpha=*/true); + EXPECT_TRUE(CompareBuffer(rgb_buffer, result)); +} + +TEST(RgbBufferTest, RgbRotateCheckSize) { + RgbBuffer source(4, 8, false); + RgbBuffer result(8, 4, false); + Fill(&source); + EXPECT_TRUE(source.Rotate(90, &result)); +} + +TEST(RgbBufferTest, RgbRotateCheckData) { + uint8_t* data = new uint8_t[12]; + data[0] = data[1] = data[2] = 1; // Pixel 1 + data[3] = data[4] = data[5] = 2; // Pixel 2 + data[6] = data[7] = data[8] = 3; // Pixel 3 + data[9] = data[10] = data[11] = 4; // Pixel 4 + RgbBuffer source(data, 2, 2, false); + RgbBuffer result(2, 2, false); + source.Rotate(90, &result); + EXPECT_EQ(2, result.buffer()->host[0]); + EXPECT_EQ(4, result.buffer()->host[3]); + EXPECT_EQ(1, result.buffer()->host[6]); + EXPECT_EQ(3, result.buffer()->host[9]); + delete[] data; +} + +TEST(RgbBufferTest, RgbRotateDifferentFormat) { + RgbBuffer source(4, 8, true); + RgbBuffer result(8, 4, false); + Fill(&source); + EXPECT_TRUE(source.Rotate(90, &result)); +} + +// Note: RGB-to-RGBA conversion currently doesn't work. +TEST(RgbBufferTest, RgbRotateDifferentFormatFail) { + RgbBuffer source(4, 8, false); + RgbBuffer result(8, 4, true); + Fill(&source); + EXPECT_FALSE(source.Rotate(90, &result)); +} + +TEST(RgbBufferTest, RgbaRotate) { + RgbBuffer source(4, 8, true); + RgbBuffer result(8, 4, true); + Fill(&source); + EXPECT_TRUE(source.Rotate(90, &result)); +} + +TEST(RgbBufferTest, RgbaRotateDifferentFormat) { + RgbBuffer source(4, 8, true); + RgbBuffer result(8, 4, false); + Fill(&source); + EXPECT_TRUE(source.Rotate(90, &result)); +} + +// Note: RGB-to-RGBA conversion currently doesn't work. +TEST(RgbBufferTest, RgbaRotateDifferentFormatFail) { + RgbBuffer source(4, 8, false); + RgbBuffer result(8, 4, true); + Fill(&source); + EXPECT_FALSE(source.Rotate(90, &result)); +} + +TEST(RgbBufferTest, PaddedRgbRotateCheckData) { + const int target_width = 2; + const int target_height = 4; + RgbBuffer source = GetPaddedRgbBuffer(); + RgbBuffer result(target_width, target_height, /*alpha=*/false); + + ASSERT_TRUE(source.Rotate(/*angle=*/90, &result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + EXPECT_EQ(result.channels(), 3); + EXPECT_EQ(result.row_stride(), target_width * /*pixel_stride=*/3); + + uint8_t rgb_data[] = {40, 50, 60, 80, 100, 120, 30, 40, 50, 60, 80, 100, + 20, 30, 40, 40, 60, 80, 10, 20, 30, 20, 40, 60}; + RgbBuffer rgb_buffer = + RgbBuffer(rgb_data, target_width, target_height, /*alpha=*/false); + EXPECT_TRUE(CompareBuffer(rgb_buffer, result)); +} + +TEST(RgbBufferTest, PaddedRgbaRotateCheckData) { + const int target_width = 2; + const int target_height = 4; + RgbBuffer result(target_width, target_height, /*alpha=*/true); + RgbBuffer source = GetPaddedRgbaBuffer(); + + ASSERT_TRUE(source.Rotate(/*angle=*/90, &result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + EXPECT_EQ(result.channels(), 4); + EXPECT_EQ(result.row_stride(), target_width * /*pixel_stride=*/4); + + uint8_t rgb_data[] = {40, 50, 60, 255, 80, 100, 120, 255, 30, 40, 50, + 255, 60, 80, 100, 255, 20, 30, 40, 255, 40, 60, + 80, 255, 10, 20, 30, 255, 20, 40, 60, 255}; + RgbBuffer rgb_buffer = + RgbBuffer(rgb_data, target_width, target_height, /*alpha=*/true); + EXPECT_TRUE(CompareBuffer(rgb_buffer, result)); +} + +TEST(RgbBufferTest, RgbaFlip) { + RgbBuffer source(16, 16, true); + RgbBuffer result(16, 16, true); + Fill(&source); + EXPECT_TRUE(source.FlipHorizontally(&result)); + EXPECT_TRUE(source.FlipVertically(&result)); +} + +// Note: Neither RGBA-to-RGB nor RGB-to-RGBA conversion currently works. +TEST(RgbBufferTest, RgbaFlipDifferentFormatFail) { + RgbBuffer source(16, 16, false); + RgbBuffer result(16, 16, true); + Fill(&source); + Fill(&result); + EXPECT_FALSE(source.FlipHorizontally(&result)); + EXPECT_FALSE(result.FlipHorizontally(&source)); + EXPECT_FALSE(source.FlipVertically(&result)); + EXPECT_FALSE(result.FlipVertically(&source)); +} + +TEST(RgbBufferTest, PaddedRgbFlipHorizontally) { + const int target_width = 4; + const int target_height = 2; + RgbBuffer result(target_width, target_height, /*alpha=*/false); + RgbBuffer source = GetPaddedRgbBuffer(); + + ASSERT_TRUE(source.FlipHorizontally(&result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + EXPECT_EQ(result.channels(), 3); + EXPECT_EQ(result.row_stride(), target_width * /*pixel_stride=*/3); + + uint8_t rgb_data[] = {40, 50, 60, 30, 40, 50, 20, 30, 40, 10, 20, 30, + 80, 100, 120, 60, 80, 100, 40, 60, 80, 20, 40, 60}; + RgbBuffer rgb_buffer = + RgbBuffer(rgb_data, target_width, target_height, /*alpha=*/false); + EXPECT_TRUE(CompareBuffer(rgb_buffer, result)); +} + +TEST(RgbBufferTest, PaddedRgbaFlipHorizontally) { + const int target_width = 4; + const int target_height = 2; + RgbBuffer result(target_width, target_height, /*alpha=*/true); + RgbBuffer source = GetPaddedRgbaBuffer(); + + ASSERT_TRUE(source.FlipHorizontally(&result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + EXPECT_EQ(result.channels(), 4); + EXPECT_EQ(result.row_stride(), target_width * /*pixel_stride=*/4); + + uint8_t rgb_data[] = {40, 50, 60, 255, 30, 40, 50, 255, 20, 30, 40, + 255, 10, 20, 30, 255, 80, 100, 120, 255, 60, 80, + 100, 255, 40, 60, 80, 255, 20, 40, 60, 255}; + RgbBuffer rgb_buffer = + RgbBuffer(rgb_data, target_width, target_height, /*alpha=*/true); + EXPECT_TRUE(CompareBuffer(rgb_buffer, result)); +} + +TEST(RgbBufferTest, RgbConvertNv21) { + RgbBuffer source(32, 8, false); + YuvBuffer result(32, 8, YuvBuffer::NV21); + Fill(&source); + EXPECT_TRUE(source.Convert(&result)); +} + +TEST(RgbBufferTest, RgbaConvertNv21) { + RgbBuffer source(32, 8, true); + YuvBuffer result(32, 8, YuvBuffer::NV21); + Fill(&source); + EXPECT_TRUE(source.Convert(&result)); +} + +TEST(RgbBufferTest, PaddedRgbConvertNv21) { + const int target_width = 4; + const int target_height = 2; + YuvBuffer result(target_width, target_height, YuvBuffer::NV21); + RgbBuffer source = GetPaddedRgbBuffer(); + + ASSERT_TRUE(source.Convert(&result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + + uint8_t yuv_data[] = {18, 28, 38, 48, 36, 56, 76, 96, 122, 135, 122, 135}; + YuvBuffer yuv_buffer = + YuvBuffer(yuv_data, target_width, target_height, YuvBuffer::NV21); + EXPECT_TRUE(CompareBuffer(yuv_buffer, result)); +} + +TEST(RgbBufferTest, PaddedRgbaConvertNv21) { + const int target_width = 4; + const int target_height = 2; + YuvBuffer result(target_width, target_height, YuvBuffer::NV21); + RgbBuffer source = GetPaddedRgbaBuffer(); + + ASSERT_TRUE(source.Convert(&result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + + uint8_t yuv_data[] = {18, 28, 38, 48, 36, 56, 76, 96, 122, 135, 122, 135}; + YuvBuffer yuv_buffer = + YuvBuffer(yuv_data, target_width, target_height, YuvBuffer::NV21); + EXPECT_TRUE(CompareBuffer(yuv_buffer, result)); +} + +TEST(RgbBufferTest, RgbConvertGray) { + uint8_t* data = new uint8_t[6]; + data[0] = 200; + data[1] = 100; + data[2] = 0; + data[3] = 0; + data[4] = 200; + data[5] = 100; + RgbBuffer source(data, 2, 1, false); + GrayBuffer result(2, 1); + EXPECT_TRUE(source.Convert(&result)); + EXPECT_EQ(118, result.buffer()->host[0]); + EXPECT_EQ(129, result.buffer()->host[1]); + delete[] data; +} + +TEST(RgbBufferTest, RgbaConvertGray) { + uint8_t* data = new uint8_t[8]; + data[0] = 200; + data[1] = 100; + data[2] = 0; + data[3] = 1; + data[4] = 0; + data[5] = 200; + data[6] = 100; + data[7] = 50; + RgbBuffer source(data, 2, 1, true); + GrayBuffer result(2, 1); + EXPECT_TRUE(source.Convert(&result)); + EXPECT_EQ(118, result.buffer()->host[0]); + EXPECT_EQ(129, result.buffer()->host[1]); + delete[] data; +} + +TEST(RgbBufferTest, PaddedRgbConvertGray) { + const int target_width = 4; + const int target_height = 2; + GrayBuffer result(target_width, target_height); + RgbBuffer source = GetPaddedRgbBuffer(); + + ASSERT_TRUE(source.Convert(&result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + + uint8_t gray_data[] = {18, 28, 38, 48, 36, 56, 76, 96}; + GrayBuffer gray_buffer = GrayBuffer(gray_data, target_width, target_height); + EXPECT_TRUE(CompareBuffer(gray_buffer, result)); +} + +TEST(RgbBufferTest, PaddedRgbaConvertGray) { + const int target_width = 4; + const int target_height = 2; + GrayBuffer result(target_width, target_height); + RgbBuffer source = GetPaddedRgbaBuffer(); + + ASSERT_TRUE(source.Convert(&result)); + EXPECT_EQ(result.width(), target_width); + EXPECT_EQ(result.height(), target_height); + + uint8_t gray_data[] = {18, 28, 38, 48, 36, 56, 76, 96}; + GrayBuffer gray_buffer = GrayBuffer(gray_data, target_width, target_height); + EXPECT_TRUE(CompareBuffer(gray_buffer, result)); +} + +TEST(RgbBufferTest, RgbConvertRgba) { + constexpr int kWidth = 2, kHeight = 1; + uint8_t rgb_data[] = {200, 100, 50, 100, 50, 20}; + RgbBuffer source(rgb_data, kWidth, kHeight, false); + RgbBuffer result(kWidth, kHeight, true); + + ASSERT_TRUE(source.Convert(&result)); + + uint8_t rgba_data[] = {200, 100, 50, 255, 100, 50, 20, 255}; + RgbBuffer rgba_buffer = RgbBuffer(rgba_data, kWidth, kHeight, true); + EXPECT_TRUE(CompareBuffer(rgba_buffer, result)); +} + +TEST(RgbBufferTest, PaddedRgbConvertRgba) { + constexpr int kWidth = 4, kHeight = 2; + RgbBuffer source = GetPaddedRgbBuffer(); + RgbBuffer result(kWidth, kHeight, true); + ASSERT_TRUE(source.Convert(&result)); + + uint8_t rgba_data[]{10, 20, 30, 255, 20, 30, 40, 255, 30, 40, 50, + 255, 40, 50, 60, 255, 20, 40, 60, 255, 40, 60, + 80, 255, 60, 80, 100, 255, 80, 100, 120, 255}; + RgbBuffer rgba_buffer = RgbBuffer(rgba_data, kWidth, kHeight, true); + EXPECT_TRUE(CompareBuffer(rgba_buffer, result)); +} + +TEST(RgbBufferTest, RgbaConvertRgb) { + constexpr int kWidth = 2, kHeight = 1; + uint8_t rgba_data[] = {200, 100, 50, 30, 100, 50, 20, 70}; + RgbBuffer source(rgba_data, kWidth, kHeight, true); + RgbBuffer result(kWidth, kHeight, false); + + ASSERT_TRUE(source.Convert(&result)); + + uint8_t rgb_data[] = {200, 100, 50, 100, 50, 20}; + RgbBuffer rgb_buffer = RgbBuffer(rgb_data, kWidth, kHeight, false); + EXPECT_TRUE(CompareBuffer(rgb_buffer, result)); +} + +TEST(RgbBufferTest, PaddedRgbaConvertRgb) { + constexpr int kWidth = 4, kHeight = 2; + RgbBuffer source = GetPaddedRgbaBuffer(); + RgbBuffer result(kWidth, kHeight, false); + + ASSERT_TRUE(source.Convert(&result)); + + uint8_t rgb_data[] = {10, 20, 30, 20, 30, 40, 30, 40, 50, 40, 50, 60, + 20, 40, 60, 40, 60, 80, 60, 80, 100, 80, 100, 120}; + RgbBuffer rgb_buffer = RgbBuffer(rgb_data, kWidth, kHeight, false); + EXPECT_TRUE(CompareBuffer(rgb_buffer, result)); +} +} // namespace +} // namespace frame_buffer +} // namespace mediapipe diff --git a/mediapipe/util/frame_buffer/yuv_buffer.cc b/mediapipe/util/frame_buffer/yuv_buffer.cc new file mode 100644 index 000000000..f96282134 --- /dev/null +++ b/mediapipe/util/frame_buffer/yuv_buffer.cc @@ -0,0 +1,152 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/yuv_buffer.h" + +#include + +#include "mediapipe/util/frame_buffer/buffer_common.h" +#include "mediapipe/util/frame_buffer/halide/yuv_flip_halide.h" +#include "mediapipe/util/frame_buffer/halide/yuv_resize_halide.h" +#include "mediapipe/util/frame_buffer/halide/yuv_rgb_halide.h" +#include "mediapipe/util/frame_buffer/halide/yuv_rotate_halide.h" +#include "mediapipe/util/frame_buffer/rgb_buffer.h" + +namespace mediapipe { +namespace frame_buffer { + +YuvBuffer::YuvBuffer(uint8_t* y_plane, uint8_t* u_plane, uint8_t* v_plane, + int width, int height, int row_stride_y, int row_stride_uv, + int pixel_stride_uv) { + // Initialize the buffer shapes: {min, extent, stride} per dimension. + // TODO: Ensure that width is less than or equal to row stride. + const halide_dimension_t y_dimensions[2] = { + {0, width, 1}, + {0, height, row_stride_y}, + }; + y_buffer_ = Halide::Runtime::Buffer(y_plane, 2, y_dimensions); + + // Note that the Halide implementation expects the planes to be in VU + // order, so we point at the V plane first. + const halide_dimension_t uv_dimensions[3] = { + {0, (width + 1) / 2, pixel_stride_uv}, + {0, (height + 1) / 2, row_stride_uv}, + {0, 2, static_cast(u_plane - v_plane)}, + }; + uv_buffer_ = Halide::Runtime::Buffer(v_plane, 3, uv_dimensions); +} + +YuvBuffer::YuvBuffer(uint8_t* data, int width, int height, Format format) + : owned_buffer_(nullptr) { + Initialize(data, width, height, format); +} + +YuvBuffer::YuvBuffer(int width, int height, Format format) + : owned_buffer_(new uint8_t[ByteSize(width, height)]) { + Initialize(owned_buffer_.get(), width, height, format); +} + +YuvBuffer::YuvBuffer(const YuvBuffer& other) + : y_buffer_(other.y_buffer_), uv_buffer_(other.uv_buffer_) { + // Never copy owned_buffer; ownership remains with the source of the copy. +} + +YuvBuffer::YuvBuffer(YuvBuffer&& other) { *this = std::move(other); } + +YuvBuffer& YuvBuffer::operator=(const YuvBuffer& other) { + if (this != &other) { + y_buffer_ = other.y_buffer_; + uv_buffer_ = other.uv_buffer_; + } + return *this; +} +YuvBuffer& YuvBuffer::operator=(YuvBuffer&& other) { + if (this != &other) { + owned_buffer_ = std::move(other.owned_buffer_); + y_buffer_ = other.y_buffer_; + uv_buffer_ = other.uv_buffer_; + } + return *this; +} + +YuvBuffer::~YuvBuffer() {} + +void YuvBuffer::Initialize(uint8_t* data, int width, int height, + Format format) { + y_buffer_ = Halide::Runtime::Buffer(data, width, height); + + uint8_t* uv_data = data + (width * height); + switch (format) { + case NV21: + // Interleaved UV (actually VU order). + uv_buffer_ = Halide::Runtime::Buffer::make_interleaved( + uv_data, (width + 1) / 2, (height + 1) / 2, 2); + break; + case YV12: + // Planar UV (actually VU order). + uv_buffer_ = Halide::Runtime::Buffer(uv_data, (width + 1) / 2, + (height + 1) / 2, 2); + // NOTE: Halide operations have not been tested extensively in this + // configuration. + break; + } +} + +bool YuvBuffer::Crop(int x0, int y0, int x1, int y1) { + if (x0 & 1 || y0 & 1) { + // YUV images must be left-and top-aligned to even X/Y coordinates. + return false; + } + + // Twiddle the buffer start and extents for each plane to crop images. + return (common::crop_buffer(x0, y0, x1, y1, y_buffer()) && + common::crop_buffer(x0 / 2, y0 / 2, x1 / 2, y1 / 2, uv_buffer())); +} + +bool YuvBuffer::Resize(YuvBuffer* output) { + const int result = yuv_resize_halide( + y_buffer(), uv_buffer(), static_cast(width()) / output->width(), + static_cast(height()) / output->height(), output->y_buffer(), + output->uv_buffer()); + return result == 0; +} + +bool YuvBuffer::Rotate(int angle, YuvBuffer* output) { + const int result = yuv_rotate_halide(y_buffer(), uv_buffer(), angle, + output->y_buffer(), output->uv_buffer()); + return result == 0; +} + +bool YuvBuffer::FlipHorizontally(YuvBuffer* output) { + const int result = yuv_flip_halide(y_buffer(), uv_buffer(), + false, // horizontal + output->y_buffer(), output->uv_buffer()); + return result == 0; +} + +bool YuvBuffer::FlipVertically(YuvBuffer* output) { + const int result = yuv_flip_halide(y_buffer(), uv_buffer(), + true, // vertical + output->y_buffer(), output->uv_buffer()); + return result == 0; +} + +bool YuvBuffer::Convert(bool halve, RgbBuffer* output) { + const int result = + yuv_rgb_halide(y_buffer(), uv_buffer(), halve, output->buffer()); + return result == 0; +} + +} // namespace frame_buffer +} // namespace mediapipe diff --git a/mediapipe/util/frame_buffer/yuv_buffer.h b/mediapipe/util/frame_buffer/yuv_buffer.h new file mode 100644 index 000000000..8e4715dcf --- /dev/null +++ b/mediapipe/util/frame_buffer/yuv_buffer.h @@ -0,0 +1,160 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef MEDIAPIPE_UTIL_FRAME_BUFFER_YUV_BUFFER_H_ +#define MEDIAPIPE_UTIL_FRAME_BUFFER_YUV_BUFFER_H_ + +#include + +#include "HalideBuffer.h" +#include "HalideRuntime.h" + +namespace mediapipe { +namespace frame_buffer { +class RgbBuffer; + +// YuvBuffer represents a view over a YUV 4:2:0 image. +// +// YuvBuffers may be copied and moved efficiently; their backing buffers are +// shared and never deep copied. +// +// YuvBuffer requires a minimum image width depending on the natural vector +// size of the platform, e.g., 16px. This is not validated by YuvBuffer. +class YuvBuffer { + public: + // YUV formats. Rather than supporting every possible format, we prioritize + // formats with broad hardware/platform support. + // + // Enum values are FourCC codes; see http://fourcc.org/yuv.php for more. + enum Format { + NV21 = 0x3132564E, // YUV420SP (VU interleaved) + YV12 = 0x32315659, // YUV420P (VU planar) + }; + + // Returns the size (in bytes) of a YUV image of the given dimensions. + static int ByteSize(int width, int height) { + // 1 byte per pixel in the Y plane, 2 bytes per 2x2 block in the UV plane. + // Dimensions with odd sizes are rounded up. + const int y_size = width * height; + const int uv_size = ((width + 1) / 2) * ((height + 1) / 2) * 2; + return y_size + uv_size; + } + + // Builds a generic YUV420 YuvBuffer with the given backing buffers, + // dimensions and strides. Supports both interleaved or planar UV with + // custom strides. + // + // Does not take ownership of any backing buffers, which must be large + // enough to fit their contents. + YuvBuffer(uint8_t* y_plane, uint8_t* u_plane, uint8_t* v_plane, int width, + int height, int row_stride_y, int row_stride_uv, + int pixel_stride_uv); + + // Builds a YuvBuffer using the given backing buffer, dimensions, and format. + // Expects an NV21- or YV12-format image only. + // + // Does not take ownership of the backing buffer (provided in 'data'), which + // must be sized to hold at least the amount indicated by ByteSize(). + YuvBuffer(uint8_t* data, int width, int height, Format format); + + // Builds a YuvBuffer using the given dimensions and format. Expects + // an NV21- or YV12-format image only. + // + // The underlying backing buffer is allocated and owned by this YuvBuffer. + YuvBuffer(int width, int height, Format format); + + // YuvBuffer is copyable. The source retains ownership of its backing buffers. + YuvBuffer(const YuvBuffer& other); + // YuvBuffer is moveable. The source loses ownership of any backing buffers. + YuvBuffer(YuvBuffer&& other); + // YuvBuffer is assignable. + YuvBuffer& operator=(const YuvBuffer& other); + YuvBuffer& operator=(YuvBuffer&& other); + + ~YuvBuffer(); + + // Performs an in-place crop. Modifies this buffer so that the new extent + // matches that of the given crop rectangle -- (x0, y0) becomes (0, 0) and + // the new width and height are x1 - x0 + 1 and y1 - y0 + 1, respectively. + // + // Note that the top-left corner (x0, y0) coordinates must be even to + // maintain alignment between the Y and UV grids. + bool Crop(int x0, int y0, int x1, int y1); + + // Resize this image to match the dimensions of the given output YuvBuffer + // and places the result into its backing buffer. + // + // Performs a resize with bilinear interpolation (over four source pixels). + bool Resize(YuvBuffer* output); + + // Rotate this image into the given buffer by the given angle (90, 180, 270). + // + // Rotation is specified in degrees counter-clockwise such that when rotating + // by 90 degrees, the top-right corner of the source becomes the top-left of + // the output. The output buffer must have its height and width swapped when + // rotating by 90 or 270. + // + // Any angle values other than (90, 180, 270) are invalid. + bool Rotate(int angle, YuvBuffer* output); + + // Flip this image horizontally/vertically into the given buffer. Both buffer + // dimensions must match. + bool FlipHorizontally(YuvBuffer* output); + bool FlipVertically(YuvBuffer* output); + + // Performs a YUV-to-RGB color format conversion and places the result + // in the given output RgbBuffer. Both buffer dimensions must match. + // + // When halve is true, the converted output is downsampled by a factor of + // two by discarding three of four luminance values in every 2x2 block. + bool Convert(bool halve, RgbBuffer* output); + + // Release ownership of the owned backing buffer. + uint8_t* Release() { return owned_buffer_.release(); } + + // Returns the halide_buffer_t* for the Y plane. + const halide_buffer_t* y_buffer() const { return y_buffer_.raw_buffer(); } + // Returns the halide_buffer_t* for the UV plane(s). + const halide_buffer_t* uv_buffer() const { return uv_buffer_.raw_buffer(); } + // Returns the halide_buffer_t* for the Y plane. + halide_buffer_t* y_buffer() { return y_buffer_.raw_buffer(); } + // Returns the halide_buffer_t* for the UV plane(s). + halide_buffer_t* uv_buffer() { return uv_buffer_.raw_buffer(); } + + // Returns the image width. + const int width() const { return y_buffer_.dim(0).extent(); } + // Returns the image height. + const int height() const { return y_buffer_.dim(1).extent(); } + + private: + void Initialize(uint8_t* data, int width, int height, Format format); + + // Non-NULL iff this YuvBuffer owns its buffer. + std::unique_ptr owned_buffer_; + + // Y (luminance) backing buffer: layout is always width x height. + Halide::Runtime::Buffer y_buffer_; + + // UV (chrominance) backing buffer; width/2 x height/2 x 2 (channel). + // May be interleaved or planar. + // + // Note that the planes are in the reverse of the usual order: channel 0 is V + // and channel 1 is U. + Halide::Runtime::Buffer uv_buffer_; +}; + +} // namespace frame_buffer +} // namespace mediapipe + +#endif // MEDIAPIPE_UTIL_FRAME_BUFFER_YUV_BUFFER_H_ diff --git a/mediapipe/util/frame_buffer/yuv_buffer_test.cc b/mediapipe/util/frame_buffer/yuv_buffer_test.cc new file mode 100644 index 000000000..a18b19a92 --- /dev/null +++ b/mediapipe/util/frame_buffer/yuv_buffer_test.cc @@ -0,0 +1,251 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "mediapipe/util/frame_buffer/yuv_buffer.h" + +#include + +#include "absl/log/log.h" +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/util/frame_buffer/rgb_buffer.h" + +// The default implementation of halide_error calls abort(), which we don't +// want. Instead, log the error and let the filter invocation fail. +extern "C" void halide_error(void*, const char* message) { + LOG(ERROR) << "Halide Error: " << message; +} + +namespace mediapipe { +namespace frame_buffer { +namespace { + +// Fill a halide_buffer_t channel with the given value. +void Fill(halide_buffer_t* buffer, int channel, int value) { + for (int y = 0; y < buffer->dim[1].extent; ++y) { + for (int x = 0; x < buffer->dim[0].extent; ++x) { + buffer->host[buffer->dim[1].stride * y + buffer->dim[0].stride * x + + buffer->dim[2].stride * channel] = value; + } + } +} + +// Fill a YuvBuffer with the given YUV color. +void Fill(YuvBuffer* buffer, uint8_t y, uint8_t u, uint8_t v) { + Fill(buffer->y_buffer(), 0, y); + Fill(buffer->uv_buffer(), 1, u); + Fill(buffer->uv_buffer(), 0, v); +} + +TEST(YuvBufferTest, Properties) { + YuvBuffer yuv(2, 8, YuvBuffer::NV21); + EXPECT_EQ(2, yuv.width()); + EXPECT_EQ(8, yuv.height()); +} + +TEST(YuvBufferTest, Release) { + YuvBuffer source(8, 8, YuvBuffer::NV21); + delete[] source.Release(); +} + +TEST(YuvBufferTest, Assign) { + YuvBuffer source(8, 8, YuvBuffer::NV21); + YuvBuffer sink(nullptr, 0, 0, YuvBuffer::NV21); + sink = source; + EXPECT_EQ(8, sink.width()); + EXPECT_EQ(8, sink.height()); + + sink = YuvBuffer(16, 16, YuvBuffer::NV21); + EXPECT_EQ(16, sink.width()); + EXPECT_EQ(16, sink.height()); +} + +TEST(YuvBufferTest, MoveAssign) { + YuvBuffer source(8, 8, YuvBuffer::NV21); + YuvBuffer sink(nullptr, 0, 0, YuvBuffer::NV21); + sink = std::move(source); + EXPECT_EQ(nullptr, source.Release()); + EXPECT_EQ(8, sink.width()); + EXPECT_EQ(8, sink.height()); +} + +TEST(YuvBufferTest, MoveConstructor) { + YuvBuffer source(8, 8, YuvBuffer::NV21); + YuvBuffer sink(std::move(source)); + EXPECT_EQ(nullptr, source.Release()); + EXPECT_EQ(8, sink.width()); + EXPECT_EQ(8, sink.height()); +} + +TEST(YuvBufferTest, GenericSemiplanarLayout) { + uint8_t y_plane[16], uv_plane[8]; + YuvBuffer buffer(y_plane, uv_plane, uv_plane + 1, 4, 4, 4, 4, 2); + Fill(&buffer, 16, 32, 64); + + for (int i = 0; i < 16; ++i) { + EXPECT_EQ(y_plane[i], 16) << i; + } + for (int i = 0; i < 4; ++i) { + EXPECT_EQ(uv_plane[2 * i], 32); + EXPECT_EQ(uv_plane[2 * i + 1], 64); + } +} + +TEST(YuvBufferTest, GenericPlanarLayout) { + uint8_t y_plane[16], u_plane[4], v_plane[4]; + YuvBuffer buffer(y_plane, u_plane, v_plane, 4, 4, 4, 2, 1); + Fill(&buffer, 16, 32, 64); + + for (int i = 0; i < 16; ++i) { + EXPECT_EQ(y_plane[i], 16) << i; + } + for (int i = 0; i < 4; ++i) { + EXPECT_EQ(u_plane[i], 32); + EXPECT_EQ(v_plane[i], 64); + } +} + +TEST(YuvBufferTest, Nv21Crop) { + YuvBuffer source(8, 8, YuvBuffer::NV21); + EXPECT_TRUE(source.Crop(2, 2, 6, 6)); +} + +TEST(YuvBufferTest, Nv21Resize) { + YuvBuffer source(8, 8, YuvBuffer::NV21); + YuvBuffer result(4, 4, YuvBuffer::NV21); + Fill(&source, 16, 32, 64); + EXPECT_TRUE(source.Resize(&result)); + + // Test odd result sizes too. + source = YuvBuffer(500, 362, YuvBuffer::NV21); + result = YuvBuffer(320, 231, YuvBuffer::NV21); + Fill(&source, 16, 32, 64); + EXPECT_TRUE(source.Resize(&result)); +} + +TEST(YuvBufferTest, Nv21ResizeDifferentFormat) { + YuvBuffer source(8, 8, YuvBuffer::NV21); + YuvBuffer result(4, 4, YuvBuffer::YV12); + Fill(&source, 16, 32, 64); + EXPECT_TRUE(source.Resize(&result)); +} + +TEST(YuvBufferTest, Nv21Rotate) { + YuvBuffer source(4, 8, YuvBuffer::NV21); + YuvBuffer result(8, 4, YuvBuffer::NV21); + Fill(&source, 16, 32, 64); + EXPECT_TRUE(source.Rotate(90, &result)); +} + +TEST(YuvBufferTest, Nv21RotateDifferentFormat) { + YuvBuffer source(8, 8, YuvBuffer::NV21); + YuvBuffer result(8, 8, YuvBuffer::YV12); + Fill(&source, 16, 32, 64); + EXPECT_TRUE(source.Rotate(90, &result)); +} + +TEST(YuvBufferTest, Nv21RotateFailBounds) { + // Expect failure if the destination doesn't have the correct bounds. + YuvBuffer source(4, 8, YuvBuffer::NV21); + YuvBuffer result(4, 8, YuvBuffer::NV21); + Fill(&source, 16, 32, 64); + EXPECT_FALSE(source.Rotate(90, &result)); +} + +TEST(YuvBufferTest, Nv21Flip) { + YuvBuffer source(16, 16, YuvBuffer::NV21); + YuvBuffer result(16, 16, YuvBuffer::NV21); + Fill(&source, 16, 32, 64); + EXPECT_TRUE(source.FlipHorizontally(&result)); + EXPECT_TRUE(source.FlipVertically(&result)); +} + +TEST(YuvBufferTest, Nv21FlipDifferentFormat) { + YuvBuffer source(16, 16, YuvBuffer::NV21); + YuvBuffer result(16, 16, YuvBuffer::YV12); + Fill(&source, 16, 32, 64); + EXPECT_TRUE(source.FlipHorizontally(&result)); + EXPECT_TRUE(source.FlipVertically(&result)); +} + +TEST(YuvBufferTest, Nv21ConvertRgb) { + // Note that RGB conversion expects at least images of width >= 32 because + // the implementation is vectorized. + YuvBuffer source(32, 8, YuvBuffer::NV21); + Fill(&source, 52, 170, 90); + + RgbBuffer result_rgb(32, 8, false); + EXPECT_TRUE(source.Convert(false, &result_rgb)); + + RgbBuffer result_rgba(32, 8, true); + EXPECT_TRUE(source.Convert(false, &result_rgba)); + + uint8_t* pixels = result_rgba.buffer()->host; + ASSERT_TRUE(pixels); + EXPECT_EQ(pixels[0], 0); + EXPECT_EQ(pixels[1], 65); + EXPECT_EQ(pixels[2], 126); + EXPECT_EQ(pixels[3], 255); +} + +TEST(YuvBufferTest, Nv21ConvertRgbCropped) { + // Note that RGB conversion expects at least images of width >= 32 because + // the implementation is vectorized. + YuvBuffer source(1024, 768, YuvBuffer::NV21); + Fill(&source, 52, 170, 90); + + // YUV images must be left-and top-aligned to even X/Y coordinates, + // regardless of whether the target image has even or odd width/height. + EXPECT_FALSE(source.Crop(1, 1, 512, 384)); + EXPECT_FALSE(source.Crop(1, 1, 511, 383)); + + YuvBuffer source1(source); + EXPECT_TRUE(source1.Crop(64, 64, 512, 384)); + RgbBuffer result_rgb(source1.width(), source1.height(), false); + EXPECT_TRUE(source1.Convert(false, &result_rgb)); + + YuvBuffer source2(source); + EXPECT_TRUE(source2.Crop(64, 64, 511, 383)); + RgbBuffer result_rgba(source2.width(), source2.height(), true); + EXPECT_TRUE(source2.Convert(false, &result_rgba)); + + uint8_t* pixels = result_rgba.buffer()->host; + ASSERT_TRUE(pixels); + EXPECT_EQ(pixels[0], 0); + EXPECT_EQ(pixels[1], 65); + EXPECT_EQ(pixels[2], 126); + EXPECT_EQ(pixels[3], 255); +} + +TEST(YuvBufferTest, Nv21ConvertRgbHalve) { + YuvBuffer source(64, 8, YuvBuffer::NV21); + Fill(&source, 52, 170, 90); + + RgbBuffer result_rgb(32, 4, false); + EXPECT_TRUE(source.Convert(true, &result_rgb)); + + RgbBuffer result_rgba(32, 4, true); + EXPECT_TRUE(source.Convert(true, &result_rgba)); + + uint8_t* pixels = result_rgba.buffer()->host; + ASSERT_TRUE(pixels); + EXPECT_EQ(pixels[0], 0); + EXPECT_EQ(pixels[1], 65); + EXPECT_EQ(pixels[2], 126); + EXPECT_EQ(pixels[3], 255); +} + +} // namespace +} // namespace frame_buffer +} // namespace mediapipe diff --git a/third_party/halide.BUILD b/third_party/halide.BUILD new file mode 100644 index 000000000..5578c5650 --- /dev/null +++ b/third_party/halide.BUILD @@ -0,0 +1,70 @@ +# Copyright 2023 The MediaPipe Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@halide//:halide.bzl", "halide_language_copts") + +licenses(["notice"]) + +package( + default_visibility = ["//visibility:public"], +) + +cc_library( + name = "language", + hdrs = ["include/Halide.h"], + copts = halide_language_copts(), + includes = ["include"], + deps = [ + ":runtime", + ], +) + +cc_library( + name = "runtime", + hdrs = glob([ + "include/HalideRuntime*.h", + "include/HalideBuffer*.h", + ]), + includes = ["include"], +) + +cc_library( + name = "lib_halide_static", + srcs = select({ + "@mediapipe//mediapipe:windows": [ + "lib/Release/Halide.lib", + "bin/Release/Halide.dll", + ], + "//conditions:default": [ + "lib/libHalide.a", + ], + }), + visibility = ["//visibility:private"], +) + +cc_library( + name = "gengen", + srcs = [ + "share/Halide/tools/GenGen.cpp", + ], + includes = [ + "include", + "share/Halide/tools", + ], + visibility = ["//visibility:public"], + deps = [ + ":language", + ":lib_halide_static", + ], +) diff --git a/third_party/halide/BUILD b/third_party/halide/BUILD new file mode 100644 index 000000000..82bab3ffd --- /dev/null +++ b/third_party/halide/BUILD @@ -0,0 +1 @@ +# This empty BUILD file is required to make Bazel treat this directory as a package. diff --git a/third_party/halide/BUILD.bazel b/third_party/halide/BUILD.bazel new file mode 100644 index 000000000..cda994204 --- /dev/null +++ b/third_party/halide/BUILD.bazel @@ -0,0 +1,40 @@ +# Copyright 2023 The MediaPipe Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@halide//:halide.bzl", "halide_library_runtimes") + +licenses(["notice"]) + +package( + default_visibility = ["//visibility:public"], +) + +halide_library_runtimes() + +# Aliases to platform-specific targets. +[ + alias( + name = target_name, + actual = select({ + "//conditions:default": "@linux_halide//:" + target_name, + "@mediapipe//mediapipe:macos": "@macos_halide//:" + target_name, + "@mediapipe//mediapipe:windows": "@windows_halide//:" + target_name, + }), + ) + for target_name in [ + "language", + "runtime", + "gengen", + ] +] diff --git a/third_party/halide/halide.bzl b/third_party/halide/halide.bzl new file mode 100644 index 000000000..68d31fe48 --- /dev/null +++ b/third_party/halide/halide.bzl @@ -0,0 +1,875 @@ +# Copyright 2023 The MediaPipe Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bazel build rules for Halide.""" + +load("@bazel_skylib//lib:collections.bzl", "collections") +load("@bazel_tools//tools/cpp:toolchain_utils.bzl", "use_cpp_toolchain") + +def halide_language_copts(): + _common_opts = [ + "-fPIC", + "-frtti", + "-Wno-conversion", + "-Wno-sign-compare", + ] + _posix_opts = [ + "$(STACK_FRAME_UNLIMITED)", + "-fno-exceptions", + "-funwind-tables", + "-fvisibility-inlines-hidden", + ] + _msvc_opts = [ + "-D_CRT_SECURE_NO_WARNINGS", + "/MD", + ] + return _common_opts + select({ + "//conditions:default": _posix_opts, + "@mediapipe//mediapipe:windows": _msvc_opts, + }) + +def halide_language_linkopts(): + _linux_opts = [ + "-ldl", + "-lpthread", + "-lz", + "-rdynamic", + ] + _osx_opts = [ + "-lz", + "-Wl,-stack_size", + "-Wl,1000000", + ] + _msvc_opts = [] + return select({ + "//conditions:default": _linux_opts, + "@mediapipe//mediapipe:macos": _osx_opts, + "@mediapipe//mediapipe:windows": _msvc_opts, + }) + +def halide_runtime_linkopts(): + """ Return the linkopts needed when linking against halide_library_runtime. + + Returns: + List to be used for linkopts. + """ + _posix_opts = [ + "-ldl", + "-lpthread", + ] + _android_opts = [ + "-llog", + ] + _msvc_opts = [] + + return select({ + "//conditions:default": _posix_opts, + "@mediapipe//mediapipe:android": _android_opts, + "@mediapipe//mediapipe:windows": _msvc_opts, + }) + +# Map of halide-target-base -> config_settings +_HALIDE_TARGET_CONFIG_SETTINGS_MAP = { + # Android + "arm-32-android": ["@mediapipe//mediapipe:android_arm"], + "arm-64-android": ["@mediapipe//mediapipe:android_arm64"], + "x86-32-android": ["@mediapipe//mediapipe:android_x86"], + "x86-64-android": ["@mediapipe//mediapipe:android_x86_64"], + # iOS + "arm-32-ios": ["@mediapipe//mediapipe:ios_armv7"], + "arm-64-ios": ["@mediapipe//mediapipe:ios_arm64", "@mediapipe//mediapipe:ios_arm64e"], + # OSX (or iOS simulator) + "x86-64-osx": [ + "@mediapipe//mediapipe:macos_x86_64", + "@mediapipe//mediapipe:ios_x86_64", + # TODO: these should map to "x86-32-osx", but that causes Kokoro to fail. + "@mediapipe//mediapipe:macos_i386", + "@mediapipe//mediapipe:ios_i386", + ], + "arm-64-osx": ["@mediapipe//mediapipe:macos_arm64"], + # Windows + "x86-64-windows": ["@mediapipe//mediapipe:windows"], + # Linux + "x86-64-linux": ["//conditions:default"], +} + +_HALIDE_TARGET_MAP_DEFAULT = { + "x86-64-linux": [ + "x86-64-linux-sse41-avx-avx2-fma", + "x86-64-linux-sse41", + "x86-64-linux", + ], + "x86-64-osx": [ + "x86-64-osx-sse41-avx-avx2-fma", + "x86-64-osx-sse41", + "x86-64-osx", + ], + "x86-64-windows": [ + "x86-64-windows-sse41-avx-avx2-fma", + "x86-64-windows-sse41", + "x86-64-windows", + ], +} + +def halide_library_default_target_map(): + return _HALIDE_TARGET_MAP_DEFAULT + +# Alphabetizes the features part of the target to make sure they always match no +# matter the concatenation order of the target string pieces. +def _canonicalize_target(halide_target): + if halide_target == "host": + return halide_target + if "," in halide_target: + fail("Multitarget may not be specified here") + tokens = halide_target.split("-") + if len(tokens) < 3: + fail("Illegal target: %s" % halide_target) + + # rejoin the tokens with the features sorted + return "-".join(tokens[0:3] + sorted(tokens[3:])) + +# Converts comma and dash separators to underscore and alphabetizes +# the features part of the target to make sure they always match no +# matter the concatenation order of the target string pieces. +def _halide_target_to_bazel_rule_name(multitarget): + subtargets = multitarget.split(",") + subtargets = [_canonicalize_target(st).replace("-", "_") for st in subtargets] + return "_".join(subtargets) + +# The second argument is True if there is a separate file generated +# for each subtarget of a multitarget output, False if not. The third +# argument is True if the output is a directory (vs. a single file). +# The fourth argument is a list of output group(s) that the files should +# be added to. + +_is_multi = True +_is_single = False +_is_file = False + +_output_extensions = { + "assembly": ("s", _is_multi, _is_file, []), + "bitcode": ("bc", _is_multi, _is_file, ["generated_bitcode"]), + "c_header": ("h", _is_single, _is_file, ["generated_headers"]), + "c_source": ("halide_generated.cpp", _is_multi, _is_file, []), + "compiler_log": ("halide_compiler_log", _is_single, _is_file, ["generated_object", "generated_compiler_log"]), + "cpp_stub": ("stub.h", _is_single, _is_file, []), + "featurization": ("featurization", _is_multi, _is_file, []), + "llvm_assembly": ("ll", _is_multi, _is_file, []), + "object": ("o", _is_single, _is_file, ["generated_object"]), + "python_extension": ("py.cpp", _is_single, _is_file, []), + "registration": ("registration.cpp", _is_single, _is_file, ["generated_registration"]), + "schedule": ("schedule.h", _is_single, _is_file, []), + "static_library": ("a", _is_single, _is_file, ["generated_object"]), + "stmt": ("stmt", _is_multi, _is_file, []), + "stmt_html": ("stmt.html", _is_multi, _is_file, []), +} + +def _add_output_file(f, fmt, output_files, output_dict, verbose_extra_outputs, verbose_output_paths): + if fmt in verbose_extra_outputs: + verbose_output_paths.append(f.path) + output_files.append(f) + if fmt in _output_extensions: + for group in _output_extensions[fmt][3]: + output_dict.setdefault(group, []).append(f) + +HalideFunctionNameInfo = provider(fields = ["function_name"]) +HalideGeneratorBinaryInfo = provider(fields = ["generator_binary"]) +HalideGeneratorNameInfo = provider(fields = ["generator_name_"]) +HalideGeneratorParamsInfo = provider(fields = ["generator_params"]) +HalideLibraryNameInfo = provider(fields = ["library_name"]) +HalideTargetFeaturesInfo = provider(fields = ["target_features"]) + +def _gengen_closure_impl(ctx): + return [ + HalideGeneratorBinaryInfo(generator_binary = ctx.attr.generator_binary), + HalideGeneratorNameInfo(generator_name_ = ctx.attr.generator_name_), + ] + +_gengen_closure = rule( + implementation = _gengen_closure_impl, + attrs = { + "generator_binary": attr.label( + executable = True, + allow_files = True, + mandatory = True, + cfg = "exec", + ), + # "generator_name" is apparently reserved by Bazel for attrs in rules + "generator_name_": attr.string(mandatory = True), + }, + provides = [HalideGeneratorBinaryInfo, HalideGeneratorNameInfo], +) + +def _halide_library_instance_impl(ctx): + generator_binary = ctx.attr.generator_closure[HalideGeneratorBinaryInfo].generator_binary if ctx.attr.generator_closure else "" + generator_name = ctx.attr.generator_closure[HalideGeneratorNameInfo].generator_name_ if ctx.attr.generator_closure else "" + return [ + HalideFunctionNameInfo(function_name = ctx.attr.function_name), + HalideGeneratorBinaryInfo(generator_binary = generator_binary), + HalideGeneratorNameInfo(generator_name_ = generator_name), + HalideGeneratorParamsInfo(generator_params = ctx.attr.generator_params), + HalideLibraryNameInfo(library_name = ctx.attr.library_name), + HalideTargetFeaturesInfo(target_features = ctx.attr.target_features), + ] + +_halide_library_instance = rule( + implementation = _halide_library_instance_impl, + attrs = { + "function_name": attr.string(), + "generator_closure": attr.label( + cfg = "exec", + providers = [HalideGeneratorBinaryInfo, HalideGeneratorNameInfo], + ), + "generator_params": attr.string_list(), + "library_name": attr.string(), + "target_features": attr.string_list(), + }, + provides = [ + HalideFunctionNameInfo, + HalideGeneratorBinaryInfo, + HalideGeneratorNameInfo, + HalideGeneratorParamsInfo, + HalideLibraryNameInfo, + HalideTargetFeaturesInfo, + ], +) + +def _gengen_impl(ctx): + if _has_dupes(ctx.attr.requested_outputs): + fail("Duplicate values in outputs: " + str(ctx.attr.requested_outputs)) + + function_name = ctx.attr.function_name[HalideFunctionNameInfo].function_name if ctx.attr.function_name else "" + generator_binary = ctx.attr.generator_binary[HalideGeneratorBinaryInfo].generator_binary if ctx.attr.generator_binary else "" + generator_name_ = ctx.attr.generator_name_[HalideGeneratorNameInfo].generator_name_ if ctx.attr.generator_name_ else "" + generator_params = ctx.attr.generator_params[HalideGeneratorParamsInfo].generator_params if ctx.attr.generator_params else [] + library_name = ctx.attr.library_name[HalideLibraryNameInfo].library_name if ctx.attr.library_name else "" + target_features = ctx.attr.target_features[HalideTargetFeaturesInfo].target_features if ctx.attr.target_features else [] + + for gp in generator_params: + if " " in gp: + fail("%s: Entries in generator_params must not contain spaces." % library_name) + + # Escape backslashes and double quotes. + generator_params = [gp.replace("\\", '\\\\"').replace('"', '\\"') for gp in generator_params] + + execution_requirements = {} + + # --- Calculate the output type(s) we're going to produce (and which ones should be verbose) + quiet_extra_outputs = [] + verbose_extra_outputs = [] + if ctx.attr.consider_halide_extra_outputs: + if "halide_extra_outputs" in ctx.var: + verbose_extra_outputs = ctx.var.get("halide_extra_outputs", "").split(",") + if "halide_extra_outputs_quiet" in ctx.var: + quiet_extra_outputs = ctx.var.get("halide_extra_outputs_quiet", "").split(",") + requested_outputs = sorted(collections.uniq(ctx.attr.requested_outputs + + verbose_extra_outputs + + quiet_extra_outputs)) + + # --- Assemble halide_target, adding extra features if necessary + base_target = ctx.attr.halide_base_target + if "," in base_target: + fail("halide_base_target should never be a multitarget") + if len(base_target.split("-")) != 3: + fail("halide_base_target should have exactly 3 components") + + target_features = target_features + ctx.var.get("halide_target_features", "").split(",") + + if "no_runtime" in target_features: + fail("Specifying 'no_runtime' in halide_target_features is not supported; " + + "please add 'add_halide_runtime_deps = False' to the halide_library() rule instead.") + + for san in ["asan", "msan", "tsan"]: + if san in target_features: + fail("halide_library doesn't support '%s' in halide_target_features; please build with --config=%s instead." % (san, san)) + + # Append the features common to everything. + target_features.append("c_plus_plus_name_mangling") + target_features.append("no_runtime") + + # Make it all neat and tidy. + target_features = sorted(collections.uniq(target_features)) + + # Get the multitarget list (if any) from halide_target_map + halide_targets = ctx.attr.halide_target_map.get(base_target, [base_target]) + + # Add the extra features to all of them + halide_targets = _add_features_to_all(halide_targets, target_features) + + leaf_name = ctx.attr.filename.split("/")[-1] + + output_files = [] + output_dict = {} + verbose_output_paths = [] + inputs = [] + + env = { + "HL_DEBUG_CODEGEN": str(ctx.var.get("halide_debug_codegen", 0)), + # --define halide_llvm_args=-time-passes is a typical usage + "HL_LLVM_ARGS": str(ctx.var.get("halide_llvm_args", "")), + } + + be_very_quiet = ctx.var.get("halide_experimental_quiet", False) # I'm hunting wabbit... + + # --- Calculate the final set of output files + for fmt in requested_outputs: + if fmt not in _output_extensions: + fail("Unknown Halide output '%s'; known outputs are %s" % + (fmt, sorted(_output_extensions.keys()))) + ext, is_multiple, is_dir, _ = _output_extensions[fmt] + + # Special-case Windows file extensions + if "windows" in halide_targets[-1]: + if ext == "o": + ext = "obj" + if ext == "a": + ext = "lib" + if is_multiple and len(halide_targets) > 1: + for h in halide_targets: + suffix = _canonicalize_target(h) + name = "%s-%s.%s" % (ctx.attr.filename, suffix, ext) + f = ctx.actions.declare_directory(name) if is_dir else ctx.actions.declare_file(name) + _add_output_file(f, fmt, output_files, output_dict, verbose_extra_outputs, verbose_output_paths) + else: + name = "%s.%s" % (ctx.attr.filename, ext) + f = ctx.actions.declare_directory(name) if is_dir else ctx.actions.declare_file(name) + _add_output_file(f, fmt, output_files, output_dict, verbose_extra_outputs, verbose_output_paths) + + # --- Progress message(s), including log info about any 'extra' files being output due to --define halide_extra_output + progress_message = "Executing generator %s with target (%s) args (%s)." % ( + generator_name_, + ",".join(halide_targets), + " ".join(generator_params), + ) + + for f in output_files: + if any([f.path.endswith(suf) for suf in [".h", ".a", ".o", ".lib", ".registration.cpp", ".bc", ".halide_compiler_log"]]): + continue + + # If an extra output was specified via --define halide_extra_outputs=foo on the command line, + # add to the progress message (so that it is ephemeral and doesn't clog stdout). + # + # (Trailing space is intentional since Starlark will append a period to the end, + # making copy-n-paste harder than it might otherwise be...) + if not be_very_quiet: + extra_msg = "Emitting extra Halide output: %s " % f.path + progress_message += "\n" + extra_msg + if f.path in verbose_output_paths: + # buildifier: disable=print + print(extra_msg) + + # --- Construct the arguments list for the Generator + arguments = ctx.actions.args() + arguments.add("-o", output_files[0].dirname) + if ctx.attr.generate_runtime: + arguments.add("-r", leaf_name) + if len(halide_targets) > 1: + fail("Only one halide_target allowed when using generate_runtime") + if function_name: + fail("halide_function_name not allowed when using generate_runtime") + else: + arguments.add("-g", generator_name_) + arguments.add("-n", leaf_name) + if function_name: + arguments.add("-f", function_name) + + if requested_outputs: + arguments.add_joined("-e", requested_outputs, join_with = ",") + + # Can't use add_joined(), as it will insert a space after target= + arguments.add("target=%s" % (",".join(halide_targets))) + if generator_params: + for p in generator_params: + for s in ["target"]: + if p.startswith("%s=" % s): + fail("You cannot specify %s in the generator_params parameter in bazel." % s) + arguments.add_all(generator_params) + + show_gen_arg = ctx.var.get("halide_show_generator_command", "") + + # If it's an exact match of a fully qualified path, show just that one. + # If it's * or "all", match everything. + if library_name and show_gen_arg in [library_name, "all", "*"] and not ctx.attr.generate_runtime: + # The 'Args' object can be printed, but can't be usefully converted to a string, or iterated, + # so we'll reproduce the logic here. We'll also take the opportunity to add or augment + # some args to be more useful to whoever runs it (eg, add `-v=1`, add some output files). + sg_args = ["-v", "1"] + sg_args += ["-o", "/tmp"] + sg_args += ["-g", generator_name_] + sg_args += ["-n", leaf_name] + if function_name: + sg_args += ["-f", function_name] + if requested_outputs: + # Ensure that several commonly-useful output are added + ro = sorted(collections.uniq(requested_outputs + ["stmt", "assembly", "llvm_assembly"])) + sg_args += ["-e", ",".join(ro)] + sg_args.append("target=%s" % (",".join(halide_targets))) + + if generator_params: + sg_args += generator_params + + # buildifier: disable=print + print( + "\n\nTo locally run the Generator for", + library_name, + "use the command:\n\n", + "bazel run -c opt", + generator_binary.label, + "--", + " ".join(sg_args), + "\n\n", + ) + + # Finally... run the Generator. + ctx.actions.run( + execution_requirements = execution_requirements, + arguments = [arguments], + env = env, + executable = generator_binary.files_to_run.executable, + mnemonic = "ExecuteHalideGenerator", + inputs = depset(direct = inputs), + outputs = output_files, + progress_message = progress_message, + exec_group = "generator", + ) + + return [ + DefaultInfo(files = depset(direct = output_files)), + OutputGroupInfo(**output_dict), + ] + +_gengen = rule( + implementation = _gengen_impl, + attrs = { + "consider_halide_extra_outputs": attr.bool(), + "filename": attr.string(), + "generate_runtime": attr.bool(default = False), + "generator_binary": attr.label( + cfg = "exec", + providers = [HalideGeneratorBinaryInfo], + ), + # "generator_name" is apparently reserved by Bazel for attrs in rules + "generator_name_": attr.label( + cfg = "exec", + providers = [HalideGeneratorNameInfo], + ), + "halide_base_target": attr.string(), + "function_name": attr.label( + cfg = "target", + providers = [ + HalideFunctionNameInfo, + ], + ), + "generator_params": attr.label( + cfg = "target", + providers = [ + HalideGeneratorParamsInfo, + ], + ), + "library_name": attr.label( + cfg = "target", + providers = [ + HalideLibraryNameInfo, + ], + ), + "target_features": attr.label( + cfg = "target", + providers = [ + HalideTargetFeaturesInfo, + ], + ), + "halide_target_map": attr.string_list_dict(), + "requested_outputs": attr.string_list(), + "_cc_toolchain": attr.label(default = "@bazel_tools//tools/cpp:current_cc_toolchain"), + }, + fragments = ["cpp"], + output_to_genfiles = True, + toolchains = use_cpp_toolchain(), + exec_groups = { + "generator": exec_group(), + }, +) + +def _add_target_features(target, features): + if "," in target: + fail("Cannot use multitarget here") + new_target = target.split("-") + for f in features: + if f and f not in new_target: + new_target.append(f) + return "-".join(new_target) + +def _add_features_to_all(halide_targets, features): + return [_canonicalize_target(_add_target_features(t, features)) for t in halide_targets] + +def _has_dupes(some_list): + clean = collections.uniq(some_list) + return sorted(some_list) != sorted(clean) + +# Target features which do not affect runtime compatibility. +_IRRELEVANT_FEATURES = collections.uniq([ + "arm_dot_prod", + "arm_fp16", + "c_plus_plus_name_mangling", + "check_unsafe_promises", + "embed_bitcode", + "enable_llvm_loop_opt", + "large_buffers", + "no_asserts", + "no_bounds_query", + "profile", + "strict_float", + "sve", + "sve2", + "trace_loads", + "trace_pipeline", + "trace_realizations", + "trace_stores", + "user_context", + "wasm_sat_float_to_int", + "wasm_signext", + "wasm_simd128", +]) + +def _discard_irrelevant_features(halide_target_features = []): + return sorted(collections.uniq([f for f in halide_target_features if f not in _IRRELEVANT_FEATURES])) + +def _halide_library_runtime_target_name(halide_target_features = []): + return "_".join(["halide_library_runtime"] + _discard_irrelevant_features(halide_target_features)) + +def _define_halide_library_runtime( + halide_target_features = [], + compatible_with = []): + target_name = _halide_library_runtime_target_name(halide_target_features) + + if not native.existing_rule("halide_library_runtime.generator"): + halide_generator( + name = "halide_library_runtime.generator", + srcs = [], + deps = [], + visibility = ["//visibility:private"], + ) + condition_deps = {} + for base_target, cfgs in _HALIDE_TARGET_CONFIG_SETTINGS_MAP.items(): + target_features = _discard_irrelevant_features(halide_target_features) + halide_target_name = _halide_target_to_bazel_rule_name(base_target) + gengen_name = "%s_%s" % (halide_target_name, target_name) + + _halide_library_instance( + name = "%s.library_instance" % gengen_name, + compatible_with = compatible_with, + function_name = "", + generator_closure = ":halide_library_runtime.generator_closure", + generator_params = [], + library_name = "", + target_features = target_features, + visibility = ["//visibility:private"], + ) + hl_instance = ":%s.library_instance" % gengen_name + + _gengen( + name = gengen_name, + compatible_with = compatible_with, + filename = "%s/%s" % (halide_target_name, target_name), + generate_runtime = True, + generator_binary = hl_instance, + generator_name_ = hl_instance, + halide_base_target = base_target, + requested_outputs = ["object"], + tags = ["manual"], + target_features = hl_instance, + visibility = ["@halide//:__subpackages__"], + ) + for cfg in cfgs: + condition_deps[cfg] = [":%s" % gengen_name] + + deps = [] + native.cc_library( + name = target_name, + compatible_with = compatible_with, + srcs = select(condition_deps), + linkopts = halide_runtime_linkopts(), + tags = ["manual"], + deps = deps, + visibility = ["//visibility:public"], + ) + + return target_name + +def _standard_library_runtime_features(): + _standard_features = [ + [], + ["cuda"], + ["metal"], + ["opencl"], + ["openglcompute"], + ["openglcompute", "egl"], + ] + return [f for f in _standard_features] + [f + ["debug"] for f in _standard_features] + +def _standard_library_runtime_names(): + return collections.uniq([_halide_library_runtime_target_name(f) for f in _standard_library_runtime_features()]) + +def halide_library_runtimes(compatible_with = []): + unused = [ + _define_halide_library_runtime(f, compatible_with = compatible_with) + for f in _standard_library_runtime_features() + ] + unused = unused # unused variable + +def halide_generator( + name, + srcs, + compatible_with = [], + copts = [], + deps = [], + generator_name = "", + includes = [], + tags = [], + testonly = False, + visibility = None): + if not name.endswith(".generator"): + fail("halide_generator rules must end in .generator") + + basename = name[:-10] # strip ".generator" suffix + if not generator_name: + generator_name = basename + + # Note: This target is public, but should not be needed by the vast + # majority of users. Unless you are writing a custom Bazel rule that + # involves Halide generation, you most probably won't need to depend on + # this rule. + native.cc_binary( + name = name, + copts = copts + halide_language_copts(), + linkopts = halide_language_linkopts(), + compatible_with = compatible_with, + srcs = srcs, + deps = [ + "@halide//:gengen", + "@halide//:language", + ] + deps, + tags = ["manual"] + tags, + testonly = testonly, + visibility = ["//visibility:public"], + ) + + _gengen_closure( + name = "%s_closure" % name, + generator_binary = name, + generator_name_ = generator_name, + compatible_with = compatible_with, + testonly = testonly, + visibility = ["//visibility:private"], + ) + +# This rule exists to allow us to select() on halide_target_features. +def _select_halide_library_runtime_impl(ctx): + f = ctx.attr.halide_target_features + + standard_runtimes = {t.label.name: t for t in ctx.attr._standard_runtimes} + + f = sorted(_discard_irrelevant_features(collections.uniq(f))) + runtime_name = _halide_library_runtime_target_name(f) + if runtime_name not in standard_runtimes: + fail(("There is no Halide runtime available for the feature set combination %s. " + + "Please use contact information from halide-lang.org to contact the Halide " + + "team to add the right combination.") % str(f)) + + return standard_runtimes[runtime_name][CcInfo] + +_select_halide_library_runtime = rule( + implementation = _select_halide_library_runtime_impl, + attrs = { + "halide_target_features": attr.string_list(), + "_standard_runtimes": attr.label_list( + default = ["@halide//:%s" % n for n in _standard_library_runtime_names()], + providers = [CcInfo], + ), + }, + provides = [CcInfo], +) + +def halide_library_from_generator( + name, + generator, + add_halide_runtime_deps = True, + compatible_with = [], + deps = [], + function_name = None, + generator_params = [], + halide_target_features = [], + halide_target_map = halide_library_default_target_map(), + includes = [], + namespace = None, + tags = [], + testonly = False, + visibility = None): + if not function_name: + function_name = name + + if namespace: + function_name = "%s::%s" % (namespace, function_name) + + generator_closure = "%s_closure" % generator + + _halide_library_instance( + name = "%s.library_instance" % name, + compatible_with = compatible_with, + function_name = function_name, + generator_closure = generator_closure, + generator_params = generator_params, + library_name = "//%s:%s" % (native.package_name(), name), + target_features = halide_target_features, + testonly = testonly, + visibility = ["//visibility:private"], + ) + hl_instance = ":%s.library_instance" % name + + condition_deps = {} + for base_target, cfgs in _HALIDE_TARGET_CONFIG_SETTINGS_MAP.items(): + base_target_name = _halide_target_to_bazel_rule_name(base_target) + gengen_name = "%s_%s" % (base_target_name, name) + _gengen( + name = gengen_name, + compatible_with = compatible_with, + consider_halide_extra_outputs = True, + filename = "%s/%s" % (base_target_name, name), + function_name = hl_instance, + generator_binary = generator_closure, + generator_name_ = generator_closure, + generator_params = hl_instance, + halide_base_target = base_target, + halide_target_map = halide_target_map, + library_name = hl_instance, + requested_outputs = ["static_library"], + tags = ["manual"] + tags, + target_features = hl_instance, + testonly = testonly, + ) + for cfg in cfgs: + condition_deps[cfg] = [":%s" % gengen_name] + + # Use a canonical target to build CC, regardless of config detected + cc_base_target = "x86-64-linux" + + for output, target_name in [ + ("c_header", "%s_h" % name), + ("c_source", "%s_cc" % name), + ]: + _gengen( + name = target_name, + compatible_with = compatible_with, + filename = name, + function_name = hl_instance, + generator_binary = generator_closure, + generator_name_ = generator_closure, + generator_params = hl_instance, + halide_base_target = cc_base_target, + library_name = hl_instance, + requested_outputs = [output], + tags = ["manual"] + tags, + target_features = hl_instance, + testonly = testonly, + ) + + _select_halide_library_runtime( + name = "%s.halide_library_runtime_deps" % name, + halide_target_features = halide_target_features, + compatible_with = compatible_with, + tags = tags, + visibility = ["//visibility:private"], + ) + + native.filegroup( + name = "%s_object" % name, + srcs = select(condition_deps), + output_group = "generated_object", + visibility = ["//visibility:private"], + compatible_with = compatible_with, + tags = tags, + testonly = testonly, + ) + + native.cc_library( + name = name, + srcs = ["%s_object" % name], + hdrs = [ + ":%s_h" % name, + ], + deps = deps + + ["@halide//:runtime"] + # for HalideRuntime.h, etc + ([":%s.halide_library_runtime_deps" % name] if add_halide_runtime_deps else []), # for the runtime implementation + defines = ["HALIDE_FUNCTION_ATTRS=HALIDE_MUST_USE_RESULT"], + compatible_with = compatible_with, + includes = includes, + tags = tags, + testonly = testonly, + visibility = visibility, + linkstatic = 1, + ) + + # Return the fully-qualified built target name. + return "//%s:%s" % (native.package_name(), name) + +def halide_library( + name, + srcs = [], + add_halide_runtime_deps = True, + copts = [], + compatible_with = [], + filter_deps = [], + function_name = None, + generator_params = [], + generator_deps = [], + generator_name = None, + halide_target_features = [], + halide_target_map = halide_library_default_target_map(), + includes = [], + namespace = None, + tags = [], + testonly = False, + visibility = None): + if not srcs and not generator_deps: + fail("halide_library needs at least one of srcs or generator_deps to provide a generator") + + halide_generator( + name = "%s.generator" % name, + srcs = srcs, + compatible_with = compatible_with, + generator_name = generator_name, + deps = generator_deps, + includes = includes, + copts = copts, + tags = tags, + testonly = testonly, + visibility = visibility, + ) + + return halide_library_from_generator( + name = name, + generator = ":%s.generator" % name, + add_halide_runtime_deps = add_halide_runtime_deps, + compatible_with = compatible_with, + deps = filter_deps, + function_name = function_name, + generator_params = generator_params, + halide_target_features = halide_target_features, + halide_target_map = halide_target_map, + includes = includes, + namespace = namespace, + tags = tags, + testonly = testonly, + visibility = visibility, + ) From 2c644214711489617f2099a8435c161f17433712 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 9 Mar 2023 09:47:04 -0800 Subject: [PATCH 035/136] Fix minor typos in MediaPipe synchronization description. PiperOrigin-RevId: 515362264 --- docs/framework_concepts/synchronization.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/framework_concepts/synchronization.md b/docs/framework_concepts/synchronization.md index e35e1032d..e12d077a7 100644 --- a/docs/framework_concepts/synchronization.md +++ b/docs/framework_concepts/synchronization.md @@ -113,14 +113,14 @@ Warning: On the other hand, it is not guaranteed that an input packet will always be available for all streams. To explain how it works, we need to introduce the definition of a settled -timestamp. We say that a timestamp in a stream is *settled* if it lower than the -timestamp bound. In other words, a timestamp is settled for a stream once the -state of the input at that timestamp is irrevocably known: either there is a +timestamp. We say that a timestamp in a stream is *settled* if it is lower than +the timestamp bound. In other words, a timestamp is settled for a stream once +the state of the input at that timestamp is irrevocably known: either there is a packet, or there is the certainty that a packet with that timestamp will not arrive. Note: For this reason, MediaPipe also allows a stream producer to explicitly -advance the timestamp bound farther that what the last packet implies, i.e. to +advance the timestamp bound farther than what the last packet implies, i.e. to provide a tighter bound. This can allow the downstream nodes to settle their inputs sooner. From 563b193bca3e9ba371e3ed6913c2f77bfa09f30c Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 9 Mar 2023 09:47:14 -0800 Subject: [PATCH 036/136] Bump Halide version from 14.0.0 to 15.0.0 and add MacOS Halide dependency PiperOrigin-RevId: 515362310 --- WORKSPACE | 28 ++++++++++++++++++---------- third_party/halide/BUILD.bazel | 5 ++++- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/WORKSPACE b/WORKSPACE index 2f2bc3d9d..8cb2b9d04 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -554,24 +554,32 @@ new_local_repository( http_archive( name = "linux_halide", - sha256 = "be3bdd067acb9ee0d37d0830821113cd69174bee46da466a836d8829fef7cf91", - strip_prefix = "Halide-14.0.0-x86-64-linux", - urls = ["https://github.com/halide/Halide/releases/download/v14.0.0/Halide-14.0.0-x86-64-linux-6b9ed2afd1d6d0badf04986602c943e287d44e46.tar.gz"], + sha256 = "f62b2914823d6e33d18693f5b74484f274523bf5402ce51988e24393d123b375", + strip_prefix = "Halide-15.0.0-x86-64-linux", + urls = ["https://github.com/halide/Halide/releases/download/v15.0.0/Halide-15.0.0-x86-64-linux-d7651f4b32f9dbd764f243134001f7554378d62d.tar.gz"], build_file = "@//third_party:halide.BUILD", ) http_archive( - name = "macos_halide", - sha256 = "569b1cda858d278d9dd68e072768d8347775e419f2ff39ff34d001ceb299e3c4", - strip_prefix = "Halide-14.0.0-x86-64-osx", - urls = ["https://github.com/halide/Halide/releases/download/v14.0.0/Halide-14.0.0-x86-64-osx-6b9ed2afd1d6d0badf04986602c943e287d44e46.tar.gz"], + name = "macos_x86_64_halide", + sha256 = "3d832aed942080ea89aa832462c68fbb906f3055c440b7b6d35093d7c52f6aab", + strip_prefix = "Halide-15.0.0-x86-64-osx", + urls = ["https://github.com/halide/Halide/releases/download/v15.0.0/Halide-15.0.0-x86-64-osx-d7651f4b32f9dbd764f243134001f7554378d62d.tar.gz"], + build_file = "@//third_party:halide.BUILD", +) + +http_archive( + name = "macos_arm_64_halide", + sha256 = "b1fad3c9810122b187303d7031d9e35fb43761f345d18cc4492c00ed5877f641", + strip_prefix = "Halide-15.0.0-arm-64-osx", + urls = ["https://github.com/halide/Halide/releases/download/v15.0.0/Halide-15.0.0-arm-64-osx-d7651f4b32f9dbd764f243134001f7554378d62d.tar.gz"], build_file = "@//third_party:halide.BUILD", ) http_archive( name = "windows_halide", - sha256 = "a7a0481af2691ec436d79c20ca441e9a701bfce409f4f763dab75a8f1d740179", - strip_prefix = "Halide-14.0.0-x86-64-windows", - urls = ["https://github.com/halide/Halide/releases/download/v14.0.0/Halide-14.0.0-x86-64-windows-6b9ed2afd1d6d0badf04986602c943e287d44e46.zip"], + sha256 = "5acf6fe161dd375856a2b43f4bb0a32815ba958b0585ee312c44e008aa7b0b64", + strip_prefix = "Halide-15.0.0-x86-64-windows", + urls = ["https://github.com/halide/Halide/releases/download/v15.0.0/Halide-15.0.0-x86-64-windows-d7651f4b32f9dbd764f243134001f7554378d62d.zip"], build_file = "@//third_party:halide.BUILD", ) diff --git a/third_party/halide/BUILD.bazel b/third_party/halide/BUILD.bazel index cda994204..659087da7 100644 --- a/third_party/halide/BUILD.bazel +++ b/third_party/halide/BUILD.bazel @@ -28,7 +28,10 @@ halide_library_runtimes() name = target_name, actual = select({ "//conditions:default": "@linux_halide//:" + target_name, - "@mediapipe//mediapipe:macos": "@macos_halide//:" + target_name, + # TODO: this shouldn't be necessary. + "@mediapipe//mediapipe:macos_i386": "@macos_x86_64_halide//:" + target_name, + "@mediapipe//mediapipe:macos_x86_64": "@macos_x86_64_halide//:" + target_name, + "@mediapipe//mediapipe:macos_arm64": "@macos_arm_64_halide//:" + target_name, "@mediapipe//mediapipe:windows": "@windows_halide//:" + target_name, }), ) From 5bd6a7082a07fb7b798a5c7b5b7c0dda3c1a56c5 Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Thu, 9 Mar 2023 10:27:43 -0800 Subject: [PATCH 037/136] Add requiredInputBufferSize as an input argument of createAudioRecord. PiperOrigin-RevId: 515374407 --- .../mediapipe/tasks/audio/core/BaseAudioTaskApi.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/audio/core/BaseAudioTaskApi.java b/mediapipe/tasks/java/com/google/mediapipe/tasks/audio/core/BaseAudioTaskApi.java index 7abde72d5..7a15e3d58 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/audio/core/BaseAudioTaskApi.java +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/audio/core/BaseAudioTaskApi.java @@ -164,12 +164,14 @@ public class BaseAudioTaskApi implements AutoCloseable { * * @param numChannels the number of audio channels. * @param sampleRate the audio sample rate. + * @param requiredInputBufferSize the required input buffer size in number of float elements. * @return an {@link android.media.AudioRecord} instance in {@link * android.media.AudioRecord#STATE_INITIALIZED} * @throws IllegalArgumentException if the model required channel count is unsupported * @throws IllegalStateException if AudioRecord instance failed to initialize */ - public static AudioRecord createAudioRecord(int numChannels, int sampleRate) { + public static AudioRecord createAudioRecord( + int numChannels, int sampleRate, int requiredInputBufferSize) { int channelConfig = 0; switch (numChannels) { case 1: @@ -190,6 +192,11 @@ public class BaseAudioTaskApi implements AutoCloseable { throw new IllegalStateException( String.format("AudioRecord.getMinBufferSize failed. Returned: %d", bufferSizeInBytes)); } + int bufferSizeMultiplier = 2; + int modelRequiredBufferSize = requiredInputBufferSize * Float.BYTES * bufferSizeMultiplier; + if (bufferSizeInBytes < modelRequiredBufferSize) { + bufferSizeInBytes = modelRequiredBufferSize; + } AudioRecord audioRecord = new AudioRecord( // including MIC, UNPROCESSED, and CAMCORDER. @@ -217,6 +224,6 @@ public class BaseAudioTaskApi implements AutoCloseable { */ public static AudioRecord createAudioRecord() { // TODO: Support creating AudioRecord based on the model specifications. - return createAudioRecord(1, 16000); + return createAudioRecord(1, 16000, 16000); } } From 5daf58009d0af54b449745a91255967808833cd4 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 9 Mar 2023 11:24:36 -0800 Subject: [PATCH 038/136] internal PiperOrigin-RevId: 515392932 --- mediapipe/framework/tool/BUILD | 1 - 1 file changed, 1 deletion(-) diff --git a/mediapipe/framework/tool/BUILD b/mediapipe/framework/tool/BUILD index c34194fbf..56ca0dc65 100644 --- a/mediapipe/framework/tool/BUILD +++ b/mediapipe/framework/tool/BUILD @@ -77,7 +77,6 @@ mediapipe_proto_library( name = "calculator_graph_template_proto", srcs = ["calculator_graph_template.proto"], def_options_lib = False, - def_py_proto = False, visibility = ["//visibility:public"], deps = [ "//mediapipe/framework:calculator_options_proto", From c2a69ab4763c1e73cf256dc4c7f69a33045ae985 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 9 Mar 2023 12:18:16 -0800 Subject: [PATCH 039/136] Update ImageFrameToGpuBufferCalculator to use api2 and GpuBuffer conversions PiperOrigin-RevId: 515407159 --- mediapipe/gpu/BUILD | 2 - .../image_frame_to_gpu_buffer_calculator.cc | 62 +++++++++++-------- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/mediapipe/gpu/BUILD b/mediapipe/gpu/BUILD index 83948226a..02fb2ff20 100644 --- a/mediapipe/gpu/BUILD +++ b/mediapipe/gpu/BUILD @@ -918,8 +918,6 @@ cc_library( visibility = ["//visibility:public"], deps = [ ":gl_calculator_helper", - ":gpu_buffer_storage_image_frame", - "//mediapipe/framework/api2:node", "//mediapipe/framework:calculator_framework", "//mediapipe/framework/formats:image_frame", "//mediapipe/framework/port:status", diff --git a/mediapipe/gpu/image_frame_to_gpu_buffer_calculator.cc b/mediapipe/gpu/image_frame_to_gpu_buffer_calculator.cc index c67fb0c62..2a8331db8 100644 --- a/mediapipe/gpu/image_frame_to_gpu_buffer_calculator.cc +++ b/mediapipe/gpu/image_frame_to_gpu_buffer_calculator.cc @@ -12,63 +12,73 @@ // See the License for the specific language governing permissions and // limitations under the License. -#include "mediapipe/framework/api2/node.h" #include "mediapipe/framework/calculator_framework.h" #include "mediapipe/framework/formats/image_frame.h" #include "mediapipe/framework/port/status.h" #include "mediapipe/gpu/gl_calculator_helper.h" +#ifdef __APPLE__ +#include "mediapipe/objc/util.h" +#endif + namespace mediapipe { -namespace api2 { -class ImageFrameToGpuBufferCalculator - : public RegisteredNode { +// Convert ImageFrame to GpuBuffer. +class ImageFrameToGpuBufferCalculator : public CalculatorBase { public: - static constexpr Input kIn{""}; - static constexpr Output kOut{""}; + ImageFrameToGpuBufferCalculator() {} - MEDIAPIPE_NODE_INTERFACE(ImageFrameToGpuBufferCalculator, kIn, kOut); - - static absl::Status UpdateContract(CalculatorContract* cc); + static absl::Status GetContract(CalculatorContract* cc); absl::Status Open(CalculatorContext* cc) override; absl::Status Process(CalculatorContext* cc) override; private: +#if !MEDIAPIPE_GPU_BUFFER_USE_CV_PIXEL_BUFFER GlCalculatorHelper helper_; +#endif // !MEDIAPIPE_GPU_BUFFER_USE_CV_PIXEL_BUFFER }; +REGISTER_CALCULATOR(ImageFrameToGpuBufferCalculator); // static -absl::Status ImageFrameToGpuBufferCalculator::UpdateContract( +absl::Status ImageFrameToGpuBufferCalculator::GetContract( CalculatorContract* cc) { + cc->Inputs().Index(0).Set(); + cc->Outputs().Index(0).Set(); // Note: we call this method even on platforms where we don't use the helper, // to ensure the calculator's contract is the same. In particular, the helper // enables support for the legacy side packet, which several graphs still use. - return GlCalculatorHelper::UpdateContract(cc); + MP_RETURN_IF_ERROR(GlCalculatorHelper::UpdateContract(cc)); + return absl::OkStatus(); } absl::Status ImageFrameToGpuBufferCalculator::Open(CalculatorContext* cc) { + // Inform the framework that we always output at the same timestamp + // as we receive a packet at. + cc->SetOffset(TimestampDiff(0)); +#if !MEDIAPIPE_GPU_BUFFER_USE_CV_PIXEL_BUFFER MP_RETURN_IF_ERROR(helper_.Open(cc)); +#endif // !MEDIAPIPE_GPU_BUFFER_USE_CV_PIXEL_BUFFER return absl::OkStatus(); } absl::Status ImageFrameToGpuBufferCalculator::Process(CalculatorContext* cc) { - auto image_frame = std::const_pointer_cast( - mediapipe::SharedPtrWithPacket(kIn(cc).packet())); - auto gpu_buffer = api2::MakePacket( - std::make_shared( - std::move(image_frame))) - .At(cc->InputTimestamp()); - // This calculator's behavior has been to do the texture upload eagerly, and - // some graphs may rely on running this on a separate GL context to avoid - // blocking another context with the read operation. So let's request GPU - // access here to ensure that the behavior stays the same. - // TODO: have a better way to do this, or defer until later. - helper_.RunInGlContext( - [&gpu_buffer] { auto view = gpu_buffer->GetReadView(0); }); - kOut(cc).Send(std::move(gpu_buffer)); +#if MEDIAPIPE_GPU_BUFFER_USE_CV_PIXEL_BUFFER + CFHolder buffer; + MP_RETURN_IF_ERROR(CreateCVPixelBufferForImageFramePacket( + cc->Inputs().Index(0).Value(), &buffer)); + cc->Outputs().Index(0).Add(new GpuBuffer(buffer), cc->InputTimestamp()); +#else + const auto& input = cc->Inputs().Index(0).Get(); + helper_.RunInGlContext([this, &input, &cc]() { + auto src = helper_.CreateSourceTexture(input); + auto output = src.GetFrame(); + glFlush(); + cc->Outputs().Index(0).Add(output.release(), cc->InputTimestamp()); + src.Release(); + }); +#endif // MEDIAPIPE_GPU_BUFFER_USE_CV_PIXEL_BUFFER return absl::OkStatus(); } -} // namespace api2 } // namespace mediapipe From 517e997179ec3835c474bc6bf1e549036a64de98 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Thu, 9 Mar 2023 15:59:22 -0800 Subject: [PATCH 040/136] Solve Linking error for Hello World iOS example PiperOrigin-RevId: 515466634 --- mediapipe/examples/ios/helloworld/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/mediapipe/examples/ios/helloworld/BUILD b/mediapipe/examples/ios/helloworld/BUILD index aed0c35a5..6bfcfaaef 100644 --- a/mediapipe/examples/ios/helloworld/BUILD +++ b/mediapipe/examples/ios/helloworld/BUILD @@ -56,5 +56,6 @@ objc_library( deps = [ "//mediapipe/examples/ios/common:CommonMediaPipeAppLibrary", "//mediapipe/graphs/edge_detection:mobile_calculators", + "//third_party/apple_frameworks:Metal", ], ) From 2d8f937913fb4451191204ebc9fda6abd03cd37d Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 9 Mar 2023 15:59:45 -0800 Subject: [PATCH 041/136] Improve docstring of image classifier model spec. PiperOrigin-RevId: 515466722 --- .../model_maker/python/vision/image_classifier/model_spec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/model_maker/python/vision/image_classifier/model_spec.py b/mediapipe/model_maker/python/vision/image_classifier/model_spec.py index ef44f86e6..d46cafe6b 100644 --- a/mediapipe/model_maker/python/vision/image_classifier/model_spec.py +++ b/mediapipe/model_maker/python/vision/image_classifier/model_spec.py @@ -28,7 +28,7 @@ class ModelSpec(object): uri: str, input_image_shape: Optional[List[int]] = None, name: str = ''): - """Initializes a new instance of the `ImageModelSpec` class. + """Initializes a new instance of the image classifier `ModelSpec` class. Args: uri: str, URI to the pretrained model. From ef4a8cde428a0c25fea37e8466d27703e43e0a50 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Thu, 9 Mar 2023 16:26:34 -0800 Subject: [PATCH 042/136] Solve iOS build error for gpu_buffer.cc PiperOrigin-RevId: 515473643 --- mediapipe/gpu/BUILD | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/mediapipe/gpu/BUILD b/mediapipe/gpu/BUILD index 02fb2ff20..bcdc9046e 100644 --- a/mediapipe/gpu/BUILD +++ b/mediapipe/gpu/BUILD @@ -272,14 +272,6 @@ selects.config_setting_group( ], ) -selects.config_setting_group( - name = "platform_ios_without_gpu", - match_all = [ - ":disable_gpu", - "//mediapipe:ios", - ], -) - selects.config_setting_group( name = "platform_macos_with_gpu", match_all = [ @@ -310,7 +302,6 @@ cc_library( ":platform_ios_with_gpu": [ ":gl_texture_view", ":gpu_buffer_storage_cv_pixel_buffer", - "//mediapipe/objc:util", "//mediapipe/objc:CFHolder", ], ":platform_macos_with_gpu": [ @@ -318,10 +309,12 @@ cc_library( ":gl_texture_view", ":gl_texture_buffer", ], - ":platform_ios_without_gpu": [ + ":disable_gpu": [], + }) + select({ + "//conditions:default": [], + "//mediapipe:ios": [ "//mediapipe/objc:util", ], - ":disable_gpu": [], }), ) From 05b505c8e2b1f74e3e05d98c73fcc50dd7fd9c62 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 9 Mar 2023 18:55:28 -0800 Subject: [PATCH 043/136] Introduce api to disable service default initialization. PiperOrigin-RevId: 515501608 --- mediapipe/framework/calculator_graph.cc | 8 ++++++- mediapipe/framework/calculator_graph.h | 31 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/mediapipe/framework/calculator_graph.cc b/mediapipe/framework/calculator_graph.cc index d803d7141..b49930b7a 100644 --- a/mediapipe/framework/calculator_graph.cc +++ b/mediapipe/framework/calculator_graph.cc @@ -631,7 +631,13 @@ absl::Status CalculatorGraph::PrepareServices() { for (const auto& [key, request] : node->Contract().ServiceRequests()) { auto packet = service_manager_.GetServicePacket(request.Service()); if (!packet.IsEmpty()) continue; - auto packet_or = request.Service().CreateDefaultObject(); + absl::StatusOr packet_or; + if (allow_service_default_initialization_) { + packet_or = request.Service().CreateDefaultObject(); + } else { + packet_or = absl::FailedPreconditionError( + "Service default initialization is disallowed."); + } if (packet_or.ok()) { MP_RETURN_IF_ERROR(service_manager_.SetServicePacket( request.Service(), std::move(packet_or).value())); diff --git a/mediapipe/framework/calculator_graph.h b/mediapipe/framework/calculator_graph.h index 93dbfe8dc..354694e39 100644 --- a/mediapipe/framework/calculator_graph.h +++ b/mediapipe/framework/calculator_graph.h @@ -405,6 +405,34 @@ class CalculatorGraph { return service_manager_.GetServiceObject(service); } + // Disallows/disables default initialization of MediaPipe graph services. + // + // IMPORTANT: MediaPipe graph serices, essentially a graph-level singletons, + // are designed in the way, so they may provide default initialization. For + // example, this allows to run OpenGL processing wihtin the graph without + // provinging a praticular OpenGL context as it can be provided by + // default-initializable `kGpuService`. (One caveat here, you may still need + // to initialize it manually to share graph context with external context.) + // + // Even if calculators require some service optionally + // (`calculator_contract->UseService(kSomeService).Optional()`), it will be + // still initialized if it allows default initialization. + // + // So far, in rare cases, this may be unwanted and strict control of what + // services are allowed in the graph can be achieved by calling this method, + // following `SetServiceObject` call for services which are allowed in the + // graph. + // + // Recommendation: do not use unless you have to (for example, default + // initialization has side effects) + // + // NOTE: must be called before `StartRun`/`Run`, where services are checked + // and can be default-initialized. + absl::Status DisallowServiceDefaultInitialization() { + allow_service_default_initialization_ = false; + return absl::OkStatus(); + } + // Sets a service object, essentially a graph-level singleton, which can be // accessed by calculators and subgraphs without requiring an explicit // connection. @@ -644,6 +672,9 @@ class CalculatorGraph { // Object to manage graph services. GraphServiceManager service_manager_; + // Indicates whether service default initialization is allowed. + bool allow_service_default_initialization_ = true; + // Vector of errors encountered while running graph. Always use RecordError() // to add an error to this vector. std::vector errors_ ABSL_GUARDED_BY(error_mutex_); From 296c34333288b721fb74c63bbdbd7c0a1e253ff5 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Fri, 10 Mar 2023 10:56:33 -0800 Subject: [PATCH 044/136] Revise the Halide Bazel build rules to be cleaner and more correct. The initial version we landed happened to work (at least in most cases) but had known workarounds in it. AFAICT, the issue seemed to be that the MediaPipe `config_setting()` values we were using didn't always resolve to the right thing when detecting the host cpu (vs the compile-target cpu), which is critical for Halide. I fixed this by avoiding the use of the MediaPipe config_settings entirely, using instead the public Bazel settings hosted at https://github.com/bazelbuild/platforms. This revision builds and runs //mediapipe/util/frame_buffer:all correctly on my mac x86-64 laptop; I haven't yet attempted to build or test on any other platform, so there may well still be glitches, but I think this is more fundamentally sound than what we had before. PiperOrigin-RevId: 515682507 --- third_party/halide.BUILD | 2 +- third_party/halide/BUILD.bazel | 18 ++++++++------- third_party/halide/halide.bzl | 41 +++++++++++++++++++++------------- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/third_party/halide.BUILD b/third_party/halide.BUILD index 5578c5650..02e701585 100644 --- a/third_party/halide.BUILD +++ b/third_party/halide.BUILD @@ -42,7 +42,7 @@ cc_library( cc_library( name = "lib_halide_static", srcs = select({ - "@mediapipe//mediapipe:windows": [ + "@halide//:halide_config_windows_x86_64": [ "lib/Release/Halide.lib", "bin/Release/Halide.dll", ], diff --git a/third_party/halide/BUILD.bazel b/third_party/halide/BUILD.bazel index 659087da7..8b69a2503 100644 --- a/third_party/halide/BUILD.bazel +++ b/third_party/halide/BUILD.bazel @@ -26,14 +26,16 @@ halide_library_runtimes() [ alias( name = target_name, - actual = select({ - "//conditions:default": "@linux_halide//:" + target_name, - # TODO: this shouldn't be necessary. - "@mediapipe//mediapipe:macos_i386": "@macos_x86_64_halide//:" + target_name, - "@mediapipe//mediapipe:macos_x86_64": "@macos_x86_64_halide//:" + target_name, - "@mediapipe//mediapipe:macos_arm64": "@macos_arm_64_halide//:" + target_name, - "@mediapipe//mediapipe:windows": "@windows_halide//:" + target_name, - }), + actual = select( + { + ":halide_config_linux_x86_64": "@linux_halide//:%s" % target_name, + ":halide_config_macos_x86_64": "@macos_x86_64_halide//:%s" % target_name, + ":halide_config_macos_arm64": "@macos_arm_64_halide//:%s" % target_name, + ":halide_config_windows_x86_64": "@windows_halide//:%s" % target_name, + # deliberately no //condition:default clause here + }, + no_match_error = "Compiling Halide code requires that the build host is one of Linux x86-64, Windows x86-64, macOS x86-64, or macOS arm64.", + ), ) for target_name in [ "language", diff --git a/third_party/halide/halide.bzl b/third_party/halide/halide.bzl index 68d31fe48..8d0d48e32 100644 --- a/third_party/halide/halide.bzl +++ b/third_party/halide/halide.bzl @@ -82,26 +82,22 @@ def halide_runtime_linkopts(): # Map of halide-target-base -> config_settings _HALIDE_TARGET_CONFIG_SETTINGS_MAP = { # Android - "arm-32-android": ["@mediapipe//mediapipe:android_arm"], - "arm-64-android": ["@mediapipe//mediapipe:android_arm64"], - "x86-32-android": ["@mediapipe//mediapipe:android_x86"], - "x86-64-android": ["@mediapipe//mediapipe:android_x86_64"], + "arm-32-android": ["@halide//:halide_config_android_arm"], + "arm-64-android": ["@halide//:halide_config_android_arm64"], + "x86-32-android": ["@halide//:halide_config_android_i386"], + "x86-64-android": ["@halide//:halide_config_android_x86_64"], # iOS - "arm-32-ios": ["@mediapipe//mediapipe:ios_armv7"], - "arm-64-ios": ["@mediapipe//mediapipe:ios_arm64", "@mediapipe//mediapipe:ios_arm64e"], + "arm-32-ios": ["@halide//:halide_config_ios_arm"], + "arm-64-ios": ["@halide//:halide_config_ios_arm64"], # OSX (or iOS simulator) - "x86-64-osx": [ - "@mediapipe//mediapipe:macos_x86_64", - "@mediapipe//mediapipe:ios_x86_64", - # TODO: these should map to "x86-32-osx", but that causes Kokoro to fail. - "@mediapipe//mediapipe:macos_i386", - "@mediapipe//mediapipe:ios_i386", - ], - "arm-64-osx": ["@mediapipe//mediapipe:macos_arm64"], + "x86-32-osx": ["@halide//:halide_config_macos_i386", "@halide//:halide_config_ios_i386"], + "x86-64-osx": ["@halide//:halide_config_macos_x86_64", "@halide//:halide_config_ios_x86_64"], + "arm-64-osx": ["@halide//:halide_config_macos_arm64"], # Windows - "x86-64-windows": ["@mediapipe//mediapipe:windows"], + "x86-64-windows": ["@halide//:halide_config_windows_x86_64"], # Linux - "x86-64-linux": ["//conditions:default"], + "x86-64-linux": ["@halide//:halide_config_linux_x86_64"], + # deliberately nothing here using //conditions:default } _HALIDE_TARGET_MAP_DEFAULT = { @@ -622,6 +618,19 @@ def _standard_library_runtime_names(): return collections.uniq([_halide_library_runtime_target_name(f) for f in _standard_library_runtime_features()]) def halide_library_runtimes(compatible_with = []): + # Note that we don't use all of these combinations + # (and some are invalid), but that's ok. + for cpu in ["arm", "arm64", "i386", "x86_64"]: + for os in ["android", "linux", "windows", "ios", "macos"]: + native.config_setting( + name = "halide_config_%s_%s" % (os, cpu), + constraint_values = [ + "@platforms//os:%s" % os, + "@platforms//cpu:%s" % cpu, + ], + visibility = ["//visibility:public"], + ) + unused = [ _define_halide_library_runtime(f, compatible_with = compatible_with) for f in _standard_library_runtime_features() From c3a32d76bede05a63e2b9db45f823b6f03b4c61f Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Fri, 10 Mar 2023 11:42:54 -0800 Subject: [PATCH 045/136] Update face geometry proto java package name. PiperOrigin-RevId: 515696170 --- mediapipe/tasks/cc/vision/face_geometry/proto/environment.proto | 2 +- .../tasks/cc/vision/face_geometry/proto/face_geometry.proto | 2 +- .../vision/face_geometry/proto/geometry_pipeline_metadata.proto | 2 +- mediapipe/tasks/cc/vision/face_geometry/proto/mesh_3d.proto | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mediapipe/tasks/cc/vision/face_geometry/proto/environment.proto b/mediapipe/tasks/cc/vision/face_geometry/proto/environment.proto index e60f3c1e1..dd771dfbb 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/proto/environment.proto +++ b/mediapipe/tasks/cc/vision/face_geometry/proto/environment.proto @@ -16,7 +16,7 @@ syntax = "proto2"; package mediapipe.tasks.vision.face_geometry.proto; -option java_package = "mediapipe.tasks.vision.facegeometry.proto"; +option java_package = "com.google.mediapipe.tasks.vision.facegeometry.proto"; option java_outer_classname = "EnvironmentProto"; // Defines the (0, 0) origin point location of the environment. diff --git a/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.proto b/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.proto index 1934828c3..45a02bbcf 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.proto +++ b/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.proto @@ -19,7 +19,7 @@ package mediapipe.tasks.vision.face_geometry.proto; import "mediapipe/framework/formats/matrix_data.proto"; import "mediapipe/tasks/cc/vision/face_geometry/proto/mesh_3d.proto"; -option java_package = "mediapipe.tasks.vision.facegeometry.proto"; +option java_package = "com.google.mediapipe.tasks.vision.facegeometry.proto"; option java_outer_classname = "FaceGeometryProto"; // Defines the face geometry pipeline estimation result format. diff --git a/mediapipe/tasks/cc/vision/face_geometry/proto/geometry_pipeline_metadata.proto b/mediapipe/tasks/cc/vision/face_geometry/proto/geometry_pipeline_metadata.proto index 54fcaf23c..53d1a1392 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/proto/geometry_pipeline_metadata.proto +++ b/mediapipe/tasks/cc/vision/face_geometry/proto/geometry_pipeline_metadata.proto @@ -18,7 +18,7 @@ package mediapipe.tasks.vision.face_geometry.proto; import "mediapipe/tasks/cc/vision/face_geometry/proto/mesh_3d.proto"; -option java_package = "mediapipe.tasks.vision.facegeometry.proto"; +option java_package = "com.google.mediapipe.tasks.vision.facegeometry.proto"; option java_outer_classname = "GeometryPipelineMetadataProto"; enum InputSource { diff --git a/mediapipe/tasks/cc/vision/face_geometry/proto/mesh_3d.proto b/mediapipe/tasks/cc/vision/face_geometry/proto/mesh_3d.proto index 45913cf02..1131ae7ed 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/proto/mesh_3d.proto +++ b/mediapipe/tasks/cc/vision/face_geometry/proto/mesh_3d.proto @@ -16,7 +16,7 @@ syntax = "proto2"; package mediapipe.tasks.vision.face_geometry.proto; -option java_package = "mediapipe.tasks.vision.facegeometry.proto"; +option java_package = "com.google.mediapipe.tasks.vision.facegeometry.proto"; option java_outer_classname = "Mesh3dProto"; message Mesh3d { From c9bd4f5957ca100d320c8346fd430ba39b83a023 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Fri, 10 Mar 2023 12:18:45 -0800 Subject: [PATCH 046/136] Internal change PiperOrigin-RevId: 515706419 --- .../custom_ops/utils/hash/BUILD | 38 ++++++++ .../custom_ops/utils/hash/murmur.cc | 95 +++++++++++++++++++ .../custom_ops/utils/hash/murmur.h | 43 +++++++++ .../custom_ops/utils/hash/murmur_test.cc | 66 +++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/BUILD create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.cc create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.h create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur_test.cc diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/BUILD b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/BUILD new file mode 100644 index 000000000..86b659245 --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/BUILD @@ -0,0 +1,38 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package(default_visibility = ["//mediapipe/tasks:internal"]) + +licenses(["notice"]) + +cc_library( + name = "murmur", + srcs = ["murmur.cc"], + hdrs = ["murmur.h"], + deps = [ + "//mediapipe/framework/port:integral_types", + "@com_google_absl//absl/base:core_headers", + "@com_google_absl//absl/base:endian", + ], +) + +cc_test( + name = "murmur_test", + srcs = ["murmur_test.cc"], + deps = [ + ":murmur", + "//mediapipe/framework/port:gtest_main", + "//mediapipe/framework/port:integral_types", + ], +) diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.cc b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.cc new file mode 100644 index 000000000..75dd161bf --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.cc @@ -0,0 +1,95 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ +// Forked from a library written by Austin Appelby and Jyrki Alakuijala. +// Original copyright message below. +// Copyright 2009 Google Inc. All Rights Reserved. +// Author: aappleby@google.com (Austin Appleby) +// jyrki@google.com (Jyrki Alakuijala) + +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.h" + +#include + +#include "absl/base/internal/endian.h" +#include "absl/base/optimization.h" +#include "mediapipe/framework/port/integral_types.h" + +namespace mediapipe::tasks::text::language_detector::custom_ops::hash { + +namespace { + +using ::absl::little_endian::Load64; + +// Murmur 2.0 multiplication constant. +static const uint64_t kMul = 0xc6a4a7935bd1e995ULL; + +// We need to mix some of the bits that get propagated and mixed into the +// high bits by multiplication back into the low bits. 17 last bits get +// a more efficiently mixed with this. +inline uint64_t ShiftMix(uint64_t val) { return val ^ (val >> 47); } + +// Accumulate 8 bytes into 64-bit Murmur hash +inline uint64_t MurmurStep(uint64_t hash, uint64_t data) { + hash ^= ShiftMix(data * kMul) * kMul; + hash *= kMul; + return hash; +} + +// Build a uint64 from 1-8 bytes. +// 8 * len least significant bits are loaded from the memory with +// LittleEndian order. The 64 - 8 * len most significant bits are +// set all to 0. +// In latex-friendly words, this function returns: +// $\sum_{i=0}^{len-1} p[i] 256^{i}$, where p[i] is unsigned. +// +// This function is equivalent to: +// uint64 val = 0; +// memcpy(&val, p, len); +// return ToHost64(val); +// +// The caller needs to guarantee that 0 <= len <= 8. +uint64_t Load64VariableLength(const void* const p, int len) { + ABSL_ASSUME(len >= 0 && len <= 8); + uint64_t val = 0; + const uint8_t* const src = static_cast(p); + for (int i = 0; i < len; ++i) { + val |= static_cast(src[i]) << (8 * i); + } + return val; +} + +} // namespace + +unsigned long long MurmurHash64WithSeed(const char* buf, // NOLINT + const size_t len, const uint64_t seed) { + // Let's remove the bytes not divisible by the sizeof(uint64). + // This allows the inner loop to process the data as 64 bit integers. + const size_t len_aligned = len & ~0x7; + const char* const end = buf + len_aligned; + uint64_t hash = seed ^ (len * kMul); + for (const char* p = buf; p != end; p += 8) { + hash = MurmurStep(hash, Load64(p)); + } + if ((len & 0x7) != 0) { + const uint64_t data = Load64VariableLength(end, len & 0x7); + hash ^= data; + hash *= kMul; + } + hash = ShiftMix(hash) * kMul; + hash = ShiftMix(hash); + return hash; +} + +} // namespace mediapipe::tasks::text::language_detector::custom_ops::hash diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.h b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.h new file mode 100644 index 000000000..abcb41a6b --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.h @@ -0,0 +1,43 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ +// Forked from a library written by Austin Appelby and Jyrki Alakuijala. +// Original copyright message below. +// Copyright 2009 Google Inc. All Rights Reserved. +// Author: aappleby@google.com (Austin Appelby) +// jyrki@google.com (Jyrki Alakuijala) +// +// MurmurHash is a fast multiplication and shifting based algorithm, +// based on Austin Appleby's MurmurHash 2.0 algorithm. + +#ifndef UTIL_HASH_MURMUR_H_ +#define UTIL_HASH_MURMUR_H_ + +#include +#include // for size_t. + +#include + +#include "mediapipe/framework/port/integral_types.h" + +namespace mediapipe::tasks::text::language_detector::custom_ops::hash { + +// Hash function for a byte array. Has a seed which allows this hash function to +// be used in algorithms that need a family of parameterized hash functions. +// e.g. Minhash. +unsigned long long MurmurHash64WithSeed(const char* buf, size_t len, // NOLINT + uint64_t seed); +} // namespace mediapipe::tasks::text::language_detector::custom_ops::hash + +#endif // UTIL_HASH_MURMUR_H_ diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur_test.cc b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur_test.cc new file mode 100644 index 000000000..6658965bf --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur_test.cc @@ -0,0 +1,66 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ +// Forked from a test library written by Jyrki Alakuijala. +// Original copyright message below. +// Copyright 2009 Google Inc. All Rights Reserved. +// Author: jyrki@google.com (Jyrki Alakuijala) +// +// Tests for the fast hashing algorithm based on Austin Appleby's +// MurmurHash 2.0 algorithm. See http://murmurhash.googlepages.com/ + +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.h" + +#include + +#include +#include + +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/framework/port/integral_types.h" + +namespace mediapipe::tasks::text::language_detector::custom_ops::hash { + +TEST(Murmur, EmptyData64) { + EXPECT_EQ(uint64_t{0}, MurmurHash64WithSeed(nullptr, uint64_t{0}, 0)); +} + +TEST(Murmur, VaryWithDifferentSeeds) { + // While in theory different seeds could return the same + // hash for the same data this is unlikely. + char data1 = 'x'; + EXPECT_NE(MurmurHash64WithSeed(&data1, 1, 100), + MurmurHash64WithSeed(&data1, 1, 101)); +} + +// Hashes don't change. +TEST(Murmur, Idempotence) { + const char data[] = "deadbeef"; + const size_t dlen = strlen(data); + + for (int i = 0; i < 10; i++) { + EXPECT_EQ(MurmurHash64WithSeed(data, dlen, i), + MurmurHash64WithSeed(data, dlen, i)); + } + + const char next_data[] = "deadbeef000---"; + const size_t next_dlen = strlen(next_data); + + for (int i = 0; i < 10; i++) { + EXPECT_EQ(MurmurHash64WithSeed(next_data, next_dlen, i), + MurmurHash64WithSeed(next_data, next_dlen, i)); + } +} +} // namespace mediapipe::tasks::text::language_detector::custom_ops::hash From db779ba78f1f436ceac8503575a93ee00ec230b9 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Fri, 10 Mar 2023 12:24:51 -0800 Subject: [PATCH 047/136] Use drishti_proto_library for libraries in mediapipe/gpu PiperOrigin-RevId: 515708036 --- mediapipe/gpu/BUILD | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/mediapipe/gpu/BUILD b/mediapipe/gpu/BUILD index bcdc9046e..8e2b1c476 100644 --- a/mediapipe/gpu/BUILD +++ b/mediapipe/gpu/BUILD @@ -932,7 +932,7 @@ mediapipe_proto_library( ], ) -proto_library( +mediapipe_proto_library( name = "gl_scaler_calculator_proto", srcs = ["gl_scaler_calculator.proto"], visibility = ["//visibility:public"], @@ -942,17 +942,6 @@ proto_library( ], ) -mediapipe_cc_proto_library( - name = "gl_scaler_calculator_cc_proto", - srcs = ["gl_scaler_calculator.proto"], - cc_deps = [ - ":scale_mode_cc_proto", - "//mediapipe/framework:calculator_cc_proto", - ], - visibility = ["//visibility:public"], - deps = [":gl_scaler_calculator_proto"], -) - cc_library( name = "gl_scaler_calculator", srcs = ["gl_scaler_calculator.cc"], From 3e8fd58400eb46a24425c8491b0c16799d0b732d Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Fri, 10 Mar 2023 14:32:17 -0800 Subject: [PATCH 048/136] Make createAudioRecord a class method not a static method. PiperOrigin-RevId: 515740313 --- .../google/mediapipe/tasks/audio/core/BaseAudioTaskApi.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/audio/core/BaseAudioTaskApi.java b/mediapipe/tasks/java/com/google/mediapipe/tasks/audio/core/BaseAudioTaskApi.java index 7a15e3d58..f577a361b 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/audio/core/BaseAudioTaskApi.java +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/audio/core/BaseAudioTaskApi.java @@ -170,7 +170,7 @@ public class BaseAudioTaskApi implements AutoCloseable { * @throws IllegalArgumentException if the model required channel count is unsupported * @throws IllegalStateException if AudioRecord instance failed to initialize */ - public static AudioRecord createAudioRecord( + public AudioRecord createAudioRecord( int numChannels, int sampleRate, int requiredInputBufferSize) { int channelConfig = 0; switch (numChannels) { @@ -222,7 +222,7 @@ public class BaseAudioTaskApi implements AutoCloseable { * @throws IllegalArgumentException if the model required channel count is unsupported * @throws IllegalStateException if AudioRecord instance failed to initialize */ - public static AudioRecord createAudioRecord() { + public AudioRecord createAudioRecord() { // TODO: Support creating AudioRecord based on the model specifications. return createAudioRecord(1, 16000, 16000); } From c94de4032d7d66c3ed6b761556232672dea7a28b Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Fri, 10 Mar 2023 21:49:05 -0800 Subject: [PATCH 049/136] Fix preprocess Callable typing PiperOrigin-RevId: 515818356 --- .../model_maker/python/core/data/dataset.py | 16 +++++++++------- .../model_maker/python/core/tasks/classifier.py | 12 +++++++----- .../model_maker/python/core/utils/model_util.py | 8 +++++--- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/mediapipe/model_maker/python/core/data/dataset.py b/mediapipe/model_maker/python/core/data/dataset.py index a92b05c0d..113969384 100644 --- a/mediapipe/model_maker/python/core/data/dataset.py +++ b/mediapipe/model_maker/python/core/data/dataset.py @@ -18,7 +18,7 @@ from __future__ import division from __future__ import print_function import functools -from typing import Callable, Optional, Tuple, TypeVar +from typing import Any, Callable, Optional, Tuple, TypeVar # Dependency imports import tensorflow as tf @@ -66,12 +66,14 @@ class Dataset(object): """ return self._size - def gen_tf_dataset(self, - batch_size: int = 1, - is_training: bool = False, - shuffle: bool = False, - preprocess: Optional[Callable[..., bool]] = None, - drop_remainder: bool = False) -> tf.data.Dataset: + def gen_tf_dataset( + self, + batch_size: int = 1, + is_training: bool = False, + shuffle: bool = False, + preprocess: Optional[Callable[..., Any]] = None, + drop_remainder: bool = False, + ) -> tf.data.Dataset: """Generates a batched tf.data.Dataset for training/evaluation. Args: diff --git a/mediapipe/model_maker/python/core/tasks/classifier.py b/mediapipe/model_maker/python/core/tasks/classifier.py index abcfff835..bfe0f027f 100644 --- a/mediapipe/model_maker/python/core/tasks/classifier.py +++ b/mediapipe/model_maker/python/core/tasks/classifier.py @@ -48,11 +48,13 @@ class Classifier(custom_model.CustomModel): self._hparams: hp.BaseHParams = None self._history: tf.keras.callbacks.History = None - def _train_model(self, - train_data: classification_ds.ClassificationDataset, - validation_data: classification_ds.ClassificationDataset, - preprocessor: Optional[Callable[..., bool]] = None, - checkpoint_path: Optional[str] = None): + def _train_model( + self, + train_data: classification_ds.ClassificationDataset, + validation_data: classification_ds.ClassificationDataset, + preprocessor: Optional[Callable[..., Any]] = None, + checkpoint_path: Optional[str] = None, + ): """Trains the classifier model. Compiles and fits the tf.keras `_model` and records the `_history`. diff --git a/mediapipe/model_maker/python/core/utils/model_util.py b/mediapipe/model_maker/python/core/utils/model_util.py index 69a8654ec..7a0b8fcf0 100644 --- a/mediapipe/model_maker/python/core/utils/model_util.py +++ b/mediapipe/model_maker/python/core/utils/model_util.py @@ -115,9 +115,11 @@ def get_steps_per_epoch(steps_per_epoch: Optional[int] = None, def convert_to_tflite( model: tf.keras.Model, quantization_config: Optional[quantization.QuantizationConfig] = None, - supported_ops: Tuple[tf.lite.OpsSet, - ...] = (tf.lite.OpsSet.TFLITE_BUILTINS,), - preprocess: Optional[Callable[..., bool]] = None) -> bytearray: + supported_ops: Tuple[tf.lite.OpsSet, ...] = ( + tf.lite.OpsSet.TFLITE_BUILTINS, + ), + preprocess: Optional[Callable[..., Any]] = None, +) -> bytearray: """Converts the input Keras model to TFLite format. Args: From 296ee33be55ba115ae7ba68eeaa35d031ce485e3 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Sat, 11 Mar 2023 13:02:56 -0800 Subject: [PATCH 050/136] Add FaceLandmarker C++ API PiperOrigin-RevId: 515912777 --- .../tasks/cc/vision/face_landmarker/BUILD | 31 ++ .../vision/face_landmarker/face_landmarker.cc | 250 ++++++++++ .../vision/face_landmarker/face_landmarker.h | 198 ++++++++ .../face_landmarker/face_landmarker_result.cc | 14 +- .../face_landmarker/face_landmarker_result.h | 4 +- .../face_landmarker_result_test.cc | 7 +- .../face_landmarker/face_landmarker_test.cc | 455 ++++++++++++++++++ 7 files changed, 947 insertions(+), 12 deletions(-) create mode 100644 mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.cc create mode 100644 mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h create mode 100644 mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_test.cc diff --git a/mediapipe/tasks/cc/vision/face_landmarker/BUILD b/mediapipe/tasks/cc/vision/face_landmarker/BUILD index 7ecc93b21..3df2f2db6 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/BUILD +++ b/mediapipe/tasks/cc/vision/face_landmarker/BUILD @@ -129,6 +129,37 @@ cc_library( ], ) +cc_library( + name = "face_landmarker", + srcs = ["face_landmarker.cc"], + hdrs = ["face_landmarker.h"], + deps = [ + ":face_landmarker_graph", + ":face_landmarker_result", + "//mediapipe/framework/api2:builder", + "//mediapipe/framework/formats:classification_cc_proto", + "//mediapipe/framework/formats:image", + "//mediapipe/framework/formats:landmark_cc_proto", + "//mediapipe/framework/formats:matrix", + "//mediapipe/framework/formats:matrix_data_cc_proto", + "//mediapipe/framework/formats:rect_cc_proto", + "//mediapipe/tasks/cc/components/containers:classification_result", + "//mediapipe/tasks/cc/core:base_options", + "//mediapipe/tasks/cc/core:base_task_api", + "//mediapipe/tasks/cc/core:task_runner", + "//mediapipe/tasks/cc/core:utils", + "//mediapipe/tasks/cc/vision/core:base_vision_task_api", + "//mediapipe/tasks/cc/vision/core:image_processing_options", + "//mediapipe/tasks/cc/vision/core:running_mode", + "//mediapipe/tasks/cc/vision/core:vision_task_api_factory", + "//mediapipe/tasks/cc/vision/face_detector/proto:face_detector_graph_options_cc_proto", + "//mediapipe/tasks/cc/vision/face_geometry/proto:face_geometry_cc_proto", + "//mediapipe/tasks/cc/vision/face_landmarker/proto:face_landmarker_graph_options_cc_proto", + "//mediapipe/tasks/cc/vision/face_landmarker/proto:face_landmarks_detector_graph_options_cc_proto", + "@com_google_absl//absl/status:statusor", + ], +) + cc_library( name = "face_landmarker_result_cc", srcs = ["face_landmarker_result.cc"], diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.cc b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.cc new file mode 100644 index 000000000..e006b4490 --- /dev/null +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.cc @@ -0,0 +1,250 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h" + +#include "mediapipe/framework/api2/builder.h" +#include "mediapipe/framework/formats/classification.pb.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/formats/landmark.pb.h" +#include "mediapipe/framework/formats/matrix.h" +#include "mediapipe/framework/formats/matrix_data.pb.h" +#include "mediapipe/framework/formats/rect.pb.h" +#include "mediapipe/tasks/cc/components/containers/classification_result.h" +#include "mediapipe/tasks/cc/core/base_task_api.h" +#include "mediapipe/tasks/cc/core/task_runner.h" +#include "mediapipe/tasks/cc/core/utils.h" +#include "mediapipe/tasks/cc/vision/core/base_vision_task_api.h" +#include "mediapipe/tasks/cc/vision/core/image_processing_options.h" +#include "mediapipe/tasks/cc/vision/core/vision_task_api_factory.h" +#include "mediapipe/tasks/cc/vision/face_detector/proto/face_detector_graph_options.pb.h" +#include "mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.pb.h" +#include "mediapipe/tasks/cc/vision/face_landmarker/proto/face_landmarker_graph_options.pb.h" +#include "mediapipe/tasks/cc/vision/face_landmarker/proto/face_landmarks_detector_graph_options.pb.h" + +namespace mediapipe { +namespace tasks { +namespace vision { +namespace face_landmarker { + +namespace { + +using FaceLandmarkerGraphOptionsProto = ::mediapipe::tasks::vision:: + face_landmarker::proto::FaceLandmarkerGraphOptions; + +constexpr char kFaceLandmarkerGraphTypeName[] = + "mediapipe.tasks.vision.face_landmarker.FaceLandmarkerGraph"; + +constexpr char kImageTag[] = "IMAGE"; +constexpr char kImageInStreamName[] = "image_in"; +constexpr char kImageOutStreamName[] = "image_out"; +constexpr char kNormRectTag[] = "NORM_RECT"; +constexpr char kNormRectStreamName[] = "norm_rect_in"; +constexpr char kNormLandmarksTag[] = "NORM_LANDMARKS"; +constexpr char kNormLandmarksStreamName[] = "norm_landmarks"; +constexpr char kBlendshapesTag[] = "BLENDSHAPES"; +constexpr char kBlendshapesStreamName[] = "blendshapes"; +constexpr char kFaceGeometryTag[] = "FACE_GEOMETRY"; +constexpr char kFaceGeometryStreamName[] = "face_geometry"; +constexpr int kMicroSecondsPerMilliSecond = 1000; + +// Creates a MediaPipe graph config that contains a subgraph node of +// "mediapipe.tasks.vision.face_ladnamrker.FaceLandmarkerGraph". If the task is +// running in the live stream mode, a "FlowLimiterCalculator" will be added to +// limit the number of frames in flight. +CalculatorGraphConfig CreateGraphConfig( + std::unique_ptr options, + bool output_face_blendshapes, bool output_facial_transformation_matrixes, + bool enable_flow_limiting) { + api2::builder::Graph graph; + auto& subgraph = graph.AddNode(kFaceLandmarkerGraphTypeName); + subgraph.GetOptions().Swap(options.get()); + graph.In(kImageTag).SetName(kImageInStreamName); + graph.In(kNormRectTag).SetName(kNormRectStreamName); + subgraph.Out(kNormLandmarksTag).SetName(kNormLandmarksStreamName) >> + graph.Out(kNormLandmarksTag); + subgraph.Out(kImageTag).SetName(kImageOutStreamName) >> graph.Out(kImageTag); + if (output_face_blendshapes) { + subgraph.Out(kBlendshapesTag).SetName(kBlendshapesStreamName) >> + graph.Out(kBlendshapesTag); + } + if (output_facial_transformation_matrixes) { + subgraph.Out(kFaceGeometryTag).SetName(kFaceGeometryStreamName) >> + graph.Out(kFaceGeometryTag); + } + if (enable_flow_limiting) { + return tasks::core::AddFlowLimiterCalculator( + graph, subgraph, {kImageTag, kNormRectTag}, kNormLandmarksTag); + } + graph.In(kImageTag) >> subgraph.In(kImageTag); + graph.In(kNormRectTag) >> subgraph.In(kNormRectTag); + return graph.GetConfig(); +} + +// Converts the user-facing FaceLandmarkerOptions struct to the internal +// FaceLandmarkerGraphOptions proto. +std::unique_ptr +ConvertFaceLandmarkerGraphOptionsProto(FaceLandmarkerOptions* options) { + auto options_proto = std::make_unique(); + auto base_options_proto = std::make_unique( + tasks::core::ConvertBaseOptionsToProto(&(options->base_options))); + options_proto->mutable_base_options()->Swap(base_options_proto.get()); + options_proto->mutable_base_options()->set_use_stream_mode( + options->running_mode != core::RunningMode::IMAGE); + + // Configure face detector options. + auto* face_detector_graph_options = + options_proto->mutable_face_detector_graph_options(); + face_detector_graph_options->set_num_faces(options->num_faces); + face_detector_graph_options->set_min_detection_confidence( + options->min_face_detection_confidence); + + // Configure face landmark detector options. + options_proto->set_min_tracking_confidence(options->min_tracking_confidence); + auto* face_landmarks_detector_graph_options = + options_proto->mutable_face_landmarks_detector_graph_options(); + face_landmarks_detector_graph_options->set_min_detection_confidence( + options->min_face_presence_confidence); + + return options_proto; +} + +FaceLandmarkerResult GetFaceLandmarkerResultFromPacketMap( + const tasks::core::PacketMap& packet_map) { + const auto& face_landmarks = packet_map.at(kNormLandmarksStreamName) + .Get>(); + std::optional> face_blendshapes; + if (packet_map.find(kBlendshapesStreamName) != packet_map.end()) { + face_blendshapes = packet_map.at(kBlendshapesStreamName) + .Get>(); + } + std::optional> matrix_data_list; + if (packet_map.find(kFaceGeometryStreamName) != packet_map.end()) { + const auto& face_geometry_list = + packet_map.at(kFaceGeometryStreamName) + .Get>(); + matrix_data_list = std::vector(face_geometry_list.size()); + std::transform(face_geometry_list.begin(), face_geometry_list.end(), + matrix_data_list->begin(), + [](const face_geometry::proto::FaceGeometry& face_geometry) { + return face_geometry.pose_transform_matrix(); + }); + } + return ConvertToFaceLandmarkerResult( + /* face_landmarks_proto = */ face_landmarks, + /* face_blendshapes_proto= */ face_blendshapes, + /* facial_transformation_matrixes_proto= */ matrix_data_list); +} + +} // namespace + +absl::StatusOr> FaceLandmarker::Create( + std::unique_ptr options) { + auto options_proto = ConvertFaceLandmarkerGraphOptionsProto(options.get()); + tasks::core::PacketsCallback packets_callback = nullptr; + if (options->result_callback) { + auto result_callback = options->result_callback; + packets_callback = [=](absl::StatusOr packet_map) { + if (!packet_map.ok()) { + Image image; + result_callback(packet_map.status(), image, Timestamp::Unset().Value()); + return; + } + if (packet_map->at(kImageOutStreamName).IsEmpty()) { + return; + } + Packet image_packet = packet_map->at(kImageOutStreamName); + if (packet_map->at(kNormLandmarksStreamName).IsEmpty()) { + Packet empty_packet = packet_map->at(kNormLandmarksStreamName); + result_callback( + {FaceLandmarkerResult()}, image_packet.Get(), + empty_packet.Timestamp().Value() / kMicroSecondsPerMilliSecond); + return; + } + result_callback( + GetFaceLandmarkerResultFromPacketMap(*packet_map), + image_packet.Get(), + packet_map->at(kNormLandmarksStreamName).Timestamp().Value() / + kMicroSecondsPerMilliSecond); + }; + } + return core::VisionTaskApiFactory::Create( + CreateGraphConfig( + std::move(options_proto), options->output_face_blendshapes, + options->output_facial_transformation_matrixes, + options->running_mode == core::RunningMode::LIVE_STREAM), + std::move(options->base_options.op_resolver), options->running_mode, + std::move(packets_callback)); +} + +absl::StatusOr FaceLandmarker::Detect( + mediapipe::Image image, + std::optional image_processing_options) { + ASSIGN_OR_RETURN(NormalizedRect norm_rect, + ConvertToNormalizedRect(image_processing_options, + /*roi_allowed=*/false)); + ASSIGN_OR_RETURN( + auto output_packets, + ProcessImageData( + {{kImageInStreamName, MakePacket(std::move(image))}, + {kNormRectStreamName, + MakePacket(std::move(norm_rect))}})); + if (output_packets[kNormLandmarksStreamName].IsEmpty()) { + return {FaceLandmarkerResult()}; + } + return GetFaceLandmarkerResultFromPacketMap(output_packets); +} + +absl::StatusOr FaceLandmarker::DetectForVideo( + mediapipe::Image image, int64_t timestamp_ms, + std::optional image_processing_options) { + ASSIGN_OR_RETURN(NormalizedRect norm_rect, + ConvertToNormalizedRect(image_processing_options, + /*roi_allowed=*/false)); + ASSIGN_OR_RETURN( + auto output_packets, + ProcessVideoData( + {{kImageInStreamName, + MakePacket(std::move(image)) + .At(Timestamp(timestamp_ms * kMicroSecondsPerMilliSecond))}, + {kNormRectStreamName, + MakePacket(std::move(norm_rect)) + .At(Timestamp(timestamp_ms * kMicroSecondsPerMilliSecond))}})); + if (output_packets[kNormLandmarksStreamName].IsEmpty()) { + return {FaceLandmarkerResult()}; + } + return GetFaceLandmarkerResultFromPacketMap(output_packets); +} + +absl::Status FaceLandmarker::DetectAsync( + mediapipe::Image image, int64_t timestamp_ms, + std::optional image_processing_options) { + ASSIGN_OR_RETURN(NormalizedRect norm_rect, + ConvertToNormalizedRect(image_processing_options, + /*roi_allowed=*/false)); + return SendLiveStreamData( + {{kImageInStreamName, + MakePacket(std::move(image)) + .At(Timestamp(timestamp_ms * kMicroSecondsPerMilliSecond))}, + {kNormRectStreamName, + MakePacket(std::move(norm_rect)) + .At(Timestamp(timestamp_ms * kMicroSecondsPerMilliSecond))}}); +} + +} // namespace face_landmarker +} // namespace vision +} // namespace tasks +} // namespace mediapipe diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h new file mode 100644 index 000000000..5a5c8404a --- /dev/null +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h @@ -0,0 +1,198 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#ifndef MEDIAPIPE_TASKS_CC_VISION_FACE_LANDMARKER_FACE_LANDMARKER_H_ +#define MEDIAPIPE_TASKS_CC_VISION_FACE_LANDMARKER_FACE_LANDMARKER_H_ + +#include +#include +#include + +#include "absl/status/statusor.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/tasks/cc/core/base_options.h" +#include "mediapipe/tasks/cc/vision/core/base_vision_task_api.h" +#include "mediapipe/tasks/cc/vision/core/image_processing_options.h" +#include "mediapipe/tasks/cc/vision/core/running_mode.h" +#include "mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h" + +namespace mediapipe { +namespace tasks { +namespace vision { +namespace face_landmarker { + +struct FaceLandmarkerOptions { + // Base options for configuring MediaPipe Tasks library, such as specifying + // the TfLite model bundle file with metadata, accelerator options, op + // resolver, etc. + tasks::core::BaseOptions base_options; + + // The running mode of the task. Default to the image mode. + // FaceLandmarker has three running modes: + // 1) The image mode for detecting face landmarks on single image inputs. + // 2) The video mode for detecting face landmarks on the decoded frames of a + // video. + // 3) The live stream mode for detecting face landmarks on the live stream of + // input data, such as from camera. In this mode, the "result_callback" + // below must be specified to receive the detection results asynchronously. + core::RunningMode running_mode = core::RunningMode::IMAGE; + + // The maximum number of faces that can be detected by the FaceLandmarker. + int num_faces = 1; + + // The minimum confidence score for the face detection to be considered + // successful. + float min_face_detection_confidence = 0.5; + + // The minimum confidence score of face presence score in the face landmark + // detection. + float min_face_presence_confidence = 0.5; + + // The minimum confidence score for the face tracking to be considered + // successful. + float min_tracking_confidence = 0.5; + + // Whether FaceLandmarker outputs face blendshapes classification. Face + // blendshapes are used for rendering the 3D face model. + bool output_face_blendshapes = false; + + // Whether FaceLandmarker outputs facial transformation_matrix. Facial + // transformation matrix is used to transform the face landmarks in canonical + // face to the detected face, so that users can apply face effects on the + // detected landmarks. + bool output_facial_transformation_matrixes = false; + + // The user-defined result callback for processing live stream data. + // The result callback should only be specified when the running mode is set + // to RunningMode::LIVE_STREAM. + std::function, const Image&, + int64_t)> + result_callback = nullptr; +}; + +// Performs face landmarks detection on the given image. +// +// TODO add the link to DevSite. +// This API expects a pre-trained face landmarker model asset bundle. +// +// Inputs: +// Image +// - The image that face landmarks detection runs on. +// std::optional +// - If provided, can be used to specify the rotation to apply to the image +// before performing face landmarks detection, by setting its 'rotation' +// field in radians (e.g. 'M_PI / 2' for a 90° anti-clockwise rotation). +// Note that specifying a region-of-interest using the 'x_center', +// 'y_center', 'width' and 'height' fields is NOT supported and will +// result in an invalid argument error being returned. +// Outputs: +// FaceLandmarkerResult +// - The face landmarks detection results. +class FaceLandmarker : tasks::vision::core::BaseVisionTaskApi { + public: + using BaseVisionTaskApi::BaseVisionTaskApi; + + // Creates a FaceLandmarker from a FaceLandmarkerOptions to process image data + // or streaming data. Face landmarker can be created with one of the following + // three running modes: + // 1) Image mode for detecting face landmarks on single image inputs. Users + // provide mediapipe::Image to the `Detect` method, and will receive the + // deteced face landmarks results as the return value. + // 2) Video mode for detecting face landmarks on the decoded frames of a + // video. Users call `DetectForVideo` method, and will receive the detected + // face landmarks results as the return value. + // 3) Live stream mode for detecting face landmarks on the live stream of the + // input data, such as from camera. Users call `DetectAsync` to push the + // image data into the FaceLandmarker, the detected results along with the + // input timestamp and the image that face landmarker runs on will be + // available in the result callback when the face landmarker finishes the + // work. + static absl::StatusOr> Create( + std::unique_ptr options); + + // Performs face landmarks detection on the given image. + // Only use this method when the FaceLandmarker is created with the image + // running mode. + // + // The optional 'image_processing_options' parameter can be used to specify + // the rotation to apply to the image before performing detection, by setting + // its 'rotation_degrees' field. Note that specifying a region-of-interest + // using the 'region_of_interest' field is NOT supported and will result in an + // invalid argument error being returned. + // + // The image can be of any size with format RGB or RGBA. + // TODO: Describes how the input image will be preprocessed + // after the yuv support is implemented. + absl::StatusOr Detect( + Image image, + std::optional image_processing_options = + std::nullopt); + + // Performs face landmarks detection on the provided video frame. + // Only use this method when the FaceLandmarker is created with the video + // running mode. + // + // The optional 'image_processing_options' parameter can be used to specify + // the rotation to apply to the image before performing detection, by setting + // its 'rotation_degrees' field. Note that specifying a region-of-interest + // using the 'region_of_interest' field is NOT supported and will result in an + // invalid argument error being returned. + // + // The image can be of any size with format RGB or RGBA. It's required to + // provide the video frame's timestamp (in milliseconds). The input timestamps + // must be monotonically increasing. + absl::StatusOr DetectForVideo( + Image image, int64_t timestamp_ms, + std::optional image_processing_options = + std::nullopt); + + // Sends live image data to perform face landmarks detection, and the results + // will be available via the "result_callback" provided in the + // FaceLandmarkerOptions. Only use this method when the FaceLandmarker + // is created with the live stream running mode. + // + // The image can be of any size with format RGB or RGBA. It's required to + // provide a timestamp (in milliseconds) to indicate when the input image is + // sent to the face landmarker. The input timestamps must be monotonically + // increasing. + // + // The optional 'image_processing_options' parameter can be used to specify + // the rotation to apply to the image before performing detection, by setting + // its 'rotation_degrees' field. Note that specifying a region-of-interest + // using the 'region_of_interest' field is NOT supported and will result in an + // invalid argument error being returned. + // + // The "result_callback" provides + // - A vector of FaceLandmarkerResult, each is the detected results + // for a input frame. + // - The const reference to the corresponding input image that the face + // landmarker runs on. Note that the const reference to the image will no + // longer be valid when the callback returns. To access the image data + // outside of the callback, callers need to make a copy of the image. + // - The input timestamp in milliseconds. + absl::Status DetectAsync(Image image, int64_t timestamp_ms, + std::optional + image_processing_options = std::nullopt); + + // Shuts down the FaceLandmarker when all works are done. + absl::Status Close() { return runner_->Close(); } +}; + +} // namespace face_landmarker +} // namespace vision +} // namespace tasks +} // namespace mediapipe + +#endif // MEDIAPIPE_TASKS_CC_VISION_FACE_LANDMARKER_FACE_LANDMARKER_H_ diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.cc b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.cc index 3f369cc16..53a171ed5 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.cc +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.cc @@ -34,7 +34,7 @@ FaceLandmarkerResult ConvertToFaceLandmarkerResult( std::optional> face_blendshapes_proto, std::optional> - facial_transformation_matrix_proto) { + facial_transformation_matrixes_proto) { FaceLandmarkerResult result; result.face_landmarks.resize(face_landmarks_proto.size()); std::transform(face_landmarks_proto.begin(), face_landmarks_proto.end(), @@ -52,12 +52,12 @@ FaceLandmarkerResult ConvertToFaceLandmarkerResult( classification_list); }); } - if (facial_transformation_matrix_proto.has_value()) { - result.facial_transformation_matrix = - std::vector(facial_transformation_matrix_proto->size()); - std::transform(facial_transformation_matrix_proto->begin(), - facial_transformation_matrix_proto->end(), - result.facial_transformation_matrix->begin(), + if (facial_transformation_matrixes_proto.has_value()) { + result.facial_transformation_matrixes = + std::vector(facial_transformation_matrixes_proto->size()); + std::transform(facial_transformation_matrixes_proto->begin(), + facial_transformation_matrixes_proto->end(), + result.facial_transformation_matrixes->begin(), [](const mediapipe::MatrixData& matrix_proto) { mediapipe::Matrix matrix; MatrixFromMatrixDataProto(matrix_proto, &matrix); diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h index 9774d80d9..35dd7a8ab 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h @@ -40,7 +40,7 @@ struct FaceLandmarkerResult { std::optional> face_blendshapes; // Optional facial transformation matrix. - std::optional> facial_transformation_matrix; + std::optional> facial_transformation_matrixes; }; // Convert face landmarks result from proto format to FaceLandmarkerResult. @@ -49,7 +49,7 @@ FaceLandmarkerResult ConvertToFaceLandmarkerResult( std::optional> face_blendshapes_proto = std::nullopt, std::optional> - facial_transformation_matrix_proto = std::nullopt); + facial_transformation_matrixes_proto = std::nullopt); } // namespace face_landmarker } // namespace vision diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result_test.cc b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result_test.cc index c3ed2d371..4123a81f3 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result_test.cc +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result_test.cc @@ -73,9 +73,10 @@ TEST(FaceLandmarkerResultTest, Succeeds) { std::nullopt)); Matrix expected_matrix{{0, 3, 6}, {1, 4, 7}, {2, 5, 8}}; - ASSERT_TRUE(face_landmarker_result.facial_transformation_matrix.has_value()); - EXPECT_EQ(face_landmarker_result.facial_transformation_matrix->size(), 1); - EXPECT_EQ(face_landmarker_result.facial_transformation_matrix->at(0), + ASSERT_TRUE( + face_landmarker_result.facial_transformation_matrixes.has_value()); + EXPECT_EQ(face_landmarker_result.facial_transformation_matrixes->size(), 1); + EXPECT_EQ(face_landmarker_result.facial_transformation_matrixes->at(0), expected_matrix); } diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_test.cc b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_test.cc new file mode 100644 index 000000000..0b6d9af73 --- /dev/null +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_test.cc @@ -0,0 +1,455 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h" + +#include +#include +#include +#include +#include +#include + +#include "absl/flags/flag.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "mediapipe/framework/deps/file_path.h" +#include "mediapipe/framework/formats/classification.pb.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/formats/landmark.pb.h" +#include "mediapipe/framework/formats/matrix.h" +#include "mediapipe/framework/formats/matrix_data.pb.h" +#include "mediapipe/framework/port/file_helpers.h" +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/tasks/cc/common.h" +#include "mediapipe/tasks/cc/components/containers/category.h" +#include "mediapipe/tasks/cc/components/containers/classification_result.h" +#include "mediapipe/tasks/cc/components/containers/landmark.h" +#include "mediapipe/tasks/cc/components/containers/rect.h" +#include "mediapipe/tasks/cc/components/processors/proto/classifier_options.pb.h" +#include "mediapipe/tasks/cc/core/base_options.h" +#include "mediapipe/tasks/cc/vision/core/image_processing_options.h" +#include "mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h" +#include "mediapipe/tasks/cc/vision/utils/image_utils.h" + +namespace mediapipe { +namespace tasks { +namespace vision { +namespace face_landmarker { +namespace { + +using ::file::Defaults; +using ::mediapipe::tasks::vision::core::ImageProcessingOptions; +using ::testing::TestParamInfo; +using ::testing::TestWithParam; +using ::testing::Values; + +constexpr char kTestDataDirectory[] = "/mediapipe/tasks/testdata/vision/"; +constexpr char kFaceLandmarkerModelBundleName[] = "face_landmarker.task"; +constexpr char kFaceLandmarkerWithBlendshapesModelBundleName[] = + "face_landmarker_with_blendshapes.task"; +constexpr char kPortraitImageName[] = "portrait.jpg"; +constexpr char kPortraitExpectedFaceLandamrksName[] = + "portrait_expected_face_landmarks.pbtxt"; +constexpr char kPortraitExpectedFaceLandamrksWithAttentionName[] = + "portrait_expected_face_landmarks_with_attention.pbtxt"; +constexpr char kPortraitExpectedBlendshapesName[] = + "portrait_expected_blendshapes_with_attention.pbtxt"; + +constexpr float kLandmarksDiffMargin = 0.03; +constexpr float kBlendshapesDiffMargin = 0.1; +constexpr float kFacialTransformationMatrixDiffMargin = 0.02; + +template +ProtoT GetExpectedProto(absl::string_view filename) { + ProtoT expected_proto; + MP_EXPECT_OK(GetTextProto(file::JoinPath("./", kTestDataDirectory, filename), + &expected_proto, Defaults())); + return expected_proto; +} + +// Struct holding the parameters for parameterized FaceLandmarkerGraphTest +// class. +struct FaceLandmarkerTestParams { + // The name of this test, for convenience when displaying test results. + std::string test_name; + // The filename of the model to test. + std::string input_model_name; + // The filename of the test image. + std::string test_image_name; + // The rotation to apply to the test image before processing, in degrees + // clockwise. + int rotation; + // The expected output face landmarker result. + FaceLandmarkerResult expected_result; +}; + +mediapipe::MatrixData MakePortraitExpectedFacialTransformationMatrix() { + const Matrix matrix{{0.9995292, -0.005092691, 0.030254554, -0.37340546}, + {0.0072318087, 0.99744856, -0.07102106, 22.212194}, + {-0.029815676, 0.07120642, 0.9970159, -64.76358}, + {0, 0, 0, 1}}; + mediapipe::MatrixData matrix_data; + MatrixDataProtoFromMatrix(matrix, &matrix_data); + return matrix_data; +} + +testing::Matcher LandmarkIs( + const components::containers::NormalizedLandmark& landmark) { + return testing::AllOf( + testing::Field(&components::containers::NormalizedLandmark::x, + testing::FloatNear(landmark.x, kLandmarksDiffMargin)), + testing::Field(&components::containers::NormalizedLandmark::y, + testing::FloatNear(landmark.y, kLandmarksDiffMargin))); +} + +void ExpectLandmarksCorrect( + const std::vector + actual_landmarks, + const std::vector + expected_landmarks) { + ASSERT_EQ(actual_landmarks.size(), expected_landmarks.size()); + for (int i = 0; i < actual_landmarks.size(); ++i) { + ASSERT_EQ(actual_landmarks[i].landmarks.size(), + expected_landmarks[i].landmarks.size()); + for (int j = 0; j < actual_landmarks[i].landmarks.size(); ++j) { + EXPECT_THAT(actual_landmarks[i].landmarks[j], + LandmarkIs(expected_landmarks[i].landmarks[j])); + } + } +} + +testing::Matcher CategoryIs( + const components::containers::Category& category) { + return testing::AllOf( + testing::Field(&components::containers::Category::index, + testing::Eq(category.index)), + testing::Field( + &components::containers::Category::score, + testing::FloatNear(category.score, kBlendshapesDiffMargin))); +} + +void ExpectBlendshapesCorrect( + const std::vector& + actual_blendshapes, + const std::vector& + expected_blendshapes) { + ASSERT_EQ(actual_blendshapes.size(), expected_blendshapes.size()); + for (int i = 0; i < actual_blendshapes.size(); ++i) { + ASSERT_EQ(actual_blendshapes[i].categories.size(), + expected_blendshapes[i].categories.size()); + for (int j = 0; j < actual_blendshapes[i].categories.size(); ++j) { + EXPECT_THAT(actual_blendshapes[i].categories[j], + CategoryIs(expected_blendshapes[i].categories[j])); + } + } +} + +void ExpectFacialTransformationMatrixCorrect( + const std::vector& actual_matrix_list, + const std::vector& expected_matrix_list) { + ASSERT_EQ(actual_matrix_list.size(), expected_matrix_list.size()); + for (int i = 0; i < actual_matrix_list.size(); ++i) { + const Matrix& actual_matrix = actual_matrix_list[i]; + const Matrix& expected_matrix = expected_matrix_list[i]; + ASSERT_EQ(actual_matrix.cols(), expected_matrix.cols()); + ASSERT_EQ(actual_matrix.rows(), expected_matrix.rows()); + for (int i = 0; i < actual_matrix.size(); ++i) { + EXPECT_NEAR(actual_matrix.data()[i], expected_matrix.data()[i], + kFacialTransformationMatrixDiffMargin); + } + } +} + +void ExpectFaceLandmarkerResultCorrect( + const FaceLandmarkerResult& actual_result, + const FaceLandmarkerResult& expected_result) { + ExpectLandmarksCorrect(actual_result.face_landmarks, + expected_result.face_landmarks); + + ASSERT_EQ(actual_result.face_blendshapes.has_value(), + expected_result.face_blendshapes.has_value()); + if (expected_result.face_blendshapes.has_value()) { + ASSERT_TRUE(actual_result.face_blendshapes.has_value()); + ExpectBlendshapesCorrect(*actual_result.face_blendshapes, + *expected_result.face_blendshapes); + } + + ASSERT_EQ(actual_result.facial_transformation_matrixes.has_value(), + expected_result.facial_transformation_matrixes.has_value()); + if (expected_result.facial_transformation_matrixes.has_value()) { + ASSERT_TRUE(actual_result.facial_transformation_matrixes.has_value()); + ExpectFacialTransformationMatrixCorrect( + *actual_result.facial_transformation_matrixes, + *expected_result.facial_transformation_matrixes); + } +} + +class ImageModeTest : public TestWithParam {}; + +TEST_P(ImageModeTest, Succeeds) { + MP_ASSERT_OK_AND_ASSIGN( + Image image, DecodeImageFromFile(file::JoinPath( + "./", kTestDataDirectory, GetParam().test_image_name))); + auto options = std::make_unique(); + options->base_options.model_asset_path = + file::JoinPath("./", kTestDataDirectory, GetParam().input_model_name); + options->running_mode = core::RunningMode::IMAGE; + options->output_face_blendshapes = + GetParam().expected_result.face_blendshapes.has_value(); + options->output_facial_transformation_matrixes = + GetParam().expected_result.facial_transformation_matrixes.has_value(); + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr face_landmarker, + FaceLandmarker::Create(std::move(options))); + FaceLandmarkerResult actual_result; + if (GetParam().rotation != 0) { + ImageProcessingOptions image_processing_options; + image_processing_options.rotation_degrees = GetParam().rotation; + MP_ASSERT_OK_AND_ASSIGN( + actual_result, + face_landmarker->Detect(image, image_processing_options)); + } else { + MP_ASSERT_OK_AND_ASSIGN(actual_result, face_landmarker->Detect(image)); + } + ExpectFaceLandmarkerResultCorrect(actual_result, GetParam().expected_result); + MP_ASSERT_OK(face_landmarker->Close()); +} + +INSTANTIATE_TEST_SUITE_P( + FaceLandmarkerTest, ImageModeTest, + Values(FaceLandmarkerTestParams{ + /* test_name= */ "Portrait", + /* input_model_name= */ kFaceLandmarkerModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksName)})}, + FaceLandmarkerTestParams{ + /* test_name= */ "PortraitWithAttention", + /* input_model_name= */ + kFaceLandmarkerWithBlendshapesModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksWithAttentionName)})}, + FaceLandmarkerTestParams{ + /* test_name= */ "PortraitWithBlendshapes", + /* input_model_name= */ + kFaceLandmarkerWithBlendshapesModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksWithAttentionName)}, + {{GetExpectedProto( + kPortraitExpectedBlendshapesName)}})}, + FaceLandmarkerTestParams{ + /* test_name= */ "PortraitWithBlendshapesWithFacialTransformatio" + "nMatrix", + /* input_model_name= */ + kFaceLandmarkerWithBlendshapesModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksWithAttentionName)}, + {{GetExpectedProto( + kPortraitExpectedBlendshapesName)}}, + {{MakePortraitExpectedFacialTransformationMatrix()}})}), + [](const TestParamInfo& info) { + return info.param.test_name; + }); + +class VideoModeTest : public TestWithParam {}; + +TEST_P(VideoModeTest, Succeeds) { + MP_ASSERT_OK_AND_ASSIGN( + Image image, DecodeImageFromFile(file::JoinPath( + "./", kTestDataDirectory, GetParam().test_image_name))); + auto options = std::make_unique(); + options->base_options.model_asset_path = + file::JoinPath("./", kTestDataDirectory, GetParam().input_model_name); + options->running_mode = core::RunningMode::VIDEO; + options->output_face_blendshapes = + GetParam().expected_result.face_blendshapes.has_value(); + options->output_facial_transformation_matrixes = + GetParam().expected_result.facial_transformation_matrixes.has_value(); + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr face_landmarker, + FaceLandmarker::Create(std::move(options))); + for (int i = 0; i < 3; ++i) { + FaceLandmarkerResult actual_result; + if (GetParam().rotation != 0) { + ImageProcessingOptions image_processing_options; + image_processing_options.rotation_degrees = GetParam().rotation; + MP_ASSERT_OK_AND_ASSIGN( + actual_result, + face_landmarker->DetectForVideo(image, i, image_processing_options)); + } else { + MP_ASSERT_OK_AND_ASSIGN(actual_result, + face_landmarker->DetectForVideo(image, i)); + } + ExpectFaceLandmarkerResultCorrect(actual_result, + GetParam().expected_result); + } + MP_ASSERT_OK(face_landmarker->Close()); +} + +INSTANTIATE_TEST_SUITE_P( + FaceLandmarkerTest, VideoModeTest, + Values(FaceLandmarkerTestParams{ + /* test_name= */ "Portrait", + /* input_model_name= */ kFaceLandmarkerModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksName)})}, + FaceLandmarkerTestParams{ + /* test_name= */ "PortraitWithAttention", + /* input_model_name= */ + kFaceLandmarkerWithBlendshapesModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksWithAttentionName)})}, + FaceLandmarkerTestParams{ + /* test_name= */ "PortraitWithBlendshapes", + /* input_model_name= */ + kFaceLandmarkerWithBlendshapesModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksWithAttentionName)}, + {{GetExpectedProto( + kPortraitExpectedBlendshapesName)}})}), + [](const TestParamInfo& info) { + return info.param.test_name; + }); + +class LiveStreamModeTest : public TestWithParam {}; + +TEST_P(LiveStreamModeTest, Succeeds) { + MP_ASSERT_OK_AND_ASSIGN( + Image image, DecodeImageFromFile(file::JoinPath( + "./", kTestDataDirectory, GetParam().test_image_name))); + auto options = std::make_unique(); + options->base_options.model_asset_path = + file::JoinPath("./", kTestDataDirectory, GetParam().input_model_name); + options->running_mode = core::RunningMode::LIVE_STREAM; + options->output_face_blendshapes = + GetParam().expected_result.face_blendshapes.has_value(); + options->output_facial_transformation_matrixes = + GetParam().expected_result.facial_transformation_matrixes.has_value(); + + std::vector face_landmarker_results; + std::vector timestamps; + options->result_callback = [&face_landmarker_results, ×tamps]( + absl::StatusOr result, + const Image& image, int64_t timestamp_ms) { + MP_ASSERT_OK(result.status()); + face_landmarker_results.push_back(std::move(result.value())); + timestamps.push_back(timestamp_ms); + }; + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr face_landmarker, + FaceLandmarker::Create(std::move(options))); + + const int iterations = 100; + for (int i = 0; i < iterations; ++i) { + FaceLandmarkerResult actual_result; + if (GetParam().rotation != 0) { + ImageProcessingOptions image_processing_options; + image_processing_options.rotation_degrees = GetParam().rotation; + MP_ASSERT_OK( + face_landmarker->DetectAsync(image, i, image_processing_options)); + } else { + MP_ASSERT_OK(face_landmarker->DetectAsync(image, i)); + } + } + MP_ASSERT_OK(face_landmarker->Close()); + + // Due to the flow limiter, the total of outputs will be smaller than the + // number of iterations. + ASSERT_LE(face_landmarker_results.size(), iterations); + ASSERT_GT(face_landmarker_results.size(), 0); + + for (int i = 0; i < face_landmarker_results.size(); ++i) { + ExpectFaceLandmarkerResultCorrect(face_landmarker_results[i], + GetParam().expected_result); + } + int64_t timestamp_ms = -1; + for (const auto& timestamp : timestamps) { + EXPECT_GT(timestamp, timestamp_ms); + timestamp_ms = timestamp; + } +} + +INSTANTIATE_TEST_SUITE_P( + FaceLandmarkerTest, LiveStreamModeTest, + Values(FaceLandmarkerTestParams{ + /* test_name= */ "Portrait", + /* input_model_name= */ kFaceLandmarkerModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksName)})}, + FaceLandmarkerTestParams{ + /* test_name= */ "PortraitWithAttention", + /* input_model_name= */ + kFaceLandmarkerWithBlendshapesModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksWithAttentionName)})}, + FaceLandmarkerTestParams{ + /* test_name= */ "PortraitWithBlendshapes", + /* input_model_name= */ + kFaceLandmarkerWithBlendshapesModelBundleName, + /* test_image_name= */ kPortraitImageName, + /* rotation= */ 0, + /* expected_result= */ + ConvertToFaceLandmarkerResult( + {GetExpectedProto( + kPortraitExpectedFaceLandamrksWithAttentionName)}, + {{GetExpectedProto( + kPortraitExpectedBlendshapesName)}})}), + [](const TestParamInfo& info) { + return info.param.test_name; + }); + +} // namespace +} // namespace face_landmarker +} // namespace vision +} // namespace tasks +} // namespace mediapipe From 131be2169ae417ead964ad17a46900034479c3d8 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Sat, 11 Mar 2023 13:12:50 -0800 Subject: [PATCH 051/136] Add FaceDetector Java API PiperOrigin-RevId: 515913662 --- .../com/google/mediapipe/tasks/vision/BUILD | 5 + .../vision/facedetector/FaceDetector.java | 463 ++++++++++++++++++ .../vision/facedetector/AndroidManifest.xml | 24 + .../mediapipe/tasks/vision/facedetector/BUILD | 19 + .../vision/facedetector/FaceDetectorTest.java | 455 +++++++++++++++++ 5 files changed, 966 insertions(+) create mode 100644 mediapipe/tasks/java/com/google/mediapipe/tasks/vision/facedetector/FaceDetector.java create mode 100644 mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/AndroidManifest.xml create mode 100644 mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/BUILD create mode 100644 mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/FaceDetectorTest.java diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD index 32518725a..bd57ffadb 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD @@ -45,6 +45,7 @@ cc_binary( deps = [ "//mediapipe/calculators/core:flow_limiter_calculator", "//mediapipe/java/com/google/mediapipe/framework/jni:mediapipe_framework_jni", + "//mediapipe/tasks/cc/vision/face_detector:face_detector_graph", "//mediapipe/tasks/cc/vision/gesture_recognizer:gesture_recognizer_graph", "//mediapipe/tasks/cc/vision/image_classifier:image_classifier_graph", "//mediapipe/tasks/cc/vision/image_embedder:image_embedder_graph", @@ -235,6 +236,7 @@ android_library( android_library( name = "facedetector", srcs = [ + "facedetector/FaceDetector.java", "facedetector/FaceDetectorResult.java", ], javacopts = [ @@ -245,7 +247,10 @@ android_library( ":core", "//mediapipe/framework:calculator_options_java_proto_lite", "//mediapipe/framework/formats:detection_java_proto_lite", + "//mediapipe/java/com/google/mediapipe/framework:android_framework", + "//mediapipe/java/com/google/mediapipe/framework/image", "//mediapipe/tasks/cc/core/proto:base_options_java_proto_lite", + "//mediapipe/tasks/cc/vision/face_detector/proto:face_detector_graph_options_java_proto_lite", "//mediapipe/tasks/java/com/google/mediapipe/tasks/components/containers:detection", "//mediapipe/tasks/java/com/google/mediapipe/tasks/core", "//third_party:autovalue", diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/facedetector/FaceDetector.java b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/facedetector/FaceDetector.java new file mode 100644 index 000000000..c23432c1b --- /dev/null +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/facedetector/FaceDetector.java @@ -0,0 +1,463 @@ +// Copyright 2023 The MediaPipe Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.mediapipe.tasks.vision.facedetector; + +import android.content.Context; +import android.os.ParcelFileDescriptor; +import com.google.auto.value.AutoValue; +import com.google.mediapipe.proto.CalculatorOptionsProto.CalculatorOptions; +import com.google.mediapipe.framework.AndroidPacketGetter; +import com.google.mediapipe.framework.Packet; +import com.google.mediapipe.framework.PacketGetter; +import com.google.mediapipe.framework.image.BitmapImageBuilder; +import com.google.mediapipe.framework.image.MPImage; +import com.google.mediapipe.tasks.core.BaseOptions; +import com.google.mediapipe.tasks.core.ErrorListener; +import com.google.mediapipe.tasks.core.OutputHandler; +import com.google.mediapipe.tasks.core.OutputHandler.ResultListener; +import com.google.mediapipe.tasks.core.TaskInfo; +import com.google.mediapipe.tasks.core.TaskOptions; +import com.google.mediapipe.tasks.core.TaskRunner; +import com.google.mediapipe.tasks.core.proto.BaseOptionsProto; +import com.google.mediapipe.tasks.vision.core.BaseVisionTaskApi; +import com.google.mediapipe.tasks.vision.core.ImageProcessingOptions; +import com.google.mediapipe.tasks.vision.core.RunningMode; +import com.google.mediapipe.tasks.vision.facedetector.proto.FaceDetectorGraphOptionsProto; +import com.google.mediapipe.formats.proto.DetectionProto.Detection; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Performs face detection on images. + * + *

The API expects a TFLite model with TFLite Model Metadata.. + * + *

    + *
  • Input image {@link MPImage} + *
      + *
    • The image that the face detector runs on. + *
    + *
  • Output FaceDetectorResult {@link FaceDetectorResult} + *
      + *
    • A FaceDetectorResult containing detected faces. + *
    + *
+ */ +public final class FaceDetector extends BaseVisionTaskApi { + private static final String TAG = FaceDetector.class.getSimpleName(); + private static final String IMAGE_IN_STREAM_NAME = "image_in"; + private static final String NORM_RECT_IN_STREAM_NAME = "norm_rect_in"; + + @SuppressWarnings("ConstantCaseForConstants") + private static final List INPUT_STREAMS = + Collections.unmodifiableList( + Arrays.asList("IMAGE:" + IMAGE_IN_STREAM_NAME, "NORM_RECT:" + NORM_RECT_IN_STREAM_NAME)); + + @SuppressWarnings("ConstantCaseForConstants") + private static final List OUTPUT_STREAMS = + Collections.unmodifiableList(Arrays.asList("DETECTIONS:detections_out", "IMAGE:image_out")); + + private static final int DETECTIONS_OUT_STREAM_INDEX = 0; + private static final int IMAGE_OUT_STREAM_INDEX = 1; + private static final String TASK_GRAPH_NAME = + "mediapipe.tasks.vision.face_detector.FaceDetectorGraph"; + + /** + * Creates a {@link FaceDetector} instance from a model file and the default {@link + * FaceDetectorOptions}. + * + * @param context an Android {@link Context}. + * @param modelPath path to the detection model with metadata in the assets. + * @throws MediaPipeException if there is an error during {@link FaceDetector} creation. + */ + public static FaceDetector createFromFile(Context context, String modelPath) { + BaseOptions baseOptions = BaseOptions.builder().setModelAssetPath(modelPath).build(); + return createFromOptions( + context, FaceDetectorOptions.builder().setBaseOptions(baseOptions).build()); + } + + /** + * Creates a {@link FaceDetector} instance from a model file and the default {@link + * FaceDetectorOptions}. + * + * @param context an Android {@link Context}. + * @param modelFile the detection model {@link File} instance. + * @throws IOException if an I/O error occurs when opening the tflite model file. + * @throws MediaPipeException if there is an error during {@link FaceDetector} creation. + */ + public static FaceDetector createFromFile(Context context, File modelFile) throws IOException { + try (ParcelFileDescriptor descriptor = + ParcelFileDescriptor.open(modelFile, ParcelFileDescriptor.MODE_READ_ONLY)) { + BaseOptions baseOptions = + BaseOptions.builder().setModelAssetFileDescriptor(descriptor.getFd()).build(); + return createFromOptions( + context, FaceDetectorOptions.builder().setBaseOptions(baseOptions).build()); + } + } + + /** + * Creates a {@link FaceDetector} instance from a model buffer and the default {@link + * FaceDetectorOptions}. + * + * @param context an Android {@link Context}. + * @param modelBuffer a direct {@link ByteBuffer} or a {@link MappedByteBuffer} of the detection + * model. + * @throws MediaPipeException if there is an error during {@link FaceDetector} creation. + */ + public static FaceDetector createFromBuffer(Context context, final ByteBuffer modelBuffer) { + BaseOptions baseOptions = BaseOptions.builder().setModelAssetBuffer(modelBuffer).build(); + return createFromOptions( + context, FaceDetectorOptions.builder().setBaseOptions(baseOptions).build()); + } + + /** + * Creates a {@link FaceDetector} instance from a {@link FaceDetectorOptions}. + * + * @param context an Android {@link Context}. + * @param detectorOptions a {@link FaceDetectorOptions} instance. + * @throws MediaPipeException if there is an error during {@link FaceDetector} creation. + */ + public static FaceDetector createFromOptions( + Context context, FaceDetectorOptions detectorOptions) { + // TODO: Consolidate OutputHandler and TaskRunner. + OutputHandler handler = new OutputHandler<>(); + handler.setOutputPacketConverter( + new OutputHandler.OutputPacketConverter() { + @Override + public FaceDetectorResult convertToTaskResult(List packets) { + // If there is no faces detected in the image, just returns empty lists. + if (packets.get(DETECTIONS_OUT_STREAM_INDEX).isEmpty()) { + return FaceDetectorResult.create( + new ArrayList<>(), + BaseVisionTaskApi.generateResultTimestampMs( + detectorOptions.runningMode(), packets.get(DETECTIONS_OUT_STREAM_INDEX))); + } + return FaceDetectorResult.create( + PacketGetter.getProtoVector( + packets.get(DETECTIONS_OUT_STREAM_INDEX), Detection.parser()), + BaseVisionTaskApi.generateResultTimestampMs( + detectorOptions.runningMode(), packets.get(DETECTIONS_OUT_STREAM_INDEX))); + } + + @Override + public MPImage convertToTaskInput(List packets) { + return new BitmapImageBuilder( + AndroidPacketGetter.getBitmapFromRgb(packets.get(IMAGE_OUT_STREAM_INDEX))) + .build(); + } + }); + detectorOptions.resultListener().ifPresent(handler::setResultListener); + detectorOptions.errorListener().ifPresent(handler::setErrorListener); + TaskRunner runner = + TaskRunner.create( + context, + TaskInfo.builder() + .setTaskName(FaceDetector.class.getSimpleName()) + .setTaskRunningModeName(detectorOptions.runningMode().name()) + .setTaskGraphName(TASK_GRAPH_NAME) + .setInputStreams(INPUT_STREAMS) + .setOutputStreams(OUTPUT_STREAMS) + .setTaskOptions(detectorOptions) + .setEnableFlowLimiting(detectorOptions.runningMode() == RunningMode.LIVE_STREAM) + .build(), + handler); + return new FaceDetector(runner, detectorOptions.runningMode()); + } + + /** + * Constructor to initialize a {@link FaceDetector} from a {@link TaskRunner} and a {@link + * RunningMode}. + * + * @param taskRunner a {@link TaskRunner}. + * @param runningMode a mediapipe vision task {@link RunningMode}. + */ + private FaceDetector(TaskRunner taskRunner, RunningMode runningMode) { + super(taskRunner, runningMode, IMAGE_IN_STREAM_NAME, NORM_RECT_IN_STREAM_NAME); + } + + /** + * Performs face detection on the provided single image with default image processing options, + * i.e. without any rotation applied. Only use this method when the {@link FaceDetector} is + * created with {@link RunningMode.IMAGE}. + * + *

{@link FaceDetector} supports the following color space types: + * + *

    + *
  • {@link Bitmap.Config.ARGB_8888} + *
+ * + * @param image a MediaPipe {@link MPImage} object for processing. + * @throws MediaPipeException if there is an internal error. + */ + public FaceDetectorResult detect(MPImage image) { + return detect(image, ImageProcessingOptions.builder().build()); + } + + /** + * Performs face detection on the provided single image. Only use this method when the {@link + * FaceDetector} is created with {@link RunningMode.IMAGE}. + * + *

{@link FaceDetector} supports the following color space types: + * + *

    + *
  • {@link Bitmap.Config.ARGB_8888} + *
+ * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param imageProcessingOptions the {@link ImageProcessingOptions} specifying how to process the + * input image before running inference. Note that region-of-interest is not supported + * by this task: specifying {@link ImageProcessingOptions#regionOfInterest()} will result in + * this method throwing an IllegalArgumentException. + * @throws IllegalArgumentException if the {@link ImageProcessingOptions} specify a + * region-of-interest. + * @throws MediaPipeException if there is an internal error. + */ + public FaceDetectorResult detect(MPImage image, ImageProcessingOptions imageProcessingOptions) { + validateImageProcessingOptions(imageProcessingOptions); + return (FaceDetectorResult) processImageData(image, imageProcessingOptions); + } + + /** + * Performs face detection on the provided video frame with default image processing options, i.e. + * without any rotation applied. Only use this method when the {@link FaceDetector} is created + * with {@link RunningMode.VIDEO}. + * + *

It's required to provide the video frame's timestamp (in milliseconds). The input timestamps + * must be monotonically increasing. + * + *

{@link FaceDetector} supports the following color space types: + * + *

    + *
  • {@link Bitmap.Config.ARGB_8888} + *
+ * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param timestampMs the input timestamp (in milliseconds). + * @throws MediaPipeException if there is an internal error. + */ + public FaceDetectorResult detectForVideo(MPImage image, long timestampMs) { + return detectForVideo(image, ImageProcessingOptions.builder().build(), timestampMs); + } + + /** + * Performs face detection on the provided video frame. Only use this method when the {@link + * FaceDetector} is created with {@link RunningMode.VIDEO}. + * + *

It's required to provide the video frame's timestamp (in milliseconds). The input timestamps + * must be monotonically increasing. + * + *

{@link FaceDetector} supports the following color space types: + * + *

    + *
  • {@link Bitmap.Config.ARGB_8888} + *
+ * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param imageProcessingOptions the {@link ImageProcessingOptions} specifying how to process the + * input image before running inference. Note that region-of-interest is not supported + * by this task: specifying {@link ImageProcessingOptions#regionOfInterest()} will result in + * this method throwing an IllegalArgumentException. + * @param timestampMs the input timestamp (in milliseconds). + * @throws IllegalArgumentException if the {@link ImageProcessingOptions} specify a + * region-of-interest. + * @throws MediaPipeException if there is an internal error. + */ + public FaceDetectorResult detectForVideo( + MPImage image, ImageProcessingOptions imageProcessingOptions, long timestampMs) { + validateImageProcessingOptions(imageProcessingOptions); + return (FaceDetectorResult) processVideoData(image, imageProcessingOptions, timestampMs); + } + + /** + * Sends live image data to perform face detection with default image processing options, i.e. + * without any rotation applied, and the results will be available via the {@link ResultListener} + * provided in the {@link FaceDetectorOptions}. Only use this method when the {@link FaceDetector} + * is created with {@link RunningMode.LIVE_STREAM}. + * + *

It's required to provide a timestamp (in milliseconds) to indicate when the input image is + * sent to the face detector. The input timestamps must be monotonically increasing. + * + *

{@link FaceDetector} supports the following color space types: + * + *

    + *
  • {@link Bitmap.Config.ARGB_8888} + *
+ * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param timestampMs the input timestamp (in milliseconds). + * @throws MediaPipeException if there is an internal error. + */ + public void detectAsync(MPImage image, long timestampMs) { + detectAsync(image, ImageProcessingOptions.builder().build(), timestampMs); + } + + /** + * Sends live image data to perform face detection, and the results will be available via the + * {@link ResultListener} provided in the {@link FaceDetectorOptions}. Only use this method when + * the {@link FaceDetector} is created with {@link RunningMode.LIVE_STREAM}. + * + *

It's required to provide a timestamp (in milliseconds) to indicate when the input image is + * sent to the face detector. The input timestamps must be monotonically increasing. + * + *

{@link FaceDetector} supports the following color space types: + * + *

    + *
  • {@link Bitmap.Config.ARGB_8888} + *
+ * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param imageProcessingOptions the {@link ImageProcessingOptions} specifying how to process the + * input image before running inference. Note that region-of-interest is not supported + * by this task: specifying {@link ImageProcessingOptions#regionOfInterest()} will result in + * this method throwing an IllegalArgumentException. + * @param timestampMs the input timestamp (in milliseconds). + * @throws IllegalArgumentException if the {@link ImageProcessingOptions} specify a + * region-of-interest. + * @throws MediaPipeException if there is an internal error. + */ + public void detectAsync( + MPImage image, ImageProcessingOptions imageProcessingOptions, long timestampMs) { + validateImageProcessingOptions(imageProcessingOptions); + sendLiveStreamData(image, imageProcessingOptions, timestampMs); + } + + /** Options for setting up a {@link FaceDetector}. */ + @AutoValue + public abstract static class FaceDetectorOptions extends TaskOptions { + + /** Builder for {@link FaceDetectorOptions}. */ + @AutoValue.Builder + public abstract static class Builder { + /** Sets the {@link BaseOptions} for the face detector task. */ + public abstract Builder setBaseOptions(BaseOptions value); + + /** + * Sets the {@link RunningMode} for the face detector task. Default to the image mode. face + * detector has three modes: + * + *
    + *
  • IMAGE: The mode for detecting faces on single image inputs. + *
  • VIDEO: The mode for detecting faces on the decoded frames of a video. + *
  • LIVE_STREAM: The mode for for detecting faces on a live stream of input data, such as + * from camera. In this mode, {@code setResultListener} must be called to set up a + * listener to receive the detection results asynchronously. + *
+ */ + public abstract Builder setRunningMode(RunningMode value); + + /** + * Sets the minimum confidence score for the face detection to be considered successful. The + * default minDetectionConfidence is 0.5. + */ + public abstract Builder setMinDetectionConfidence(Float value); + + /** + * Sets the minimum non-maximum-suppression threshold for face detection to be considered + * overlapped. The default minSuppressionThreshold is 0.3. + */ + public abstract Builder setMinSuppressionThreshold(Float value); + + /** + * Sets the {@link ResultListener} to receive the detection results asynchronously when the + * face detector is in the live stream mode. + */ + public abstract Builder setResultListener(ResultListener value); + + /** Sets an optional {@link ErrorListener}}. */ + public abstract Builder setErrorListener(ErrorListener value); + + abstract FaceDetectorOptions autoBuild(); + + /** + * Validates and builds the {@link FaceDetectorOptions} instance. + * + * @throws IllegalArgumentException if the result listener and the running mode are not + * properly configured. The result listener should only be set when the face detector is + * in the live stream mode. + */ + public final FaceDetectorOptions build() { + FaceDetectorOptions options = autoBuild(); + if (options.runningMode() == RunningMode.LIVE_STREAM) { + if (!options.resultListener().isPresent()) { + throw new IllegalArgumentException( + "The face detector is in the live stream mode, a user-defined result listener" + + " must be provided in FaceDetectorOptions."); + } + } else if (options.resultListener().isPresent()) { + throw new IllegalArgumentException( + "The face detector is in the image or the video mode, a user-defined result" + + " listener shouldn't be provided in FaceDetectorOptions."); + } + return options; + } + } + + abstract BaseOptions baseOptions(); + + abstract RunningMode runningMode(); + + abstract float minDetectionConfidence(); + + abstract float minSuppressionThreshold(); + + abstract Optional> resultListener(); + + abstract Optional errorListener(); + + public static Builder builder() { + return new AutoValue_FaceDetector_FaceDetectorOptions.Builder() + .setRunningMode(RunningMode.IMAGE) + .setMinDetectionConfidence(0.5f) + .setMinSuppressionThreshold(0.3f); + } + + /** Converts a {@link FaceDetectorOptions} to a {@link CalculatorOptions} protobuf message. */ + @Override + public CalculatorOptions convertToCalculatorOptionsProto() { + BaseOptionsProto.BaseOptions.Builder baseOptionsBuilder = + BaseOptionsProto.BaseOptions.newBuilder(); + baseOptionsBuilder.setUseStreamMode(runningMode() != RunningMode.IMAGE); + baseOptionsBuilder.mergeFrom(convertBaseOptionsToProto(baseOptions())); + FaceDetectorGraphOptionsProto.FaceDetectorGraphOptions.Builder taskOptionsBuilder = + FaceDetectorGraphOptionsProto.FaceDetectorGraphOptions.newBuilder() + .setBaseOptions(baseOptionsBuilder); + taskOptionsBuilder.setMinDetectionConfidence(minDetectionConfidence()); + taskOptionsBuilder.setMinSuppressionThreshold(minSuppressionThreshold()); + return CalculatorOptions.newBuilder() + .setExtension( + FaceDetectorGraphOptionsProto.FaceDetectorGraphOptions.ext, + taskOptionsBuilder.build()) + .build(); + } + } + + /** + * Validates that the provided {@link ImageProcessingOptions} doesn't contain a + * region-of-interest. + */ + private static void validateImageProcessingOptions( + ImageProcessingOptions imageProcessingOptions) { + if (imageProcessingOptions.regionOfInterest().isPresent()) { + throw new IllegalArgumentException("FaceDetector doesn't support region-of-interest."); + } + } +} diff --git a/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/AndroidManifest.xml b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/AndroidManifest.xml new file mode 100644 index 000000000..01cbc3a6f --- /dev/null +++ b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/BUILD b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/BUILD new file mode 100644 index 000000000..c14486766 --- /dev/null +++ b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/BUILD @@ -0,0 +1,19 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package(default_visibility = ["//mediapipe/tasks:internal"]) + +licenses(["notice"]) + +# TODO: Enable this in OSS diff --git a/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/FaceDetectorTest.java b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/FaceDetectorTest.java new file mode 100644 index 000000000..d995accd5 --- /dev/null +++ b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/facedetector/FaceDetectorTest.java @@ -0,0 +1,455 @@ +// Copyright 2023 The MediaPipe Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.mediapipe.tasks.vision.facedetector; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.content.res.AssetManager; +import android.graphics.BitmapFactory; +import android.graphics.RectF; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.mediapipe.framework.MediaPipeException; +import com.google.mediapipe.framework.image.BitmapImageBuilder; +import com.google.mediapipe.framework.image.MPImage; +import com.google.mediapipe.tasks.components.containers.NormalizedKeypoint; +import com.google.mediapipe.tasks.core.BaseOptions; +import com.google.mediapipe.tasks.core.TestUtils; +import com.google.mediapipe.tasks.vision.core.ImageProcessingOptions; +import com.google.mediapipe.tasks.vision.core.RunningMode; +import com.google.mediapipe.tasks.vision.facedetector.FaceDetector.FaceDetectorOptions; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Suite; +import org.junit.runners.Suite.SuiteClasses; + +/** Test for {@link FaceDetector}. */ +@RunWith(Suite.class) +@SuiteClasses({FaceDetectorTest.General.class, FaceDetectorTest.RunningModeTest.class}) +public class FaceDetectorTest { + private static final String MODEL_FILE = "face_detection_short_range.tflite"; + private static final String CAT_IMAGE = "cat.jpg"; + private static final String PORTRAIT_IMAGE = "portrait.jpg"; + private static final String PORTRAIT_ROTATED_IMAGE = "portrait_rotated.jpg"; + private static final float KEYPOINTS_DIFF_TOLERANCE = 0.01f; + private static final float PIXEL_DIFF_TOLERANCE = 5.0f; + private static final RectF PORTRAIT_FACE_BOUNDING_BOX = new RectF(283, 115, 514, 349); + private static final List PORTRAIT_FACE_KEYPOINTS = + Collections.unmodifiableList( + Arrays.asList( + NormalizedKeypoint.create(0.44416f, 0.17643f), + NormalizedKeypoint.create(0.55514f, 0.17731f), + NormalizedKeypoint.create(0.50467f, 0.22657f), + NormalizedKeypoint.create(0.50227f, 0.27199f), + NormalizedKeypoint.create(0.36063f, 0.20143f), + NormalizedKeypoint.create(0.60841f, 0.20409f))); + private static final RectF PORTRAIT_ROTATED_FACE_BOUNDING_BOX = new RectF(674, 283, 910, 519); + private static final List PORTRAIT_ROTATED_FACE_KEYPOINTS = + Collections.unmodifiableList( + Arrays.asList( + NormalizedKeypoint.create(0.82075f, 0.44679f), + NormalizedKeypoint.create(0.81965f, 0.56261f), + NormalizedKeypoint.create(0.76194f, 0.51719f), + NormalizedKeypoint.create(0.71993f, 0.51360f), + NormalizedKeypoint.create(0.80700f, 0.36298f), + NormalizedKeypoint.create(0.80882f, 0.61204f))); + + @RunWith(AndroidJUnit4.class) + public static final class General extends FaceDetectorTest { + + @Test + public void detect_successWithValidModels() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .build(); + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + FaceDetectorResult results = faceDetector.detect(getImageFromAsset(PORTRAIT_IMAGE)); + assertContainsSinglePortraitFace( + results, PORTRAIT_FACE_BOUNDING_BOX, PORTRAIT_FACE_KEYPOINTS); + } + + @Test + public void detect_succeedsWithMinDetectionConfidence() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setMinDetectionConfidence(1.0f) + .build(); + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + FaceDetectorResult results = faceDetector.detect(getImageFromAsset(PORTRAIT_IMAGE)); + // Set minDetectionConfidence to 1.0, so the detected face should be all filtered out. + assertThat(results.detections().isEmpty()).isTrue(); + } + + @Test + public void detect_succeedsWithEmptyFace() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setMinDetectionConfidence(1.0f) + .build(); + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + FaceDetectorResult results = faceDetector.detect(getImageFromAsset(CAT_IMAGE)); + assertThat(results.detections().isEmpty()).isTrue(); + } + + @Test + public void detect_succeedsWithModelFileObject() throws Exception { + FaceDetector faceDetector = + FaceDetector.createFromFile( + ApplicationProvider.getApplicationContext(), + TestUtils.loadFile(ApplicationProvider.getApplicationContext(), MODEL_FILE)); + FaceDetectorResult results = faceDetector.detect(getImageFromAsset(PORTRAIT_IMAGE)); + assertContainsSinglePortraitFace( + results, PORTRAIT_FACE_BOUNDING_BOX, PORTRAIT_FACE_KEYPOINTS); + } + + @Test + public void detect_succeedsWithModelBuffer() throws Exception { + FaceDetector faceDetector = + FaceDetector.createFromBuffer( + ApplicationProvider.getApplicationContext(), + TestUtils.loadToDirectByteBuffer( + ApplicationProvider.getApplicationContext(), MODEL_FILE)); + FaceDetectorResult results = faceDetector.detect(getImageFromAsset(PORTRAIT_IMAGE)); + assertContainsSinglePortraitFace( + results, PORTRAIT_FACE_BOUNDING_BOX, PORTRAIT_FACE_KEYPOINTS); + } + + @Test + public void detect_succeedsWithModelBufferAndOptions() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions( + BaseOptions.builder() + .setModelAssetBuffer( + TestUtils.loadToDirectByteBuffer( + ApplicationProvider.getApplicationContext(), MODEL_FILE)) + .build()) + .build(); + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + FaceDetectorResult results = faceDetector.detect(getImageFromAsset(PORTRAIT_IMAGE)); + assertContainsSinglePortraitFace( + results, PORTRAIT_FACE_BOUNDING_BOX, PORTRAIT_FACE_KEYPOINTS); + } + + @Test + public void create_failsWithMissingModel() throws Exception { + String nonexistentFile = "/path/to/non/existent/file"; + MediaPipeException exception = + assertThrows( + MediaPipeException.class, + () -> + FaceDetector.createFromFile( + ApplicationProvider.getApplicationContext(), nonexistentFile)); + assertThat(exception).hasMessageThat().contains(nonexistentFile); + } + + @Test + public void create_failsWithInvalidModelBuffer() throws Exception { + // Create a non-direct model ByteBuffer. + ByteBuffer modelBuffer = + TestUtils.loadToNonDirectByteBuffer( + ApplicationProvider.getApplicationContext(), MODEL_FILE); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + FaceDetector.createFromBuffer( + ApplicationProvider.getApplicationContext(), modelBuffer)); + + assertThat(exception) + .hasMessageThat() + .contains("The model buffer should be either a direct ByteBuffer or a MappedByteBuffer."); + } + + @Test + public void detect_succeedsWithRotation() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .build(); + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + ImageProcessingOptions imageProcessingOptions = + ImageProcessingOptions.builder().setRotationDegrees(-90).build(); + FaceDetectorResult results = + faceDetector.detect(getImageFromAsset(PORTRAIT_ROTATED_IMAGE), imageProcessingOptions); + assertContainsSinglePortraitFace( + results, PORTRAIT_ROTATED_FACE_BOUNDING_BOX, PORTRAIT_ROTATED_FACE_KEYPOINTS); + } + + @Test + public void detect_failsWithRegionOfInterest() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .build(); + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + ImageProcessingOptions imageProcessingOptions = + ImageProcessingOptions.builder().setRegionOfInterest(new RectF(0, 0, 1, 1)).build(); + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> faceDetector.detect(getImageFromAsset(PORTRAIT_IMAGE), imageProcessingOptions)); + assertThat(exception) + .hasMessageThat() + .contains("FaceDetector doesn't support region-of-interest"); + } + } + + @RunWith(AndroidJUnit4.class) + public static final class RunningModeTest extends FaceDetectorTest { + + @Test + public void create_failsWithIllegalResultListenerInNonLiveStreamMode() throws Exception { + for (RunningMode mode : new RunningMode[] {RunningMode.IMAGE, RunningMode.VIDEO}) { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setRunningMode(mode) + .setResultListener((faceDetectorResult, inputImage) -> {}) + .build()); + assertThat(exception) + .hasMessageThat() + .contains("a user-defined result listener shouldn't be provided"); + } + } + + @Test + public void create_failsWithMissingResultListenerInLiveSteamMode() throws Exception { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setRunningMode(RunningMode.LIVE_STREAM) + .build()); + assertThat(exception) + .hasMessageThat() + .contains("a user-defined result listener must be provided"); + } + + @Test + public void detect_failsWithCallingWrongApiInImageMode() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setRunningMode(RunningMode.IMAGE) + .build(); + + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + MediaPipeException exception = + assertThrows( + MediaPipeException.class, + () -> + faceDetector.detectForVideo( + getImageFromAsset(PORTRAIT_IMAGE), /* timestampsMs= */ 0)); + assertThat(exception).hasMessageThat().contains("not initialized with the video mode"); + exception = + assertThrows( + MediaPipeException.class, + () -> + faceDetector.detectAsync( + getImageFromAsset(PORTRAIT_IMAGE), /* timestampsMs= */ 0)); + assertThat(exception).hasMessageThat().contains("not initialized with the live stream mode"); + } + + @Test + public void detect_failsWithCallingWrongApiInVideoMode() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setRunningMode(RunningMode.VIDEO) + .build(); + + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + MediaPipeException exception = + assertThrows( + MediaPipeException.class, + () -> faceDetector.detect(getImageFromAsset(PORTRAIT_IMAGE))); + assertThat(exception).hasMessageThat().contains("not initialized with the image mode"); + exception = + assertThrows( + MediaPipeException.class, + () -> + faceDetector.detectAsync( + getImageFromAsset(PORTRAIT_IMAGE), /* timestampsMs= */ 0)); + assertThat(exception).hasMessageThat().contains("not initialized with the live stream mode"); + } + + @Test + public void detect_failsWithCallingWrongApiInLiveSteamMode() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setRunningMode(RunningMode.LIVE_STREAM) + .setResultListener((faceDetectorResult, inputImage) -> {}) + .build(); + + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + + MediaPipeException exception = + assertThrows( + MediaPipeException.class, + () -> faceDetector.detect(getImageFromAsset(PORTRAIT_IMAGE))); + assertThat(exception).hasMessageThat().contains("not initialized with the image mode"); + exception = + assertThrows( + MediaPipeException.class, + () -> + faceDetector.detectForVideo( + getImageFromAsset(PORTRAIT_IMAGE), /* timestampsMs= */ 0)); + assertThat(exception).hasMessageThat().contains("not initialized with the video mode"); + } + + @Test + public void detect_successWithImageMode() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setRunningMode(RunningMode.IMAGE) + .build(); + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + FaceDetectorResult results = faceDetector.detect(getImageFromAsset(PORTRAIT_IMAGE)); + assertContainsSinglePortraitFace( + results, PORTRAIT_FACE_BOUNDING_BOX, PORTRAIT_FACE_KEYPOINTS); + } + + @Test + public void detect_successWithVideoMode() throws Exception { + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setRunningMode(RunningMode.VIDEO) + .build(); + FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options); + for (int i = 0; i < 3; i++) { + FaceDetectorResult results = + faceDetector.detectForVideo(getImageFromAsset(PORTRAIT_IMAGE), /* timestampsMs= */ i); + assertContainsSinglePortraitFace( + results, PORTRAIT_FACE_BOUNDING_BOX, PORTRAIT_FACE_KEYPOINTS); + } + } + + @Test + public void detect_failsWithOutOfOrderInputTimestamps() throws Exception { + MPImage image = getImageFromAsset(PORTRAIT_IMAGE); + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setRunningMode(RunningMode.LIVE_STREAM) + .setResultListener( + (faceDetectorResult, inputImage) -> { + assertContainsSinglePortraitFace( + faceDetectorResult, PORTRAIT_FACE_BOUNDING_BOX, PORTRAIT_FACE_KEYPOINTS); + }) + .build(); + try (FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options)) { + faceDetector.detectAsync(image, /* timestampsMs= */ 1); + MediaPipeException exception = + assertThrows( + MediaPipeException.class, + () -> faceDetector.detectAsync(image, /* timestampsMs= */ 0)); + assertThat(exception) + .hasMessageThat() + .contains("having a smaller timestamp than the processed timestamp"); + } + } + + @Test + public void detect_successWithLiveSteamMode() throws Exception { + MPImage image = getImageFromAsset(PORTRAIT_IMAGE); + FaceDetectorOptions options = + FaceDetectorOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(MODEL_FILE).build()) + .setRunningMode(RunningMode.LIVE_STREAM) + .setResultListener( + (faceDetectorResult, inputImage) -> { + assertContainsSinglePortraitFace( + faceDetectorResult, PORTRAIT_FACE_BOUNDING_BOX, PORTRAIT_FACE_KEYPOINTS); + }) + .build(); + try (FaceDetector faceDetector = + FaceDetector.createFromOptions(ApplicationProvider.getApplicationContext(), options)) { + for (int i = 0; i < 3; i++) { + faceDetector.detectAsync(image, /* timestampsMs= */ i); + } + } + } + } + + private static MPImage getImageFromAsset(String filePath) throws Exception { + AssetManager assetManager = ApplicationProvider.getApplicationContext().getAssets(); + InputStream istr = assetManager.open(filePath); + return new BitmapImageBuilder(BitmapFactory.decodeStream(istr)).build(); + } + + private static void assertContainsSinglePortraitFace( + FaceDetectorResult results, + RectF expectedboundingBox, + List expectedKeypoints) { + assertThat(results.detections()).hasSize(1); + assertApproximatelyEqualBoundingBoxes( + results.detections().get(0).boundingBox(), expectedboundingBox); + assertThat(results.detections().get(0).keypoints().isPresent()).isTrue(); + assertApproximatelyEqualKeypoints( + results.detections().get(0).keypoints().get(), expectedKeypoints); + } + + private static void assertApproximatelyEqualBoundingBoxes( + RectF boundingBox1, RectF boundingBox2) { + assertThat(boundingBox1.left).isWithin(PIXEL_DIFF_TOLERANCE).of(boundingBox2.left); + assertThat(boundingBox1.top).isWithin(PIXEL_DIFF_TOLERANCE).of(boundingBox2.top); + assertThat(boundingBox1.right).isWithin(PIXEL_DIFF_TOLERANCE).of(boundingBox2.right); + assertThat(boundingBox1.bottom).isWithin(PIXEL_DIFF_TOLERANCE).of(boundingBox2.bottom); + } + + private static void assertApproximatelyEqualKeypoints( + List keypoints1, List keypoints2) { + assertThat(keypoints1.size()).isEqualTo(keypoints2.size()); + for (int i = 0; i < keypoints1.size(); i++) { + assertThat(keypoints1.get(i).x()) + .isWithin(KEYPOINTS_DIFF_TOLERANCE) + .of(keypoints2.get(i).x()); + assertThat(keypoints1.get(i).y()) + .isWithin(KEYPOINTS_DIFF_TOLERANCE) + .of(keypoints2.get(i).y()); + } + } +} From 89be4c7b64f047318589e2d76a754bdcd9ae712c Mon Sep 17 00:00:00 2001 From: kinaryml Date: Sun, 12 Mar 2023 16:09:04 -0700 Subject: [PATCH 052/136] Added some files for the face landmarker implementation --- .../tasks/python/components/containers/BUILD | 9 + .../components/containers/matrix_data.py | 79 +++ mediapipe/tasks/python/test/vision/BUILD | 25 + .../test/vision/face_landmarker_test.py | 182 +++++++ mediapipe/tasks/python/vision/BUILD | 25 + .../tasks/python/vision/face_landmarker.py | 449 ++++++++++++++++++ 6 files changed, 769 insertions(+) create mode 100644 mediapipe/tasks/python/components/containers/matrix_data.py create mode 100644 mediapipe/tasks/python/test/vision/face_landmarker_test.py create mode 100644 mediapipe/tasks/python/vision/face_landmarker.py diff --git a/mediapipe/tasks/python/components/containers/BUILD b/mediapipe/tasks/python/components/containers/BUILD index 7108617ff..f60f6dc22 100644 --- a/mediapipe/tasks/python/components/containers/BUILD +++ b/mediapipe/tasks/python/components/containers/BUILD @@ -73,6 +73,15 @@ py_library( ], ) +py_library( + name = "matrix_data", + srcs = ["matrix_data.py"], + deps = [ + "//mediapipe/framework/formats:matrix_data_py_pb2", + "//mediapipe/tasks/python/core:optional_dependencies", + ], +) + py_library( name = "detections", srcs = ["detections.py"], diff --git a/mediapipe/tasks/python/components/containers/matrix_data.py b/mediapipe/tasks/python/components/containers/matrix_data.py new file mode 100644 index 000000000..9f0d5dfd5 --- /dev/null +++ b/mediapipe/tasks/python/components/containers/matrix_data.py @@ -0,0 +1,79 @@ +# Copyright 2022 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Matrix data data class.""" + +import dataclasses +import enum +from typing import Any, Optional + +from mediapipe.framework.formats import matrix_data_pb2 +from mediapipe.tasks.python.core.optional_dependencies import doc_controls + +_MatrixDataProto = matrix_data_pb2.MatrixData + + +@dataclasses.dataclass +class MatrixData: + """This stores the Matrix data. + + Here the data is stored in column-major order by default. + + Attributes: + rows: The number of rows in the matrix. + cols: The number of columns in the matrix. + data: The data stored in the matrix. + layout: The order in which the data are stored. Defaults to COLUMN_MAJOR. + """ + + class Layout(enum.Enum): + COLUMN_MAJOR = 0 + ROW_MAJOR = 1 + + rows: Optional[int] = None + cols: Optional[int] = None + data: Optional[float] = None + layout: Optional[Layout] = None + + @doc_controls.do_not_generate_docs + def to_pb2(self) -> _MatrixDataProto: + """Generates a MatrixData protobuf object.""" + return _MatrixDataProto( + rows=self.rows, + cols=self.cols, + data=self.data, + layout=self.layout) + + @classmethod + @doc_controls.do_not_generate_docs + def create_from_pb2(cls, pb2_obj: _MatrixDataProto) -> 'MatrixData': + """Creates a `MatrixData` object from the given protobuf object.""" + return MatrixData( + rows=pb2_obj.rows, + cols=pb2_obj.cols, + data=pb2_obj.data, + layout=pb2_obj.layout) + + def __eq__(self, other: Any) -> bool: + """Checks if this object is equal to the given object. + + Args: + other: The object to be compared with. + + Returns: + True if the objects are equal. + """ + if not isinstance(other, MatrixData): + return False + + return self.to_pb2().__eq__(other.to_pb2()) diff --git a/mediapipe/tasks/python/test/vision/BUILD b/mediapipe/tasks/python/test/vision/BUILD index 48ecc30b3..0a1a18fff 100644 --- a/mediapipe/tasks/python/test/vision/BUILD +++ b/mediapipe/tasks/python/test/vision/BUILD @@ -114,3 +114,28 @@ py_test( "@com_google_protobuf//:protobuf_python", ], ) + +py_test( + name = "face_landmarker_test", + srcs = ["face_landmarker_test.py"], + data = [ + "//mediapipe/tasks/testdata/vision:test_images", + "//mediapipe/tasks/testdata/vision:test_models", + "//mediapipe/tasks/testdata/vision:test_protos", + ], + deps = [ + "//mediapipe/python:_framework_bindings", + "//mediapipe/framework/formats:landmark_py_pb2", + "//mediapipe/tasks/python/components/containers:category", + "//mediapipe/tasks/python/components/containers:landmark", + "//mediapipe/tasks/python/components/containers:rect", + "//mediapipe/tasks/python/components/containers:classification_result", + "//mediapipe/tasks/python/components/containers:matrix_data", + "//mediapipe/tasks/python/core:base_options", + "//mediapipe/tasks/python/test:test_utils", + "//mediapipe/tasks/python/vision:face_landmarker", + "//mediapipe/tasks/python/vision/core:image_processing_options", + "//mediapipe/tasks/python/vision/core:vision_task_running_mode", + "@com_google_protobuf//:protobuf_python", + ], +) diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py new file mode 100644 index 000000000..acdc0251e --- /dev/null +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -0,0 +1,182 @@ +# Copyright 2022 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for face landmarker.""" + +import enum +from unittest import mock + +from absl.testing import absltest +from absl.testing import parameterized +import numpy as np + +from google.protobuf import text_format +from mediapipe.framework.formats import landmark_pb2 +from mediapipe.python._framework_bindings import image as image_module +from mediapipe.tasks.python.components.containers import category as category_module +from mediapipe.tasks.python.components.containers import landmark as landmark_module +from mediapipe.tasks.python.components.containers import rect as rect_module +from mediapipe.tasks.python.components.containers import classification_result as classification_result_module +from mediapipe.tasks.python.core import base_options as base_options_module +from mediapipe.tasks.python.test import test_utils +from mediapipe.tasks.python.vision import face_landmarker +from mediapipe.tasks.python.vision.core import image_processing_options as image_processing_options_module +from mediapipe.tasks.python.vision.core import vision_task_running_mode as running_mode_module + +FaceLandmarkerResult = face_landmarker.FaceLandmarkerResult +_BaseOptions = base_options_module.BaseOptions +_Category = category_module.Category +_Rect = rect_module.Rect +_Landmark = landmark_module.Landmark +_NormalizedLandmark = landmark_module.NormalizedLandmark +_Image = image_module.Image +_FaceLandmarker = face_landmarker.FaceLandmarker +_FaceLandmarkerOptions = face_landmarker.FaceLandmarkerOptions +_RUNNING_MODE = running_mode_module.VisionTaskRunningMode +_ImageProcessingOptions = image_processing_options_module.ImageProcessingOptions + +_FACE_LANDMARKER_BUNDLE_ASSET_FILE = 'face_landmarker.task' +_FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE = 'face_landmarker_with_blendshapes.task' +_PORTRAIT_IMAGE = 'portrait.jpg' +_PORTRAIT_EXPECTED_FACE_LANDMARKS = 'portrait_expected_face_landmarks.pbtxt' +_PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION = 'portrait_expected_face_landmarks_with_attention.pbtxt' +_PORTRAIT_EXPECTED_BLENDSHAPES = 'portrait_expected_blendshapes_with_attention.pbtxt' +_LANDMARKS_DIFF_MARGIN = 0.03 +_BLENDSHAPES_DIFF_MARGIN = 0.1 +_FACIAL_TRANSFORMATION_MATRIX_DIFF_MARGIN = 0.02 + + +def _get_expected_face_landmarks(file_path: str): + proto_file_path = test_utils.get_test_data_path(file_path) + with open(proto_file_path, 'rb') as f: + proto = landmark_pb2.NormalizedLandmarkList() + text_format.Parse(f.read(), proto) + landmarks = [] + for landmark in proto.landmark: + landmarks.append(_NormalizedLandmark.create_from_pb2(landmark)) + return landmarks + + +class ModelFileType(enum.Enum): + FILE_CONTENT = 1 + FILE_NAME = 2 + + +class HandLandmarkerTest(parameterized.TestCase): + + def setUp(self): + super().setUp() + self.test_image = _Image.create_from_file( + test_utils.get_test_data_path(_PORTRAIT_IMAGE)) + self.model_path = test_utils.get_test_data_path( + _FACE_LANDMARKER_BUNDLE_ASSET_FILE) + + def _expect_landmarks_correct(self, actual_landmarks, expected_landmarks): + # Expects to have the same number of faces detected. + self.assertLen(actual_landmarks, len(expected_landmarks)) + + for i, rename_me in enumerate(actual_landmarks): + self.assertAlmostEqual( + rename_me.x, + expected_landmarks[i].x, + delta=_LANDMARKS_DIFF_MARGIN) + self.assertAlmostEqual( + rename_me.y, + expected_landmarks[i].y, + delta=_LANDMARKS_DIFF_MARGIN) + + def _expect_blendshapes_correct(self, actual_blendshapes, expected_blendshapes): + # Expects to have the same number of blendshapes. + self.assertLen(actual_blendshapes, len(expected_blendshapes)) + + for i, rename_me in enumerate(actual_blendshapes): + self.assertEqual(rename_me.index, expected_blendshapes[i].index) + self.assertAlmostEqual( + rename_me.score, + expected_blendshapes[i].score, + delta=_BLENDSHAPES_DIFF_MARGIN) + + def _expect_facial_transformation_matrix_correct(self, actual_matrix_list, + expected_matrix_list): + self.assertLen(actual_matrix_list, len(expected_matrix_list)) + + for i, rename_me in enumerate(actual_matrix_list): + self.assertEqual(rename_me.rows, expected_matrix_list[i].rows) + self.assertEqual(rename_me.cols, expected_matrix_list[i].cols) + self.assertAlmostEqual( + rename_me.data, + expected_matrix_list[i].data, + delta=_FACIAL_TRANSFORMATION_MATRIX_DIFF_MARGIN) + + def test_create_from_file_succeeds_with_valid_model_path(self): + # Creates with default option and valid model file successfully. + with _FaceLandmarker.create_from_model_path(self.model_path) as landmarker: + self.assertIsInstance(landmarker, _FaceLandmarker) + + def test_create_from_options_succeeds_with_valid_model_path(self): + # Creates with options containing model file successfully. + base_options = _BaseOptions(model_asset_path=self.model_path) + options = _FaceLandmarkerOptions(base_options=base_options) + with _FaceLandmarker.create_from_options(options) as landmarker: + self.assertIsInstance(landmarker, _FaceLandmarker) + + def test_create_from_options_fails_with_invalid_model_path(self): + # Invalid empty model path. + with self.assertRaisesRegex( + RuntimeError, 'Unable to open file at /path/to/invalid/model.tflite'): + base_options = _BaseOptions( + model_asset_path='/path/to/invalid/model.tflite') + options = _FaceLandmarkerOptions(base_options=base_options) + _FaceLandmarker.create_from_options(options) + + def test_create_from_options_succeeds_with_valid_model_content(self): + # Creates with options containing model content successfully. + with open(self.model_path, 'rb') as f: + base_options = _BaseOptions(model_asset_buffer=f.read()) + options = _FaceLandmarkerOptions(base_options=base_options) + landmarker = _FaceLandmarker.create_from_options(options) + self.assertIsInstance(landmarker, _FaceLandmarker) + + @parameterized.parameters( + (ModelFileType.FILE_NAME, + _get_expected_face_landmarks(_PORTRAIT_EXPECTED_FACE_LANDMARKS)), + (ModelFileType.FILE_CONTENT, + _get_expected_face_landmarks(_PORTRAIT_EXPECTED_FACE_LANDMARKS))) + def test_detect(self, model_file_type, expected_result): + # Creates face landmarker. + if model_file_type is ModelFileType.FILE_NAME: + base_options = _BaseOptions(model_asset_path=self.model_path) + elif model_file_type is ModelFileType.FILE_CONTENT: + with open(self.model_path, 'rb') as f: + model_content = f.read() + base_options = _BaseOptions(model_asset_buffer=model_content) + else: + # Should never happen + raise ValueError('model_file_type is invalid.') + + options = _FaceLandmarkerOptions(base_options=base_options, + output_face_blendshapes=True) + landmarker = _FaceLandmarker.create_from_options(options) + + # Performs face landmarks detection on the input. + detection_result = landmarker.detect(self.test_image) + # Comparing results. + self._expect_landmarks_correct(detection_result.face_landmarks, + expected_result.face_landmarks) + # Closes the face landmarker explicitly when the face landmarker is not used + # in a context. + landmarker.close() + + +if __name__ == '__main__': + absltest.main() diff --git a/mediapipe/tasks/python/vision/BUILD b/mediapipe/tasks/python/vision/BUILD index eda8e290d..62b760561 100644 --- a/mediapipe/tasks/python/vision/BUILD +++ b/mediapipe/tasks/python/vision/BUILD @@ -152,3 +152,28 @@ py_library( "//mediapipe/tasks/python/vision/core:vision_task_running_mode", ], ) + +py_library( + name = "face_landmarker", + srcs = [ + "face_landmarker.py", + ], + deps = [ + "//mediapipe/framework/formats:classification_py_pb2", + "//mediapipe/framework/formats:landmark_py_pb2", + "//mediapipe/framework/formats:matrix_data_py_pb2", + "//mediapipe/python:_framework_bindings", + "//mediapipe/python:packet_creator", + "//mediapipe/python:packet_getter", + "//mediapipe/tasks/cc/vision/face_landmarker/proto:face_landmarker_graph_options_py_pb2", + "//mediapipe/tasks/python/components/containers:category", + "//mediapipe/tasks/python/components/containers:landmark", + "//mediapipe/tasks/python/components/containers:matrix_data", + "//mediapipe/tasks/python/core:base_options", + "//mediapipe/tasks/python/core:optional_dependencies", + "//mediapipe/tasks/python/core:task_info", + "//mediapipe/tasks/python/vision/core:base_vision_task_api", + "//mediapipe/tasks/python/vision/core:image_processing_options", + "//mediapipe/tasks/python/vision/core:vision_task_running_mode", + ], +) diff --git a/mediapipe/tasks/python/vision/face_landmarker.py b/mediapipe/tasks/python/vision/face_landmarker.py new file mode 100644 index 000000000..93a296f92 --- /dev/null +++ b/mediapipe/tasks/python/vision/face_landmarker.py @@ -0,0 +1,449 @@ +# Copyright 2022 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""MediaPipe face landmarker task.""" + +import dataclasses +import enum +from typing import Callable, Mapping, Optional, List + +from mediapipe.framework.formats import classification_pb2 +from mediapipe.framework.formats import landmark_pb2 +from mediapipe.framework.formats import matrix_data_pb2 +from mediapipe.python import packet_creator +from mediapipe.python import packet_getter +from mediapipe.python._framework_bindings import image as image_module +from mediapipe.python._framework_bindings import packet as packet_module +from mediapipe.tasks.cc.vision.face_landmarker.proto import face_landmarker_graph_options_pb2 +from mediapipe.tasks.python.components.containers import category as category_module +from mediapipe.tasks.python.components.containers import landmark as landmark_module +from mediapipe.tasks.python.components.containers import matrix_data as matrix_data_module +from mediapipe.tasks.python.core import base_options as base_options_module +from mediapipe.tasks.python.core import task_info as task_info_module +from mediapipe.tasks.python.core.optional_dependencies import doc_controls +from mediapipe.tasks.python.vision.core import base_vision_task_api +from mediapipe.tasks.python.vision.core import image_processing_options as image_processing_options_module +from mediapipe.tasks.python.vision.core import vision_task_running_mode as running_mode_module + +_BaseOptions = base_options_module.BaseOptions +_FaceLandmarkerGraphOptionsProto = face_landmarker_graph_options_pb2.FaceLandmarkerGraphOptions +_RunningMode = running_mode_module.VisionTaskRunningMode +_ImageProcessingOptions = image_processing_options_module.ImageProcessingOptions +_TaskInfo = task_info_module.TaskInfo + +_IMAGE_IN_STREAM_NAME = 'image_in' +_IMAGE_OUT_STREAM_NAME = 'image_out' +_IMAGE_TAG = 'IMAGE' +_NORM_RECT_STREAM_NAME = 'norm_rect_in' +_NORM_RECT_TAG = 'NORM_RECT' +_NORM_LANDMARKS_STREAM_NAME = 'norm_landmarks' +_NORM_LANDMARKS_TAG = 'NORM_LANDMARKS' +_BLENDSHAPES_STREAM_NAME = 'blendshapes' +_BLENDSHAPES_TAG = 'BLENDSHAPES' +_FACE_GEOMETRY_STREAM_NAME = 'face_geometry' +_FACE_GEOMETRY_TAG = 'FACE_GEOMETRY' +_TASK_GRAPH_NAME = 'mediapipe.tasks.vision.face_landmarker.FaceLandmarkerGraph' +_MICRO_SECONDS_PER_MILLISECOND = 1000 + + +class Blendshapes(enum.IntEnum): + """The 52 blendshape coefficients.""" + NEUTRAL = 0 + BROW_DOWN_LEFT = 1 + BROW_DOWN_RIGHT = 2 + BROW_INNER_UP = 3 + BROW_OUTER_UP_LEFT = 4 + BROW_OUTER_UP_RIGHT = 5 + CHEEK_PUFF = 6 + CHEEK_SQUINT_LEFT = 7 + CHEEK_SQUINT_RIGHT = 8 + EYE_BLINK_LEFT = 9 + EYE_BLINK_RIGHT = 10 + EYE_LOOK_DOWN_LEFT = 11 + EYE_LOOK_DOWN_RIGHT = 12 + EYE_LOOK_IN_LEFT = 13 + EYE_LOOK_IN_RIGHT = 14 + EYE_LOOK_OUT_LEFT = 15 + EYE_LOOK_OUT_RIGHT = 16 + EYE_LOOK_UP_LEFT = 17 + EYE_LOOK_UP_RIGHT = 18 + EYE_SQUINT_LEFT = 19 + EYE_SQUINT_RIGHT = 20 + EYE_WIDE_LEFT = 21 + EYE_WIDE_RIGHT = 22 + JAW_FORWARD = 23 + JAW_LEFT = 24 + JAW_OPEN = 25 + JAW_RIGHT = 26 + MOUTH_CLOSE = 27 + MOUTH_DIMPLE_LEFT = 28 + MOUTH_DIMPLE_RIGHT = 29 + MOUTH_FROWN_LEFT = 30 + MOUTH_FROWN_RIGHT = 31 + MOUTH_FUNNEL = 32 + MOUTH_LEFT = 33 + MOUTH_LOWER_DOWN_LEFT = 34 + MOUTH_LOWER_DOWN_RIGHT = 35 + MOUTH_PRESS_LEFT = 36 + MOUTH_PRESS_RIGHT = 37 + MOUTH_PUCKER = 38 + MOUTH_RIGHT = 39 + MOUTH_ROLL_LOWER = 40 + MOUTH_ROLL_UPPER = 41 + MOUTH_SHRUG_LOWER = 42 + MOUTH_SHRUG_UPPER = 43 + MOUTH_SMILE_LEFT = 44 + MOUTH_SMILE_RIGHT = 45 + MOUTH_STRETCH_LEFT = 46 + MOUTH_STRETCH_RIGHT = 47 + MOUTH_UPPER_UP_LEFT = 48 + MOUTH_UPPER_UP_RIGHT = 49 + NOSE_SNEER_LEFT = 50 + NOSE_SNEER_RIGHT = 51 + + +@dataclasses.dataclass +class FaceLandmarkerResult: + """The face landmarks detection result from FaceLandmarker, where each vector element represents a single face detected in the image. + + Attributes: + face_landmarks: Detected face landmarks in normalized image coordinates. + face_blendshapes: Optional face blendshapes results. + facial_transformation_matrixes: Optional facial transformation matrix. + """ + + face_landmarks: List[List[landmark_module.NormalizedLandmark]] + face_blendshapes: List[List[category_module.Category]] + facial_transformation_matrixes: List[matrix_data_module.MatrixData] + + +def _build_landmarker_result( + output_packets: Mapping[str, packet_module.Packet]) -> FaceLandmarkerResult: + """Constructs a `FaceLandmarkerResult` from output packets.""" + face_landmarks_proto_list = packet_getter.get_proto_list( + output_packets[_NORM_LANDMARKS_STREAM_NAME]) + face_blendshapes_proto_list = packet_getter.get_proto_list( + output_packets[_BLENDSHAPES_STREAM_NAME]) + facial_transformation_matrixes_proto_list = packet_getter.get_proto_list( + output_packets[_FACE_GEOMETRY_STREAM_NAME]) + + face_landmarks_results = [] + for proto in face_landmarks_proto_list: + face_landmarks = landmark_pb2.NormalizedLandmarkList() + face_landmarks.MergeFrom(proto) + face_landmarks_list = [] + for face_landmark in face_landmarks.landmark: + face_landmarks.append( + landmark_module.NormalizedLandmark.create_from_pb2(face_landmark)) + face_landmarks_results.append(face_landmarks_list) + + face_blendshapes_results = [] + for proto in face_blendshapes_proto_list: + face_blendshapes_categories = [] + face_blendshapes_classifications = classification_pb2.ClassificationList() + face_blendshapes_classifications.MergeFrom(proto) + for face_blendshapes in face_blendshapes_classifications.classification: + face_blendshapes_categories.append( + category_module.Category( + index=face_blendshapes.index, + score=face_blendshapes.score, + display_name=face_blendshapes.display_name, + category_name=face_blendshapes.label)) + face_blendshapes_results.append(face_blendshapes_categories) + + facial_transformation_matrixes_results = [] + for proto in facial_transformation_matrixes_proto_list: + matrix_data = matrix_data_pb2.MatrixData() + matrix_data.MergeFrom(proto) + matrix = matrix_data_module.MatrixData.create_from_pb2(matrix_data) + facial_transformation_matrixes_results.append(matrix) + + return FaceLandmarkerResult(face_landmarks_results, face_blendshapes_results, + facial_transformation_matrixes_results) + + +@dataclasses.dataclass +class FaceLandmarkerOptions: + """Options for the face landmarker task. + + Attributes: + base_options: Base options for the face landmarker task. + running_mode: The running mode of the task. Default to the image mode. + HandLandmarker has three running modes: 1) The image mode for detecting + face landmarks on single image inputs. 2) The video mode for detecting + face landmarks on the decoded frames of a video. 3) The live stream mode + for detecting face landmarks on the live stream of input data, such as + from camera. In this mode, the "result_callback" below must be specified + to receive the detection results asynchronously. + num_faces: The maximum number of faces that can be detected by the + FaceLandmarker. + min_face_detection_confidence: The minimum confidence score for the face + detection to be considered successful. + min_face_presence_confidence: The minimum confidence score of face presence + score in the face landmark detection. + min_tracking_confidence: The minimum confidence score for the face tracking + to be considered successful. + output_face_blendshapes: Whether FaceLandmarker outputs face blendshapes + classification. Face blendshapes are used for rendering the 3D face model. + output_facial_transformation_matrixes: Whether FaceLandmarker outputs facial + transformation_matrix. Facial transformation matrix is used to transform + the face landmarks in canonical face to the detected face, so that users + can apply face effects on the detected landmarks. + result_callback: The user-defined result callback for processing live stream + data. The result callback should only be specified when the running mode + is set to the live stream mode. + """ + base_options: _BaseOptions + running_mode: _RunningMode = _RunningMode.IMAGE + num_faces: Optional[int] = 1 + min_face_detection_confidence: Optional[float] = 0.5 + min_face_presence_confidence: Optional[float] = 0.5 + min_tracking_confidence: Optional[float] = 0.5 + output_face_blendshapes: Optional[bool] = False + output_facial_transformation_matrixes: Optional[bool] = False + result_callback: Optional[Callable[ + [FaceLandmarkerResult, image_module.Image, int], None]] = None + + @doc_controls.do_not_generate_docs + def to_pb2(self) -> _FaceLandmarkerGraphOptionsProto: + """Generates an FaceLandmarkerGraphOptions protobuf object.""" + base_options_proto = self.base_options.to_pb2() + base_options_proto.use_stream_mode = False if self.running_mode == _RunningMode.IMAGE else True + + # Initialize the face landmarker options from base options. + face_landmarker_options_proto = _FaceLandmarkerGraphOptionsProto( + base_options=base_options_proto) + + # Configure face detector options. + face_landmarker_options_proto.face_detector_graph_options.num_faces = self.num_faces + face_landmarker_options_proto.face_detector_graph_options.min_detection_confidence = self.min_face_detection_confidence + + # Configure face landmark detector options. + face_landmarker_options_proto.min_tracking_confidence = self.min_tracking_confidence + face_landmarker_options_proto.face_landmarks_detector_graph_options.min_detection_confidence = self.min_face_detection_confidence + return face_landmarker_options_proto + + +class FaceLandmarker(base_vision_task_api.BaseVisionTaskApi): + """Class that performs face landmarks detection on images.""" + + @classmethod + def create_from_model_path(cls, model_path: str) -> 'FaceLandmarker': + """Creates an `FaceLandmarker` object from a TensorFlow Lite model and the default `FaceLandmarkerOptions`. + + Note that the created `FaceLandmarker` instance is in image mode, for + detecting face landmarks on single image inputs. + + Args: + model_path: Path to the model. + + Returns: + `FaceLandmarker` object that's created from the model file and the + default `FaceLandmarkerOptions`. + + Raises: + ValueError: If failed to create `FaceLandmarker` object from the + provided file such as invalid file path. + RuntimeError: If other types of error occurred. + """ + base_options = _BaseOptions(model_asset_path=model_path) + options = FaceLandmarkerOptions( + base_options=base_options, running_mode=_RunningMode.IMAGE) + return cls.create_from_options(options) + + @classmethod + def create_from_options(cls, + options: FaceLandmarkerOptions) -> 'FaceLandmarker': + """Creates the `FaceLandmarker` object from face landmarker options. + + Args: + options: Options for the face landmarker task. + + Returns: + `FaceLandmarker` object that's created from `options`. + + Raises: + ValueError: If failed to create `FaceLandmarker` object from + `FaceLandmarkerOptions` such as missing the model. + RuntimeError: If other types of error occurred. + """ + + def packets_callback(output_packets: Mapping[str, packet_module.Packet]): + if output_packets[_IMAGE_OUT_STREAM_NAME].is_empty(): + return + + image = packet_getter.get_image(output_packets[_IMAGE_OUT_STREAM_NAME]) + if output_packets[_IMAGE_OUT_STREAM_NAME].is_empty(): + return + + if output_packets[_NORM_LANDMARKS_STREAM_NAME].is_empty(): + empty_packet = output_packets[_NORM_LANDMARKS_STREAM_NAME] + options.result_callback( + FaceLandmarkerResult([], [], []), image, + empty_packet.timestamp.value // _MICRO_SECONDS_PER_MILLISECOND) + return + + face_landmarks_result = _build_landmarker_result(output_packets) + timestamp = output_packets[_NORM_LANDMARKS_STREAM_NAME].timestamp + options.result_callback(face_landmarks_result, image, + timestamp.value // _MICRO_SECONDS_PER_MILLISECOND) + + task_info = _TaskInfo( + task_graph=_TASK_GRAPH_NAME, + input_streams=[ + ':'.join([_IMAGE_TAG, _IMAGE_IN_STREAM_NAME]), + ':'.join([_NORM_RECT_TAG, _NORM_RECT_STREAM_NAME]), + ], + output_streams=[ + ':'.join([_NORM_LANDMARKS_TAG, _NORM_LANDMARKS_STREAM_NAME]), + ':'.join([_BLENDSHAPES_TAG, _BLENDSHAPES_STREAM_NAME]), + ':'.join([ + _FACE_GEOMETRY_TAG, _FACE_GEOMETRY_STREAM_NAME + ]), ':'.join([_IMAGE_TAG, _IMAGE_OUT_STREAM_NAME]) + ], + task_options=options) + return cls( + task_info.generate_graph_config( + enable_flow_limiting=options.running_mode == + _RunningMode.LIVE_STREAM), options.running_mode, + packets_callback if options.result_callback else None) + + def detect( + self, + image: image_module.Image, + image_processing_options: Optional[_ImageProcessingOptions] = None + ) -> FaceLandmarkerResult: + """Performs face landmarks detection on the given image. + + Only use this method when the FaceLandmarker is created with the image + running mode. + + The image can be of any size with format RGB or RGBA. + TODO: Describes how the input image will be preprocessed after the yuv + support is implemented. + + Args: + image: MediaPipe Image. + image_processing_options: Options for image processing. + + Returns: + The face landmarks detection results. + + Raises: + ValueError: If any of the input arguments is invalid. + RuntimeError: If face landmarker detection failed to run. + """ + normalized_rect = self.convert_to_normalized_rect( + image_processing_options, roi_allowed=False) + output_packets = self._process_image_data({ + _IMAGE_IN_STREAM_NAME: + packet_creator.create_image(image), + _NORM_RECT_STREAM_NAME: + packet_creator.create_proto(normalized_rect.to_pb2()) + }) + + if output_packets[_NORM_LANDMARKS_STREAM_NAME].is_empty(): + return FaceLandmarkerResult([], [], []) + + return _build_landmarker_result(output_packets) + + def detect_for_video( + self, + image: image_module.Image, + timestamp_ms: int, + image_processing_options: Optional[_ImageProcessingOptions] = None + ) -> FaceLandmarkerResult: + """Performs face landmarks detection on the provided video frame. + + Only use this method when the FaceLandmarker is created with the video + running mode. + + Only use this method when the FaceLandmarker is created with the video + running mode. It's required to provide the video frame's timestamp (in + milliseconds) along with the video frame. The input timestamps should be + monotonically increasing for adjacent calls of this method. + + Args: + image: MediaPipe Image. + timestamp_ms: The timestamp of the input video frame in milliseconds. + image_processing_options: Options for image processing. + + Returns: + The face landmarks detection results. + + Raises: + ValueError: If any of the input arguments is invalid. + RuntimeError: If face landmarker detection failed to run. + """ + normalized_rect = self.convert_to_normalized_rect( + image_processing_options, roi_allowed=False) + output_packets = self._process_video_data({ + _IMAGE_IN_STREAM_NAME: + packet_creator.create_image(image).at( + timestamp_ms * _MICRO_SECONDS_PER_MILLISECOND), + _NORM_RECT_STREAM_NAME: + packet_creator.create_proto(normalized_rect.to_pb2()).at( + timestamp_ms * _MICRO_SECONDS_PER_MILLISECOND) + }) + + if output_packets[_NORM_LANDMARKS_STREAM_NAME].is_empty(): + return FaceLandmarkerResult([], [], []) + + return _build_landmarker_result(output_packets) + + def detect_async( + self, + image: image_module.Image, + timestamp_ms: int, + image_processing_options: Optional[_ImageProcessingOptions] = None + ) -> None: + """Sends live image data to perform face landmarks detection. + + The results will be available via the "result_callback" provided in the + FaceLandmarkerOptions. Only use this method when the FaceLandmarker is + created with the live stream running mode. + + Only use this method when the FaceLandmarker is created with the live + stream running mode. The input timestamps should be monotonically increasing + for adjacent calls of this method. This method will return immediately after + the input image is accepted. The results will be available via the + `result_callback` provided in the `FaceLandmarkerOptions`. The + `detect_async` method is designed to process live stream data such as + camera input. To lower the overall latency, face landmarker may drop the + input images if needed. In other words, it's not guaranteed to have output + per input image. + + The `result_callback` provides: + - The face landmarks detection results. + - The input image that the face landmarker runs on. + - The input timestamp in milliseconds. + + Args: + image: MediaPipe Image. + timestamp_ms: The timestamp of the input image in milliseconds. + image_processing_options: Options for image processing. + + Raises: + ValueError: If the current input timestamp is smaller than what the + face landmarker has already processed. + """ + normalized_rect = self.convert_to_normalized_rect( + image_processing_options, roi_allowed=False) + self._send_live_stream_data({ + _IMAGE_IN_STREAM_NAME: + packet_creator.create_image(image).at( + timestamp_ms * _MICRO_SECONDS_PER_MILLISECOND), + _NORM_RECT_STREAM_NAME: + packet_creator.create_proto(normalized_rect.to_pb2()).at( + timestamp_ms * _MICRO_SECONDS_PER_MILLISECOND) + }) From ffea85e470cb1b1fa243c759920438044be12eff Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Mon, 13 Mar 2023 05:09:52 -0700 Subject: [PATCH 053/136] Internal change PiperOrigin-RevId: 516179167 --- .../quality/padding_effect_generator_test.cc | 18 +++++++++++++++++- .../autoflip/quality/testdata/result_0.3.jpg | Bin 3290 -> 3295 bytes .../autoflip/quality/testdata/result_0.6.jpg | Bin 6203 -> 6211 bytes .../autoflip/quality/testdata/result_1.jpg | Bin 8386 -> 8420 bytes .../autoflip/quality/testdata/result_3.4.jpg | Bin 7803 -> 7797 bytes 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mediapipe/examples/desktop/autoflip/quality/padding_effect_generator_test.cc b/mediapipe/examples/desktop/autoflip/quality/padding_effect_generator_test.cc index 787baa370..84b229d80 100644 --- a/mediapipe/examples/desktop/autoflip/quality/padding_effect_generator_test.cc +++ b/mediapipe/examples/desktop/autoflip/quality/padding_effect_generator_test.cc @@ -138,7 +138,23 @@ void TestWithAspectRatio(const double aspect_ratio, std::string result_image; MP_ASSERT_OK( mediapipe::file::GetContents(result_string_path, &result_image)); - EXPECT_EQ(result_image, output_string); + if (result_image != output_string) { + // There may be slight differences due to the way the JPEG was encoded or + // the OpenCV version used to generate the reference files. Compare + // pixel-by-pixel using the Peak Signal-to-Noise Ratio instead. + cv::Mat result_mat = + cv::imdecode(cv::Mat(1, result_image.size(), CV_8UC1, + const_cast(result_image.data())), + cv::IMREAD_UNCHANGED); + cv::Mat output_mat = + cv::imdecode(cv::Mat(1, output_string.size(), CV_8UC1, + const_cast(output_string.data())), + cv::IMREAD_UNCHANGED); + ASSERT_EQ(result_mat.rows, output_mat.rows); + ASSERT_EQ(result_mat.cols, output_mat.cols); + ASSERT_EQ(result_mat.type(), output_mat.type()); + EXPECT_GT(cv::PSNR(result_mat, output_mat), 45.0); + } } else { std::string output_string_path = mediapipe::file::JoinPath( absl::GetFlag(FLAGS_output_folder), diff --git a/mediapipe/examples/desktop/autoflip/quality/testdata/result_0.3.jpg b/mediapipe/examples/desktop/autoflip/quality/testdata/result_0.3.jpg index 53ebcf770e4257196d7baeb0791b2dc68af6934a..3602046e6c145b175f4c28563044b046d58455ab 100644 GIT binary patch delta 1324 zcmV+{1=IT48Q&SO%L0Fx&x!_+{L*d}3rZ?8H@z#Apvyaj8--Kz{pm#h^tp=mY`M5n z=Bf#*Rg*OCOM^u8Qv+t5nq*j7D58K0X*i`Op^@=Q<4WN2Vyj6tGFfV)$(n~FrWDDZ zZ+eKzeQFX(r3y_&&D2sK=9&bio3PQa($scDt0}3HYNxc+iJX7aOqoR*amkvnr!__# z@mCVHOyrueEfj*5sK||v6;fxb40x(@Rx+K9sjW(~K21Z6nw6I(l_SZhPUl)_Ard^) z=?zU3Rb^uWv{5mgQmBL4g9o);+9?PXe5PL2ndT2_Rfn3mR8SOBm&_s!}-yw}ftGDOj>)qs|pT$Z0&oT6;2i%?~=1P48LWVW%vnk7gX^ zn#r1!HD|>6t4iEfQjLpD8lY~qVTTo2&MV5P6QxL1Jk)>jnx(%6rjAOgO2kTKD>HDX13>J1kNhrA4}Z^|ss8|eqyGTn=>GtP z$>Hz$^VL7^)PMY4czqNIljaE{2Kr3Va6QM9cnKc~J9Q@No@6^7<&$0tAP5_}mo)Ax z9^;c<3L*nAJ;RfD2_JtxiU6P{=A}t9I+Bd%1iCAQVbgBXzqlrNfXtjeky;092&BRiiay9P9&wN z2Q-c?58NF;<}?vSjotMrA9_7%L;|XXHpg_EMPp-w{cOLLU^S`>J!*( zuT@%3Y4WvJCz|!CtqwUoO?Nz1DNi*s$;DQ4lUxm+l{nZ$>sC;4P{z46X;+HQOF_+v zQl&78RXDD7qceYcmR6rot5f$^WMr&j(5+CtJ({6on$Dz=Q9}(h^rz!ArFg99txBH7 zDMeO2YV4_1S$VH!o6zExtvhWvB^GQhkcxk`Cf+GkY51<1aIu`7%CbjQ z%v{!kEksaPWMyL)5yBx)RK-6OwT+Ejn1E8zMKVOiAzF15nhTMXq}fu@ijG8AF&@fL zwER&`K_Of~C^WQ&tsxP!VxligA*$q@*0Dr3XwgL#kzqv?Pyt00Pyt00Pyt00PytA3 iXrKsPb5IeO`Kx4Ro3^b=#I!z&D5xkXlW_u10{_{;FlRvk delta 1424 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 diff --git a/mediapipe/examples/desktop/autoflip/quality/testdata/result_1.jpg b/mediapipe/examples/desktop/autoflip/quality/testdata/result_1.jpg index c03cd46108d10d57416381874e712adb46f2f71f..741e078e9b1c97c8df6d49f53e2c6d4257514ef8 100644 GIT binary patch literal 8420 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 HA&mYP^T;NJ delta 6490 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 B_GAD6 diff --git a/mediapipe/examples/desktop/autoflip/quality/testdata/result_3.4.jpg b/mediapipe/examples/desktop/autoflip/quality/testdata/result_3.4.jpg index 5ec4ea6ec9b8ee55343a54a70fef50923f352479..4efbe7da295db120484f94cfb8c1aae92f8c1af0 100644 GIT binary patch delta 3915 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 delta 3808 zcmY+Hc{tST|HeNhoMeee_Bvx9isWRSvG3bt3xjh?jU@+3_C6%k5RE}9`zTq5P!yxZ zp6q7Il6}cKm_f7tobNg3cYVKq-2Xg(JlAtQ*ZqFI@0+Se*Q@!JSL8u^bn*vm!7rX{ zfJA2l%Z5)t6aX*NJRt3%e7#YgUo zhfY^a(SK=WrKHto1`0X8(?5?^tW4oiEROqEdV905@(QBr0v&z*v#ZmSqQA@7f+=2EGZ8`pynbb;-m5_iDq=dR z@-1WAG{RxQ%0}L@IA4-?MDWN%g^`4>dES<-{k-1RyaXM2$ zd2XssV29X5sJo=orz4%dYbX9H|%)YQk;Q`Z+Gwmy!8dCFH37w6v)Yb34p{9}*tvOuR= zFAw`RM@WIe1W68s^ZeeqsOV(eh0^*WxA-``G#(P?1WKaf0@_()9WUu9QT|Mec7%EMeNS6TKi^B zVT9}*J%)+26yZ)%-WO_B=ACQcXj^dUUP*8vE6ioQP26;X;3WR=T~`E;tQg$hefx4C zHAvY1hHGr^(x$GYvcNod4Fh^s&kxX%{Z@!a$}fmgBRpGsKp}J zHJfpf4Pel7tQHA2U~JVg*Lu>P^Ru6LOqCdd{?lQ4hjDs$kI@p)o@i?KSVQ-`p6nLF zX=Z6#SGOhFvL+OiA{GUmNPFockvv0uQ8%ih$tmBrA+|MQHjSZ*%)7%AG-Lgz*3~N= zj3#h>Z9z2PD8pah)nJqX4dH7`6IG?ZO9|KF`|&j)y$`plDYO_eKnq&Sg+8uL3<7Da zH~O#8i!l8|iJANtIR=wSKS{QpkDlf_Vfbf=I2}zX?J5U^sfd{&cAPI5 z^a4}C&LCzJ6&C8 zgfHqVqZO+|9g5M4z(3;XA)NlE*?ONaoSw0$`FqHu*)NPZ#%d*5y5w=bOxjn%^@ghw zha*2|J5oAj;wuP>14-b!=NGKwL&TH|J1 zWW>kE?c_Nf^E>T^zI^#w!H@VC>1v?=q8J1d)_(e z%*@gLBo}qCnMCR4NnXqaBUsY zIs07YqW!C}WdKPr6Z-(0!2ERWo-Gf(>S=j{3Y+4z{j89s>@74!W(s(>hqMZ=U3q5U z@v+c4NO5eEJR!(PpYgnFD~UNOWa``1bxr$7YykL$b$UPJ`-bdL<${~Dcg#jI1X7$Z zHNu42*c6w&%fIbxRZ<%~@1Y;7le+JBYtYl*;il8aS91epom3{&Y7{mM3c+DM3E>2L zP0>%bznRoGkH{irzoPY0eRwzEHjm&Oj@A-CXh}mbU!Nl=0>5cYb*Pw`u!82si_QHz zk3oH;$()L(GarSKw&27G0KmGXil54jw89GBij4W!Oo3ddrT))>c19Yzo`x3M>eh^hJ`B?)Kh6C@*){Z`_zla0 zuRYiEnzAgtbLoM-cHl;AvI2uXD)2xYymfbF9O29ySIC&ZeL6#f+Rdm)iH{wt!|r}9*z`$Y)cqt`mVw<+d)jU?8!&R8WgsfgzNxkjm5#ZG5O!N}3TIdN00ATHRnRJmH$EeFoW2L6j&O_2&|dtL!4L?vO8+~V&?KV~lh z(XNivh9pto24OX)qEklp(zI| z)t;iWZar&cl5h6#`r(&juWZx+$zU9<;A0K0b_<>=>*k|jc~o1-=C%o8`W7QzlN6zA zPX5xK>FP45+TMj&Ja$(K7ql0}A0_E(Tov$IM+6J@hy408hdFVYF(hLE$NNux>nVvW z|7;Iwnk}0tkCv2^0Rvik(|PtU?BAK6?2Z`f>q20-PH-2L{UD)Np(hNs^pjdeX}W89 zW?tO%&l~#Yt=m;^S4s+T8#f|Pb(8Y0Z^;}#pj}aqFv1l0OCsGdwsSL!Wo+O^E5%>V z)dLB(pLjFVR}QYGv(l`%8c_X_&tIOj87Zo>R12wDQ7q26i~Vp|4@?+B3s?borE(!@ zf6AzlUBjSF&y_Z9d?A4c?0OQ;2L9k?1A`%?{=qPO;hq0E8!Fe5#+VAbaK)PnI~@8~ z#F~c9W0zMe2&U@D_{2PJ`27v&ZS>nP1v})xZJ$2Dr}f#%8B-C*JnFH>O&)_Gxs0vG zQ_?fF{ll8MgpP0Sgm|-+ZGAEI-C9I@`>(eL)jQq zZcRAcfFG{m^`OL;7^PYIZ5=WupfbOZOT)SgyGkoacnFq!&%+Fw^VwzBP8f0U3Szv% zXU`?y)We4ruODg(aM4;~CV<0ac!58~9eVe<;)n}oc_t()xNfn84Rq(r#;DFJejR56 zDCErBL~Y0Y1oZeIOv)Q+^=hTf!$3eC%5|I3$U)u3BidE}kd(TxLDd&;+LaGkKqGcc ze%l=w^XLpZazzX0P+l>>Q(E=|vBD#vE9aG-K0wSmRTbb+>h-J?M0jd+KD0DL{#Xgg z@EyCyA9C~tD5R#%s;^lN-)WpY`97+yU^}3;T{R*liQG(D%zEbuV!knPF5}S5TkF56 z!hRK1g~TrDuLO)kV~vKa175aaEYy1Z+d~OzBMr7tYmYo_$3NGs7Q%(Oi#STXw5j-_ z4>OsPzJq(w(y>wC8@tevxrQLX-{n>I@cVah`CQZ0xCu#){X0f=h2+xv=cKOw=BQl8 zajRFvbqdR<^I(Z?b!Li_+@6ewp0vKJk;(>+b_Q9koMm_vyBl1i$6VU_XcS(%`~Js1 zX~142t(f^NSz~#v|Lld}QBj?&=JazUO8S~ErOwaf)h}7|;O}Y|CCU14!F=ezwVI|8 zn4}>pK>FwW>tz3V-tE$|T|BHgf2r;J8I%PZhz&dKWXOONGLjvscvSrx4{M9SxxOyk zWfCmzcNe$%XGL~-=9^*CLb)iV@tG(|_}ymh3SU|;Nt8%%vi;LRM%^Q?C;G&4K+%>$ zl4^{pe^Eb}6(&P#itE$=k4woSird@z>DFBNLVZ|2T*ujF(OHS3&jO!W32&*sOX?qp z-5g4>=Zr{VU7Ngqg^y^R3<$iWPd$%@DYl0)twbmD*}yIfl08w*q_P2xW1sR*HIUbM zA@Uv%;6%GmN9c#Xx~vPkI$bHQI?++#5_Wyb5iwa)j8;zlh*m5-{FfU0(MvLXnxSj} z2~uvlqLAM01wZB8TbZ>7W7uuE<^+?C`6k(L^Wv9lWsB8Dqq%Owm*mJdmHh5UloCs! z2W2K6tInfmblgh$9`5PTBOhm_?AI@P=kG(bG%2DVGzz#58q`-qHb6m!?MxXTL?8ie zvh&hE5y;uuSMkp%&x4k-ScE;!uL#;?$YzPjIxP1%XC)bO*9y}VITA+iNSR-#I{PGa zKA|70C}%$SHlF(Bx-ogq$q#tCGI)aC8xy5()41Z@4k^5vew1WRM<)QZ?M__O}GE2Tu01h zv3=A*qZ*fCN@jf{$!yu6W(bw=|3J?E3T_LsXqviM)t2-8^82#U5C=^CqBbY&5s&-8 zheTn7!C%PJg&p|=<9yBP4Z=dXc+J}EMoNMgovgt!dG=2-Y?l6m*fKSZVm!Fmx+@4W zMWq5SMhME>ryM}D>5I|96&6@BCnrn*4d1p4^QK>A1GtR0v^j#(7J15r4ZMcav=y&< n6liZAE^+yCX(H6otfNT7&)8^M`}qqwSY`XaFOlF7V1N5Rs1TdQ From 1d2041b9920371b4c186e7ebc8ec734c8bd8447d Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Mon, 13 Mar 2023 08:40:12 -0700 Subject: [PATCH 054/136] Internal change PiperOrigin-RevId: 516220827 --- .../java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl index 89fdb32dc..5c5a154d8 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl @@ -34,18 +34,19 @@ _AUDIO_TASKS_JAVA_PROTO_LITE_TARGETS = [ ] _VISION_TASKS_JAVA_PROTO_LITE_TARGETS = [ - "//mediapipe/tasks/cc/vision/object_detector/proto:object_detector_options_java_proto_lite", - "//mediapipe/tasks/cc/vision/image_classifier/proto:image_classifier_graph_options_java_proto_lite", + "//mediapipe/tasks/cc/vision/face_detector/proto:face_detector_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/gesture_recognizer/proto:gesture_classifier_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/gesture_recognizer/proto:gesture_embedder_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/gesture_recognizer/proto:gesture_recognizer_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/gesture_recognizer/proto:hand_gesture_recognizer_graph_options_java_proto_lite", + "//mediapipe/tasks/cc/vision/image_classifier/proto:image_classifier_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_embedder/proto:image_embedder_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_java_proto_lite", "//mediapipe/tasks/cc/vision/hand_detector/proto:hand_detector_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/hand_landmarker/proto:hand_landmarker_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/hand_landmarker/proto:hand_landmarks_detector_graph_options_java_proto_lite", + "//mediapipe/tasks/cc/vision/object_detector/proto:object_detector_options_java_proto_lite", ] _TEXT_TASKS_JAVA_PROTO_LITE_TARGETS = [ From efae2830f1e3559eeb50ddf7bdbd2e4ea02e7852 Mon Sep 17 00:00:00 2001 From: kinaryml Date: Mon, 13 Mar 2023 08:46:41 -0700 Subject: [PATCH 055/136] Updated face landmarker implementation and tests --- mediapipe/python/BUILD | 1 + .../test/vision/face_landmarker_test.py | 9 ++- .../tasks/python/vision/face_landmarker.py | 66 +++++++++++-------- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/mediapipe/python/BUILD b/mediapipe/python/BUILD index f56e5b3d4..49142108e 100644 --- a/mediapipe/python/BUILD +++ b/mediapipe/python/BUILD @@ -94,6 +94,7 @@ cc_library( "//mediapipe/tasks/cc/vision/image_embedder:image_embedder_graph", "//mediapipe/tasks/cc/vision/image_segmenter:image_segmenter_graph", "//mediapipe/tasks/cc/vision/object_detector:object_detector_graph", + "//mediapipe/tasks/cc/vision/face_landmarker:face_landmarker_graph", ] + select({ # TODO: Build text_classifier_graph and text_embedder_graph on Windows. "//mediapipe:windows": [], diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py index acdc0251e..a9dd57151 100644 --- a/mediapipe/tasks/python/test/vision/face_landmarker_test.py +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -152,7 +152,7 @@ class HandLandmarkerTest(parameterized.TestCase): _get_expected_face_landmarks(_PORTRAIT_EXPECTED_FACE_LANDMARKS)), (ModelFileType.FILE_CONTENT, _get_expected_face_landmarks(_PORTRAIT_EXPECTED_FACE_LANDMARKS))) - def test_detect(self, model_file_type, expected_result): + def test_detect(self, model_file_type, expected_face_landmarks): # Creates face landmarker. if model_file_type is ModelFileType.FILE_NAME: base_options = _BaseOptions(model_asset_path=self.model_path) @@ -164,15 +164,14 @@ class HandLandmarkerTest(parameterized.TestCase): # Should never happen raise ValueError('model_file_type is invalid.') - options = _FaceLandmarkerOptions(base_options=base_options, - output_face_blendshapes=True) + options = _FaceLandmarkerOptions(base_options=base_options) landmarker = _FaceLandmarker.create_from_options(options) # Performs face landmarks detection on the input. detection_result = landmarker.detect(self.test_image) # Comparing results. - self._expect_landmarks_correct(detection_result.face_landmarks, - expected_result.face_landmarks) + self._expect_landmarks_correct(detection_result.face_landmarks[0], + expected_face_landmarks) # Closes the face landmarker explicitly when the face landmarker is not used # in a context. landmarker.close() diff --git a/mediapipe/tasks/python/vision/face_landmarker.py b/mediapipe/tasks/python/vision/face_landmarker.py index 93a296f92..c109c646a 100644 --- a/mediapipe/tasks/python/vision/face_landmarker.py +++ b/mediapipe/tasks/python/vision/face_landmarker.py @@ -132,10 +132,6 @@ def _build_landmarker_result( """Constructs a `FaceLandmarkerResult` from output packets.""" face_landmarks_proto_list = packet_getter.get_proto_list( output_packets[_NORM_LANDMARKS_STREAM_NAME]) - face_blendshapes_proto_list = packet_getter.get_proto_list( - output_packets[_BLENDSHAPES_STREAM_NAME]) - facial_transformation_matrixes_proto_list = packet_getter.get_proto_list( - output_packets[_FACE_GEOMETRY_STREAM_NAME]) face_landmarks_results = [] for proto in face_landmarks_proto_list: @@ -143,30 +139,36 @@ def _build_landmarker_result( face_landmarks.MergeFrom(proto) face_landmarks_list = [] for face_landmark in face_landmarks.landmark: - face_landmarks.append( + face_landmarks_list.append( landmark_module.NormalizedLandmark.create_from_pb2(face_landmark)) face_landmarks_results.append(face_landmarks_list) face_blendshapes_results = [] - for proto in face_blendshapes_proto_list: - face_blendshapes_categories = [] - face_blendshapes_classifications = classification_pb2.ClassificationList() - face_blendshapes_classifications.MergeFrom(proto) - for face_blendshapes in face_blendshapes_classifications.classification: - face_blendshapes_categories.append( - category_module.Category( - index=face_blendshapes.index, - score=face_blendshapes.score, - display_name=face_blendshapes.display_name, - category_name=face_blendshapes.label)) - face_blendshapes_results.append(face_blendshapes_categories) + if _BLENDSHAPES_STREAM_NAME in output_packets: + face_blendshapes_proto_list = packet_getter.get_proto_list( + output_packets[_BLENDSHAPES_STREAM_NAME]) + for proto in face_blendshapes_proto_list: + face_blendshapes_categories = [] + face_blendshapes_classifications = classification_pb2.ClassificationList() + face_blendshapes_classifications.MergeFrom(proto) + for face_blendshapes in face_blendshapes_classifications.classification: + face_blendshapes_categories.append( + category_module.Category( + index=face_blendshapes.index, + score=face_blendshapes.score, + display_name=face_blendshapes.display_name, + category_name=face_blendshapes.label)) + face_blendshapes_results.append(face_blendshapes_categories) facial_transformation_matrixes_results = [] - for proto in facial_transformation_matrixes_proto_list: - matrix_data = matrix_data_pb2.MatrixData() - matrix_data.MergeFrom(proto) - matrix = matrix_data_module.MatrixData.create_from_pb2(matrix_data) - facial_transformation_matrixes_results.append(matrix) + if _FACE_GEOMETRY_STREAM_NAME in output_packets: + facial_transformation_matrixes_proto_list = packet_getter.get_proto_list( + output_packets[_FACE_GEOMETRY_STREAM_NAME]) + for proto in facial_transformation_matrixes_proto_list: + matrix_data = matrix_data_pb2.MatrixData() + matrix_data.MergeFrom(proto) + matrix = matrix_data_module.MatrixData.create_from_pb2(matrix_data) + facial_transformation_matrixes_results.append(matrix) return FaceLandmarkerResult(face_landmarks_results, face_blendshapes_results, facial_transformation_matrixes_results) @@ -298,19 +300,25 @@ class FaceLandmarker(base_vision_task_api.BaseVisionTaskApi): options.result_callback(face_landmarks_result, image, timestamp.value // _MICRO_SECONDS_PER_MILLISECOND) + output_streams = [ + ':'.join([_NORM_LANDMARKS_TAG, _NORM_LANDMARKS_STREAM_NAME]), + ':'.join([_IMAGE_TAG, _IMAGE_OUT_STREAM_NAME]) + ] + + if options.output_face_blendshapes: + output_streams.append( + ':'.join([_BLENDSHAPES_TAG, _BLENDSHAPES_STREAM_NAME])) + if options.output_facial_transformation_matrixes: + output_streams.append( + ':'.join([_FACE_GEOMETRY_TAG, _FACE_GEOMETRY_STREAM_NAME])) + task_info = _TaskInfo( task_graph=_TASK_GRAPH_NAME, input_streams=[ ':'.join([_IMAGE_TAG, _IMAGE_IN_STREAM_NAME]), ':'.join([_NORM_RECT_TAG, _NORM_RECT_STREAM_NAME]), ], - output_streams=[ - ':'.join([_NORM_LANDMARKS_TAG, _NORM_LANDMARKS_STREAM_NAME]), - ':'.join([_BLENDSHAPES_TAG, _BLENDSHAPES_STREAM_NAME]), - ':'.join([ - _FACE_GEOMETRY_TAG, _FACE_GEOMETRY_STREAM_NAME - ]), ':'.join([_IMAGE_TAG, _IMAGE_OUT_STREAM_NAME]) - ], + output_streams=output_streams, task_options=options) return cls( task_info.generate_graph_config( From 490d1a751624843d249260be8680ebda367d30b3 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 13 Mar 2023 10:41:33 -0700 Subject: [PATCH 056/136] Refactor Web code for InteractiveSegmenter PiperOrigin-RevId: 516254891 --- .../tasks/web/audio/core/audio_task_runner.ts | 6 +- mediapipe/tasks/web/core/task_runner.ts | 10 ++- .../text/text_classifier/text_classifier.ts | 6 +- .../web/text/text_embedder/text_embedder.ts | 6 +- mediapipe/tasks/web/vision/core/BUILD | 10 +++ .../tasks/web/vision/core/render_utils.ts | 78 +++++++++++++++++++ mediapipe/tasks/web/vision/core/types.d.ts | 34 ++++++++ .../web/vision/core/vision_task_runner.ts | 6 +- .../tasks/web/vision/image_segmenter/BUILD | 2 +- .../vision/image_segmenter/image_segmenter.ts | 21 +---- 10 files changed, 139 insertions(+), 40 deletions(-) create mode 100644 mediapipe/tasks/web/vision/core/render_utils.ts create mode 100644 mediapipe/tasks/web/vision/core/types.d.ts diff --git a/mediapipe/tasks/web/audio/core/audio_task_runner.ts b/mediapipe/tasks/web/audio/core/audio_task_runner.ts index ff39185f2..2c327f1ab 100644 --- a/mediapipe/tasks/web/audio/core/audio_task_runner.ts +++ b/mediapipe/tasks/web/audio/core/audio_task_runner.ts @@ -36,11 +36,9 @@ export abstract class AudioTaskRunner extends TaskRunner { /** Sends a single audio clip to the graph and awaits results. */ protected processAudioClip(audioData: Float32Array, sampleRate?: number): T { - // Increment the timestamp by 1 millisecond to guarantee that we send - // monotonically increasing timestamps to the graph. - const syntheticTimestamp = this.getLatestOutputTimestamp() + 1; return this.process( - audioData, sampleRate ?? this.defaultSampleRate, syntheticTimestamp); + audioData, sampleRate ?? this.defaultSampleRate, + this.getSynctheticTimestamp()); } } diff --git a/mediapipe/tasks/web/core/task_runner.ts b/mediapipe/tasks/web/core/task_runner.ts index 79b2ca173..a01bb1c92 100644 --- a/mediapipe/tasks/web/core/task_runner.ts +++ b/mediapipe/tasks/web/core/task_runner.ts @@ -175,9 +175,13 @@ export abstract class TaskRunner { Math.max(this.latestOutputTimestamp, timestamp); } - /** Returns the latest output timestamp. */ - protected getLatestOutputTimestamp() { - return this.latestOutputTimestamp; + /** + * Gets a syncthethic timestamp in ms that can be used to send data to the + * next packet. The timestamp is one millisecond past the last timestamp + * received from the graph. + */ + protected getSynctheticTimestamp(): number { + return this.latestOutputTimestamp + 1; } /** Throws the error from the error listener if an error was raised. */ diff --git a/mediapipe/tasks/web/text/text_classifier/text_classifier.ts b/mediapipe/tasks/web/text/text_classifier/text_classifier.ts index b28817613..2495bf5a9 100644 --- a/mediapipe/tasks/web/text/text_classifier/text_classifier.ts +++ b/mediapipe/tasks/web/text/text_classifier/text_classifier.ts @@ -131,11 +131,9 @@ export class TextClassifier extends TaskRunner { * @return The classification result of the text */ classify(text: string): TextClassifierResult { - // Increment the timestamp by 1 millisecond to guarantee that we send - // monotonically increasing timestamps to the graph. - const syntheticTimestamp = this.getLatestOutputTimestamp() + 1; this.classificationResult = {classifications: []}; - this.graphRunner.addStringToStream(text, INPUT_STREAM, syntheticTimestamp); + this.graphRunner.addStringToStream( + text, INPUT_STREAM, this.getSynctheticTimestamp()); this.finishProcessing(); return this.classificationResult; } diff --git a/mediapipe/tasks/web/text/text_embedder/text_embedder.ts b/mediapipe/tasks/web/text/text_embedder/text_embedder.ts index 1034de033..3b7f4f7e4 100644 --- a/mediapipe/tasks/web/text/text_embedder/text_embedder.ts +++ b/mediapipe/tasks/web/text/text_embedder/text_embedder.ts @@ -135,10 +135,8 @@ export class TextEmbedder extends TaskRunner { * @return The embedding resuls of the text */ embed(text: string): TextEmbedderResult { - // Increment the timestamp by 1 millisecond to guarantee that we send - // monotonically increasing timestamps to the graph. - const syntheticTimestamp = this.getLatestOutputTimestamp() + 1; - this.graphRunner.addStringToStream(text, INPUT_STREAM, syntheticTimestamp); + this.graphRunner.addStringToStream( + text, INPUT_STREAM, this.getSynctheticTimestamp()); this.finishProcessing(); return this.embeddingResult; } diff --git a/mediapipe/tasks/web/vision/core/BUILD b/mediapipe/tasks/web/vision/core/BUILD index a0a008122..5135feecc 100644 --- a/mediapipe/tasks/web/vision/core/BUILD +++ b/mediapipe/tasks/web/vision/core/BUILD @@ -21,6 +21,11 @@ mediapipe_ts_declaration( ], ) +mediapipe_ts_declaration( + name = "types", + srcs = ["types.d.ts"], +) + mediapipe_ts_library( name = "vision_task_runner", srcs = ["vision_task_runner.ts"], @@ -51,6 +56,11 @@ mediapipe_ts_library( ], ) +mediapipe_ts_library( + name = "render_utils", + srcs = ["render_utils.ts"], +) + jasmine_node_test( name = "vision_task_runner_test", deps = [":vision_task_runner_test_lib"], diff --git a/mediapipe/tasks/web/vision/core/render_utils.ts b/mediapipe/tasks/web/vision/core/render_utils.ts new file mode 100644 index 000000000..c5f931b38 --- /dev/null +++ b/mediapipe/tasks/web/vision/core/render_utils.ts @@ -0,0 +1,78 @@ +/** @fileoverview Utility functions used in the vision demos. */ + +/** + * Copyright 2023 The MediaPipe Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Pre-baked color table for a maximum of 12 classes. +const CM_ALPHA = 128; +const COLOR_MAP = [ + [0, 0, 0, CM_ALPHA], // class 0 is BG = transparent + [255, 0, 0, CM_ALPHA], // class 1 is red + [0, 255, 0, CM_ALPHA], // class 2 is light green + [0, 0, 255, CM_ALPHA], // class 3 is blue + [255, 255, 0, CM_ALPHA], // class 4 is yellow + [255, 0, 255, CM_ALPHA], // class 5 is light purple / magenta + [0, 255, 255, CM_ALPHA], // class 6 is light blue / aqua + [128, 128, 128, CM_ALPHA], // class 7 is gray + [255, 128, 0, CM_ALPHA], // class 8 is orange + [128, 0, 255, CM_ALPHA], // class 9 is dark purple + [0, 128, 0, CM_ALPHA], // class 10 is dark green + [255, 255, 255, CM_ALPHA] // class 11 is white; could do black instead? +]; + + +/** Helper function to draw a confidence mask */ +export function drawConfidenceMask( + + ctx: CanvasRenderingContext2D, image: Float32Array|Uint8Array, + width: number, height: number): void { + const uint8ClampedArray = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < image.length; i++) { + uint8ClampedArray[4 * i] = 128; + uint8ClampedArray[4 * i + 1] = 0; + uint8ClampedArray[4 * i + 2] = 0; + uint8ClampedArray[4 * i + 3] = image[i] * 255; + } + ctx.putImageData(new ImageData(uint8ClampedArray, width, height), 0, 0); +} + +/** + * Helper function to draw a category mask. For GPU, we only have F32Arrays + * for now. + */ +export function drawCategoryMask( + ctx: CanvasRenderingContext2D, image: Float32Array|Uint8Array, + width: number, height: number): void { + const uint8ClampedArray = new Uint8ClampedArray(width * height * 4); + const isFloatArray = image instanceof Float32Array; + for (let i = 0; i < image.length; i++) { + const colorIndex = isFloatArray ? Math.round(image[i] * 255) : image[i]; + const color = COLOR_MAP[colorIndex]; + + // When we're given a confidence mask by accident, we just log and return. + // TODO: We should fix this. + if (!color) { + console.warn('No color for ', colorIndex); + return; + } + + uint8ClampedArray[4 * i] = color[0]; + uint8ClampedArray[4 * i + 1] = color[1]; + uint8ClampedArray[4 * i + 2] = color[2]; + uint8ClampedArray[4 * i + 3] = color[3]; + } + ctx.putImageData(new ImageData(uint8ClampedArray, width, height), 0, 0); +} diff --git a/mediapipe/tasks/web/vision/core/types.d.ts b/mediapipe/tasks/web/vision/core/types.d.ts new file mode 100644 index 000000000..aaa3e2f78 --- /dev/null +++ b/mediapipe/tasks/web/vision/core/types.d.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2023 The MediaPipe Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The segmentation tasks return the segmentation result as a Uint8Array + * (when the default mode of `CATEGORY_MASK` is used) or as a Float32Array (for + * output type `CONFIDENCE_MASK`). The `WebGLTexture` output type is reserved + * for future usage. + */ +export type SegmentationMask = Uint8Array|Float32Array|WebGLTexture; + +/** + * A callback that receives the computed masks from the segmentation tasks. The + * callback either receives a single element array with a category mask (as a + * `[Uint8Array]`) or multiple confidence masks (as a `Float32Array[]`). + * The returned data is only valid for the duration of the callback. If + * asynchronous processing is needed, all data needs to be copied before the + * callback returns. + */ +export type SegmentationMaskCallback = + (masks: SegmentationMask[], width: number, height: number) => void; diff --git a/mediapipe/tasks/web/vision/core/vision_task_runner.ts b/mediapipe/tasks/web/vision/core/vision_task_runner.ts index b3e8ed4db..4b34aca92 100644 --- a/mediapipe/tasks/web/vision/core/vision_task_runner.ts +++ b/mediapipe/tasks/web/vision/core/vision_task_runner.ts @@ -74,11 +74,7 @@ export abstract class VisionTaskRunner extends TaskRunner { 'Task is not initialized with image mode. ' + '\'runningMode\' must be set to \'IMAGE\'.'); } - - // Increment the timestamp by 1 millisecond to guarantee that we send - // monotonically increasing timestamps to the graph. - const syntheticTimestamp = this.getLatestOutputTimestamp() + 1; - this.process(image, imageProcessingOptions, syntheticTimestamp); + this.process(image, imageProcessingOptions, this.getSynctheticTimestamp()); } /** Sends a single video frame to the graph and awaits results. */ diff --git a/mediapipe/tasks/web/vision/image_segmenter/BUILD b/mediapipe/tasks/web/vision/image_segmenter/BUILD index d15fe63f1..3ca2a64eb 100644 --- a/mediapipe/tasks/web/vision/image_segmenter/BUILD +++ b/mediapipe/tasks/web/vision/image_segmenter/BUILD @@ -19,8 +19,8 @@ mediapipe_ts_library( "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_jspb_proto", "//mediapipe/tasks/web/core", "//mediapipe/tasks/web/vision/core:image_processing_options", + "//mediapipe/tasks/web/vision/core:types", "//mediapipe/tasks/web/vision/core:vision_task_runner", - "//mediapipe/web/graph_runner:graph_runner_image_lib_ts", "//mediapipe/web/graph_runner:graph_runner_ts", ], ) diff --git a/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts b/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts index 3a2e9f2af..85c222a28 100644 --- a/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts +++ b/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts @@ -21,6 +21,7 @@ import {ImageSegmenterGraphOptions as ImageSegmenterGraphOptionsProto} from '../ import {SegmenterOptions as SegmenterOptionsProto} from '../../../../tasks/cc/vision/image_segmenter/proto/segmenter_options_pb'; import {WasmFileset} from '../../../../tasks/web/core/wasm_fileset'; import {ImageProcessingOptions} from '../../../../tasks/web/vision/core/image_processing_options'; +import {SegmentationMask, SegmentationMaskCallback} from '../../../../tasks/web/vision/core/types'; import {VisionGraphRunner, VisionTaskRunner} from '../../../../tasks/web/vision/core/vision_task_runner'; import {ImageSource, WasmModule} from '../../../../web/graph_runner/graph_runner'; // Placeholder for internal dependency on trusted resource url @@ -28,27 +29,9 @@ import {ImageSource, WasmModule} from '../../../../web/graph_runner/graph_runner import {ImageSegmenterOptions} from './image_segmenter_options'; export * from './image_segmenter_options'; +export {SegmentationMask, SegmentationMaskCallback}; export {ImageSource}; // Used in the public API -/** - * The ImageSegmenter returns the segmentation result as a Uint8Array (when - * the default mode of `CATEGORY_MASK` is used) or as a Float32Array (for - * output type `CONFIDENCE_MASK`). The `WebGLTexture` output type is reserved - * for future usage. - */ -export type SegmentationMask = Uint8Array|Float32Array|WebGLTexture; - -/** - * A callback that receives the computed masks from the image segmenter. The - * callback either receives a single element array with a category mask (as a - * `[Uint8Array]`) or multiple confidence masks (as a `Float32Array[]`). - * The returned data is only valid for the duration of the callback. If - * asynchronous processing is needed, all data needs to be copied before the - * callback returns. - */ -export type SegmentationMaskCallback = - (masks: SegmentationMask[], width: number, height: number) => void; - const IMAGE_STREAM = 'image_in'; const NORM_RECT_STREAM = 'norm_rect'; const GROUPED_SEGMENTATIONS_STREAM = 'segmented_masks'; From 85600ca326fa13c2b4a5b19454948fa408776f6b Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 13 Mar 2023 13:08:03 -0700 Subject: [PATCH 057/136] Add Keypoint and Region-of-interest PiperOrigin-RevId: 516299794 --- .../tasks/web/components/containers/BUILD | 5 +++ .../web/components/containers/keypoint.d.ts | 33 +++++++++++++++++++ mediapipe/tasks/web/vision/core/BUILD | 3 ++ mediapipe/tasks/web/vision/core/types.d.ts | 8 +++++ 4 files changed, 49 insertions(+) create mode 100644 mediapipe/tasks/web/components/containers/keypoint.d.ts diff --git a/mediapipe/tasks/web/components/containers/BUILD b/mediapipe/tasks/web/components/containers/BUILD index a0db59d0b..0126e83c9 100644 --- a/mediapipe/tasks/web/components/containers/BUILD +++ b/mediapipe/tasks/web/components/containers/BUILD @@ -15,6 +15,11 @@ mediapipe_ts_declaration( deps = [":category"], ) +mediapipe_ts_declaration( + name = "keypoint", + srcs = ["keypoint.d.ts"], +) + mediapipe_ts_declaration( name = "landmark", srcs = ["landmark.d.ts"], diff --git a/mediapipe/tasks/web/components/containers/keypoint.d.ts b/mediapipe/tasks/web/components/containers/keypoint.d.ts new file mode 100644 index 000000000..3aaf9eb06 --- /dev/null +++ b/mediapipe/tasks/web/components/containers/keypoint.d.ts @@ -0,0 +1,33 @@ +/** + * Copyright 2023 The MediaPipe Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A keypoint, defined by the coordinates (x, y), normalized by the image + * dimensions. + */ +export declare interface NormalizedKeypoint { + /** X in normalized image coordinates. */ + x: number; + + /** Y in normalized image coordinates. */ + y: number; + + /** Optional label of the keypoint. */ + label?: string; + + /** Optional score of the keypoint. */ + score?: number; +} diff --git a/mediapipe/tasks/web/vision/core/BUILD b/mediapipe/tasks/web/vision/core/BUILD index 5135feecc..cd2954eb3 100644 --- a/mediapipe/tasks/web/vision/core/BUILD +++ b/mediapipe/tasks/web/vision/core/BUILD @@ -24,6 +24,9 @@ mediapipe_ts_declaration( mediapipe_ts_declaration( name = "types", srcs = ["types.d.ts"], + deps = [ + "//mediapipe/tasks/web/components/containers:keypoint", + ], ) mediapipe_ts_library( diff --git a/mediapipe/tasks/web/vision/core/types.d.ts b/mediapipe/tasks/web/vision/core/types.d.ts index aaa3e2f78..b88683aae 100644 --- a/mediapipe/tasks/web/vision/core/types.d.ts +++ b/mediapipe/tasks/web/vision/core/types.d.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import {NormalizedKeypoint} from '../../../../tasks/web/components/containers/keypoint'; + /** * The segmentation tasks return the segmentation result as a Uint8Array * (when the default mode of `CATEGORY_MASK` is used) or as a Float32Array (for @@ -32,3 +34,9 @@ export type SegmentationMask = Uint8Array|Float32Array|WebGLTexture; */ export type SegmentationMaskCallback = (masks: SegmentationMask[], width: number, height: number) => void; + +/** A Region-Of-Interest (ROI) to represent a region within an image. */ +export declare interface RegionOfInterest { + /** The ROI in keypoint format. */ + keypoint: NormalizedKeypoint; +} From eac2e337f64c2366894b4b3566e816c8dfa4b845 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 13 Mar 2023 13:51:30 -0700 Subject: [PATCH 058/136] Sort vision tasks in README.md PiperOrigin-RevId: 516312229 --- mediapipe/tasks/web/vision/README.md | 66 ++++++++++++++-------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/mediapipe/tasks/web/vision/README.md b/mediapipe/tasks/web/vision/README.md index 9e86eafd3..c1f15ec26 100644 --- a/mediapipe/tasks/web/vision/README.md +++ b/mediapipe/tasks/web/vision/README.md @@ -2,23 +2,42 @@ This package contains the vision tasks for MediaPipe. -## Object Detection +## Gesture Recognition -The MediaPipe Object Detector task lets you detect the presence and location of -multiple classes of objects within images or videos. +The MediaPipe Gesture Recognizer task lets you recognize hand gestures in real +time, and provides the recognized hand gesture results along with the landmarks +of the detected hands. You can use this task to recognize specific hand gestures +from a user, and invoke application features that correspond to those gestures. ``` const vision = await FilesetResolver.forVisionTasks( "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm" ); -const objectDetector = await ObjectDetector.createFromModelPath(vision, - "https://storage.googleapis.com/mediapipe-tasks/object_detector/efficientdet_lite0_uint8.tflite" +const gestureRecognizer = await GestureRecognizer.createFromModelPath(vision, + "https://storage.googleapis.com/mediapipe-tasks/gesture_recognizer/gesture_recognizer.task" ); const image = document.getElementById("image") as HTMLImageElement; -const detections = objectDetector.detect(image); +const recognitions = gestureRecognizer.recognize(image); ``` -For more information, refer to the [Object Detector](https://developers.google.com/mediapipe/solutions/vision/object_detector/web_js) documentation. +## Hand Landmark Detection + +The MediaPipe Hand Landmarker task lets you detect the landmarks of the hands in +an image. You can use this Task to localize key points of the hands and render +visual effects over the hands. + +``` +const vision = await FilesetResolver.forVisionTasks( + "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm" +); +const handLandmarker = await HandLandmarker.createFromModelPath(vision, + "https://storage.googleapis.com/mediapipe-tasks/hand_landmarker/hand_landmarker.task" +); +const image = document.getElementById("image") as HTMLImageElement; +const landmarks = handLandmarker.detect(image); +``` + +For more information, refer to the [Handlandmark Detection](https://developers.google.com/mediapipe/solutions/vision/hand_landmarker/web_js) documentation. ## Image Classification @@ -56,40 +75,21 @@ imageSegmenter.segment(image, (masks, width, height) => { }); ``` -## Gesture Recognition +## Object Detection -The MediaPipe Gesture Recognizer task lets you recognize hand gestures in real -time, and provides the recognized hand gesture results along with the landmarks -of the detected hands. You can use this task to recognize specific hand gestures -from a user, and invoke application features that correspond to those gestures. +The MediaPipe Object Detector task lets you detect the presence and location of +multiple classes of objects within images or videos. ``` const vision = await FilesetResolver.forVisionTasks( "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm" ); -const gestureRecognizer = await GestureRecognizer.createFromModelPath(vision, - "https://storage.googleapis.com/mediapipe-tasks/gesture_recognizer/gesture_recognizer.task" +const objectDetector = await ObjectDetector.createFromModelPath(vision, + "https://storage.googleapis.com/mediapipe-tasks/object_detector/efficientdet_lite0_uint8.tflite" ); const image = document.getElementById("image") as HTMLImageElement; -const recognitions = gestureRecognizer.recognize(image); +const detections = objectDetector.detect(image); ``` -## Handlandmark Detection - -The MediaPipe Hand Landmarker task lets you detect the landmarks of the hands in -an image. You can use this Task to localize key points of the hands and render -visual effects over the hands. - -``` -const vision = await FilesetResolver.forVisionTasks( - "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm" -); -const handLandmarker = await HandLandmarker.createFromModelPath(vision, - "https://storage.googleapis.com/mediapipe-tasks/hand_landmarker/hand_landmarker.task" -); -const image = document.getElementById("image") as HTMLImageElement; -const landmarks = handLandmarker.detect(image); -``` - -For more information, refer to the [Handlandmark Detection](https://developers.google.com/mediapipe/solutions/vision/hand_landmarker/web_js) documentation. +For more information, refer to the [Object Detector](https://developers.google.com/mediapipe/solutions/vision/object_detector/web_js) documentation. From c32ddcb04c5204d35b237a6837c338b44c86d3ac Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 13 Mar 2023 14:55:35 -0700 Subject: [PATCH 059/136] Add alwayslink to face_stylizer_graph PiperOrigin-RevId: 516330940 --- mediapipe/tasks/cc/vision/face_stylizer/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/mediapipe/tasks/cc/vision/face_stylizer/BUILD b/mediapipe/tasks/cc/vision/face_stylizer/BUILD index 7da4e6e74..f62991d45 100644 --- a/mediapipe/tasks/cc/vision/face_stylizer/BUILD +++ b/mediapipe/tasks/cc/vision/face_stylizer/BUILD @@ -47,6 +47,7 @@ cc_library( "//mediapipe/tasks/cc/vision/face_stylizer/proto:face_stylizer_graph_options_cc_proto", "@com_google_absl//absl/status:statusor", ], + alwayslink = 1, ) cc_library( From 0f58d89992e96da3a2560ae6d8a9a15617092605 Mon Sep 17 00:00:00 2001 From: Esha Uboweja Date: Mon, 13 Mar 2023 15:27:49 -0700 Subject: [PATCH 060/136] Preserves all elements of `BASE_HAND_RECTS` input streams in `HandAssociationCalculator`. PiperOrigin-RevId: 516339343 --- .../vision/hand_landmarker/calculators/BUILD | 1 + .../hand_association_calculator.cc | 83 ++++++--- .../hand_association_calculator_test.cc | 166 ++++++++++++------ .../hand_landmarker/hand_landmarker_graph.cc | 4 +- 4 files changed, 175 insertions(+), 79 deletions(-) diff --git a/mediapipe/tasks/cc/vision/hand_landmarker/calculators/BUILD b/mediapipe/tasks/cc/vision/hand_landmarker/calculators/BUILD index f45681fb3..73d3f38eb 100644 --- a/mediapipe/tasks/cc/vision/hand_landmarker/calculators/BUILD +++ b/mediapipe/tasks/cc/vision/hand_landmarker/calculators/BUILD @@ -36,6 +36,7 @@ cc_library( ":hand_association_calculator_cc_proto", "//mediapipe/calculators/util:association_calculator", "//mediapipe/framework:calculator_framework", + "//mediapipe/framework:collection_item_id", "//mediapipe/framework/api2:node", "//mediapipe/framework/formats:rect_cc_proto", "//mediapipe/framework/port:rectangle", diff --git a/mediapipe/tasks/cc/vision/hand_landmarker/calculators/hand_association_calculator.cc b/mediapipe/tasks/cc/vision/hand_landmarker/calculators/hand_association_calculator.cc index dffdbdd38..011bce2b9 100644 --- a/mediapipe/tasks/cc/vision/hand_landmarker/calculators/hand_association_calculator.cc +++ b/mediapipe/tasks/cc/vision/hand_landmarker/calculators/hand_association_calculator.cc @@ -19,6 +19,7 @@ limitations under the License. #include "mediapipe/framework/api2/node.h" #include "mediapipe/framework/calculator_framework.h" +#include "mediapipe/framework/collection_item_id.h" #include "mediapipe/framework/formats/rect.pb.h" #include "mediapipe/framework/port/rectangle.h" #include "mediapipe/framework/port/status.h" @@ -29,30 +30,55 @@ namespace mediapipe::api2 { using ::mediapipe::NormalizedRect; -// HandAssociationCalculator accepts multiple inputs of vectors of -// NormalizedRect. The output is a vector of NormalizedRect that contains -// rects from the input vectors that don't overlap with each other. When two -// rects overlap, the rect that comes in from an earlier input stream is -// kept in the output. If a rect has no ID (i.e. from detection stream), -// then a unique rect ID is assigned for it. - -// The rects in multiple input streams are effectively flattened to a single -// list. For example: -// Stream1 : rect 1, rect 2 -// Stream2: rect 3, rect 4 -// Stream3: rect 5, rect 6 -// (Conceptually) flattened list : rect 1, 2, 3, 4, 5, 6 -// In the flattened list, if a rect with a higher index overlaps with a rect a -// lower index, beyond a specified IOU threshold, the rect with the lower -// index will be in the output, and the rect with higher index will be -// discarded. +// Input: +// BASE_RECTS - Vector of NormalizedRect. +// RECTS - Vector of NormalizedRect. +// +// Output: +// No tag - Vector of NormalizedRect. +// +// Example use: +// node { +// calculator: "HandAssociationCalculator" +// input_stream: "BASE_RECTS:base_rects" +// input_stream: "RECTS:0:rects0" +// input_stream: "RECTS:1:rects1" +// input_stream: "RECTS:2:rects2" +// output_stream: "output_rects" +// options { +// [mediapipe.HandAssociationCalculatorOptions.ext] { +// min_similarity_threshold: 0.1 +// } +// } +// +// IMPORTANT Notes: +// - Rects from input streams tagged with "BASE_RECTS" are always preserved. +// - This calculator checks for overlap among rects from input streams tagged +// with "RECTS". Rects are prioritized based on their index in the vector and +// input streams to the calculator. When two rects overlap, the rect that +// comes from an input stream with lower tag-index is kept in the output. +// - Example of inputs for the node above: +// "base_rects": rect 0, rect 1 +// "rects0": rect 2, rect 3 +// "rects1": rect 4, rect 5 +// "rects2": rect 6, rect 7 +// (Conceptually) flattened list: 0, 1, 2, 3, 4, 5, 6, 7. +// Rects 0, 1 will be preserved. Rects 2, 3, 4, 5, 6, 7 will be checked for +// overlap. If a rect with a higher index overlaps with a rect with lower +// index, beyond a specified IOU threshold, the rect with the lower index +// will be in the output, and the rect with higher index will be discarded. // TODO: Upgrade this to latest API for calculators class HandAssociationCalculator : public CalculatorBase { public: static absl::Status GetContract(CalculatorContract* cc) { // Initialize input and output streams. - for (auto& input_stream : cc->Inputs()) { - input_stream.Set>(); + for (CollectionItemId id = cc->Inputs().BeginId("BASE_RECTS"); + id != cc->Inputs().EndId("BASE_RECTS"); ++id) { + cc->Inputs().Get(id).Set>(); + } + for (CollectionItemId id = cc->Inputs().BeginId("RECTS"); + id != cc->Inputs().EndId("RECTS"); ++id) { + cc->Inputs().Get(id).Set>(); } cc->Outputs().Index(0).Set>(); @@ -89,7 +115,24 @@ class HandAssociationCalculator : public CalculatorBase { CalculatorContext* cc) { std::vector result; - for (const auto& input_stream : cc->Inputs()) { + for (CollectionItemId id = cc->Inputs().BeginId("BASE_RECTS"); + id != cc->Inputs().EndId("BASE_RECTS"); ++id) { + const auto& input_stream = cc->Inputs().Get(id); + if (input_stream.IsEmpty()) { + continue; + } + + for (auto rect : input_stream.Get>()) { + if (!rect.has_rect_id()) { + rect.set_rect_id(GetNextRectId()); + } + result.push_back(rect); + } + } + + for (CollectionItemId id = cc->Inputs().BeginId("RECTS"); + id != cc->Inputs().EndId("RECTS"); ++id) { + const auto& input_stream = cc->Inputs().Get(id); if (input_stream.IsEmpty()) { continue; } diff --git a/mediapipe/tasks/cc/vision/hand_landmarker/calculators/hand_association_calculator_test.cc b/mediapipe/tasks/cc/vision/hand_landmarker/calculators/hand_association_calculator_test.cc index 138164209..c22b1a7e6 100644 --- a/mediapipe/tasks/cc/vision/hand_landmarker/calculators/hand_association_calculator_test.cc +++ b/mediapipe/tasks/cc/vision/hand_landmarker/calculators/hand_association_calculator_test.cc @@ -27,6 +27,8 @@ namespace mediapipe { namespace { using ::mediapipe::NormalizedRect; +using ::testing::ElementsAre; +using ::testing::EqualsProto; class HandAssociationCalculatorTest : public testing::Test { protected: @@ -87,9 +89,9 @@ class HandAssociationCalculatorTest : public testing::Test { TEST_F(HandAssociationCalculatorTest, NormRectAssocTest) { CalculatorRunner runner(ParseTextProtoOrDie(R"pb( calculator: "HandAssociationCalculator" - input_stream: "input_vec_0" - input_stream: "input_vec_1" - input_stream: "input_vec_2" + input_stream: "BASE_RECTS:input_vec_0" + input_stream: "RECTS:0:input_vec_1" + input_stream: "RECTS:1:input_vec_2" output_stream: "output_vec" options { [mediapipe.HandAssociationCalculatorOptions.ext] { @@ -103,20 +105,23 @@ TEST_F(HandAssociationCalculatorTest, NormRectAssocTest) { input_vec_0->push_back(nr_0_); input_vec_0->push_back(nr_1_); input_vec_0->push_back(nr_2_); - runner.MutableInputs()->Index(0).packets.push_back( - Adopt(input_vec_0.release()).At(Timestamp(1))); + runner.MutableInputs() + ->Tag("BASE_RECTS") + .packets.push_back(Adopt(input_vec_0.release()).At(Timestamp(1))); // Input Stream 1: nr_3, nr_4. auto input_vec_1 = std::make_unique>(); input_vec_1->push_back(nr_3_); input_vec_1->push_back(nr_4_); - runner.MutableInputs()->Index(1).packets.push_back( + auto index_id = runner.MutableInputs()->GetId("RECTS", 0); + runner.MutableInputs()->Get(index_id).packets.push_back( Adopt(input_vec_1.release()).At(Timestamp(1))); // Input Stream 2: nr_5. auto input_vec_2 = std::make_unique>(); input_vec_2->push_back(nr_5_); - runner.MutableInputs()->Index(2).packets.push_back( + index_id = runner.MutableInputs()->GetId("RECTS", 1); + runner.MutableInputs()->Get(index_id).packets.push_back( Adopt(input_vec_2.release()).At(Timestamp(1))); MP_ASSERT_OK(runner.Run()) << "Calculator execution failed."; @@ -134,25 +139,18 @@ TEST_F(HandAssociationCalculatorTest, NormRectAssocTest) { EXPECT_EQ(3, assoc_rects.size()); // Check that IDs are filled in and contents match. - EXPECT_EQ(assoc_rects[0].rect_id(), 1); - assoc_rects[0].clear_rect_id(); - EXPECT_THAT(assoc_rects[0], testing::EqualsProto(nr_0_)); - - EXPECT_EQ(assoc_rects[1].rect_id(), 2); - assoc_rects[1].clear_rect_id(); - EXPECT_THAT(assoc_rects[1], testing::EqualsProto(nr_1_)); - - EXPECT_EQ(assoc_rects[2].rect_id(), 3); - assoc_rects[2].clear_rect_id(); - EXPECT_THAT(assoc_rects[2], testing::EqualsProto(nr_2_)); + nr_0_.set_rect_id(1); + nr_1_.set_rect_id(2); + nr_2_.set_rect_id(3); + EXPECT_THAT(assoc_rects, ElementsAre(EqualsProto(nr_0_), EqualsProto(nr_1_), + EqualsProto(nr_2_))); } TEST_F(HandAssociationCalculatorTest, NormRectAssocTestWithTrackedHands) { CalculatorRunner runner(ParseTextProtoOrDie(R"pb( calculator: "HandAssociationCalculator" - input_stream: "input_vec_0" - input_stream: "input_vec_1" - input_stream: "input_vec_2" + input_stream: "BASE_RECTS:input_vec_0" + input_stream: "RECTS:0:input_vec_1" output_stream: "output_vec" options { [mediapipe.HandAssociationCalculatorOptions.ext] { @@ -169,14 +167,15 @@ TEST_F(HandAssociationCalculatorTest, NormRectAssocTestWithTrackedHands) { input_vec_0->push_back(nr_0_); nr_1_.set_rect_id(-1); input_vec_0->push_back(nr_1_); - runner.MutableInputs()->Index(0).packets.push_back( - Adopt(input_vec_0.release()).At(Timestamp(1))); + runner.MutableInputs() + ->Tag("BASE_RECTS") + .packets.push_back(Adopt(input_vec_0.release()).At(Timestamp(1))); // Input Stream 1: nr_2, nr_3. Newly detected palms. auto input_vec_1 = std::make_unique>(); input_vec_1->push_back(nr_2_); input_vec_1->push_back(nr_3_); - runner.MutableInputs()->Index(1).packets.push_back( + runner.MutableInputs()->Tag("RECTS").packets.push_back( Adopt(input_vec_1.release()).At(Timestamp(1))); MP_ASSERT_OK(runner.Run()) << "Calculator execution failed."; @@ -192,23 +191,17 @@ TEST_F(HandAssociationCalculatorTest, NormRectAssocTestWithTrackedHands) { EXPECT_EQ(3, assoc_rects.size()); // Check that IDs are filled in and contents match. - EXPECT_EQ(assoc_rects[0].rect_id(), -2); - EXPECT_THAT(assoc_rects[0], testing::EqualsProto(nr_0_)); - - EXPECT_EQ(assoc_rects[1].rect_id(), -1); - EXPECT_THAT(assoc_rects[1], testing::EqualsProto(nr_1_)); - - EXPECT_EQ(assoc_rects[2].rect_id(), 1); - assoc_rects[2].clear_rect_id(); - EXPECT_THAT(assoc_rects[2], testing::EqualsProto(nr_2_)); + nr_2_.set_rect_id(1); + EXPECT_THAT(assoc_rects, ElementsAre(EqualsProto(nr_0_), EqualsProto(nr_1_), + EqualsProto(nr_2_))); } TEST_F(HandAssociationCalculatorTest, NormRectAssocTestReverse) { CalculatorRunner runner(ParseTextProtoOrDie(R"pb( calculator: "HandAssociationCalculator" - input_stream: "input_vec_0" - input_stream: "input_vec_1" - input_stream: "input_vec_2" + input_stream: "BASE_RECTS:input_vec_0" + input_stream: "RECTS:0:input_vec_1" + input_stream: "RECTS:1:input_vec_2" output_stream: "output_vec" options { [mediapipe.HandAssociationCalculatorOptions.ext] { @@ -220,14 +213,16 @@ TEST_F(HandAssociationCalculatorTest, NormRectAssocTestReverse) { // Input Stream 0: nr_5. auto input_vec_0 = std::make_unique>(); input_vec_0->push_back(nr_5_); - runner.MutableInputs()->Index(0).packets.push_back( - Adopt(input_vec_0.release()).At(Timestamp(1))); + runner.MutableInputs() + ->Tag("BASE_RECTS") + .packets.push_back(Adopt(input_vec_0.release()).At(Timestamp(1))); // Input Stream 1: nr_4, nr_3 auto input_vec_1 = std::make_unique>(); input_vec_1->push_back(nr_4_); input_vec_1->push_back(nr_3_); - runner.MutableInputs()->Index(1).packets.push_back( + auto index_id = runner.MutableInputs()->GetId("RECTS", 0); + runner.MutableInputs()->Get(index_id).packets.push_back( Adopt(input_vec_1.release()).At(Timestamp(1))); // Input Stream 2: nr_2, nr_1, nr_0. @@ -235,7 +230,8 @@ TEST_F(HandAssociationCalculatorTest, NormRectAssocTestReverse) { input_vec_2->push_back(nr_2_); input_vec_2->push_back(nr_1_); input_vec_2->push_back(nr_0_); - runner.MutableInputs()->Index(2).packets.push_back( + index_id = runner.MutableInputs()->GetId("RECTS", 1); + runner.MutableInputs()->Get(index_id).packets.push_back( Adopt(input_vec_2.release()).At(Timestamp(1))); MP_ASSERT_OK(runner.Run()) << "Calculator execution failed."; @@ -253,23 +249,78 @@ TEST_F(HandAssociationCalculatorTest, NormRectAssocTestReverse) { EXPECT_EQ(3, assoc_rects.size()); // Outputs are in same order as inputs, and IDs are filled in. - EXPECT_EQ(assoc_rects[0].rect_id(), 1); - assoc_rects[0].clear_rect_id(); - EXPECT_THAT(assoc_rects[0], testing::EqualsProto(nr_5_)); + nr_5_.set_rect_id(1); + nr_4_.set_rect_id(2); + nr_0_.set_rect_id(3); + EXPECT_THAT(assoc_rects, ElementsAre(EqualsProto(nr_5_), EqualsProto(nr_4_), + EqualsProto(nr_0_))); +} - EXPECT_EQ(assoc_rects[1].rect_id(), 2); - assoc_rects[1].clear_rect_id(); - EXPECT_THAT(assoc_rects[1], testing::EqualsProto(nr_4_)); +TEST_F(HandAssociationCalculatorTest, NormRectAssocTestReservesBaseRects) { + CalculatorRunner runner(ParseTextProtoOrDie(R"pb( + calculator: "HandAssociationCalculator" + input_stream: "BASE_RECTS:input_vec_0" + input_stream: "RECTS:0:input_vec_1" + input_stream: "RECTS:1:input_vec_2" + output_stream: "output_vec" + options { + [mediapipe.HandAssociationCalculatorOptions.ext] { + min_similarity_threshold: 0.1 + } + } + )pb")); - EXPECT_EQ(assoc_rects[2].rect_id(), 3); - assoc_rects[2].clear_rect_id(); - EXPECT_THAT(assoc_rects[2], testing::EqualsProto(nr_0_)); + // Input Stream 0: nr_5, nr_3, nr_1. + auto input_vec_0 = std::make_unique>(); + input_vec_0->push_back(nr_5_); + input_vec_0->push_back(nr_3_); + input_vec_0->push_back(nr_1_); + runner.MutableInputs() + ->Tag("BASE_RECTS") + .packets.push_back(Adopt(input_vec_0.release()).At(Timestamp(1))); + + // Input Stream 1: nr_4. + auto input_vec_1 = std::make_unique>(); + input_vec_1->push_back(nr_4_); + auto index_id = runner.MutableInputs()->GetId("RECTS", 0); + runner.MutableInputs()->Get(index_id).packets.push_back( + Adopt(input_vec_1.release()).At(Timestamp(1))); + + // Input Stream 2: nr_2, nr_0. + auto input_vec_2 = std::make_unique>(); + input_vec_2->push_back(nr_2_); + input_vec_2->push_back(nr_0_); + index_id = runner.MutableInputs()->GetId("RECTS", 1); + runner.MutableInputs()->Get(index_id).packets.push_back( + Adopt(input_vec_2.release()).At(Timestamp(1))); + + MP_ASSERT_OK(runner.Run()) << "Calculator execution failed."; + const std::vector& output = runner.Outputs().Index(0).packets; + EXPECT_EQ(1, output.size()); + auto assoc_rects = output[0].Get>(); + + // Rectangles are added in the following sequence: + // nr_5 is added because it is in BASE_RECTS input stream. + // nr_3 is added because it is in BASE_RECTS input stream. + // nr_1 is added because it is in BASE_RECTS input stream. + // nr_4 is added because it does not overlap with nr_5. + // nr_2 is NOT added because it overlaps with nr_4. + // nr_0 is NOT added because it overlaps with nr_3. + EXPECT_EQ(4, assoc_rects.size()); + + // Outputs are in same order as inputs, and IDs are filled in. + nr_5_.set_rect_id(1); + nr_3_.set_rect_id(2); + nr_1_.set_rect_id(3); + nr_4_.set_rect_id(4); + EXPECT_THAT(assoc_rects, ElementsAre(EqualsProto(nr_5_), EqualsProto(nr_3_), + EqualsProto(nr_1_), EqualsProto(nr_4_))); } TEST_F(HandAssociationCalculatorTest, NormRectAssocSingleInputStream) { CalculatorRunner runner(ParseTextProtoOrDie(R"pb( calculator: "HandAssociationCalculator" - input_stream: "input_vec" + input_stream: "BASE_RECTS:input_vec" output_stream: "output_vec" options { [mediapipe.HandAssociationCalculatorOptions.ext] { @@ -282,8 +333,9 @@ TEST_F(HandAssociationCalculatorTest, NormRectAssocSingleInputStream) { auto input_vec = std::make_unique>(); input_vec->push_back(nr_3_); input_vec->push_back(nr_5_); - runner.MutableInputs()->Index(0).packets.push_back( - Adopt(input_vec.release()).At(Timestamp(1))); + runner.MutableInputs() + ->Tag("BASE_RECTS") + .packets.push_back(Adopt(input_vec.release()).At(Timestamp(1))); MP_ASSERT_OK(runner.Run()) << "Calculator execution failed."; const std::vector& output = runner.Outputs().Index(0).packets; @@ -292,12 +344,12 @@ TEST_F(HandAssociationCalculatorTest, NormRectAssocSingleInputStream) { // Rectangles are added in the following sequence: // nr_3 is added 1st. - // nr_5 is NOT added because it overlaps with nr_3. - EXPECT_EQ(1, assoc_rects.size()); + // nr_5 is added 2nd. The calculator assumes it does not overlap with nr_3. + EXPECT_EQ(2, assoc_rects.size()); - EXPECT_EQ(assoc_rects[0].rect_id(), 1); - assoc_rects[0].clear_rect_id(); - EXPECT_THAT(assoc_rects[0], testing::EqualsProto(nr_3_)); + nr_3_.set_rect_id(1); + nr_5_.set_rect_id(2); + EXPECT_THAT(assoc_rects, ElementsAre(EqualsProto(nr_3_), EqualsProto(nr_5_))); } } // namespace diff --git a/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker_graph.cc b/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker_graph.cc index 4a3db9f4d..21e43fc82 100644 --- a/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker_graph.cc +++ b/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker_graph.cc @@ -318,9 +318,9 @@ class HandLandmarkerGraph : public core::ModelTaskGraph { .set_min_similarity_threshold( tasks_options.min_tracking_confidence()); prev_hand_rects_from_landmarks >> - hand_association[Input>::Multiple("")][0]; + hand_association[Input>("BASE_RECTS")]; hand_rects_from_hand_detector >> - hand_association[Input>::Multiple("")][1]; + hand_association[Input>("RECTS")]; auto hand_rects = hand_association.Out(""); hand_rects >> clip_hand_rects.In(""); } else { From cb4b0ea93da523dcaca243dad306ad43a253f167 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 13 Mar 2023 15:35:11 -0700 Subject: [PATCH 061/136] Disable OpenCL dependency for OpenCV PiperOrigin-RevId: 516341303 --- third_party/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/third_party/BUILD b/third_party/BUILD index e2044cfd9..7522bab1b 100644 --- a/third_party/BUILD +++ b/third_party/BUILD @@ -112,6 +112,7 @@ cmake_external( "WITH_JPEG": "ON", "WITH_PNG": "ON", "WITH_TIFF": "ON", + "WITH_OPENCL": "OFF", "WITH_WEBP": "OFF", # Optimization flags "CV_ENABLE_INTRINSICS": "ON", From 1b4a835be0a73eaa83bf80fdd991fe4293d29b54 Mon Sep 17 00:00:00 2001 From: Chris McClanahan Date: Mon, 13 Mar 2023 16:07:36 -0700 Subject: [PATCH 062/136] Internal change PiperOrigin-RevId: 516349788 --- mediapipe/calculators/image/BUILD | 1 + .../calculators/image/affine_transformation.h | 3 + .../image/affine_transformation_runner_gl.cc | 141 ++++++++++++++---- .../image/affine_transformation_runner_gl.h | 3 +- .../affine_transformation_runner_opencv.cc | 23 ++- .../affine_transformation_runner_opencv.h | 3 +- .../image/warp_affine_calculator.cc | 28 +++- .../image/warp_affine_calculator.proto | 13 ++ .../image/warp_affine_calculator_test.cc | 101 ++++++++++--- ...with_rotation_border_zero_interp_cubic.png | Bin 0 -> 65093 bytes 10 files changed, 256 insertions(+), 60 deletions(-) create mode 100644 mediapipe/calculators/tensor/testdata/image_to_tensor/medium_sub_rect_with_rotation_border_zero_interp_cubic.png diff --git a/mediapipe/calculators/image/BUILD b/mediapipe/calculators/image/BUILD index 18a1d60ae..d627bdc4a 100644 --- a/mediapipe/calculators/image/BUILD +++ b/mediapipe/calculators/image/BUILD @@ -748,6 +748,7 @@ cc_test( "//mediapipe/calculators/tensor:testdata/image_to_tensor/medium_sub_rect_keep_aspect_with_rotation_border_zero.png", "//mediapipe/calculators/tensor:testdata/image_to_tensor/medium_sub_rect_with_rotation.png", "//mediapipe/calculators/tensor:testdata/image_to_tensor/medium_sub_rect_with_rotation_border_zero.png", + "//mediapipe/calculators/tensor:testdata/image_to_tensor/medium_sub_rect_with_rotation_border_zero_interp_cubic.png", "//mediapipe/calculators/tensor:testdata/image_to_tensor/noop_except_range.png", ], tags = ["desktop_only_test"], diff --git a/mediapipe/calculators/image/affine_transformation.h b/mediapipe/calculators/image/affine_transformation.h index 40793e7a1..3e40e46dc 100644 --- a/mediapipe/calculators/image/affine_transformation.h +++ b/mediapipe/calculators/image/affine_transformation.h @@ -29,6 +29,9 @@ class AffineTransformation { // pixels will be calculated. enum class BorderMode { kZero, kReplicate }; + // Pixel sampling interpolation method. + enum class Interpolation { kLinear, kCubic }; + struct Size { int width; int height; diff --git a/mediapipe/calculators/image/affine_transformation_runner_gl.cc b/mediapipe/calculators/image/affine_transformation_runner_gl.cc index 361dfc902..006416916 100644 --- a/mediapipe/calculators/image/affine_transformation_runner_gl.cc +++ b/mediapipe/calculators/image/affine_transformation_runner_gl.cc @@ -77,8 +77,11 @@ class GlTextureWarpAffineRunner std::unique_ptr> { public: GlTextureWarpAffineRunner(std::shared_ptr gl_helper, - GpuOrigin::Mode gpu_origin) - : gl_helper_(gl_helper), gpu_origin_(gpu_origin) {} + GpuOrigin::Mode gpu_origin, + AffineTransformation::Interpolation interpolation) + : gl_helper_(gl_helper), + gpu_origin_(gpu_origin), + interpolation_(interpolation) {} absl::Status Init() { return gl_helper_->RunInGlContext([this]() -> absl::Status { const GLint attr_location[kNumAttributes] = { @@ -103,28 +106,83 @@ class GlTextureWarpAffineRunner } )"; + // TODO Move bicubic code to common shared place. constexpr GLchar kFragShader[] = R"( - DEFAULT_PRECISION(highp, float) - in vec2 sample_coordinate; - uniform sampler2D input_texture; + DEFAULT_PRECISION(highp, float) - #ifdef GL_ES - #define fragColor gl_FragColor - #else - out vec4 fragColor; - #endif // defined(GL_ES); + in vec2 sample_coordinate; + uniform sampler2D input_texture; + uniform vec2 input_size; - void main() { - vec4 color = texture2D(input_texture, sample_coordinate); - #ifdef CUSTOM_ZERO_BORDER_MODE - float out_of_bounds = - float(sample_coordinate.x < 0.0 || sample_coordinate.x > 1.0 || - sample_coordinate.y < 0.0 || sample_coordinate.y > 1.0); - color = mix(color, vec4(0.0, 0.0, 0.0, 0.0), out_of_bounds); - #endif // defined(CUSTOM_ZERO_BORDER_MODE) - fragColor = color; - } - )"; + #ifdef GL_ES + #define fragColor gl_FragColor + #else + out vec4 fragColor; + #endif // defined(GL_ES); + + #ifdef CUBIC_INTERPOLATION + vec4 sample(sampler2D tex, vec2 tex_coord, vec2 tex_size) { + const vec2 halve = vec2(0.5,0.5); + const vec2 one = vec2(1.0,1.0); + const vec2 two = vec2(2.0,2.0); + const vec2 three = vec2(3.0,3.0); + const vec2 six = vec2(6.0,6.0); + + // Calculate the fraction and integer. + tex_coord = tex_coord * tex_size - halve; + vec2 frac = fract(tex_coord); + vec2 index = tex_coord - frac + halve; + + // Calculate weights for Catmull-Rom filter. + vec2 w0 = frac * (-halve + frac * (one - halve * frac)); + vec2 w1 = one + frac * frac * (-(two+halve) + three/two * frac); + vec2 w2 = frac * (halve + frac * (two - three/two * frac)); + vec2 w3 = frac * frac * (-halve + halve * frac); + + // Calculate weights to take advantage of bilinear texture lookup. + vec2 w12 = w1 + w2; + vec2 offset12 = w2 / (w1 + w2); + + vec2 index_tl = index - one; + vec2 index_br = index + two; + vec2 index_eq = index + offset12; + + index_tl /= tex_size; + index_br /= tex_size; + index_eq /= tex_size; + + // 9 texture lookup and linear blending. + vec4 color = vec4(0.0); + color += texture2D(tex, vec2(index_tl.x, index_tl.y)) * w0.x * w0.y; + color += texture2D(tex, vec2(index_eq.x, index_tl.y)) * w12.x *w0.y; + color += texture2D(tex, vec2(index_br.x, index_tl.y)) * w3.x * w0.y; + + color += texture2D(tex, vec2(index_tl.x, index_eq.y)) * w0.x * w12.y; + color += texture2D(tex, vec2(index_eq.x, index_eq.y)) * w12.x *w12.y; + color += texture2D(tex, vec2(index_br.x, index_eq.y)) * w3.x * w12.y; + + color += texture2D(tex, vec2(index_tl.x, index_br.y)) * w0.x * w3.y; + color += texture2D(tex, vec2(index_eq.x, index_br.y)) * w12.x *w3.y; + color += texture2D(tex, vec2(index_br.x, index_br.y)) * w3.x * w3.y; + return color; + } + #else + vec4 sample(sampler2D tex, vec2 tex_coord, vec2 tex_size) { + return texture2D(tex, tex_coord); + } + #endif // defined(CUBIC_INTERPOLATION) + + void main() { + vec4 color = sample(input_texture, sample_coordinate, input_size); + #ifdef CUSTOM_ZERO_BORDER_MODE + float out_of_bounds = + float(sample_coordinate.x < 0.0 || sample_coordinate.x > 1.0 || + sample_coordinate.y < 0.0 || sample_coordinate.y > 1.0); + color = mix(color, vec4(0.0, 0.0, 0.0, 0.0), out_of_bounds); + #endif // defined(CUSTOM_ZERO_BORDER_MODE) + fragColor = color; + } + )"; // Create program and set parameters. auto create_fn = [&](const std::string& vs, @@ -137,14 +195,28 @@ class GlTextureWarpAffineRunner glUseProgram(program); glUniform1i(glGetUniformLocation(program, "input_texture"), 1); GLint matrix_id = glGetUniformLocation(program, "transform_matrix"); - return Program{.id = program, .matrix_id = matrix_id}; + GLint size_id = glGetUniformLocation(program, "input_size"); + return Program{ + .id = program, .matrix_id = matrix_id, .size_id = size_id}; }; const std::string vert_src = absl::StrCat(mediapipe::kMediaPipeVertexShaderPreamble, kVertShader); - const std::string frag_src = absl::StrCat( - mediapipe::kMediaPipeFragmentShaderPreamble, kFragShader); + std::string interpolation_def; + switch (interpolation_) { + case AffineTransformation::Interpolation::kCubic: + interpolation_def = R"( + #define CUBIC_INTERPOLATION + )"; + break; + case AffineTransformation::Interpolation::kLinear: + break; + } + + const std::string frag_src = + absl::StrCat(mediapipe::kMediaPipeFragmentShaderPreamble, + interpolation_def, kFragShader); ASSIGN_OR_RETURN(program_, create_fn(vert_src, frag_src)); @@ -152,9 +224,9 @@ class GlTextureWarpAffineRunner std::string custom_zero_border_mode_def = R"( #define CUSTOM_ZERO_BORDER_MODE )"; - const std::string frag_custom_zero_src = - absl::StrCat(mediapipe::kMediaPipeFragmentShaderPreamble, - custom_zero_border_mode_def, kFragShader); + const std::string frag_custom_zero_src = absl::StrCat( + mediapipe::kMediaPipeFragmentShaderPreamble, + custom_zero_border_mode_def, interpolation_def, kFragShader); return create_fn(vert_src, frag_custom_zero_src); }; #if GL_CLAMP_TO_BORDER_MAY_BE_SUPPORTED @@ -256,6 +328,7 @@ class GlTextureWarpAffineRunner } glUseProgram(program->id); + // uniforms Eigen::Matrix eigen_mat(matrix.data()); if (IsMatrixVerticalFlipNeeded(gpu_origin_)) { // @matrix describes affine transformation in terms of TOP LEFT origin, so @@ -275,6 +348,10 @@ class GlTextureWarpAffineRunner eigen_mat.transposeInPlace(); glUniformMatrix4fv(program->matrix_id, 1, GL_FALSE, eigen_mat.data()); + if (interpolation_ == AffineTransformation::Interpolation::kCubic) { + glUniform2f(program->size_id, texture.width(), texture.height()); + } + // vao glBindVertexArray(vao_); @@ -327,6 +404,7 @@ class GlTextureWarpAffineRunner struct Program { GLuint id; GLint matrix_id; + GLint size_id; }; std::shared_ptr gl_helper_; GpuOrigin::Mode gpu_origin_; @@ -335,6 +413,8 @@ class GlTextureWarpAffineRunner Program program_; std::optional program_custom_zero_; GLuint framebuffer_ = 0; + AffineTransformation::Interpolation interpolation_ = + AffineTransformation::Interpolation::kLinear; }; #undef GL_CLAMP_TO_BORDER_MAY_BE_SUPPORTED @@ -344,9 +424,10 @@ class GlTextureWarpAffineRunner absl::StatusOr>>> CreateAffineTransformationGlRunner( - std::shared_ptr gl_helper, GpuOrigin::Mode gpu_origin) { - auto runner = - absl::make_unique(gl_helper, gpu_origin); + std::shared_ptr gl_helper, GpuOrigin::Mode gpu_origin, + AffineTransformation::Interpolation interpolation) { + auto runner = absl::make_unique( + gl_helper, gpu_origin, interpolation); MP_RETURN_IF_ERROR(runner->Init()); return runner; } diff --git a/mediapipe/calculators/image/affine_transformation_runner_gl.h b/mediapipe/calculators/image/affine_transformation_runner_gl.h index 677e0720d..826c7b5c1 100644 --- a/mediapipe/calculators/image/affine_transformation_runner_gl.h +++ b/mediapipe/calculators/image/affine_transformation_runner_gl.h @@ -29,7 +29,8 @@ absl::StatusOr>>> CreateAffineTransformationGlRunner( std::shared_ptr gl_helper, - mediapipe::GpuOrigin::Mode gpu_origin); + mediapipe::GpuOrigin::Mode gpu_origin, + AffineTransformation::Interpolation interpolation); } // namespace mediapipe diff --git a/mediapipe/calculators/image/affine_transformation_runner_opencv.cc b/mediapipe/calculators/image/affine_transformation_runner_opencv.cc index 46026a987..c43d73ff7 100644 --- a/mediapipe/calculators/image/affine_transformation_runner_opencv.cc +++ b/mediapipe/calculators/image/affine_transformation_runner_opencv.cc @@ -39,9 +39,22 @@ cv::BorderTypes GetBorderModeForOpenCv( } } +int GetInterpolationForOpenCv( + AffineTransformation::Interpolation interpolation) { + switch (interpolation) { + case AffineTransformation::Interpolation::kLinear: + return cv::INTER_LINEAR; + case AffineTransformation::Interpolation::kCubic: + return cv::INTER_CUBIC; + } +} + class OpenCvRunner : public AffineTransformation::Runner { public: + OpenCvRunner(AffineTransformation::Interpolation interpolation) + : interpolation_(GetInterpolationForOpenCv(interpolation)) {} + absl::StatusOr Run( const ImageFrame& input, const std::array& matrix, const AffineTransformation::Size& size, @@ -142,19 +155,23 @@ class OpenCvRunner cv::warpAffine(in_mat, out_mat, cv_affine_transform, cv::Size(out_mat.cols, out_mat.rows), - /*flags=*/cv::INTER_LINEAR | cv::WARP_INVERSE_MAP, + /*flags=*/interpolation_ | cv::WARP_INVERSE_MAP, GetBorderModeForOpenCv(border_mode)); return out_image; } + + private: + int interpolation_ = cv::INTER_LINEAR; }; } // namespace absl::StatusOr< std::unique_ptr>> -CreateAffineTransformationOpenCvRunner() { - return absl::make_unique(); +CreateAffineTransformationOpenCvRunner( + AffineTransformation::Interpolation interpolation) { + return absl::make_unique(interpolation); } } // namespace mediapipe diff --git a/mediapipe/calculators/image/affine_transformation_runner_opencv.h b/mediapipe/calculators/image/affine_transformation_runner_opencv.h index 200281c95..6de48d4cf 100644 --- a/mediapipe/calculators/image/affine_transformation_runner_opencv.h +++ b/mediapipe/calculators/image/affine_transformation_runner_opencv.h @@ -25,7 +25,8 @@ namespace mediapipe { absl::StatusOr< std::unique_ptr>> -CreateAffineTransformationOpenCvRunner(); +CreateAffineTransformationOpenCvRunner( + AffineTransformation::Interpolation interpolation); } // namespace mediapipe diff --git a/mediapipe/calculators/image/warp_affine_calculator.cc b/mediapipe/calculators/image/warp_affine_calculator.cc index 615d1697c..388701773 100644 --- a/mediapipe/calculators/image/warp_affine_calculator.cc +++ b/mediapipe/calculators/image/warp_affine_calculator.cc @@ -53,6 +53,17 @@ AffineTransformation::BorderMode GetBorderMode( } } +AffineTransformation::Interpolation GetInterpolation( + mediapipe::WarpAffineCalculatorOptions::Interpolation interpolation) { + switch (interpolation) { + case mediapipe::WarpAffineCalculatorOptions::INTER_UNSPECIFIED: + case mediapipe::WarpAffineCalculatorOptions::INTER_LINEAR: + return AffineTransformation::Interpolation::kLinear; + case mediapipe::WarpAffineCalculatorOptions::INTER_CUBIC: + return AffineTransformation::Interpolation::kCubic; + } +} + template class WarpAffineRunnerHolder {}; @@ -61,16 +72,22 @@ template <> class WarpAffineRunnerHolder { public: using RunnerType = AffineTransformation::Runner; - absl::Status Open(CalculatorContext* cc) { return absl::OkStatus(); } + absl::Status Open(CalculatorContext* cc) { + interpolation_ = GetInterpolation( + cc->Options().interpolation()); + return absl::OkStatus(); + } absl::StatusOr GetRunner() { if (!runner_) { - ASSIGN_OR_RETURN(runner_, CreateAffineTransformationOpenCvRunner()); + ASSIGN_OR_RETURN(runner_, + CreateAffineTransformationOpenCvRunner(interpolation_)); } return runner_.get(); } private: std::unique_ptr runner_; + AffineTransformation::Interpolation interpolation_; }; #endif // !MEDIAPIPE_DISABLE_OPENCV @@ -85,12 +102,14 @@ class WarpAffineRunnerHolder { gpu_origin_ = cc->Options().gpu_origin(); gl_helper_ = std::make_shared(); + interpolation_ = GetInterpolation( + cc->Options().interpolation()); return gl_helper_->Open(cc); } absl::StatusOr GetRunner() { if (!runner_) { - ASSIGN_OR_RETURN( - runner_, CreateAffineTransformationGlRunner(gl_helper_, gpu_origin_)); + ASSIGN_OR_RETURN(runner_, CreateAffineTransformationGlRunner( + gl_helper_, gpu_origin_, interpolation_)); } return runner_.get(); } @@ -99,6 +118,7 @@ class WarpAffineRunnerHolder { mediapipe::GpuOrigin::Mode gpu_origin_; std::shared_ptr gl_helper_; std::unique_ptr runner_; + AffineTransformation::Interpolation interpolation_; }; #endif // !MEDIAPIPE_DISABLE_GPU diff --git a/mediapipe/calculators/image/warp_affine_calculator.proto b/mediapipe/calculators/image/warp_affine_calculator.proto index 20e6c1b07..b68f71ac3 100644 --- a/mediapipe/calculators/image/warp_affine_calculator.proto +++ b/mediapipe/calculators/image/warp_affine_calculator.proto @@ -31,6 +31,13 @@ message WarpAffineCalculatorOptions { BORDER_REPLICATE = 2; } + // Pixel sampling interpolation methods. See @interpolation. + enum Interpolation { + INTER_UNSPECIFIED = 0; + INTER_LINEAR = 1; + INTER_CUBIC = 2; + } + // Pixel extrapolation method. // When converting image to tensor it may happen that tensor needs to read // pixels outside image boundaries. Border mode helps to specify how such @@ -43,4 +50,10 @@ message WarpAffineCalculatorOptions { // to be flipped vertically as tensors are expected to start at top. // (DEFAULT or unset interpreted as CONVENTIONAL.) optional GpuOrigin.Mode gpu_origin = 2; + + // Sampling method for neighboring pixels. + // INTER_LINEAR (bilinear) linearly interpolates from the nearest 4 neighbors. + // INTER_CUBIC (bicubic) interpolates a small neighborhood with cubic weights. + // INTER_UNSPECIFIED or unset interpreted as INTER_LINEAR. + optional Interpolation interpolation = 3; } diff --git a/mediapipe/calculators/image/warp_affine_calculator_test.cc b/mediapipe/calculators/image/warp_affine_calculator_test.cc index 959912cc9..b911b66fd 100644 --- a/mediapipe/calculators/image/warp_affine_calculator_test.cc +++ b/mediapipe/calculators/image/warp_affine_calculator_test.cc @@ -63,7 +63,8 @@ void RunTest(const std::string& graph_text, const std::string& tag, const cv::Mat& input, cv::Mat expected_result, float similarity_threshold, std::array matrix, int out_width, int out_height, - absl::optional border_mode) { + std::optional border_mode, + std::optional interpolation) { std::string border_mode_str; if (border_mode) { switch (*border_mode) { @@ -75,8 +76,20 @@ void RunTest(const std::string& graph_text, const std::string& tag, break; } } + std::string interpolation_str; + if (interpolation) { + switch (*interpolation) { + case AffineTransformation::Interpolation::kLinear: + interpolation_str = "interpolation: INTER_LINEAR"; + break; + case AffineTransformation::Interpolation::kCubic: + interpolation_str = "interpolation: INTER_CUBIC"; + break; + } + } auto graph_config = mediapipe::ParseTextProtoOrDie( - absl::Substitute(graph_text, /*$0=*/border_mode_str)); + absl::Substitute(graph_text, /*$0=*/border_mode_str, + /*$1=*/interpolation_str)); std::vector output_packets; tool::AddVectorSink("output_image", &graph_config, &output_packets); @@ -132,7 +145,8 @@ struct SimilarityConfig { void RunTest(cv::Mat input, cv::Mat expected_result, const SimilarityConfig& similarity, std::array matrix, int out_width, int out_height, - absl::optional border_mode) { + std::optional border_mode, + std::optional interpolation) { RunTest(R"( input_stream: "input_image" input_stream: "output_size" @@ -146,12 +160,13 @@ void RunTest(cv::Mat input, cv::Mat expected_result, options { [mediapipe.WarpAffineCalculatorOptions.ext] { $0 # border mode + $1 # interpolation } } } )", "cpu", input, expected_result, similarity.threshold_on_cpu, matrix, - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); RunTest(R"( input_stream: "input_image" @@ -171,6 +186,7 @@ void RunTest(cv::Mat input, cv::Mat expected_result, options { [mediapipe.WarpAffineCalculatorOptions.ext] { $0 # border mode + $1 # interpolation } } } @@ -181,7 +197,7 @@ void RunTest(cv::Mat input, cv::Mat expected_result, } )", "cpu_image", input, expected_result, similarity.threshold_on_cpu, - matrix, out_width, out_height, border_mode); + matrix, out_width, out_height, border_mode, interpolation); RunTest(R"( input_stream: "input_image" @@ -201,6 +217,7 @@ void RunTest(cv::Mat input, cv::Mat expected_result, options { [mediapipe.WarpAffineCalculatorOptions.ext] { $0 # border mode + $1 # interpolation gpu_origin: TOP_LEFT } } @@ -212,7 +229,7 @@ void RunTest(cv::Mat input, cv::Mat expected_result, } )", "gpu", input, expected_result, similarity.threshold_on_gpu, matrix, - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); RunTest(R"( input_stream: "input_image" @@ -237,6 +254,7 @@ void RunTest(cv::Mat input, cv::Mat expected_result, options { [mediapipe.WarpAffineCalculatorOptions.ext] { $0 # border mode + $1 # interpolation gpu_origin: TOP_LEFT } } @@ -253,7 +271,7 @@ void RunTest(cv::Mat input, cv::Mat expected_result, } )", "gpu_image", input, expected_result, similarity.threshold_on_gpu, - matrix, out_width, out_height, border_mode); + matrix, out_width, out_height, border_mode, interpolation); } std::array GetMatrix(cv::Mat input, mediapipe::NormalizedRect roi, @@ -287,10 +305,11 @@ TEST(WarpAffineCalculatorTest, MediumSubRectKeepAspect) { int out_height = 256; bool keep_aspect_ratio = true; std::optional border_mode = {}; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.82}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, MediumSubRectKeepAspectBorderZero) { @@ -312,10 +331,11 @@ TEST(WarpAffineCalculatorTest, MediumSubRectKeepAspectBorderZero) { bool keep_aspect_ratio = true; std::optional border_mode = AffineTransformation::BorderMode::kZero; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.81}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, MediumSubRectKeepAspectWithRotation) { @@ -337,10 +357,11 @@ TEST(WarpAffineCalculatorTest, MediumSubRectKeepAspectWithRotation) { bool keep_aspect_ratio = true; std::optional border_mode = AffineTransformation::BorderMode::kReplicate; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.77}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, MediumSubRectKeepAspectWithRotationBorderZero) { @@ -362,10 +383,11 @@ TEST(WarpAffineCalculatorTest, MediumSubRectKeepAspectWithRotationBorderZero) { bool keep_aspect_ratio = true; std::optional border_mode = AffineTransformation::BorderMode::kZero; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.75}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, MediumSubRectWithRotation) { @@ -386,10 +408,11 @@ TEST(WarpAffineCalculatorTest, MediumSubRectWithRotation) { bool keep_aspect_ratio = false; std::optional border_mode = AffineTransformation::BorderMode::kReplicate; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.81}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, MediumSubRectWithRotationBorderZero) { @@ -411,10 +434,38 @@ TEST(WarpAffineCalculatorTest, MediumSubRectWithRotationBorderZero) { bool keep_aspect_ratio = false; std::optional border_mode = AffineTransformation::BorderMode::kZero; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.80}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); +} + +TEST(WarpAffineCalculatorTest, MediumSubRectWithRotationBorderZeroInterpCubic) { + mediapipe::NormalizedRect roi; + roi.set_x_center(0.65f); + roi.set_y_center(0.4f); + roi.set_width(0.5f); + roi.set_height(0.5f); + roi.set_rotation(M_PI * -45.0f / 180.0f); + auto input = GetRgb( + "/mediapipe/calculators/" + "tensor/testdata/image_to_tensor/input.jpg"); + auto expected_output = GetRgb( + "/mediapipe/calculators/" + "tensor/testdata/image_to_tensor/" + "medium_sub_rect_with_rotation_border_zero_interp_cubic.png"); + int out_width = 256; + int out_height = 256; + bool keep_aspect_ratio = false; + std::optional border_mode = + AffineTransformation::BorderMode::kZero; + std::optional interpolation = + AffineTransformation::Interpolation::kCubic; + RunTest(input, expected_output, + {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.78}, + GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, LargeSubRect) { @@ -435,10 +486,11 @@ TEST(WarpAffineCalculatorTest, LargeSubRect) { bool keep_aspect_ratio = false; std::optional border_mode = AffineTransformation::BorderMode::kReplicate; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.95}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, LargeSubRectBorderZero) { @@ -459,10 +511,11 @@ TEST(WarpAffineCalculatorTest, LargeSubRectBorderZero) { bool keep_aspect_ratio = false; std::optional border_mode = AffineTransformation::BorderMode::kZero; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.92}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, LargeSubRectKeepAspect) { @@ -483,10 +536,11 @@ TEST(WarpAffineCalculatorTest, LargeSubRectKeepAspect) { bool keep_aspect_ratio = true; std::optional border_mode = AffineTransformation::BorderMode::kReplicate; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.97}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, LargeSubRectKeepAspectBorderZero) { @@ -508,10 +562,11 @@ TEST(WarpAffineCalculatorTest, LargeSubRectKeepAspectBorderZero) { bool keep_aspect_ratio = true; std::optional border_mode = AffineTransformation::BorderMode::kZero; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.97}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, LargeSubRectKeepAspectWithRotation) { @@ -532,10 +587,11 @@ TEST(WarpAffineCalculatorTest, LargeSubRectKeepAspectWithRotation) { int out_height = 128; bool keep_aspect_ratio = true; std::optional border_mode = {}; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.91}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, LargeSubRectKeepAspectWithRotationBorderZero) { @@ -557,10 +613,11 @@ TEST(WarpAffineCalculatorTest, LargeSubRectKeepAspectWithRotationBorderZero) { bool keep_aspect_ratio = true; std::optional border_mode = AffineTransformation::BorderMode::kZero; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.88}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, NoOp) { @@ -581,10 +638,11 @@ TEST(WarpAffineCalculatorTest, NoOp) { bool keep_aspect_ratio = true; std::optional border_mode = AffineTransformation::BorderMode::kReplicate; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.99}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } TEST(WarpAffineCalculatorTest, NoOpBorderZero) { @@ -605,10 +663,11 @@ TEST(WarpAffineCalculatorTest, NoOpBorderZero) { bool keep_aspect_ratio = true; std::optional border_mode = AffineTransformation::BorderMode::kZero; + std::optional interpolation = {}; RunTest(input, expected_output, {.threshold_on_cpu = 0.99, .threshold_on_gpu = 0.99}, GetMatrix(input, roi, keep_aspect_ratio, out_width, out_height), - out_width, out_height, border_mode); + out_width, out_height, border_mode, interpolation); } } // namespace diff --git a/mediapipe/calculators/tensor/testdata/image_to_tensor/medium_sub_rect_with_rotation_border_zero_interp_cubic.png b/mediapipe/calculators/tensor/testdata/image_to_tensor/medium_sub_rect_with_rotation_border_zero_interp_cubic.png new file mode 100644 index 0000000000000000000000000000000000000000..8d2e266a98ef8016e87e8343e095dc8c945b35f8 GIT binary patch literal 65093 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ⅇ-5IW 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 Date: Mon, 13 Mar 2023 16:41:03 -0700 Subject: [PATCH 063/136] Make ImageToTensorCalculator use kGpuService optionally PiperOrigin-RevId: 516358053 --- mediapipe/calculators/tensor/BUILD | 16 ++-- .../tensor/image_to_tensor_calculator.cc | 4 +- .../tensor/image_to_tensor_calculator_test.cc | 78 +++++++++++++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/mediapipe/calculators/tensor/BUILD b/mediapipe/calculators/tensor/BUILD index 7a29c3af8..a76b75494 100644 --- a/mediapipe/calculators/tensor/BUILD +++ b/mediapipe/calculators/tensor/BUILD @@ -997,17 +997,20 @@ cc_library( ":image_to_tensor_converter_gl_buffer", "//mediapipe/gpu:gl_calculator_helper", "//mediapipe/gpu:gpu_buffer", + "//mediapipe/gpu:gpu_service", ], "//mediapipe:apple": [ ":image_to_tensor_converter_metal", "//mediapipe/gpu:gl_calculator_helper", "//mediapipe/gpu:MPPMetalHelper", "//mediapipe/gpu:gpu_buffer", + "//mediapipe/gpu:gpu_service", ], "//conditions:default": [ ":image_to_tensor_converter_gl_buffer", "//mediapipe/gpu:gl_calculator_helper", "//mediapipe/gpu:gpu_buffer", + "//mediapipe/gpu:gpu_service", ], }), ) @@ -1045,6 +1048,10 @@ cc_test( ":image_to_tensor_calculator", ":image_to_tensor_converter", ":image_to_tensor_utils", + "@com_google_absl//absl/flags:flag", + "@com_google_absl//absl/memory", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/strings:str_format", "//mediapipe/framework:calculator_framework", "//mediapipe/framework:calculator_runner", "//mediapipe/framework/deps:file_path", @@ -1061,11 +1068,10 @@ cc_test( "//mediapipe/framework/port:opencv_imgproc", "//mediapipe/framework/port:parse_text_proto", "//mediapipe/util:image_test_utils", - "@com_google_absl//absl/flags:flag", - "@com_google_absl//absl/memory", - "@com_google_absl//absl/strings", - "@com_google_absl//absl/strings:str_format", - ], + ] + select({ + "//mediapipe:apple": [], + "//conditions:default": ["//mediapipe/gpu:gl_context"], + }), ) cc_library( diff --git a/mediapipe/calculators/tensor/image_to_tensor_calculator.cc b/mediapipe/calculators/tensor/image_to_tensor_calculator.cc index 499b497b0..d15d35086 100644 --- a/mediapipe/calculators/tensor/image_to_tensor_calculator.cc +++ b/mediapipe/calculators/tensor/image_to_tensor_calculator.cc @@ -45,9 +45,11 @@ #elif MEDIAPIPE_OPENGL_ES_VERSION >= MEDIAPIPE_OPENGL_ES_31 #include "mediapipe/calculators/tensor/image_to_tensor_converter_gl_buffer.h" #include "mediapipe/gpu/gl_calculator_helper.h" +#include "mediapipe/gpu/gpu_service.h" #else #include "mediapipe/calculators/tensor/image_to_tensor_converter_gl_texture.h" #include "mediapipe/gpu/gl_calculator_helper.h" +#include "mediapipe/gpu/gpu_service.h" #endif // MEDIAPIPE_METAL_ENABLED #endif // !MEDIAPIPE_DISABLE_GPU @@ -147,7 +149,7 @@ class ImageToTensorCalculator : public Node { #if MEDIAPIPE_METAL_ENABLED MP_RETURN_IF_ERROR([MPPMetalHelper updateContract:cc]); #else - MP_RETURN_IF_ERROR(mediapipe::GlCalculatorHelper::UpdateContract(cc)); + cc->UseService(kGpuService).Optional(); #endif // MEDIAPIPE_METAL_ENABLED #endif // MEDIAPIPE_DISABLE_GPU diff --git a/mediapipe/calculators/tensor/image_to_tensor_calculator_test.cc b/mediapipe/calculators/tensor/image_to_tensor_calculator_test.cc index ed7d93886..3795b1fa0 100644 --- a/mediapipe/calculators/tensor/image_to_tensor_calculator_test.cc +++ b/mediapipe/calculators/tensor/image_to_tensor_calculator_test.cc @@ -41,6 +41,10 @@ #include "mediapipe/framework/port/status_matchers.h" #include "mediapipe/util/image_test_utils.h" +#if !MEDIAPIPE_DISABLE_GPU && !MEDIAPIPE_METAL_ENABLED +#include "mediapipe/gpu/gl_context.h" +#endif // !MEDIAPIPE_DISABLE_GPU && !MEDIAPIPE_METAL_ENABLED + namespace mediapipe { namespace { @@ -507,5 +511,79 @@ TEST(ImageToTensorCalculatorTest, NoOpExceptRangeAndUseInputImageDims) { /*tensor_width=*/std::nullopt, /*tensor_height=*/std::nullopt, /*keep_aspect=*/false, BorderMode::kZero, roi); } + +TEST(ImageToTensorCalculatorTest, CanBeUsedWithoutGpuServiceSet) { + auto graph_config = + mediapipe::ParseTextProtoOrDie(R"pb( + input_stream: "input_image" + node { + calculator: "ImageToTensorCalculator" + input_stream: "IMAGE:input_image" + output_stream: "TENSORS:tensor" + options { + [mediapipe.ImageToTensorCalculatorOptions.ext] { + output_tensor_float_range { min: 0.0f max: 1.0f } + } + } + } + )pb"); + CalculatorGraph graph; + MP_ASSERT_OK(graph.Initialize(graph_config)); + MP_ASSERT_OK(graph.DisallowServiceDefaultInitialization()); + MP_ASSERT_OK(graph.StartRun({})); + auto image_frame = + std::make_shared(ImageFormat::SRGBA, 128, 256, 4); + Image image = Image(std::move(image_frame)); + Packet packet = MakePacket(std::move(image)); + MP_ASSERT_OK( + graph.AddPacketToInputStream("input_image", packet.At(Timestamp(1)))); + MP_ASSERT_OK(graph.WaitUntilIdle()); + MP_ASSERT_OK(graph.CloseAllPacketSources()); + MP_ASSERT_OK(graph.WaitUntilDone()); +} + +#if !MEDIAPIPE_DISABLE_GPU && !MEDIAPIPE_METAL_ENABLED + +TEST(ImageToTensorCalculatorTest, + FailsGracefullyWhenGpuServiceNeededButNotAvailable) { + auto graph_config = + mediapipe::ParseTextProtoOrDie(R"pb( + input_stream: "input_image" + node { + calculator: "ImageToTensorCalculator" + input_stream: "IMAGE:input_image" + output_stream: "TENSORS:tensor" + options { + [mediapipe.ImageToTensorCalculatorOptions.ext] { + output_tensor_float_range { min: 0.0f max: 1.0f } + } + } + } + )pb"); + CalculatorGraph graph; + MP_ASSERT_OK(graph.Initialize(graph_config)); + MP_ASSERT_OK(graph.DisallowServiceDefaultInitialization()); + MP_ASSERT_OK(graph.StartRun({})); + + MP_ASSERT_OK_AND_ASSIGN(auto context, + GlContext::Create(nullptr, /*create_thread=*/true)); + Packet packet; + context->Run([&packet]() { + auto image_frame = + std::make_shared(ImageFormat::SRGBA, 128, 256, 4); + Image image = Image(std::move(image_frame)); + // Ensure image is available on GPU to force ImageToTensorCalculator to + // run on GPU. + ASSERT_TRUE(image.ConvertToGpu()); + packet = MakePacket(std::move(image)); + }); + MP_ASSERT_OK( + graph.AddPacketToInputStream("input_image", packet.At(Timestamp(1)))); + EXPECT_THAT(graph.WaitUntilIdle(), + StatusIs(absl::StatusCode::kInternal, + HasSubstr("GPU service not available"))); +} +#endif // !MEDIAPIPE_DISABLE_GPU && !MEDIAPIPE_METAL_ENABLED + } // namespace } // namespace mediapipe From 57f106e0a72645f871f2875b1eaa51c6ca7f02a2 Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Mon, 13 Mar 2023 16:53:22 -0700 Subject: [PATCH 064/136] Wait until the metal backend finishes its work in the TensorsToImageCalculator. PiperOrigin-RevId: 516360846 --- .../face_stylizer/calculators/tensors_to_image_calculator.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/tasks/cc/vision/face_stylizer/calculators/tensors_to_image_calculator.cc b/mediapipe/tasks/cc/vision/face_stylizer/calculators/tensors_to_image_calculator.cc index 03760c6b3..d9825b15f 100644 --- a/mediapipe/tasks/cc/vision/face_stylizer/calculators/tensors_to_image_calculator.cc +++ b/mediapipe/tasks/cc/vision/face_stylizer/calculators/tensors_to_image_calculator.cc @@ -294,7 +294,7 @@ absl::Status TensorsToImageCalculator::MetalProcess(CalculatorContext* cc) { threadsPerThreadgroup:threads_per_group]; [compute_encoder endEncoding]; [command_buffer commit]; - + [command_buffer waitUntilCompleted]; kOutputImage(cc).Send(Image(output)); return absl::OkStatus(); } From 46ba1d805155f261787d953d72047d36416f15e3 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Mon, 13 Mar 2023 18:46:54 -0700 Subject: [PATCH 065/136] Use ExternalFile to set metadata of GeometryPipelineCalculator. PiperOrigin-RevId: 516384491 --- mediapipe/tasks/cc/vision/face_geometry/BUILD | 4 +- .../cc/vision/face_geometry/calculators/BUILD | 3 + .../geometry_pipeline_calculator.cc | 33 +++------ .../geometry_pipeline_calculator.proto | 3 +- .../face_geometry_from_landmarks_graph.cc | 10 +-- ...face_geometry_from_landmarks_graph_test.cc | 69 ++++++++++++------ .../tasks/cc/vision/face_geometry/proto/BUILD | 9 +++ .../proto/face_geometry_graph_options.proto | 28 +++++++ .../tasks/cc/vision/face_landmarker/BUILD | 2 + .../face_landmarker/face_landmarker_graph.cc | 21 +++++- .../cc/vision/face_landmarker/proto/BUILD | 1 + .../proto/face_landmarker_graph_options.proto | 5 ++ .../face_landmarker_with_blendshapes.task | Bin 3680528 -> 3700070 bytes third_party/external_files.bzl | 10 +-- 14 files changed, 137 insertions(+), 61 deletions(-) create mode 100644 mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry_graph_options.proto diff --git a/mediapipe/tasks/cc/vision/face_geometry/BUILD b/mediapipe/tasks/cc/vision/face_geometry/BUILD index 265b0dc9e..6bd9912b2 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/BUILD +++ b/mediapipe/tasks/cc/vision/face_geometry/BUILD @@ -19,9 +19,6 @@ package(default_visibility = ["//mediapipe/tasks:internal"]) cc_library( name = "face_geometry_from_landmarks_graph", srcs = ["face_geometry_from_landmarks_graph.cc"], - data = [ - "//mediapipe/tasks/cc/vision/face_geometry/data:geometry_pipeline_metadata_landmarks", - ], deps = [ "//mediapipe/calculators/core:begin_loop_calculator", "//mediapipe/calculators/core:end_loop_calculator", @@ -39,6 +36,7 @@ cc_library( "//mediapipe/tasks/cc/vision/face_geometry/calculators:geometry_pipeline_calculator_cc_proto", "//mediapipe/tasks/cc/vision/face_geometry/proto:environment_cc_proto", "//mediapipe/tasks/cc/vision/face_geometry/proto:face_geometry_cc_proto", + "//mediapipe/tasks/cc/vision/face_geometry/proto:face_geometry_graph_options_cc_proto", "//mediapipe/util:graph_builder_utils", "@com_google_absl//absl/status:statusor", ], diff --git a/mediapipe/tasks/cc/vision/face_geometry/calculators/BUILD b/mediapipe/tasks/cc/vision/face_geometry/calculators/BUILD index b134c81f4..b3d4e604a 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/calculators/BUILD +++ b/mediapipe/tasks/cc/vision/face_geometry/calculators/BUILD @@ -45,6 +45,7 @@ mediapipe_proto_library( srcs = ["geometry_pipeline_calculator.proto"], deps = [ "//mediapipe/framework:calculator_options_proto", + "//mediapipe/tasks/cc/core/proto:external_file_proto", ], ) @@ -59,6 +60,8 @@ cc_library( "//mediapipe/framework/port:ret_check", "//mediapipe/framework/port:status", "//mediapipe/framework/port:statusor", + "//mediapipe/tasks/cc/core:external_file_handler", + "//mediapipe/tasks/cc/core/proto:external_file_cc_proto", "//mediapipe/tasks/cc/vision/face_geometry/libs:geometry_pipeline", "//mediapipe/tasks/cc/vision/face_geometry/libs:validation_utils", "//mediapipe/tasks/cc/vision/face_geometry/proto:environment_cc_proto", diff --git a/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc b/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc index d6082e62d..78cb1146a 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc +++ b/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc @@ -24,6 +24,8 @@ #include "mediapipe/framework/port/status.h" #include "mediapipe/framework/port/status_macros.h" #include "mediapipe/framework/port/statusor.h" +#include "mediapipe/tasks/cc/core/external_file_handler.h" +#include "mediapipe/tasks/cc/core/proto/external_file.pb.h" #include "mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.pb.h" #include "mediapipe/tasks/cc/vision/face_geometry/libs/geometry_pipeline.h" #include "mediapipe/tasks/cc/vision/face_geometry/libs/validation_utils.h" @@ -69,8 +71,8 @@ using ::mediapipe::tasks::vision::face_geometry::proto:: // A vector of face geometry data. // // Options: -// metadata_path (`string`, optional): -// Defines a path for the geometry pipeline metadata file. +// metadata_file (`ExternalFile`, optional): +// Defines an ExternalFile for the geometry pipeline metadata file. // // The geometry pipeline metadata file format must be the binary // `GeometryPipelineMetadata` proto. @@ -95,7 +97,7 @@ class GeometryPipelineCalculator : public CalculatorBase { ASSIGN_OR_RETURN( GeometryPipelineMetadata metadata, - ReadMetadataFromFile(options.metadata_path()), + ReadMetadataFromFile(options.metadata_file()), _ << "Failed to read the geometry pipeline metadata from file!"); MP_RETURN_IF_ERROR(ValidateGeometryPipelineMetadata(metadata)) @@ -155,32 +157,19 @@ class GeometryPipelineCalculator : public CalculatorBase { private: static absl::StatusOr ReadMetadataFromFile( - const std::string& metadata_path) { - ASSIGN_OR_RETURN(std::string metadata_blob, - ReadContentBlobFromFile(metadata_path), - _ << "Failed to read a metadata blob from file!"); + const core::proto::ExternalFile& metadata_file) { + ASSIGN_OR_RETURN( + const auto file_handler, + core::ExternalFileHandler::CreateFromExternalFile(&metadata_file)); GeometryPipelineMetadata metadata; - RET_CHECK(metadata.ParseFromString(metadata_blob)) + RET_CHECK( + metadata.ParseFromString(std::string(file_handler->GetFileContent()))) << "Failed to parse a metadata proto from a binary blob!"; return metadata; } - static absl::StatusOr ReadContentBlobFromFile( - const std::string& unresolved_path) { - ASSIGN_OR_RETURN(std::string resolved_path, - mediapipe::PathToResourceAsFile(unresolved_path), - _ << "Failed to resolve path! Path = " << unresolved_path); - - std::string content_blob; - MP_RETURN_IF_ERROR( - mediapipe::GetResourceContents(resolved_path, &content_blob)) - << "Failed to read content blob! Resolved path = " << resolved_path; - - return content_blob; - } - std::unique_ptr geometry_pipeline_; }; diff --git a/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.proto b/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.proto index afcc20a13..a748cdf8b 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.proto +++ b/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.proto @@ -17,11 +17,12 @@ syntax = "proto2"; package mediapipe.tasks.vision.face_geometry; import "mediapipe/framework/calculator_options.proto"; +import "mediapipe/tasks/cc/core/proto/external_file.proto"; message FaceGeometryPipelineCalculatorOptions { extend mediapipe.CalculatorOptions { optional FaceGeometryPipelineCalculatorOptions ext = 512499200; } - optional string metadata_path = 1; + optional core.proto.ExternalFile metadata_file = 1; } diff --git a/mediapipe/tasks/cc/vision/face_geometry/face_geometry_from_landmarks_graph.cc b/mediapipe/tasks/cc/vision/face_geometry/face_geometry_from_landmarks_graph.cc index 08b3d1bf4..8c69a31fd 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/face_geometry_from_landmarks_graph.cc +++ b/mediapipe/tasks/cc/vision/face_geometry/face_geometry_from_landmarks_graph.cc @@ -28,6 +28,7 @@ limitations under the License. #include "mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.pb.h" #include "mediapipe/tasks/cc/vision/face_geometry/proto/environment.pb.h" #include "mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.pb.h" +#include "mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry_graph_options.pb.h" #include "mediapipe/util/graph_builder_utils.h" namespace mediapipe::tasks::vision::face_geometry { @@ -49,10 +50,6 @@ constexpr char kIterableTag[] = "ITERABLE"; constexpr char kBatchEndTag[] = "BATCH_END"; constexpr char kItemTag[] = "ITEM"; -constexpr char kGeometryPipelineMetadataPath[] = - "mediapipe/tasks/cc/vision/face_geometry/data/" - "geometry_pipeline_metadata_landmarks.binarypb"; - struct FaceGeometryOuts { Stream> multi_face_geometry; }; @@ -127,6 +124,7 @@ class FaceGeometryFromLandmarksGraph : public Subgraph { } ASSIGN_OR_RETURN(auto outs, BuildFaceGeometryFromLandmarksGraph( + *sc->MutableOptions(), graph.In(kFaceLandmarksTag) .Cast>(), graph.In(kImageSizeTag).Cast>(), @@ -138,6 +136,7 @@ class FaceGeometryFromLandmarksGraph : public Subgraph { private: absl::StatusOr BuildFaceGeometryFromLandmarksGraph( + proto::FaceGeometryGraphOptions& graph_options, Stream> multi_face_landmarks, Stream> image_size, std::optional> environment, Graph& graph) { @@ -185,7 +184,8 @@ class FaceGeometryFromLandmarksGraph : public Subgraph { "mediapipe.tasks.vision.face_geometry.FaceGeometryPipelineCalculator"); auto& geometry_pipeline_options = geometry_pipeline.GetOptions(); - geometry_pipeline_options.set_metadata_path(kGeometryPipelineMetadataPath); + geometry_pipeline_options.Swap( + graph_options.mutable_geometry_pipeline_options()); image_size >> geometry_pipeline.In(kImageSizeTag); multi_face_landmarks_no_iris >> geometry_pipeline.In(kMultiFaceLandmarksTag); diff --git a/mediapipe/tasks/cc/vision/face_geometry/face_geometry_from_landmarks_graph_test.cc b/mediapipe/tasks/cc/vision/face_geometry/face_geometry_from_landmarks_graph_test.cc index df935135d..74baff5d8 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/face_geometry_from_landmarks_graph_test.cc +++ b/mediapipe/tasks/cc/vision/face_geometry/face_geometry_from_landmarks_graph_test.cc @@ -20,6 +20,7 @@ limitations under the License. #include "absl/status/statusor.h" #include "absl/strings/str_format.h" #include "absl/strings/string_view.h" +#include "absl/strings/substitute.h" #include "mediapipe/framework/api2/port.h" #include "mediapipe/framework/calculator_framework.h" #include "mediapipe/framework/calculator_runner.h" @@ -31,6 +32,7 @@ limitations under the License. #include "mediapipe/framework/port/gtest.h" #include "mediapipe/framework/port/parse_text_proto.h" #include "mediapipe/framework/tool/sink.h" +#include "mediapipe/tasks/cc/core/proto/external_file.pb.h" #include "mediapipe/tasks/cc/vision/face_geometry/proto/environment.pb.h" #include "mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.pb.h" @@ -49,6 +51,9 @@ constexpr char kTestDataDirectory[] = "/mediapipe/tasks/testdata/vision/"; constexpr char kFaceLandmarksFileName[] = "face_blendshapes_in_landmarks.prototxt"; constexpr char kFaceGeometryFileName[] = "face_geometry_expected_out.pbtxt"; +constexpr char kGeometryPipelineMetadataPath[] = + "mediapipe/tasks/cc/vision/face_geometry/data/" + "geometry_pipeline_metadata_landmarks.binarypb"; std::vector GetLandmarks(absl::string_view filename) { NormalizedLandmarkList landmarks; @@ -89,17 +94,25 @@ void MakeInputPacketsAndRunGraph(CalculatorGraph& graph) { TEST(FaceGeometryFromLandmarksGraphTest, DefaultEnvironment) { CalculatorGraphConfig graph_config = ParseTextProtoOrDie< - CalculatorGraphConfig>(R"pb( - input_stream: "FACE_LANDMARKS:face_landmarks" - input_stream: "IMAGE_SIZE:image_size" - output_stream: "FACE_GEOMETRY:face_geometry" - node { - calculator: "mediapipe.tasks.vision.face_geometry.FaceGeometryFromLandmarksGraph" - input_stream: "FACE_LANDMARKS:face_landmarks" - input_stream: "IMAGE_SIZE:image_size" - output_stream: "FACE_GEOMETRY:face_geometry" - } - )pb"); + CalculatorGraphConfig>(absl::Substitute( + R"pb( + input_stream: "FACE_LANDMARKS:face_landmarks" + input_stream: "IMAGE_SIZE:image_size" + output_stream: "FACE_GEOMETRY:face_geometry" + node { + calculator: "mediapipe.tasks.vision.face_geometry.FaceGeometryFromLandmarksGraph" + input_stream: "FACE_LANDMARKS:face_landmarks" + input_stream: "IMAGE_SIZE:image_size" + output_stream: "FACE_GEOMETRY:face_geometry" + options: { + [mediapipe.tasks.vision.face_geometry.proto.FaceGeometryGraphOptions + .ext] { + geometry_pipeline_options { metadata_file { file_name: "$0" } } + } + } + } + )pb", + kGeometryPipelineMetadataPath)); std::vector output_packets; tool::AddVectorSink("face_geometry", &graph_config, &output_packets); @@ -116,19 +129,27 @@ TEST(FaceGeometryFromLandmarksGraphTest, DefaultEnvironment) { TEST(FaceGeometryFromLandmarksGraphTest, SideInEnvironment) { CalculatorGraphConfig graph_config = ParseTextProtoOrDie< - CalculatorGraphConfig>(R"pb( - input_stream: "FACE_LANDMARKS:face_landmarks" - input_stream: "IMAGE_SIZE:image_size" - input_side_packet: "ENVIRONMENT:environment" - output_stream: "FACE_GEOMETRY:face_geometry" - node { - calculator: "mediapipe.tasks.vision.face_geometry.FaceGeometryFromLandmarksGraph" - input_stream: "FACE_LANDMARKS:face_landmarks" - input_stream: "IMAGE_SIZE:image_size" - input_side_packet: "ENVIRONMENT:environment" - output_stream: "FACE_GEOMETRY:face_geometry" - } - )pb"); + CalculatorGraphConfig>(absl::Substitute( + R"pb( + input_stream: "FACE_LANDMARKS:face_landmarks" + input_stream: "IMAGE_SIZE:image_size" + input_side_packet: "ENVIRONMENT:environment" + output_stream: "FACE_GEOMETRY:face_geometry" + node { + calculator: "mediapipe.tasks.vision.face_geometry.FaceGeometryFromLandmarksGraph" + input_stream: "FACE_LANDMARKS:face_landmarks" + input_stream: "IMAGE_SIZE:image_size" + input_side_packet: "ENVIRONMENT:environment" + output_stream: "FACE_GEOMETRY:face_geometry" + options: { + [mediapipe.tasks.vision.face_geometry.proto.FaceGeometryGraphOptions + .ext] { + geometry_pipeline_options { metadata_file { file_name: "$0" } } + } + } + } + )pb", + kGeometryPipelineMetadataPath)); std::vector output_packets; tool::AddVectorSink("face_geometry", &graph_config, &output_packets); diff --git a/mediapipe/tasks/cc/vision/face_geometry/proto/BUILD b/mediapipe/tasks/cc/vision/face_geometry/proto/BUILD index 9559448f3..c9dd15845 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/proto/BUILD +++ b/mediapipe/tasks/cc/vision/face_geometry/proto/BUILD @@ -44,3 +44,12 @@ mediapipe_proto_library( name = "mesh_3d_proto", srcs = ["mesh_3d.proto"], ) + +mediapipe_proto_library( + name = "face_geometry_graph_options_proto", + srcs = ["face_geometry_graph_options.proto"], + deps = [ + "//mediapipe/framework:calculator_options_proto", + "//mediapipe/tasks/cc/vision/face_geometry/calculators:geometry_pipeline_calculator_proto", + ], +) diff --git a/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry_graph_options.proto b/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry_graph_options.proto new file mode 100644 index 000000000..03831d1dc --- /dev/null +++ b/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry_graph_options.proto @@ -0,0 +1,28 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto2"; + +package mediapipe.tasks.vision.face_geometry.proto; + +import "mediapipe/framework/calculator_options.proto"; +import "mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.proto"; + +message FaceGeometryGraphOptions { + extend mediapipe.CalculatorOptions { + optional FaceGeometryGraphOptions ext = 515723506; + } + + optional FaceGeometryPipelineCalculatorOptions geometry_pipeline_options = 1; +} diff --git a/mediapipe/tasks/cc/vision/face_landmarker/BUILD b/mediapipe/tasks/cc/vision/face_landmarker/BUILD index 3df2f2db6..ac78edda5 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/BUILD +++ b/mediapipe/tasks/cc/vision/face_landmarker/BUILD @@ -210,8 +210,10 @@ cc_library( "//mediapipe/tasks/cc/vision/face_detector:face_detector_graph", "//mediapipe/tasks/cc/vision/face_detector/proto:face_detector_graph_options_cc_proto", "//mediapipe/tasks/cc/vision/face_geometry:face_geometry_from_landmarks_graph", + "//mediapipe/tasks/cc/vision/face_geometry/calculators:geometry_pipeline_calculator_cc_proto", "//mediapipe/tasks/cc/vision/face_geometry/proto:environment_cc_proto", "//mediapipe/tasks/cc/vision/face_geometry/proto:face_geometry_cc_proto", + "//mediapipe/tasks/cc/vision/face_geometry/proto:face_geometry_graph_options_cc_proto", "//mediapipe/tasks/cc/vision/face_landmarker/proto:face_blendshapes_graph_options_cc_proto", "//mediapipe/tasks/cc/vision/face_landmarker/proto:face_landmarker_graph_options_cc_proto", "//mediapipe/tasks/cc/vision/face_landmarker/proto:face_landmarks_detector_graph_options_cc_proto", diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc index 52c8b08a0..d6cc630b2 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc @@ -40,8 +40,10 @@ limitations under the License. #include "mediapipe/tasks/cc/core/utils.h" #include "mediapipe/tasks/cc/metadata/utils/zip_utils.h" #include "mediapipe/tasks/cc/vision/face_detector/proto/face_detector_graph_options.pb.h" +#include "mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.pb.h" #include "mediapipe/tasks/cc/vision/face_geometry/proto/environment.pb.h" #include "mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.pb.h" +#include "mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry_graph_options.pb.h" #include "mediapipe/tasks/cc/vision/face_landmarker/proto/face_blendshapes_graph_options.pb.h" #include "mediapipe/tasks/cc/vision/face_landmarker/proto/face_landmarker_graph_options.pb.h" #include "mediapipe/tasks/cc/vision/face_landmarker/proto/face_landmarks_detector_graph_options.pb.h" @@ -93,6 +95,8 @@ constexpr char kFaceDetectorTFLiteName[] = "face_detector.tflite"; constexpr char kFaceLandmarksDetectorTFLiteName[] = "face_landmarks_detector.tflite"; constexpr char kFaceBlendshapeTFLiteName[] = "face_blendshapes.tflite"; +constexpr char kFaceGeometryPipelineMetadataName[] = + "geometry_pipeline_metadata_landmarks.binarypb"; struct FaceLandmarkerOutputs { Source> landmark_lists; @@ -305,6 +309,7 @@ class FaceLandmarkerGraph : public core::ModelTaskGraph { absl::StatusOr GetConfig( SubgraphContext* sc) override { Graph graph; + bool output_geometry = HasOutput(sc->OriginalNode(), kFaceGeometryTag); if (sc->Options() .base_options() .has_model_asset()) { @@ -318,6 +323,18 @@ class FaceLandmarkerGraph : public core::ModelTaskGraph { sc->MutableOptions(), !sc->Service(::mediapipe::tasks::core::kModelResourcesCacheService) .IsAvailable())); + if (output_geometry) { + // Set the face geometry metdata file for + // FaceGeometryFromLandmarksGraph. + ASSIGN_OR_RETURN(auto face_geometry_pipeline_metadata_file, + model_asset_bundle_resources->GetModelFile( + kFaceGeometryPipelineMetadataName)); + SetExternalFile(face_geometry_pipeline_metadata_file, + sc->MutableOptions() + ->mutable_face_geometry_graph_options() + ->mutable_geometry_pipeline_options() + ->mutable_metadata_file()); + } } std::optional> environment; if (HasSideInput(sc->OriginalNode(), kEnvironmentTag)) { @@ -338,7 +355,6 @@ class FaceLandmarkerGraph : public core::ModelTaskGraph { .face_landmarks_detector_graph_options() .has_face_blendshapes_graph_options())); } - bool output_geometry = HasOutput(sc->OriginalNode(), kFaceGeometryTag); ASSIGN_OR_RETURN( auto outs, BuildFaceLandmarkerGraph( @@ -481,6 +497,9 @@ class FaceLandmarkerGraph : public core::ModelTaskGraph { auto& face_geometry_from_landmarks = graph.AddNode( "mediapipe.tasks.vision.face_geometry." "FaceGeometryFromLandmarksGraph"); + face_geometry_from_landmarks + .GetOptions() + .Swap(tasks_options.mutable_face_geometry_graph_options()); if (environment.has_value()) { *environment >> face_geometry_from_landmarks.SideIn(kEnvironmentTag); } diff --git a/mediapipe/tasks/cc/vision/face_landmarker/proto/BUILD b/mediapipe/tasks/cc/vision/face_landmarker/proto/BUILD index f943420c6..d3e236619 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/proto/BUILD +++ b/mediapipe/tasks/cc/vision/face_landmarker/proto/BUILD @@ -60,5 +60,6 @@ mediapipe_proto_library( "//mediapipe/framework:calculator_proto", "//mediapipe/tasks/cc/core/proto:base_options_proto", "//mediapipe/tasks/cc/vision/face_detector/proto:face_detector_graph_options_proto", + "//mediapipe/tasks/cc/vision/face_geometry/proto:face_geometry_graph_options_proto", ], ) diff --git a/mediapipe/tasks/cc/vision/face_landmarker/proto/face_landmarker_graph_options.proto b/mediapipe/tasks/cc/vision/face_landmarker/proto/face_landmarker_graph_options.proto index 67599295e..dc8654608 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/proto/face_landmarker_graph_options.proto +++ b/mediapipe/tasks/cc/vision/face_landmarker/proto/face_landmarker_graph_options.proto @@ -21,6 +21,7 @@ import "mediapipe/framework/calculator.proto"; import "mediapipe/framework/calculator_options.proto"; import "mediapipe/tasks/cc/core/proto/base_options.proto"; import "mediapipe/tasks/cc/vision/face_detector/proto/face_detector_graph_options.proto"; +import "mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry_graph_options.proto"; import "mediapipe/tasks/cc/vision/face_landmarker/proto/face_landmarks_detector_graph_options.proto"; option java_package = "com.google.mediapipe.tasks.vision.facelandmarker.proto"; @@ -45,4 +46,8 @@ message FaceLandmarkerGraphOptions { // Minimum confidence for face landmarks tracking to be considered // successfully. optional float min_tracking_confidence = 4 [default = 0.5]; + + // Options for FaceGeometryGraph to get facial transformation matrix. + optional face_geometry.proto.FaceGeometryGraphOptions + face_geometry_graph_options = 5; } diff --git a/mediapipe/tasks/testdata/vision/face_landmarker_with_blendshapes.task b/mediapipe/tasks/testdata/vision/face_landmarker_with_blendshapes.task index d20846326dce2b8956c500b4cee60ee96e865294..04adf1841069b8e884d9158a8e54bcf741c086e6 100644 GIT binary patch delta 20033 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

@>2s6%vj<;1eRZiD#+ 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

bbNSty1l*q z`Wt=!>HYUV-GB3E+20ZY$uQ74H8{=lR9i^HE~LN-o9FYKkLT;IgjHQ(|~xYHJ_)6_eq%Cyfp>)Q1W5FK<(zn zB*IwGn`ZDv#KOP~%}h-dFt^6wL@dnBUCq4JHKT;mFlfo=X+~{hlo&-2s~I2&iA0#s zxmWYV7+b5Y)lxu2cDr3`_4M?FU__Cb&Arw#j>9PFSrGWLy{{j?R0daO{JJ!k1^s*t z{j%v;`pvBO5hASeFLnn~AyS-_(-rbJV>D;-Q&eQSNU*PMzoqYSo>eM<+(>Ah^V#Z?nsEt=B{f({kE?@8}(<>{PQzE$CqD7|BZdjyR~~& z7IaCB#1#m-iaEPl@4<2L0L?+WYUuqG7fX^5!BHKwpXA_fMCJre-YFcJdiWMioNNIJ z9TgfHY6p>-gEJ!`nlB~42#DOv!O(Q+NonTb$U;GY*uhi{04N6MV(!Sc=p29zIq4wB z;#ysKNa!R6u7J(V5D+)Gq$_!UE($NUf7gM%pzJyFv(3&j+PF&oMZP~%1ioe**;X@% z1e?V5jUHrE-pj{ctOTdcGly&Z=kryuy|uguTQS-4)}m}bTZ+$@8Nib2=jM%wSg_2c zoX@*qM`3sWH2u~8@2}>mRmE{PrXftl9v+S%4sYJP8TZ4K=T8q0@7~-b47D{UM3!cr zr60?`=g<}j4`yT0!1rbfPx#gVAJcI<`$Oz~XSQ=_oHvpm-!w4|t(pojus_x!G z=q2o6;O3_40FF)~AqpKI@?7%iEGbGDBLbp9HIEWlH0Ml2J$JFz+NDiOaTpSTPt*D7 z>FM>|D~3#vm}!YyIv{~*C3G+oCNOWc5lI?SDW$e%ZrD5V zy0^CE*SmMzXzoQ}Ap}AIvp`_(0O@r41Q>}UkxZx4Jm>DLookU0N^LCCs>T?_y{U1K zT8cTUu7;y`n%a#l0XwE$YZKx**T7gxsilO}Bn1Wk`QxXbKYmIv#TXWV#5?T;bwp?_ z^t@Kq7c+3vug_=W%OQamcG>j@my}S0iLp)<2xdSC)vFZ?A`-Y8za1MwUfUb=ruJaX8!a7-v4cPTtgvHk7t%OHcS2md+{0?NFBNBwr8*jeIE|>nqT#N{TTCZ4JgD91f?SKmG1^|K;&9?+&-6*zMhG zb)0j(e|QKOqL(`^JLy#dRThd8qciZpAo0}VEdboc$pU=WH42b~C))KPM$hBO!7V%e)mt}t) z^1G53SBv%8y1EFRYm1^+gIo;ztF^KGTLcP%yP6^4!V>B3KxR6OV+fcOon=^)4I73> z52=B4GeS}6PKlv3N;5hYkd9H(F+v1iq(f9dVuUcdTcs2>5Ev~Z2TF<5_k42r3l9Da z#{FFPd7bwgP_*wS^ipT|$qr`RyXBk7=Fq=Gczz{AEI-)9Yp72T&^Ohf-ShwTt1RwK zPPY}KXHAeA{Y0qY1#50Ob^>oAEHj*S&gmo_;1>{VT6T^<B*@-{-nQ-j?TWTJykgPVK4SGdHb>T zZ-blxkH*@tml7C)2K6()e0(m@X2`7&O5XMSlJ?((!1#)b zoC2~bsx&xe9_~p_=#t@YApX7=Ah?GRa2CTARGo`+WvERxiPTSV>?5gKC1R zOPJLuy-_k=!O{rkWMO8I3$6NH7`dd}ebnryz@85lt5AA!;zth02_Y4T&TV;Cb-JLM z;Op{-*|5mPVWN_1PfIT1p0WA2C0zu5o2QMghAbEEZ?AV{p%uRrXA2lwM%lX=uf#|% zH_yF{>UhL%>_cz;{X;DF8il>d`__XS_(w8cEpv-6Bo5oeX%uyy6Lqrwnr?c-XKBB{ zWP&Cd2W)1g2_plmuv7pK9S)RxV_;ao(~cR$X+zm=sqe=hf) zj^xymhmfr@uIY>nL@E~xuxgGT!yb9;H2m zT#{o{25WYHcjA`zm^LXv4U`!JNx=T@*Q#M?J^Q7L3!HDSU|Tc(Z>MQSnzi3J?^>B^ z9bVV3DE9K`#J?fvfjh^57egIC4Jl~D!7!o$T>R73LeasRyQHrzQs=wo96ZNMV#SrE zXNK$kWupp7yx<*&^T-?h)fxm4^N`>PbV>HVlg=ZG@xlqC8c+reP;_Wf}*j5+g~0JH%1D)=RNOAWI^? z;3}gc02M07N$gx{VC^oV4en=*%_ZUGs`x^g$cRW<-9E0I3BC9E8J`pMsTV#pUH$!@ zd+9w=QZN*14EILUuB(#k@BvdAy@rFDjgwswGUC+&@fCrQ*_()a3$ z*C&22#(lY$*xC{vv_-?*$EV$TbL>|lk7w&mp2p#D^J*2QE{5ubj>+K`D6c4vc1bg@ zLCIhKhyd0RfQL!f)F5S6cL4?0FVQ?e0=}2-#QduGDUSrQMUarBSGfpH_)eE1z74B^ z9AJp3Q<&_cEyH|;8E{BRQTT`W3u=i#Q4Y!N?F*Q|vQsgT zKh8z}Fn+Y6rad~C!UfSHRU}!R~3N&>SF!(b8z|#uo~;g>iz=a zV5vL&VrPGMz!pqd=6FjS23~W(H9r;#RClsxkrelKqW#XLgpF&Bf0tb<76 zDXBuPKMqr5=NZ@1`UPWZcRD;2&KRIl z!$Pdz=j(5=(nr7n7b@M8Jr~PIv|wbWHdi|SXurCy=VBGeBmYKDoG&k36}V3%#y;@; z_s;03X}O|cU=@S2E&c=4{8ecL7~)6xRFZ+0sW3p;wPj(5(Q~3K>*&0Y;7wGIW(Rws zD@I0m3+;Nkf4(w`d2Pft%tyX0b2Hy0OGfGa_c$=@WGAS&VmdHgqF!6KvW3#KN<-iQ zW?mT|kl~0FPT+3Z_2;jD_spvdqk)td&1%4uGn}t&h}_M{Ba-KnIWzZ6A(7NWmTh0H zH^|}+5TiR9Nf2zCb>Z$r^&GqaZ^+yA*{_SLPmwzf#uQkC%C&ucg6 zk+*F1KQCD9SKd=Fr!Z{^DpK{a5wFLx&zSf990LJ-Ekc-#)P*_7%OIhUD0j5L=@~?g zmlP$F7(;J+XH~pNwLA1GpE8#?rs2pN_DI=&g2op1u2riG^Wd=I!th(PsNtm))*JQBSry(dqO?}Z8d zxn9i#tY%#=FPJBv*}taswAmzU`0x7*Yi7Uu<3FFg^SThxG^Ap|OEGS}2d(H5R-}A; zEe4|Asx=J>)j1?jWY(9{^xd*131k4Uy6wQrzrUwPUh@E@D9~Y`qN>Uw4{cJ<-=jJo zAMascriE9}o6_`lW>#Yie*rStt;Nc>A^e8oW}cXFYhnEK_VxAZ^>oG2_Kc1-e`>oy znW+rL7~S-F#mLca_|;MJjebxMkGB9gj9*P$ToBM%+%!Bcj6DWPhD+GHNe*28`vJS`xgCUDu9ax zJ{{UM>y%UJo;$RDw-uJ`#bfU_EB1B$Yu;ZQnuzURyFA0UkeqA0C6SlsJU7=sW;wDv z!dQ}iCq0hT-6Nf9O`@nGH~{S2+SL{w77+q`1O-T?D>*K{9`NS$FAgu=6Q_nk)jfpq zM}dd{FbqAjboQ)RNLvCeOY4s8k4I)F9Yl;Jj#%p$clfX=h?rpB*ocML{tR5TR!Tbz=H7o z=w^q}H4&!CD9~e|(!bwJ4jY8QR#H~P$4zdhL+gM^uBT`6sI9&01^DR2o8q2d)hbg( zZM&a(a~@ucnLtXc=&GN#KA<7huzXGzww9Pl!q)^(a#ZG;WPtKNS3Sc?PD(8jtojR| zF++kKtpY1a7#@?CRK#pyLhf6lCpvJgdkk_iWFVM>8)a$SoBIU}XO6;U)B{s+j>IhE zl>Q@SdZ&Y)>6U_ax&ir;Vi++92~HhD)31gql0-K*FVKjz*rm=R_{v_&E%lX&lUOMTy|E}eqPb%=0 z$&;i__C~*QvgdMaVhnaygz}nOitRsAiFl3Uir(cXBK;6Yh?|pxn^T_1sQmqt0^3@_ z`X}N+TE`Qlk|h<>+pHUI4+%%1s;Vl*Mkco;hFdw32D}R=O=^DyDwbuPcG&8E!F-ke zW#a-ae=@$@*{;Neh<}cvKiajZD@mW84&4;zJWU#@(6Kf#3ueh?b@mh?eToU4kkAD; zWOYtmXd@+jpM0Ik&o-WUv>hxMV6tEIyr9FKOb%+OZffmm@Zs2(*AjizFeo7ec*;;TIR2J5RM z<(}{}Z_dZe+ns7CwB4PExdSJ#$v8H@;MMvZ+*2k8p5!;eM=C^5bjIti%H`!Q+lzee ztv^@I)`Z%??6y|t1N^xnCoN1HMmyypa->fHSUPIH%OU$qEio_%RDI&{wbrG@K5>R< zEVJu=0RS|9-MLwOu$3c;I2IA|tyCli*;lwf<>dMksj%HWtiqx-&}lkwBIMmX=on_k z<^1dhMsa8#bsm(ziLmb~I@%qG%y|P*6He$gW~3J_6XpFHd&T?W!AHU;#s9zX(gy!2 z?fK9S#`dcmb*CP1v+_Pt8&imixNPcK9+60Vz|4tqq+(5wPZl2pF+`8zqC>yeq?(YV zW4~}JV&mMXxZp+$32)!erRB2pyYIaE)CUrM+14CtRr4pu`UPd^y4LL94cw^+VbKGzvwVK_RQG zCa58O&uM9D0S&%S)|Du%E@-UvDg+1aC>=&%A(#;#si6*V-EV2@ngzEupa9lHD3T@_zy$P zq(j|B|4pIfK9Y^sF>Up_8}gJrF;o=Ufi?VpyMV`8nfs1y3WTK?4fm`oqviXUr2$4| zK{O7)9&Q>i(tekD|KjP^jy51o&GPgz$kQUG8xS`zsc^hEt^rwtI7>=t2#{*b3`O3d zcoLxTwJasR@gDu#!n_akw;MdxbGdl{fgIqDi#i&Y10sK>clIBvZ*7&gbI3o?k)Dbh zbVw%^(^9q*T(uog<=_TUk2OyX$-`v zPi}JFzq>=~kXG7C%oKc1AA2<} zxEM;o^lTwM@}*RAT!T1B;_>0(sQ%FI$I#g-CSGxkm1z2y*k~{k#Q#Z*ahi+6)<&Fo zi=K(p3G|qk;<1nhOiE4a$*lJht6~tl(()Xh`4J_r^}?N+_Vc{F<&Nc=#w`n7#O*T+ z_LsuvxXq@K+HT%Tv4!7%Hh!LYi$$$EFK{V`Hw3JU3EkmM@(s&3P8(Y!I<5~Z(TEoX zGMMZ20ie%f9~m0e9$NI|V^cO{jDrEHNKg3&Rx49tSiv_e@v6hmViVYuEKaZR0h1xH zP)0SsNd*+=@#i`l(ng%2h=5W2k^6)&dEH5b>g(430Ciq)IhkB4-LK>JABi z!~bdlMS7(UJFvPyu9Xjtw|~XXgAK07#~a<8jkg;2^mM)jsJrs0~f+kWsGcza~=+{cYlzPMpw%!x{(0w+V&75VF)W6Z~tY9iVp%w zmWSwAgu`;7l$yZpcycw<*@->%vEKMWV2QmQKFsB<%SG#qdYd#gRc_v^0LcW>5@9Fm zyNeg8e92Ck6>(qNaskJMXJkuHm&N1!4=M~LDufOR;l~A7L+yM%ab`F<0GUVVDnP+g5`0(?t zjs|U%nYA-_#m}gJzklESzPYqdk=y9{xDm~x+TGLD@$(qP1RdrUE7bmkxw`1%JNq(IhCXE<;@ILmqE zfi;i-lcUR^^)aB~cyPt|PX<)1=1*C3z8&lq-u|=&;S`2>kDB^ zGBqp6AMCcrHuLC1e^lPl^byn97(-_przmZ}HN9lWUA8@0XB~>GvW+P-z#3__*?1t= z&jcm0THHh4pY^AbJAdYM8S2;T!|}Id{cQn@Ux{_e6*juMi`Bau?IBR;mBQXW<6Gn?Fq9nT&z^lOSkeIVB}a9_@raw1}8` zX3W$?7y2XMsC1zkMo>*Bx!v(ox+KiZ0`=As;@+|#H;tvP!OlY(vY}(;9?dhml24}% zSxLsR{^BqMuah_-*%PD|UzOqkUU5qtbia$Ao_u(-XsQ5{l9CBrE|V@Xa%g5MvJ)c7 zVNDMeW3wcqcqevJ_3hGoLwWWhxDo0%uBUD@&s2LSn*W`M8DqD`t=6nlRuN?c2ca{A zzyOj;fDC{46hUu%=CH9&m5i7F$mpy_>UGb#pek>t5XcOIjfBB5m>+IlNPy(ey<1QD zrsIAzl@h=BSJBZ{o`GD>%y=DEt0zcgW@R(h)N9)zp`*jr2*g2mQLiUS>-TWj4}4Ly zM?t;%rUlDS^ev>e`X{C0hsurthQlFO9i#cD+`+D2n)bIT>Aq5j*tya82pHvQT8F+G+00`FC)8Bq9Qr}0+qSFy$Rkv_FZ8o>I>%)1RNUNiDrg{ZFg6t#N)hQROn!G?;=eR zD1)*Dhz1yKP63w41~UDVO%j5eTbuMCSbOk?=_H<#o>q>+S|e>Ver?D2%8H3u*Xp1KXO{tu93fIZ7ftJP2jY3eTLDv^Yi~YDniSLs~4nTDv@7qqD2) zzQodUlwyftt%drEmqyR?nYypN_TB9UH$5{;H)}CebB+HJuc16MEO&~KTDkf~9+;YZ z&!ScwR&hyy*0C6Hg5jKO^Pn0=L_74y za8LVQuFh)o$@#*;*~81j0deXMoHue+J}0YxJW`GcO{*@I#M=MV*bAwpE`MZ2IWRLWv<9cfz ze&!5n-4UX?FG`dOvf3WTb`FipZLROyUv@-n++zz=k7FLkZ1cR}uk7B+WwL_vb_w40 z?GhrUJj=?*c?Vhu>GxON)0Y@zY$tv$`LA+qJ5UnUW>z@{i?4Z9<&+53APl}T%d3Aa zs3_|%)CBX(VUQqmr0k9FAB~4~#iZqphX#W*bdy$}q;$Mm2Y9gcj9}+Ez+RI9J?7>| zg;oT{3_3FI|^fmsU;};gVYEPuY%XX%O6=wdemL|m<`#`a5{R+FE z123xjsjFG^U<|Pm6K}8aJ(s&bJiw)F<5s2SF3`TC2t%DhZ4go?HY@E&dD1=dWHU(l zUuUeX}o%Xx@0_bK3IUI;BN-#$JL!; z_h#$WbO2b#GIMKdhidKthgJB3_(#JZzd$JW(squdEP;~rks~&Ge^H4C$pT1iWdC41 zS{lWa>kM`)Hp0)tAC4@lGpWalzxh<)!B6T)a7xUz#f<4zf~K9NAX!=f?m8^PbEA1NqlTKw!Vq} zDJns%gS@L9zYYwf5ii64{?2(_m5__+V#}JZ`EN9rJRM4kln~+-SamNxOVX5EJ-aA4 z>xqz~*0e_gZL|WXW)BC;>R2s-GMDmc<>`Bn3U~HIzm{da1EV$X@-`1oRMx+pQ;-*< zuJ&T+UryUxk80L6=}BmZjP$OBjW8IwlTM3w?^dEl!XA*@c@%xCxbo&4{ade8G(p#M z{Rb$e^n@O5?pIq3rioTz&3+O8`TO3O#ZYkB&f1FYAr*PAPfBE~vL%PD?O`-#7csz{ zmB_TYoMw)&pD%3XXN8^!)CtZesKpQC)Y8@=KC~U?)df&q{&p*V5c$sojX}6nE0KoT z9a6a`6u#ByV?v+CT!*cAJ8@oS8ynCjq82WXk14%Pn|d5f{BrKT67gs4{;R%Md7Aoz zq%djP`h`AuV&c|V4hYc;`p@IsgpRL%zg$T`kAr8q*SBRH(?RV!hTYHUV8F}G@I!cm zy6b~=0qs`fb3z&tR=Y`SIHmMRbsB5!aQY|k3tM~h*Pc(TwdMjKoA0cBpHmKh@xQz8 zT5osx`8`(K-#$deuu(FA7y7>09R3Ru!n(M-e1$uE_%b)ZU6V7PkLay370H(s>5m@>YhO8quJTk_C*WtG+pSNrmxRX zP^^oZmXp91MPCnr)3lPQ~(m}PiXg$8B$NWUvj4x()(jjdA6K&ewcw00m zJecV)HT*)Jlu(Rm0<6x|eoY zzNB@yAE{PE**jxO6R$;zGb_j=S&IaNxSf3|)I6$iv#F!B_tZU11YF8_e@y!TKhTy1 zpNOD?kZ`wWJU?l>P@lhr_Zv}2`~wvl-a$MpnUs@jDPxpF3F1SI*Hoh!)3h_H)nQh z`YbSu{7`OW(%p`j*pRwg^y-@v#efoMoJb(Pd+bTPh8k2nP8xK(X`U~VcM+^Q`*f~m zH6fRc3&hq-)7v1Wxx$b%^j3}d-4Vz;*~<36T&2$%aZ405aIt$ICUVHh2@YH2<0WT_ z9oc_3n{jJGSN1P@9|~Tv+&V~A8$k!VoR>zPoSl==W6{lfhxg6uMOi)d{0tDE119F# zhmd1NJ=gyL!yV6&G>wEgSxsUMp8%!!xB?lGAW4=Vz$+zd=qup=KhLlysyodbRc6Hw z@|Cd;$fpaJ`70Jr;zue*)_>1^kvw?#Qn_TZLxS%qDk0e^Ex)lUMO*jjL;xlQ+#in_ z&#y|X_ie~BjwJ=wQF&~6dG*tjPAC!aMp74VgURVa<22{_2tb<=Q|9lrLnDy_9)e6N zAnFxIe{iB?1Y76kaZ=(4Uh;Rw1?We_gThR6nYYExSqw@U{z2DPfoLIe?!Z{rO{g06 z<09hXmWSO?dM1hzg#0d}Q#w;y zQ8oYs7N0vhgj?S#<3ootoWNafsH`8R*1|Cqa=Wk^b(h+E{lZ#idYXHiJ#n`+d?@H> z7Qa)1oMl#WBSiTmZztTtQSbSKGF5TQH-C2PZU8^8HDw=&^IevHTJ;m#!5I6e9kooY zIs0K(XMz(5bdngp?v}&FWi!4Mp)ssQahx_ygSM;aq2O2AWv{ic`i~5K<8}BND)T9`pdz=BDSMHD$BI9`0de0+BG#Py# zqgEp0yE{ZuV#EFw?=*0jak&?o+Z zAJoq!NcHp72kAvXdB*szDO#7>QPnlLQ_5x{d6(?I)3idD^wJF}YJmw&ca<25;G$~X zN)+RXe06bgLeY7rkG(aBzl$F}#*jy}0#JBY!2b?F%C+$DBDEyyx?$!KwJ2@u#vbZ~@XS?&+<4WI%xvxXcHkLO*NFRZA{AzFT@yocz(MMf#B8XQHa z8g<~Nu%?Ri#r}p28X2rEqHiHu^7XO+aF(V6^FX0x=pU&G)S!oWVQO3B%Cq@SkWJSA zS^$!zczVj(v^|cEPrLysEuwcjbd7NUh)&I5!2SBl&9!n5F0x?C8gdLxrXZ=si5qze zLCZ__SrtsBE0vu%lbDYm`kze6W?tT~BT_=w9yQCrBL{y&HfPJ+`7nTtv-m!VjfvLH zhR5IjKhb7Ftx~*G&>~sq(b-MLt}DJOws*W4VE1X9HzH!=3bL4)AwcQ^NuH`)M`Ai> zWG9cLT@e;Q7rY#Q9)Soq@!ZC>CD(8CkBx0PJWyHckjHh6R$nASi|9V0zG#d-P_MCo z$JIaSH6HP+G4S*}kLd3BS&3R2gqA)i3sfzMBh8M+fNZ8l-)7n3G73V|B=E%1ZonfT z)KOSk3USAgQPJT41n$4DB?ldFgODKlyBfM{^i4!HqyY<(L-(IMU&&Bm zhM5oS8vSKI{Iy!+mu!V*I6r&lyLkNZeOxTRcDDtQ*jR{sXEJ!E0X?^MxT(N zHPuxa8}s4z>72xSXSu}eCl~O6lY$>n{K*5mlnG)B{w$=GQ4FhLI;$+Ip|1bxrl#Lt z=O8+gyjBX03(A4E;OeaX^{4dB&?vAV-u(V5EobiCoX;E-q%-Bw4j%4++7P%Hxg97E zc*r#M<1p`0d76kT6Lt-78Kw{*rJNx;?yX&28z0dr=WVzL97Jje}DdGvnZ%KYs2Kv~3DSAsV-56W*h^?&jII4+W_#yuYiM{iH5C{8~Q>2QkaaQP5Ln68UmI zg3Z%OS{Mn-c!7R&-8XaXg?m(=geb};6iOvciM*u$d2p5%Qe5uWa-elvg`^Z8A`t-g z`zcl5WF8U{;@}3=7p8wo$34g!Fd>}iS8q21^ohVoZD<)Ig-bRqZpL_VaVBq$`t$8F zE@m_~yV$h#Q%dpI1-R5St$vVbgR$;JUY;OWqGSGd;J@=(d%6ppDy_#OhE6lYhi7ir z7}CJfTgQc|t}*Xw4;P-Xes{u){aTsLa-Oo7dM|5!8eJ>@&_ZBK4cSY&l$?_X@^ybs zz2fcQtm$S&`zoV?&6-5o@b4cX_vcdo2mGR1xXj#Pmw>+ffU22sGCBiB2G?(Na zeV3KYI3CSNlN$}O1ic8YCw8m1k_mbH=d-DTSf7+--2EBbpFv_O_D9--w?NNvci=zX z`Bryn2?hyh%1VAHM{;dSh$9)|yBf8d%sqpq$>%=#x11{K+2S)lqSOv;;li!JEXEYp^DHqJ9`^(>=e>*4)yUicIXVT%_aQH_2CWg zFgO^akdnn3kP>jKYy%5_T?uh!{~`X-k8{{>ZvV`$rLjm2vL$?JOYos)(pF0)Tc&W; zdREI^T_@PcB2&-$bQf#cGI(+d0wH!b#*ix7F+AdE-O35ytS865gj)xMjX6AbvDCvN}<761U1V8l9az zy!rEOe^oqj1<9Zx5>(V$Yy7ft`RaU2mE~$SjW{ z@3hv$dY-3}4+PIk@xR1`v+!J%_??O=g;%h&*dmSKvinfX16LAjKFQH%I`Bu@q ze8af`iQb)w(H29}ZrGn+YM=fj5b-*Zm=0E@v;}v3rZUtZB@PY@baTwyj$nYj8@eP^ z{YzDwV)@P;kN!QH8g%&L#GZ^?wUM0V@|hKPD>*K<9KO(?p}E{c zuBAmHrOnzi%~FpDB!%yFU^lr3kRX;A_QdVKqn$46fzjObI0N)s_#i3E z0bhH#nEBrDUu8U&{pqv%m@H`VL?>`2*nIc*y3yf@Nb3jd5?QE!Bjfb%Ky$MI{`Uz4 z;9?Wj?FlV19@cq`NHm+zwW>s?;sl+TTQ@vFKv@+a;p$4bM-cXW0oAXN7jLXw`q^hj zg5cSoO)c?mOtZ7M+fnuVb9bNOy#70AE+tJxr_OUM%DyVf>Y25uqi@E@FM=qd+&_Ci zN;u&mfK)n)-ch)UCEkKUDg8d?4vt0eF-mSl)JhDq3JB`cYl7%m{0PEFp3@HTc0y$J zxxcEvwOz``zxDJoV@T~tYuZZtC3Vbp+(>{>NDmHkP5Kj2$i1_3-%8w419|r*ZVgUQ z_jXaG2S|;NW*}j@)h1tf4jvX2r-MTf;T*B`Iu3UCs#!4Z(HpioqKcjhmkCTaKM8h0 zBbc<-o{(*=K!lV`Hxd*`M-QQ=#?2X+Q^9B{vS%iaq$lNK8f(dJG)BpP&F~a{f#CQm1JF;$u5?=O%dn_6==?{)6 z;L}weWMeTe7&4WrGzqS+Kq`?!`YO2C#mw*Hma#9?tA|F~B}2{A4qHAD_grBy@U~pK z5|K0(>!De9yJl7={0*Pmwm$XNC%3Vg8E`~C@QcNlocGxA5oeNwBj?&&zB#eP?mh;Y zN0isroC162Mi_fRyJ=_L!VBklY#-qE)^ywh1?V1{Fi5EaVcsm`Fu_EIminj{Mk%Za zB|fN{$bTKPg+Pco{CzC9QZ|+EQ}dffrPwwA&F`vNoK0&L<;m2x-t{QIpP>4SFrgRc zM5UgN7uL81E8*t8ImDgSc@ylG_euyndrtY@)6rlRV`RMx!)eWv!_?y*?aLr(yZq~K zshd+u0vFj6pHoOkdGC-14Pdne$^{rh$P!?rei$M!x#5*bx6oZLh38#`9_|B_GmUl` zt@ji4Z0M00g1qneKf%w~D_i`TOIalzfr+$0-^1U~Q3g_%F1&9I1+i#zMEmhITmBZd z`3FK!ZF%I*(lp&g);2i`pV+}ij=%4$<2_Qt94VyfcxN;glaGB* z1d6qnOUCTXq#bD(>UU|1HA55480rf=WCyzP~ht0Jm(>U?QKs$cg2`J&6TbUJ@ za{3jGH(Gf--xyax^gY{cWksJe9^K|a@{zX`pF9IsvTK@CRM9g-OX(rQNxKy;K#pKH9rD!>bDfb<=of~e$NEs<-cm zVTaFx44L9N{tb<*A=UJoIFgQ365)45555!1^7|lsw}fJBr9IJE+Y$ec*RSe;kpw7b zT>UwkgfHN5`wJ~T_P8Z7dJOs2sCL)MUHaff#JLu(Tn#iAkv(+L-5+^7NL~CyCZ59j|5+fG`uKr#bMP1PKJjO0=W}C2|HUCS6I@Nl> ztvQTc9DPlWCYE+q+-kQT@D0jO9x3A$(^(rTK$RY%Z3vR3gHwZ`Vj$i;k4SlHoFXwY zEdiMC82WRKtIeFi&~fTTm`l%snF@Ll)}P@dc23PFE(JwnA0+aoX`{0T{lLzt#?{p( zL533AocZAHh=<0o0BYQa_hWxva%K6!6QD^g$d~)KiKE(s9 z@ngPHst>O+-DED4b5s@$3beS<<>{Z?4E8a&-K@LitLqo91t$SLand2&EccB#SPd|u z%9d38_MJ}nI)7S(q0Zwms(7fh_o6EVgM5OeS6Gb0kBk9Q;@*xT8KXHLWs`%fPOy{T7 zocM_w@naNn^G|Y%jt6Y~QGeHY{)-2yeXk_O4f=v^n}{`UzsK|l?6z2R*SB3fB=Hr< zaiu$xyni;HFB=r}AFuul%U$Saa2y&vhEs2OFqJAj5BV=>6~s62o-w?pIGN?Huis5( zZYDj1hNw5sw-hfx3VkM7Za2Q*rE`Z!K+VqoGUs3?eKvdhpjwAcyJ>K;4ped?reS1F zz;LeYsPw05dvco}I98?;z!S6%sUr*glc0^61*U?>zD`>&M!;zFktz4l<&+_?#Ki$Z zQPraK!i{|kpXB8NCuw+tH-QHdR2baom&o%4!lnH*n8xQxKCBkWKNjbS0 zQfrSAyX~mXV&$|d^V{02Zt0JKx*#o31I?AO`RaU|$Jl^VlAkA`c-x1PY#$J21v|9((^)tl&IR?Gn&Dj|@D4Yu~% z9}MDAC3JI(zzc%si(eR66{j8h=wTt%;sD9v>$!E>sTLx7d%Zg`y^rPlvRNczZG6#; zrHLLA{jLg&MfxrtczXjx-{%LB4}ZS_CDohut6EcdUT-ibXuzD>yIUgH4d-5Y@$znZ zpDGfcSPs9rAVfb`d6^DT+T-UUg9@m_x0U@8iXfR&;d2d981j_&aZi1WKrq{HF-j}_ zM_Ax(-VN7zB;k6U*@FUn(~*x6x*cIrc%_iU)OIipSc_eGWc52+)$*m zkgvA8%MJWu&?N8K@*xFD^84C5$SzJC+Wn>-6juClwf_6&=;4(ZFdI#`t@+-5#=jX- zyt{W2%sCd55jGG#50|>_bZ0Bb(wkiytb=a5of)57zJ2Mdj1%}tmz<}A2BM#7@iY~# ztq4_5PX+u@XuHCmCK$Ld7@=x-WaD8VoEKh^;?d?&t*=b2lWy{TsZL`8Unv7bzmys9 zpg?B^HJ>1?d4f(}fS2I2t?hHpCXCGyK+CNexa_&e5e!LVK4o^adpN(zG6t5_#-2Eh zJ{985UUD~_+y2!+c)C12CD|K?a&L2Vj2wKyMVeeHT_bi*C7RY%gHF>6&=oBnGTa^} z)9%i`JIOTQQ$h8>ZR_+}Dv6h?B7M;(P6U52-c16sbvgL#sifW5QaVX4cgPd1YDbPGbsZk0chu|04>-N!_u~}5|bfyti*}S1<5?hE@M$rbnqcrZw)4TzI!o&hi4g(9S=;FME-4i8Fu+-9ROZ#F8*%^I@z6-OVXt7 z8`qmyA&63I$Itz~xv$cbuY??Q$obQ|8+tjm)Wb;?*p`6s#eHMRiVE_t)rix>O}zEx zl^U_zU0QYBoBB+|CP7C+w?18Qc5`HZeG)3_PK|GK<4=iZRNQKR)X(~)qhdlB`n0;r zBmPDD*j9Vfw6=S0d1(HhX?ON8bAvS7T;i!nF$ut0JuzZ}3d%2E{~Z`U_jpxT4{PXXR*P*`LMsOVoPmx4rq$ zV^@hhch40QGom+pU0mMKp&p$(O%lfs}O*-oK8aXMFI+Wp2V1D`hJpc`;C|Fo<!xMXh#ily1`B=Ku&LYt%2b9cXe#TYq5{m=?0&9 zH=Z`J=Te7D#AHa_`*f=BLMvc{(SFsHq@XB-b}&iAjw(UCk+zTJLBhy5RwVJ;?B$25 zSF0Cv%U6eo17di(v%-a_v(x=*pC!Ul=dh2@Fm5h%$QniXnzmwiKL)m3(H&$nAQ<5O zyX9DM`}F7UJa)@R5q)0fsE8{f%af6Iw_?hx-8`l}9h+2gxT7jD^UAwk^!)P8G;nJ{ zo&!qZQAY6b?g|&-tlpF%pfl)Y%mP^4U@ttn{(EfS<(95*+x<|bsMv5CCz=4sy&EqM z>@-Z$O21*1@%aI${SZaUb=)}Lu;rf!n2O`=TrtC+k6)_rBBB+%Ufh#m&Z zPPWrjjuw`3hPYLtAP)o!46IRat$0bZ9T@!YkGM0(^64Jz;@#r)Ivga$P$$LFD)Wnt zkP*K z#xD==#{0tL^`yw)vg+YbBTIvT&Ayn_Svi$NJ_l5k?2y0^3$fHBeNa}JfA8)43itW_ z;0<=?4M^Bg4Lm=u+9OQ|9mA0;X&G3=^H4S7udC|A?y!?8w8vNQJCCv~#t$A+K?1y+ zkoRpwY6rbjCULqbf*o3rlrJ~H9J8$~;f(BqPnUyaCmY2lkElT?3{ZHm>wi4q^QUlcKbu}Zb}O1Gztbt&r*Ff<=MePR`rjmk9ugcY>i~0uH*h?b9`-8A|qt+}~}kAUm}s0!W@f?+`z7*5y-dWw@L7My`no=NlYk5&U{E zE&hspila5g0-kaO$>97M&Gj>rXm7PLGD80P-}d$EhpLx{N7HpYr8}(sB|R5Maz4x5 zVP{NS!4QRm?`>yC65?5my`+ok1_h$QXr){cWYqFPj`P0eA&25SBiS|C6N}W<^U80_ zRDaeFFK2!kLO>bJ#GT#2VIM!uu2Jf~7`^#>@$kCmdiiFdu0*x#d}niDfKp)w8$Fr~ zlz7?r&ixhs2&%V#6L>1aY~R|%^5c=fW|r~9FX^|T#aaR%XA;&6ca`zd)C1xh5(vd# zp~bJ`FZ^IonBrvKYy7NV6Y&#W;Be?n;s$UCr7%E7jl*~b{y&b+JCM!yYvTz)>;y$y zB|@zlvGwGR(0UZ`3xH)~6S)M3vEYCPs6HHfb?iTRSi=3TGeDWyIaB)g?xT;0El^)J<(&72e zJ&;8MKT5p%!>goQ}qad<^HeR?%oFC7C{YDZM};DFT`Zxpb)b$x88Qy{U6LoqPQ=& z`ybM7)6Mla(qOB3#_gr3Y)Q%7jV}ueg}{nG=Xr(kR3_dK;d+G*fV@d9dYT61t&Sj@ zI!nm@^EJCAH;x}gSOW9b7&X^Y1^mmsPE=dZ>t)X{x9$vVc<+I4^NJUSJxNBa@xyOJ zf8Shbr}AslcH>X-v)eR~6B9FkMFBkTL^+W}oIFqHif>JSC&_5gX)b~v37a~Rj+$b( z^6}f{`0~oiA*`ol(4OGMl7i@IUuFJX)U$v>xuMMe|8s!=331MQ@LAFNBuy3Ly1oQ2 zZc`I6v6@XV)8aL(LWm17^OKp*v-6RV6*5IBy)@0w)%Ra#GELAA zyf!8)l|Q`EW6Mwa%WT|A&9*G0$qLnmGd!hoQ_rGIdt;*wL7Nva&C zt0vKr<5U7OKZI}?$mfLTjODnF6W5GX0={+$6tc+!@`SPPU;!=;Pl$PwW1hg@77+hJ z$fyw00@N#&3~6Z`i2c8Gt=#PhcD3#_3pu_xILgRWAS?V3y#@p_fRd}2Q4{O8he@GVX$9Ao!R`MjESqbkA*9Z!pB)K? zyMf?sV4uUpoIKjE`S%deKuem1)+1wZ^e7vv*Rw@DF=1g*oW6x%qaoXwwlEyu*f?5Q z3Q!pGV+%e!nOkYn&-6A;;)FZ^YV$JE&@}X2Z`tT)x|s_ zB(Z-g8bWLR8nQz@WJ?S2*zuodFDJ&;;45smI1Tc$oCB=_Db^VPofmGC)u+KLf7 z%YFGL6Yg$hU||ClmRfX*Wd^)m^pwLy=2CpliA!HOAl!Ox{qplW;7h$nM`4?TYgd0X zPJ3g@L=1`pYi_FeFp-cJUOSR5Fb|4deA|VmSh5hqu>ZdAVK5*A|?hq?C$EAS$ zvQx{SPF!FsahIEOg~7Vng?Fx1#pwb0!|l0hNN7M{$a*R<>@%9-vpf$a0Ip|gg$(R& zKh-kcaN&~q(Ga^|!UhF@aLS?{hNIkeu8*v*mMa#nFFrln#KvWSs8X4x z@nM<;p?=>Hk0-Yi;Kq>>ky8eIzaEm^VX2D`j_R3RAiw}FhVKhL+&$~=Rwdt>mPaLE zfjFZh`=89+95J%zC5RmHjmoDES&2RHZ+t!imXFAR51)#mp;>5HZ{b(0GZ^x2PTGLq zHB=VpldVaS1|ytS*a3e!BN9W1-XTvZLk%y8BGts7i+Gv57XJ7>5=-(af&*r|2mTn$ zPE^vBjft5eVr4p8znjnKZ@GBz<;#sz!1eIxj>mlP(eBDj)9^oh+Edzs;5VmR8Z{la zf0lv@7PoBP-VgM%;`ri=m-h}~`~Wjl%2EZehqaX{+pS492Kmg(9?+oa-UfQLk^JkF zRcvkvy~HqmVE@v0uTex*#*+;SLV*Bu-ex&B2T6pezsrw`=gSo}C71TFG)QZhlT6c{ z`g)za0laf8W0tcqpH7wEhxh&ZoXbV~zgcm?Nan=(dA3?fH~ZTTBk{`+n&^mz`59Nn z#r;o#bwOe5Jdl#$%qsQ>(pME%6H zm~PCy1|Mq+=Vi3Ws^|k;Te&3YI}FT@?2Y#g2XHvwdHMO)eb`I%+=8#RCWC3XYtNBf zDE@}5!bP!lBQcBK(NCB8UQ#RBClY|o;@vDqzG6h-BWN50u7Aie-`wrA^x~j`P*XmJ z)li7(_7$gppfmK&1K6Q7UNi*cNE)aB!@=27st!ts1`Jo-rQpW`&?O+O7*Lo^wkT)( zrgiA(?-M%L{vYit2b<91^cRnsEq@HnTIl!HM61Gph?E5^oRh*RcC|!z>+8g{t-{dJ z2Qa-|VkSM*ecD()JO)YeIcxiPP= z4vU4qH+n<^8Z{>l{Iqa8Q2dk9G#o(z(AD&pVqq;{>pWl2bu>eL+w{!ZHloztYu5yN z(%uVTg^;eBL~;K2mr8xeg#)X@se}4-Xbw?R0#TBcp1$e=kiv)#L2^8}p9~z#Sy|}j z?h2ZC+*jtjpZW~zXkkk(MVi#I^J!Qfgo}%l^q3VdPJ?YQa;1q2HFRcR)iEftl`+QPXMGao!kw=^lK_tRluFJ*FReD|z-ROEE<1&7lD+e!C*A zVEt1iepp3d78+}igzif3rl3`V!)%%5UF3U@|338*BP2Lz|GqzO7rYn!!t1zgZ`Z8( z0$a6sI_GZV95E|5`~ZTRdjRHXKzQ&ASDtj5Xi65KbP#Zk z{t-(~1OVaiS=Ar=J^`rdgaVO{z}H8Y{AG?y{Wg3mj=kl??RbmxGc}qm*H^sC$7!Mo z78^@fhEX=zT$YpeV4YB$4iM>Eas z#n+I?vTVI0BW;(LPb_xCYTSI12w^Be1;J6+p}(v#QK}$OGsC04c^<<#fk*JN6KSub z6w>vd(+fk>jq|#bI9EyH5*wOz()dm5;G^|8q0sYdeZ>k?GS6GgirQnp+=ZQ7 zk)g)4CT*6>?}=5Uo^F-^_MkFm^;{w7Tk~y8w<>-SHkwge^9eO$>&}p1g@V1kJ%x+S zaY7BRdduGR3xKTw)$wbuGCZf`*8CwzM+fuA|pX`smdSYtSy3hnC>Y zJWFS7D3xHGz2SS$mmOl8)Z<`b3O~Pbe2FWCnU1FhEomhu$=%j*^4frZfJFe2F`^gy z7wN<9A^(HPT-I;YedpXeXIRY6jLI-bOiCxGc^(1L(Of#1F^Sdnuf$h<^fjYR>mnY*oXbA z*dg4;#)gUZd$14Ca)J>mHi`RIN&lWqY}Z%~6Z2dr>_^Udf{pb_GErA+_2XmP^@-_c z+*s_3m)cLhbjw*;23k)};6D9~C$RsF50*U_>B+$CG7p(RL_EDCP}|pkJhmM^@5Ed; zxZ>DmtIO8>6ho{eWEtCum4g~!axCv4^0WVGFb`D*e{PiP2=dFKEoSij@^8(%g@4Ce zZfFrfv5eW|EY5N%yJOq;skQAy@aYrz$!aHad?G+q_wVnZ5=?P=d`Qqe4uiwJz8)C68FBgsHXf$T&T-38?lUQY7EH?5~Dm-fsk54LHX{{#AKnZt1`GSIi zeAfRh_-&6@w5_CYAd<~AnNxnY`z1^v2s{~eVz*eGSoxsME(N|Cz4`PXIMinygA!Pj z0YAtznG%7%e;jr081s=K_{-Fhz2&S>oPdD&;Cx_0t*0oZ3T)HWHK%o%-kZdZ+I+kq z^Lz;eUt|XUyfYi#4-vo_2fu^@*O%re8x`J-=<8Rw5Q}0C+n?4b1b*u!rxm9Wrx{O7 zn`JA#w3mOdCPnt~A#7WjRjs=%fKBNxf@&C2_(BIjySHAxs3(~f>NNiO-ViO?oH0aQm@zquYJG$;BYH)}%MpPB2( zrbtb=$l|)B;VN@L&!J~OvMD4S?u!Oeg0vidk2&xmSC@8L!J}LFoAZMJ<4*p;1Ao3^ zJzv!L;2L~LMNZcT^yy?`#?cBJkiRV!zaN^H>X#l0g?g#dfq%+)KbsO zH?Cw%m}puyxx7p4C6GblKtq<=EwX*F(_fB{52>sM9@%+)cX!>;zLUynDg{Tq*yiWO zG#jF$Vy3zZ9_)0EGx!7Hp&PrhY;EvVA;bQ!3jR;vns_obgFQZG)(*j)N$bv=v!z!u zb7oMYQWR!AeFFoA{j4tgBEj^S|JU4(kz8SeAbhZnl~zMi8zG;Wj-X6wd@VH;Y;}^f zv;?qqck`$^ef;*D#pKGC*tpAf^)wKt(@hmq;&O3f5r_4)QY&gODRHrS>V%@@#W~?g zmmGamTs}r>jvKwrGM-5mE)$323I*0dqN3`yJt~o3hm6!q`RNULstEX`v$G?i5cZ+o zg$n@(l~NY%x1%PIUQgkv@0ql6i>hX&)Yic8eHQQdes)dh$Ae|gDEA3Pc`CTmPQw5A zV}z46_;`GhfC?b+R$CO`h5S2kPL03G%iIA1a_lpWL~9MltEY{lQ#C-OfwbpqxiK!J zC!_MS1#jpWa`-0OB20dHQc=F7ODU`>x5^y36ab>0VT;X|u6{nqBeH=jKsh46_L4d+VLCq};N)(<-n65n9O;*jy>0w>w&$VrlUc3)+ zn>i>RF4&M)LJX}MyuhEIe_XMGw8|3M z!PsAqmNAHKwzCKF{v~y-w$j)LdSA}Hnw%g4!eR`os&aEAsYt0{lpf_3d?F%tt06?G zIH--nJv9614(h~C$poF2l;4}S(JE1FcTX}N5O{>VRp^o6o zlk@YdKfR|>Kc^k@OXz*79CP^Mjd#!=O4eR!GPE`5#s3k{?CUVoqE$sgh(y_Yw*37= z-bACx@5c1C`R&!+m=z&@iH%RZ?HQ8q4j=_^f?Hx5T>@iNAlr+~o@a4wG6eMhT~ zn~Mfm$Zh?I7L<+H9lj;=Yu~7y`Gx|DCy3%yY01?$@;L{P?2(b;2Gbleqvu{ zf*XBeb~eZ$dgk|Z+hD-wMC0|Rextp#el_@=ZkIq`?uOMx zX^4sw&rrjFWk60Ah#w~g8>)$tgS>0f11VAQ&GiGjyX24JmG}Q>yG<&wEL7}#|Gpcw zBcb81|NAm0{x+wo?PK_!7uh~ndRsrK1Vc*g5X(RO)?t75Y3JEEQ|O_y;?4NZMxb>N zOk?@Y5D78v-g^tQXgIh{19f2Mp-qXX--kFw`kD!ru2>dT>Kd9*lI!}2kc~uldn;@p zG?D3Wc%7v?APw%CA>+Q&%GF+%ZoYkWGMwwDKhnFx4)G@Vs|p`|JwRAx;3pz6G^2Hm z^E%^22uO5W>(g~2TQ}N0O(WBx562yiJfI#;QbyvDK_cJ6nn6S*zJ&T857+!q5z`&@Qh-!O z`Y=u>zp;TUy#gQSZN~Jj2t)ttUixVsN8pM3F^YWiH5);W8bJy(C{(#*P?>MD)z18- zUi(Tqt`9nrqa9mj+4i!*29U+`b2DTS1IY6<@PBJBm|!{$5o3Vo(R^F0E#IqF4diGB z=OMD4qidF!Y(@+DySAKH_^r9yQGHd;OUI$EJkNM#%ZWAT&a~eMP~j%Np#!RxQomKf)@SS!3hLb=jnqRQR<88kT}>#uUF$K@)1UWX4wD;K*Pb$ z>!6*HwpI<_tS$254omZ&_@Mtf?gTvpdHDxlIUR+7_x5~OW{Sg`l0sk%C*dlwiA9NQ z!6)5Kd-*r3`Zs^AuX7b;=O0)hl8b+kVzYif$na)`>gkYV!*U}2*bti@_6%{zbr~Yc(<@dyIAcC+0Upz72THY{Ghi&~~NU#GnoObXJ*6~Mo+vadb_@7?s z=!{zG5@$Eo6%jIFB8B4A-fMnHL^hXQ(7|&f==F3Gx^#ir&9R?k&dVBp-=juNXxpZImkB z{?QM!xfou^O5`>bEay^^79<8>f7%t0QDP!kWd#4b6sZ1Uk(3}xL)`vHaGX&&eq%#@ z_oqF5U5LNGZ)m8W94l>fcUw*8%S@k;#109o`|J~_;tb-E#KkaZ*7M?Aw;cdFncai; z=tT*uay&{)1Yj)7!RQpPv?KlO1MCj}+brm>2RUt$6cjy`X@Sjczp6Fg{pboKLS?q+ zIuX0}K408sl;GSS*iwofpe2~X^v|Wn+YH-f(^o#Q(vHf#HMbHU`}KetDvVh%aH&QZ zf4W0q(cBV*-(e23XSyD&f1Gd}RO6F~wL7eaM z?nhIQlN&L;)|S*o}Li7vPySi(Ss`Zs`7NxFAiB}s+h(~Ji8jnvl#8}cL^EaH# zPfu&R6px{_vdg2}rdz8C5QB-Fxkk60jU#U<)(sb$*T*zaR;P(lc*$q6Xiu*Hc355z zo@p(BlMd%}SBQc>E}gL&Ps5-IoF6vQ{R4S|ee+kY)RUG03bFx30YL14CB!IJM0d5i zJ&jDeAN$&DqOmeWW_`#9s=(sp7y6sv5G3uoylm@UN)|^mxA}u6|Im~E5zTP}S)1R= z58b+XE8WK4cVBm~4*A#slc4mu|821mR!ky?JihUH8LsWCfs{Dl2?N;=1i`8988mZavGX1Etfuh%|QLBowEBgR*7mYu1UyghM< z3>`yJU*0%B&Hx>!0b};I`xZn1?BTyFw*|gDe4p6+5&P!V>Gsx!^~dZ+ zU+IDZRyJAKQ1rYxq>f=(MYC&&N{7c7WqnV@SO9fP1#_XY0X{~6lq6{u(d1zdz;KmZ zKe6bq9~Z`iIrxvWW+j885F@Rgd~+@8uWA`P!o@x9ztkMf&3SdmrneD~7`7-*T#o+p zEI;)-#`C~GOCX}FJ0!;Vwb&712t5+T)+C(X=H^^$BoII>0&9to-i7_Vx&77Gr2XG* zj?{|OaLQ6T-U#VcZ_TfMA-8@7JRkIHMBZ*M23{#4Hi?87|&8y3>;rZ4QEK z${D0Rzl3*u{!Aqe*xflnkjiIt1N!p+-!oWYZdU<+!#{Q9d;MB{#-?Ce_(FX*Gedk#S{#Eo_=>d+@@B4721G zAc$QmyzCB_VFIe2f+XdN;YGV-gS3P^8z-Q-vond`sGQW4cYMx`w|pYV87>;6P(aX* zf`-B!IKKsh4=2sF9yxvd8jD3ObBKq`Oxwv$x@T;t8_!Xt5MCx>Dr$+ujDSyND^xm` z18t?fYeFFxggCXC(v{EY;q93yxi6=B_oe9V?>SQ;!T^m=tT;HEKM>Eo_kv&Ft%k8pr~UQ1Sbkqd-Xjftr=38v!N@hdzqxUN;9#8urH17wv92Rus$r1<#*9ad zpC$5>39Y0r;M;z!Qa!rrK(Y`>@!t94U7A0uFjpttTyFW-|0KA_`{ufTZ)iw`!^LWO zIHMmYp8EMkd&|r|NMf7oEU&?DcbCnx4bG*Q!EwWdE*_6wsWq^u-+5fei;E`L7tNy) zPD_opXioYHKsebfQ-_4K)qGE!8QO_vpVVf#y{685oBy9clgoAf!^PtOw*4^oJ$DlH zj>TTz&**R)A*&x^10a%w=DV9>kPpu7 zhQRIu-A-(B0t(%n#@a;gKn4;c#t!gq^iWQ|hMcw>0XJ?zc;qMG^U;pVT`9Kp^W=dW z*PliOKQ>Sm>EUV@K0&8vVr-O5;pu4)e*l&|o2_#j6-xF>Lf*^5NJVplqO-7EUkxOi zl$dx~AGV25hY>gIoU>4YH0T(WdY_Y2L0%yfz_`j%$Lt!0L~KWO$p9Qs=(ZF57XFyr zn4pvJi)F5WY>|Zf;~Z~b)6seO85K|^{jMh8dg2zh#jB=As&6!2bgEGZO@7@U3H80Z z*gE?xJz3*I5`HD$7X!wwvZ}vV1q&CVBU^;rES_d-u@?=lz44P_+^m+#w0oJrzOKP( zSK`Q*xwZ_@{7-ODOt*i0t7>BdF&_UJBOUet+xN%ADLCH=V3RSCLoAp{nb|*#{WYYO z4O1DuAf#sJb18AMAa4Stik_SAwU14+6|7^?>|_FP!*^d2F5KXX{WIK!oE-No{G0E_ zU);|kdr}ywnNsf_b^aPV5$Kl-ZD!9Ez5}DxQQquaHWA|TPX+omC+HSE4ce$p|*%HC*V=cI2 zXVp;A(82#wH$Z}jlWqxgiiTW&DtFJ152fIw#VWz$l*GeriUw3vG14qJjygQul4`&E zK2ms4E$YMKv6Oyk_m)(rjNYp}|LRe~6IFb$x2MO}E^~E4OC?%KNm2_o6&D;XU{n;M zCkHmK$w9-*_FcYTatz%8!{S8*_A8iO&9%Uq-|-<1;P!wb6VksIpK_&Hc?{g!nHQ*bF>xCqPYTJ;B5c@PBZif<%*eUqBMRw>|3>LxK z>`-svqZKc{ey%H{@Nsi%Zoj$MK^1}RANRxudK`(Ba^9u;H#WnY+B$H4C#$bj9a%-R zB!-OQ(0{_KmKEt@mAT~!QUcqovyAW9ZXz+rP5DYmWN2Vu>2&7c+AFEbK^$_Ybqw_# zV+(h}JGqSYd~0&$UouGeQFqi3=V#P=h_Wqf&=H5fI`(X6W+X0Vq&01j?GYP+91J&k zgq*~Ube{fkW}90O%KR2sphnsQ!iufBxz`xUEZS~buZLdNJ45f#>a|Y!4<=%E% zCi=*;u8)RceZXUBRaLN@gYvq2HL=>!+8;}%1bV$e7=29dKxsD39g;5dPe}%$Gk}@? z>G;dUO82Y(UR)QyoHBiuSs|cznY&vjRQl!RalLIOdjmr?#fW6C=OuV1tU1SXs_Ig^ z=hmsS*90&Hthg3RQlcRQ9^Y>EcAohagkEC(DqvdVFhlJ%lWCSGDv|sS199!A-LR-#FD<8P=XYE6j=2{5Is_|OD3F^CeH9wpMrfn=GLBlX zKdF3Vi%i{k9}?no>e^NBKux1{Y?cMO`QhJwPLZjY9Q7=xL}HVF_V$^ARJgvYGuuG3 z2xb*NdWZ}%mfetd`0t?g_2Ee92~+3}?Tw9Ku|OQtd#L+`Z@BTZ!FNnzT1qK@bXec8 z3zK&tFwzIpxU)i{^ZDtiYAz(^PCl*SH-l$_gN0TxpWt`aFZC4}i`Kk9&kqk-H8f3F zNE(kI21o9pv}{WNJHb{a$}+6$sf*53qW~Rg@gdyfEKCcS{!d*7swAL%)TjjIx>ROo zD~M~byCbb*`G~6?3~i(5a6uDac}F5FWqi5sfukG%Kmgz|QrU>a1V;Hvsn>=^IV+Mo z#jv=AD9IJS*&79($IK8_3tx`^^T-G}Ll-HjywH!z^j%E3J3GBO^Im|~a`fNcY`o{O z5+dEFm*)LXhjY6SoFfTs#(P_eBivG=J#RjXTG&j6Cfn2V6G7U!L?~v%Q z?rN?=xGbf^)C|D9SRHZc2bLH!l-$%XR{aWuh8I`uv{pufQlKs`A1yaHQvinTt?nN= z=5PtZQMy*+f*ZvX1O>veeD&;2h-IdUv$`@DDYk{tkE9SK-P=Y8PQ~TCpyU2fQX3v8 zm0}bIK6#UB<>*aTlvZq><0gB2&|Y=!#ENZ;CE$HS1C~Ei8&l(U*_^&H7^qaG?85;BF|w5r#d4wS4XP_m3;G_z=LOBjeyt*j zQu#gB%QL8znEMU%yR}p7za453h_zXY3W~P`feXK<*Li)vQ+n7ZF(31QJ?iTcuvSj4N{mUc_lvg~413P;^PQj8rfb-TJ`5zclYmNyJJU-ug9F#QwfO>9w>6M>c zp{z>nYLAu0ba@FYDTvYT#}W-IhNu6+myD1c^8pPjT=n4Kph)vnr`8c8VO@`jb9j-U zV}vZp=@|7c2!L=&OCzY-BUKwIYum)q)}Q0U1vg28iG(!Y?tFgldE!iRkyy5SIC2mA2_>@1((Vh2VS~ zZ~Nn?eP_hIqkM2F-{JMDP$vO^bsHV(8E_f}jzy4(iHgU0!dMGjC1?7DZT_n!pMey5 zYB6yNBw#g(hh~RVFxgU50z>bx^55=)*9n!4Am{Jq5KlyY{Lo^ZK?ork`~75m;?57s zdW6rr@|G<-*ViqTI^KCnBw9N!cjbBbQounrRmFZD6WQF1wTzfQv8vnm)O&EuUzhsQ zN44jMNsEb~S%27P;c_~`FWV7}w%nYnQ$*#K^p8a&!S$@};4X~b*u))Zc3Ewf*`6-a zH6@^WXYF^8B0Mqh@w*bW$LCLB@jx>V@u^vnEqJy-HLr~uGKm>9B$@kU`LlbaVa9 zsA?uKs05tB6bbOgdBFf3!Edgv28C!xukJ70DrX(9E#u*JPp)k>JP!X$de=hV@8g#k zW{2&<^wl~(TJQ}TahRR=^P{z{5~V|!eem{6FKLw6ZIw_x8j#C$P=di2cxg|$9kwTi zsSUJRxB;TNOnQ-VV6H7VF47c3K0by+wWUV_aYK)sc*v5~B(Q`?6`vA&I@eziYJ}YV zny$k(J93_whj_FyI*f8ZND3b?=&}Dzi?K>Wl)@fFTVTOL4>Q- zCUa-r|ANMEiDrM@y%Sz#?%9r=`4-jwlk|QC_p_U@OwwD`r2MaU)eNMf+ISz6+i|DD zUB9z#blmh8T%Yyc9&m3sCv5S+X+8+Us@NYxmRQL{0!G}RK8d^MoHfHe)`PI*iUo(-~|Fh;rxNWXByB2Gd&_>1+( zvP)c^U9NeXVcnuQhA^5tN3qW%TkJKAIP`+`5LsImqjv-?XYGsv7dczs5Kq2nx$P2%Rm2 znyt2pWYNU)4?l~kaCi!S;qW$(vK&8pV44w9 z>Ej1jp76DrEbY}0951LYm!2U(CCSdD>-ljbb4pUXeyw@)HsnatTM5Qx7gMN3 zvBTw8x@{>T!|`UOpp?K#mz*$fS=5VQ)x3tEP$zes4HmdWNcTW?F+3nrMp9i3=Sqgs zASN#kx{2w^8j6bXo4eH5&`~;YmYp*PPalm<^E-T2CdERVkDK_sDBIR*EhEWuHy=r- z^cF^2`I=9z?oo@%JJaAN6damv==dHZE%sF$82I)Pzy>7%B@QS46(0%go=tvYAQD+O z@72p4vAF8i^~96QAPHIhGU|FG_|*Y9RFt zXa>q(j#erA2#>;ns+P-h@BH;Lf_pnhP=CZ3n#{+K0f?D;m~+DNp(LM8fW~6_h0^9W z^W;VH#>9S8_8ux)vhEWyl?hppCt#lB$gg&Phk7czh~dmYHH!D;xqq-T><>RbfMY*p zv~FJf`^OI_OrRdBLA}b2A+~Jak@i+JbgPX9bY#=$_j^RItI1Z@YC2CCh4zvf0>znx zwI1inL0`1tDQp6fiB1eu#_}wz`SS{)mnQ;hv>(-UhqIaC>j8WtL-8d!pitVlq7c=} zoqJIHS2!v*n;j)7%gT4BwUUWMv4-+-3qK`n(uEM!k@@f&r&Eg9#dJ`ji$lp6|6pQA zTbrz;{-c8X#z7+$)#W>_Md8K*Br!%+xhjHbU%h>tfM^y4QQi9?L}AI^JofR*u}oZq z=nPHOgE>QuY$+^^3Y*+q5prH({h%Z}X7S zUtgJ?Evg226|*KvCSe~yxO>cpTgjk><$lI?{6@cYblV>Gi}{X>1!^K-B2ga+nhEiC zOkN5$n?I-J2}GEKNNUx94$O^C{n!H^;;%Sp**NSD!`t)vKGMZcgf2X=an;M>&O?3z zPlUsz-C5xuG#2*Hsi*t?*@@OSf4KdxOffNSYtn}0n&eP)Kmbv@$P~FSi2B21 z$n}d~k@=|>p_m!QsE8usqyd53zG}}b{4~jr9eUN@YYY%Js;bF!3c+7L>$4;XXO;J=nqs)^ z`ihfN7m#CtWB@L^_48M)mKnu6TOle+FP=LkCGA0Y(wbl8BVT3F!k1agug|x58!YOM z<=#=$^X~*$O<)P0O+($HZ*#?9C?jxq4;#V?Yln1;@sa+$$`r`_9E^!A-i{&3@=*CvS1H>)%n^ECV7iszz{?YoATqRTSPdB77`vQhw~o|7oIN!~k@7-BFH29gq>kCak$KvO>geT~;t6077-(DYkOVg~dWxn2~eGyf>gz57#LSyR-% zyM;vq77l=75t5I5>>7j_F&v&;k3YZb`emBgY4x=rn5Osg)8xBOc1PFUx!=HJM5Hb-SZ=!4)`OzMH)rc}ql!1Bw}(O< zXRfWX3VHa>;5W+- z-OOKCA>D*zKwCX|B~Tn}!R0k{D?8>l-FpVXV#0lLtq!iBZ}_ex7MME!T=Kff7obAb z(SG1`pu78#+sM*O zvm6cn_0w?MUm17gQSZHSN?mfcji6rnd{OV#`z?;DA;~xBZ0&4ssWvPDsf_&R35@}( z7>ti@5mFlP{->{6G(Wtmav~)tLK8B>M}P4zt@1~lF@(=UfrTdf@_2%#*ZxN=&8f(^ zXD7dQnQzW^n_p5-9+#M7{t$mJ^Z!De;OL?!-i1m(bq!@ek1a1vhDng&R0>Cbp+M=l zELn70Z*J3q`A$K|ML%uFZ3JrLs7|Spmn#3GR2EMu3C$C&DFzi5J4wpsv?yyEh0Byq z&{fO>Ev$ht!@<0gRaSsX?cYs-zNum1a5~0X8!W?#pT>6x1s8J!MwH~ofPVEfj5B86 zfcW&FF2$G3=G87^%KqTls#M|WOZoNif3|2_rBn_q1g4ePIY{cW!mrGlDdU6J-ptcy;W9nN1D+SDwq-*!!2&RjZfXHY!2?4PPn zm)nTR_$9_hQ`*~bOMAP?cDr)Bm9!u@crbpw+|}ELHrn@trxMxVv)6>3bFC+75Es9_ z*l3otXmt38B!2gLMMCJl4|-ziBJ=Nx7HfygZ8S?K{yluuAd>!bzD?xxg)Agxj7Td* z%E^}Zmr+Rshr+WAxNmv!SpQIK^~;xF@dFFdopq$C!I%t^kU*QOwnwqf&XQ7fqa+RX z?Iwxd%;10kJ*7ScNxIF&(5*R#^N@&WJSN|S4KT!l15|GdZ4@u@4wM>J3&dbqlA=92 z0@6nBKvK-6ry(CTrFH1*E4AzGK>gwqsMxj#`n?;DkDvCW|Mr;;@;e=M`9YenRyV6S zo9*Zw5B*%Z!`rJ5r}L>Y&dR-8uEjf)=b^bm-9MD-&I0{2=I+WKv9WAhCa4V@VqxVC zto<;=!KZ}eM1dV>jA@l@ozK_Lul7PN>b+ZUzdA4UR#(qi*{U-C)^zIGk_fj(=Lxpi z-wRVc2A(u7S?KS-d+jg+@T5FXS5FqBd*q4a)_Ex44Zuus z1p+WF&MLsB$JuL`3nQu-+|w(X!63J5FAJ5u-O3rQ!QFRrQa2AJXItaGvQtvA1?KE^ zyy>5dpSm?IF($PExJ`P?V8(9u_ipDp35#KG&WAs;Xsbp6$YQ}id@;!!?PEh$F`g@p zf3i|2BY0D7O6kejMI(;{-xDU~wl?Kvued!CgNLSdP4-WXKQKg!eT42lwwU=DtI1?g zp3@l=D1uH>xL}soOqYYJ=uXZ{k#$)-+1r^0*6O83QW*Fop}37lKF<%SVURo)&CK$O zP&kLJM1TXQa!-nhUAl_W+~j=NbDRS|eMCf>Phx~Fh_{|>Ral)FAn|+it$rk+hjYzT zkf1?(hXlTn8KW5G3|jL0XIdi0NyL7pYtiL??ZMPr6|=C{`HJ**(4F8rpJDsM);&xX z|4vH@tFwu(+M2Wu6~A+~2C|mS55O@%}byI7Jo)*GhW%0k+Ju)W%EA1L3#HD*B5GV|s@lqdde?cGn^C z=@7T4k1R0{bv{@41z%qrZ*-oi*=D;sLiH0+WLh~R2^#ZCe?RmK3-(k$XkNlBw5Zr< z(1z%$B+I8QE&>@X^qD_wJckewmxAN}DJ%l3*O}uh(OL;Snwna+%a}jpM7TL34vP9( zfS3P!*=M@Nsy0RUDDY8=r;eccEDNf>{v-P|0|_$DM@;-Y!yUa7Pg2NV_OQ-v@ZN(z zN*`v}7x4+a%hVpack;J*DI_4a>2_rkz$^AHIZ4f)GJB=cMr?eFXOu$fV&Y=p5S{4 zajB0uziHB~H=wH%9nkWSN4D(958y*17K#PemA=Ot$|AIW|LIOn45uL8(q|7CIq$Yk zt!TOcJ#2c~+MIkExzEV6BbS^LA|>93@RG9D`w>W_06}!m zlKrQLDY>jxMT$^@)IFC9twd3u_lw>-A4)kWLm|A{vsn*>G^u!$N!IKhgw4+Vj8N7x zOCho`%Ot?k18mO|*seF1@XzPjwW)13dB138&+lcr`s@?zD(j1U9(O*T3LXt|NLm~O z=J_?PA-G1I-^U}a40uysx~n2wbil7Xsp5$m00`j6htq`p(|BZ0_Cl*!Pai^EOn%wd zsDwE_4@+&tddHW!A4JDaBw`yyS@vk@{W>WIc~?5YoA@`Wj`hmD*-?(3^xexriC*gc z`~o-0Suf(`4u39u(wr2@?3H%pMa@O9jAX@pn|i(CDbXzS=7B9_6~nf`&MMtnfu?EM}yww^H~a$wcoP{;T7PhE`a&*ow3lv*eGPw))DK4s9 zCIu~-l()9a2vKxSMpu)6wGIeUzFnwaVlrL#|1qU2b)zIn&ys)zd4{}^{a~_LgU91b zweVKPS;S~H^PN$n*}TVqa4ht5!)pb?cita~()zPqop+u@hV&_(~0Pg-(br)o#Zs>oi zojhyFz~HT8%+*lA8IvY)Ek$^UBe zJP;wfcvZat7r{_w_4~ zI2#O<=YfrN6Q%ym*U_cwC6W29g~9MEVL0Ree18iY_Wk{Ys0g?S!7stRzFkWm-X(fZ zTnOPXR02gseDwIxtBK~pNC-!3V)!N2*Yf+97%g_4?QeM&+PS}%vHG2GyC=dLb-n7h zmcQ+IDfU8xTFzY8yas7QsDJoe`}$w{pI=Q4bO6DLP+-(5fOJa!=DhQ2Qt@_8NEO8Q z-*_Grs)>w1Yy0==ysUvC<2xe}jii7g28FXvG5*LNY$G{u>P4QlUqv*B2zlIpCFR@2 zoJOob_mpM9tvvBI5w*HCDO7#MgiDTy$>45=0<5Yu@fk3`WWZ9h=(w@^K}tzk34dlA zur66Crq~y(FE~1FFf4cRUxQGyEOKI;#YXeiK}~*k*awgg6iyY5BZC2;br4xoy5xFM z9!fp1r!U!aC!5?5R;0<7IB?+Z`g5;wBDjh-oZ=8^{p&PivEq242ezOQ6r#(w-$?^* z&5p?Hq^(HneXRplZ{eL(TA!T`5`ch5#WGR6U#tZ?zA-te8;L4`(dLu@oze7q(n#%) z7=E_LWhL)`TD*%0-L!1(U$qlCRkjUd0DhF^%j$Y?&ECX*%7ODVGr*k{xHF9k6m58? z{eJ*GLBhT%rIg+^BDioOLa>`oj{x6KUzP!~gDgo4);-Aylmr(VI<*T3}gpFr*wg| zxu6{%-+~MWM{`0>SOoC8>c@u*C%1^eP4Ni!h$&Ym5oYrNVrC}j5UJCbxxn_4*w*kC z!+ZyK_Psj5b}91H4zOj%c5ZjZjTKMbh`2_8w-;{5c3@-x`{mfp?*4O65#YTy$-lC_ zR~p~|a3mvq`*{8jfBuuNzx@`<{Mipag||;{&#Rly6N81SX25Vwt~gySY$3(qbd9e@ z4CJ<@*AtI8XZK*S{g-gRu#{3-Tg_qZUTZDF;cnWKTY-!j-z0}Sv+A7@l=ES7JY@Rs ztPiJzoS84{iUf6`W$8Fd{J5!rUzNg@jaTlyLF(!r{mq z(Q)tijjqlT-Jl^<4on?7<_H_ZSd;lVLNCVc$GD&lk?FblOhN$&uX(#km$!5_^K`s<3PN%{5P%5-;Q6A@m-VtN0e7dn z6Y0`hdwzaC9p=o)5D7V$I8`5#gf>kR0G3jSuoN*J%PV3mwG_OW=Xt%X>)KlDZfkut zsp>S<)*1k4@1+PJl~T3~mICsTl40h?%;q{x6CzIY{N~LYQ_WaoS=KbkL>TI2n$W%X zWPRcGb-~OsD#@PpX{zoVX2T5};#scmaBwkpnD?$et;ecQO47Z9xFK)Z_hnHdINdMhSU;yRZRYx$0 zAP0azh#-ZCaDvEo1wt?cCs@f1!H{OU=d{M|1VBM_oo^cGgUU08%I;`9LI6;B$xlRA z5u*WTjl#+>qZnzh5Q#U|1RxHnQsO~H9r)aP=W3kA>>Jn~*iZ6%PVuJxn-Mr{SI)O% ziGOS=5s=<7(@MA{_1}*7fh}qq6#DDNy8Zc~hkoBl0L0cWlmMXttlH1IeE!Wh|MZXk z_0K;4!kCk#pEWic>e^_kgiyAxPJ|p}w8Ke(;7n!cVn+ZbAe%TpM}#oB)Al)IOmd1a zbhSW6q$<u1z(Qi3kAqWC92gPSqnKtQ115W?|+jLnNE3t#4ioJYaaCR`y*EV6PDL|2ih!92tNQH@ zRMl)tElk!L9}fk{iIZWHlWRkpr`6C}a9wiVYY8XLsltQUni&MJ5Oe-}Nps^&QLz!Wefb z4bcepDXX|Jz#@Q5gEqJfZcOX7S7N)9Hhlx6VXH}O=l#}Lr*nmojqMs|0I-b^u)y64 zYzBJs9>dvRp_|*MrFFdx@NeJYg9~))2J%ltn0W*fAgSrnmFL5ow{L&-&GVZ_d-eT8 z5UW|%83EkXkQhA0l_(QACb9_T$VzkuwMamOHdHspsZui{O%0WoXh0C588-Ex7|cdy zcMG?z85j_Uqu9j^_mnypWbb`9&10Cm*%%XF%5VUHrrLU+RCA*IvRqVkZ4D69a|m;u zUM55&GYdupF2Ww5HZl(Y;L1phIqwxA%{?KH?MLJu4xnz)fy@f8029m5%1eEbyl`1M z;BYjBjVlg-2ABX{5IR_*lOzNI!iYvY1a!j)x+w(@Hvl^i=Vg=no=C!X-~JlTVA}@Y z!2%lZF#bqf`Jnr?w}^?zBi7a)pPrHUboZba;Lx1-5FX*EHyCK{zO}80*n0-l`ABSB z;BIEZ`vhR=y(gTwA5RrF2+XlxW=@oo=St23Bf?V3^Yint9EcI4-?;bQTT4hM^~;g4 zw+15{)!sECS^Va1q!FP6Aw;HQ=_F|@Pn@|&?~+~V#1_i?Yf`G3uh_bRSIQL410%IjO&yrIc!1XE%I~w^VWpzaRVX( zfmmXGGzzsyK!IWG4jvfEnS>3SxGu$r(9IDgE;JOw_RkQ&7@JSPp<|Rkkr1F+@G!*L zMF~I~XrJAxOn?xy32)#=nx+>X?)qhMY^3}QNj8Pe1?*AJ`wnxveYk7Vms9K-LF0M1 zx%LjuT%-6cJcP~q-y9!qV1Mn0Vg7sf0U&(zmJqmybu%+?LKivQoerOV@9wLwr(?n5 zAVMS>PD~;K;NS+?43~2E5P(2P6hRb7a2<6SLBw`5j2ZyL!_3vJFc2uA1E`ro(wZHT z(1c?i5z&|s;AW1TX_vc4O5N`3GX_8kY4KZY^E_u6Z&{YsT6&0l2^R@>Gi|M@f5Rv7wtZPdeS-KEH ztTp?sQB{}a0*E;PU|E)-*#Ll9bMBp)m?*+kHI*tz$f7wYQkbiUsVXt0gi-H3cPr-T zm^6QCn-3$~nzzjz0F%L%RjfRF?%rFgwUThtj!Zz9txUgs{rb;;_Oox_Jq8WspWQC_ zrjQC7cQEmX!2r66E^imZd)7bCEp1JTw+8_MFj5AC2^mAes_NWyQm+;gurI&&mkpv5 zDWW1Mf?l#nUk&CHhWYX|EPjajD|PBR@Q5Dq|hq#P(Zd}W3P zL?8@IKkn0SOn||QI3WdFVbpu!KVbSeY+?>Jf{oP0W6}MlLBHZ|b zxH-7}i9J|@u^5GjA0Hoo{`u!`-+hZn9UC!EK&{Sem6^&g^3lIU4 zsG8p;Ys5&zMSu_(|HDsz`WHX{`Q!6hAq-;ey{i&)Q%zc1xWfp^HxOm~gY02_adCHG za5MM2^?!K-#4vUfi{i*m=nP|Y%=Jl|!`N@vw;~RrAPVLX31(+@reN~%D{fDq*zUs? zA2?AEjw4LKjKau_%($EQ?a4r!9Vf~!Mk8(5SrwzSxqr9)=g4O7TsVvHaB=8yj1Z~bSVeee5Cd+TeO@>*r8MHs_6g>zP02EMcPRBUy0NDp8p zgp)9Ov}H|T1^_cbPZnRQ`T%5nHmpO$VAK#ZF++f<4Km~ytQa>O3dqT_?wiYrNSBa9 z;j>h*%LHt}3VMX9b~S(;UM&&WT30n99v3G@#+Zd9*AA|wOtns>)I^7}XK3FVIPki$ zyOUomfZ`zP-e+G9?LxhQ1p+e&8HFJzL=XmJaHh77V2nWw!YGV{0Te(HnDjoUJn8A9 z7&kvmFUX)9C1SXNEjfVQy1z&TZjJZ-_Y5 zc86tIR9kC{ndaJFTcaU}O3ZS1f1gHhbc{{4)>?{edoe@K%^!v4gg3kO}mCkDag#FJgw``zxd*( zKl|C+rzef!1I%6BTJOF0uoP<<(I}27k9%L}3-sVa+x^n|1Aqe;LIr&QS#07~E&s|@?`}XzYw~uXU5HJ-y zOwQa9IwVH|VvPBXBsnvLFcwB3L~vvvMkH`>+06Zo>Gus38txDX(J2SR1S00xk=^27 z>sGiM<*|(8yY7GI?Sp*&XKs;I{teQth#Iwr~*5p|l@ z)@q#)+{`nIKOPUvM1-w3fN;0oJ2N9@OFGW?r>CbUj9e>NWLl$5)3ml85l_!giTO;2 zLjrtB1J!Q($3r9*IUY}CnyQrN^U~URSyK2x+!s}?rCgQ^0SI%gHP^(R1oSpygjB~> z)eIDT(|_L5+aRJ!RhTurY-szivhKAMVNq>n1`#;V5k^Gj*;WuNEVV4AUw!l15oE5v z{ae31*NQGn?_ov(;2l_SH-mI7%5Mw6x4i83vt4hw?ePjPU9AujLcHiOr@={m5ufhw zxmiEVPykR22XqfN3G2R&GN;{}N%j<%_E-l2ZuSF=L0~YVp<(4mY__EWkQoi{*8gV8 z5{@Ket7_zpM*6x-MhrL;;QG0@F9tWb#yGmi@7oc$Hc&)FY*Ia2;J=}Eu|Ijg5VxoQ zCXLviFm%EPz0DFS@NiS@+8u%`&&R`jf5*ooGJzZR{@L&U-hcVu|DXTU&wug9fA;Uy zIwOUHNG(iC1Yv|l)sbMUL}OtZ{fFw2I5?1*&GV?krHR~<;6^wJQ&M<{%oJdd)WO}y zWC%hcHo%q5$R1AsKa*#wXP{kpE%taCUVT5CB)GC`qQidxTemlf$m^C3{Z zx7K@C+%-?#1DpwgIUrQct+Q05$vU^bCPFLBxxY$)xw%D_o`N{Z(3!IuLm*?7&bU$_ zLj-zZH&+C7AH0NY0P|(O+DqrxzKktu!O`M_h`2SLz>O|Y zuI1bGaepXwYyRs!z<1{gnR5;(YokfTE(H-cnRWV6fM16E;+V^vly!m8xJfez7nx{FpG$+YpW#gmU~I0s z&4e`PWy5C6a=QXRDaG45vfT(oayRXY(<~xp8lk!D(w>?2^a03BMbL^eef#wI)6aiC z9S*(SROp~jb8|Q$?EeeJklL6@OU(Ib*+!&YJAQ)fx2!f;+;!U|2Z)(Li z?+_br1vbmM#pM7n*6RMZ@tV1g@?+dY@>k9eBH|EX+|%H=c@jp-aN(Apk0^>5X|wrT z`FMkgZWt6JtomT>?|bDMFYUj^USje0lYsrS-&+68=v0z-SYg1uyq9p z2gjjYr9)BgCdtbXAp>aU9Gz$RQ0n-0RpE*V%tEZtbw8i(Zf-~nK+LQ}fMYNK3vVlG zbbcXX5`I!o1pu;2XJ*T?AY!_K^ZA^iec5gi;pXaws#OZ1h){T_T5Cg^W%zCgLgZS( zK&`uX;;EEE0N~xaLQ~UD%u*|nKw&JD(-jOr$2>d;5OXMlnvfQ$K7Y`~X|U^oO$M)dwC1WxAyl@ zq5AIWV1ETh6xU}&yth+E!}x$45-|iakG1sxAf|VZ?c5~qZp&b^(AS`TSWe7{Z%+|oC~fX81mPOgZgy}Fqydto zNiP;IjCgzSgocn7?JYSQ^hq>oFvABh;SPfm;n*f4VJF`1@9t#d`o!Q15e}WsE$~lI zwrzvRir>~e9J>?9Kq>$?i+!!MXHdUq3Aq{k#S>zKjBvN;`%r;|@5V`st;u2gdGj1I z*KJ4(0A&3v^U)-{O?L;GYJg_~?UysTBf$6W@BZH3`)_{r`pv)iSAUF%y)TSZxYoI> z5Y04O<@N|M7XUNuImgB`H6k3;iET-^NAJA|mjShHFF}Bf;7jvj8A5!zSpTmG&X$ zl-&SRou;X^eiKE)vNjP3h-8DQ>NHP?a5&6_SZeWSDD$M?RLRgZ`m$c?G-a@mk^599 z?R{AmzMZR1M1-LM5kui8xjHxqXW6Wn^i4PKz1LcEL7Q0)irLU&2y<2Kt+TKYcXtg3 zLUZ4A;=;_N6vpe6CB#BZ#GJ7fGm#N9+casc=e~aV`s>p#K9^cPy1V0AVmH}g5tPaZ z00ikp00D%hrnDrC#SoEtCAU|5gm`;6KTdc=;5BX^ODIhlUPY+({;K!mRzg777&x#+ zU>F+{GjM#40N`|BX|@MsyGKA2BLo;$eoF>61D_6HFFtJT_e7bt-|~fs;QcRtvm;(E z>+vP8j}8LEcFyDFzw6U*3*@h-bOYERU=)5~6qBzG=H@+bUp3C`#+*)gBtOGm2TtpM zI2_7!fGXx5ZYY9`R4W_~0Qfimv%mRYfA#v$|NJk$`SzW*MUjL>N_C6glF0C0Cn*x5 z2&)ct69jg1PNhy}cOWEWaP;0yy9!Ir@*yTO&$KT)-OXGWBAqr(kR?4vNc74iHjO?I&ec!`+u)KDpJE^6a8OC&~{aZa9Plz#x!glrasXl^*B&6%4)t+^o*70soe>T*dy9z`J`U_ywcw=ci?Rgn@u{J}?e z_duj;%MO!mLIk^y7LIj!iZ+QKXxP7aL zYxD4nTg0Z{@WLn$_~%-#y#>H-H&5II{^QET5XgbptbaZ?MpAtf>;vwpRebj?S7R1J zMP!<$d7ewHV9h9 zAxfmc&BlWzL?HqO1&GMzeB)r|#6m4Rh!_DCtoL4P;T#-BG(^`kBaJXOB63$iL}E|A zaH}?D5SCSkjNW#EpuC@!z0`tC#7q{hW~N5u;sdia@7=IjfrOAD`xU}F zB)Q0`IXQtt7=kgHxdDKDOKSl$eWp>?gR!YLA)?!= zz?RrEc5`;Szn_Si7}|NY$^>rN zV&N$gNdPd<2SiM@&J4Fz)l5}8k-+Fy9ipL3g3%aRh+n^c4Mah>YNswm3L?()Tx%UZ zAF&kCp}jXP%aR5A5o@KC4uZt9B* zjd{QU!D;RjDNoj^+iiPkUcIzpeRyaVoy;&}52VjwzgfDHk_ zCg8JGev>&iP@wTN+ERv{Ba|loCdP-C#sUWPbCVq;k7)bsxcyat-Mqm_X@@V`ePnv7 z8+dDMpG>5-A!TW=cT2q;*)W_8rvo-ufsi5^d)WYP_TWt=;QH%k{CBEP-q{0@jQz7M zcABPA3Ne{QvqrVJxRWAqID#^80e~O<=5PL2f9JpY<=3zO(|`KuJWtP0i&-ZUDVvtE z0dMW)$G$ZFyNkFz@E%IE#cbQ{<&(Fvz^34cNU$vHyT_+xd3C&-0I{`x zsuQ#HrbDg_wzU-B|l5KnH@pK4v=w{vKI@eMG=x~@@Z%fn5`4ZvU z+WgVSxnlNBl9bC*%K3b5z32L_wF;%MR9DqK(@c`7rA4M4wH|J1IF)GvfYJ66W5a+u zAQA!-+t%=fh^3U(HOc1Y#zcUy6FKq{ds?t30XM9tYsmld%WdqEEkKDq(Yf(Z<0EfT%XLxj z@qV5KkI`)9SFnEn>f5N#YX~1(Ahc%>xR1!XLCLZh008llHH2H9aclH%J%Mc!SHt+0 z5==citA!&|M%=Ql+@awRE`|T_Z~y1N_{A4L`RQM{8)Ed{h@zJ2^VHU!V_b55#+DH> zQ$#p=(l=AiYtxgkJli6y%4XUcA`uIVc{hkWX#@bEYDJidv4CnfwbA+xu$?{F>r8l4 z_5*+<{NxU8txeOEkk|9`b8D@XB1Hs)8H7@8f7FbHKNUWP(a z=Bc&rYHmnCsDy^3UV>&UV8%cK^wIsp{o!z|b3q1mLA*bn`bRS#=YstF_^zLyXOX+Z zp)#-QrS}#PYN~FF_H(-!#9VFZZRzXlUw#cvA00oN=}@r>Au$CbFrw{@Adj8>u)&+# z>fg%%+veMm*{vRYEe-4e{7AdLJD$7l-|Ge$E9vSe5(nK-ra;&`=&wlTvbJSyYF?(v zA-;L@<{%$eLU~LTmdhakmb_9Ud+8*GC4UM4OUA0uRF$*mHhm-=FP0ZzZ zI2)z5_)2!f^1aH zBCrI_2LA2SGkx);2>tN0?;VPm`e?t2FmpmrwPx7O&}JfUuJC&+;2P@hzYaH$9ve5C zH_Ybt`v!LJKex9ZN0@FG)4o#2GR1v)-5f~=FpLam{s;RWAl$g6h*>k-|JLo^7@va1 z^33fKFTVHg@NG-vrTO%_E6fCf{jY4edd*KiUu0Q&XAO3g$-T(d{|FeJg_S+{lMU>XGoXRxS z@VG23=VR`Sj&$41%w`%IBmZzBW}+ez0O5{+(=?@T5+ouLVTaHZHwnljfC;$>C%I0$ zndyjuUSwQ(CsXa@^7Ez-pH%(K=BHX)Yi(Jrb!49lAaWOMj%wkq?i~q`q7*Xo)>i9L z3eQuSr;1#|)Xmv|kfMh-7v!mwW2q0P2|XsMAKyRR&4-Wf?qA*A-_3{9e7HZJ9!_@; z$J5>6c&PK6XlJy7EQH5ey=z~W-d5F4Lgx1P{1g}p-dvy8%e(XA^X2mN{PgYHw{O0A z`}le>HZ~SQppEdx+rO(i&_)0n@la$%c!N`8NG;;~nxGpH1Y11tmuK`Z?!C7!hW)?x z!~7aA;t}iG0deixx;@O(;Vi*4C*M@12+?0x&Iww43%; z`A|whVq)m6?c08D^QpB0(z>qea#=2yYzXMBrPWSF<^=aubvjPV<+8TsYNeEQS@Spx zo9R^R`Ep_6%lUFV(QU;v%LZwzlRS!q+2NVh-O=@kNGTkc*#-dQ3`2kfLy;)Z&8(K1 z_%E?VgNmkj%>zW((nm6Qyx5B6aTkO3sNL!0`c>ga^FT zhTKB>E68*84I3YHbbj3oin(|m)!wzu)3HwVd^x8$&tB2X+coz}h|4S&wFtY1ZN?`8 zJuD)Lk1Qi1iEuzlI*{eYWMS$&G++RV46)vQ;pK)(E=Dpw@>fJ$*R?Gx0Tz+DR4Wxc zw3`xg4pwKza1BLk1c8K7VWzTPE^S>+sqh^X07qAJ#X@p0E_{UIg8Xp2dv*8la5&u` zkN3yB`{VK9czSiZzdIfuPWPX@dUcrQ@CdhXV3N;jX=LchVit=JIN3?b>uySx^ACTh~dIr?dkdX?OXon`03s0ff)mk zv+)Exwp1lw`r;yLKW>By%5*m#qKZy0d3VKuyi8 zlmYte^MoVjz}3jvKB$|z79t{1)9&U(K!DLxI*2n7ECkG?s@7T+zP~#? zeS7)p>(@o(?s$B4e-8i{o~f9cQy6<-5{@xdV{cHM50(IKT+jXUTr+@M%>2gaUqAVc z_1{eY^=)A&#qK~N0OCz00Jn*aIG~!b;x{~W*NViAC9>CouPJIieCrH0Ps29@wSV1` z-ke*0jS-1M&NLoD`)>Mg%>5PN85oW`?52F{t=;4L9n}E<+|2rm<9o+I!cIHgGoyZ0 z>*@n6l~Lc?`qka~IyGG~aKp+ciWCvFd^z8ngXTgw&9_DV|*A9-M zz*uWJ-X9<4`E;s>hvVHx_pd&>yT3o)-5*bfX_`xYINr_EWTqHk7kgeVh%h8l(MaPU z0+tYbFat;_JQZ;a2k_`I$#FWK9HU!zjF0a={_P+9*5z_pm&^IlmJ2+e&*{8^Xd4N+ zyMb$0FcHQ`kYi7y2N1NAFL0xyZ1LK*%6B*@^&`LAHNdSeh+8n|Wr+{=!o!wE!%evh z5w&aY`t94dpWIKM-p^8)(5I>DvgSfBMV95Froxi?w9`C4JwL13-Q5Y`TuMP0^&diF z7Bf2>4rQwOA<`CSqr9rJ6jPmQAwnq?5p((@0BBc0B%Uxlkq}h@bu>>(GH|Wy`TXTqznV(<{oniDj~^a{1tTfo;bwu3 z!AUyW!@~~^;IQ&Hx#9b(0TFUw;D^UwcmlY+bA1#sXh#yaj5SBP=VIh7vZsL=r8S3f z40%hcuEc|#>H}9s(RWz?WPffb)ehEt=NBE|a>jNa z5L=e8oAo^%&)EKI{dcG05CQ7hObP%HoAQCV<=M(xHr>)*Egbn2641~x_5gIi^Z^Fe z(KtFmTYm5Ne&_%AfBHZC>7V}TFTZ^41c=~nh*XMb4|hWtnhLZzS|VZ^!fkmvN9*DC zga|_?2ynM>AtojgkqA}W)PEB?O}@Cu*6gpT3rfspio={mfIBz|%~ajoOe1PC2DPV# zZ)fIdJ|NPvT$%=uO9^7;AcLINozxy8E_D)Bb^uphGz!(zeE-qIryo6h{K>X-|T5m^8!5Y6FZ;2KCsA}pm)5f)}4X9xsf z1Pf^HImnt2Er|VI4iEQ7J@UG+xs7=MOvpq=@zV6a=nBT|&UH>CB5m5txWy}17oO`F zw|=p$3UFJ$yAA)pBV>B-Pz++=9(QbeD4kEJ#bwpIhgYwTMLpKFui*1xa-Rd(Z9xIZ ziQcJ&%Ixa%eCXPfHJtP>B1Do>CYF*?Q>`g6w`JX6Oy{%aij7&eXVZ_F;Mt4JI z5y{T-r>7^PVah$C)~aUHG_}@>lx0~&%EtCFE|qC#tY+pbxij6pNZC&b5rz-xg;9kz z%hO$?07SITIL09mvqz{K!`REobI4YMtpU<`+v!4)qUv0vo1&C*IJCC>rFwgiLYxBn z7-MKW<+jiKU}#Z}BJXx@xCGja&c61Bx! zu=WmDV(50gui3x=eA2APR@&W#+xFQ1*3(98uwWoMW^C&^Oss)ZKt!l&_WA;VFsG#2 z2FP5KBiw~AkjNo&ln0uul*k#oxEU%jA?Dc=Ml5oX%W+&rij z2-7fhx_2m)~+ ztyn%r0U!~QTDS-op&}v-3@BKpQmQbMhq?7|ryyhkCPpeU%|zQourU(`nUHrl=$0T9 z0|?4!=voGV*e({}9@yOk=r@tgR@%GW?0>o0kDKlOK0S0#I&gnj&7Pjm)*Az3?@M3o z(T+d(eiNfWMrIb@dS!8I6)LqXmyU$HCf|k}LpCLf6jcMF=jZ49hgWIb>oz9T%xaz1 z-k7Mjmax8?G4ttk>T1~wHWb_do*tiwDEU9`1H)ma*4q7iAVgs?^Xb59TXSOuso4k< zqYj)%WoE1Zc{z}o0V#2uytP$}gYb|g$%832i(6oqWKfY!bHXh`(l?0qY!BvA%mdu( zGyj67A<$Yd8{kWqQyAQ}G!);!CO#x!F z<}cRysG4q(dtB+BdvHJO_zTP@-ot!`Z@5h{aQh!~f#%{+C~V@#X8+-yp)et|GP8 zd72N)xo;od-Ha1&6{C6Fa!OgIQjrjLK3UG#zjfG>DKzEFuCM zF+`nl64r3d36(MQ#2LI*CInKQmLfdv^$PLI@!t z40pHgrNYObeEPe;^9R5AgWs-lXsfS_bvISJtn1R&)_T|8RD*JrQV;@;F(V{dec?bP z5~2|B{G;5Ai;yr0vk)?h@HCqUl}S>4CcJ|gxT6Cy)QJMtT?^=z2AayCJ1HnV1R)Nt zcU}R?DNQ(()<*qjLw?2@j{)ZF%MtO$C){rO_wT)E{EgcpFpQ)l^MSF-5CQ=yrsC(P zxBWm)g(oh=y{owqVfgrv5n$%9&F1w0VTsK+Mg%jjYl{FWQyDR`dBD1?N%4$`bO1~o z?uY*$XMfrxTe4jTVtaBw5t(_18sGdtVj|f932+1l07SFdg4ot3$!IeD#WvFiNng}v zY)Kr{Z7tC#0wg68^BeH0>eXCz@6C)j$9)g_u-#8Y-YP)d3M;#nLu6*eIquu{UTf{O z4+k~vy{%i1h_-Drq)GtfFu6T8wcf^Hcah|y%*;JhyD(=UHM1hp)tET#muZ?*_clyx zSvb|%sZ`5BD9mi2?iK(A@+ETig8P-J&JOivO`kI7gg5i`=ZPgE(z}@oOK#|HT517e zF7@TZ>5qQ&BdPVBH{XQin7s+%HcG368V~>{VK_=Sm|5x9ErScYvm=ey-@5#JRS;ah zG9LjLHL3C8x;CCMtbZuY+i2i6DH|V6UHdPYw$C`4A$EWWU<~+yU6*uumGj^IZC=Q# z`Tvam^V@seANl``{&|hA{=1w3K7fi#(7!-BGv^&Y4(b2wyZe8PInx6O@(i42DIo5i z%}SOYQ?%DQJ4-RnR@l!%oJZqC0ce==?Jpv7IHE@=_U&i?(l7k24?q0DpZws9&tF+s z*G9y(7VSlQPso1U076CziXdPjRRbTL4iQesvsep`;XG(+<0Q`mz+<|adbqiR3zt$r z5QEITt4dc&jTcPcnv1;|0)~6ErpF^OK`nLLw$}P`=#np6OVh^c$UfT9*aHY{ zOQQ!e(jji#<SRhOX)0o7t=sW!9E6q?<)c3GAl7K1zR1U(Y%id1~gRu>T#;o&sr zL-V&V%Yb$r0Ev0};fmDdvoBw~_u+@kB5&5ZVB1zOr)5dsz!6}sMnB?ohQP%@QVmNA@X&+13bF`nzG zd&Wqkk!%kik8WMPLl#W)F=DG(rXl0H6Xp=1QSr=un0kznM2CkP1OgC*K^XM)oo~GJ zYk%+8{^U=7^gsOf|Mu0(FOCOa*N5BNCn9AoOE=qrH((?nFq)Ex@NkSPJCM9BOl;;p zW}6}+Fy(c1Q-jdFa|9e01k$sumjnvYl~OVNXvca*@NH|`)|Vnu#H_d8THBx$7AjSm zSvSBjh?t3-Ld+sOfT(v5$Gf{{&!4>Yy>I@-ySry!zIgcQ2Vb0CHS2*6DQ_~&xum0| zInmn8LINd#1%;>(6#}6s?8S)hF$9TuF!Vtb0j6F6oS~K_kk0354Y&vwW&^1q9%PP& z93+IW`~O_U{gL9#Ish(8p&*Q)2uCEOgn4&8=%sR+MPeomf!AB)(JpxO368xqGOJ7E zzoZnD)j?eCyj?PaNk8G;?UPa>bj!uag+l`n!#(e8ssQs9rA3r!`sZVEzlhx0>K2TI zNW!vi8<&#F^7H4jVr!IkKOnR8wk0OT;41aJ^hO%I1d zZm8ZhH4m<4YBKWovGLQCDy`MsIx+R=0AOLnWSd*>A{_vT$=WbDs6Y6#Lt0E1 zha}wc#i&}X1*g?w9&*fl5I%bVK<~O#0YcB=MhIN%$De+_t*zFDm=`G|v1#}4MT!uH zS$1IVaRg_4+{67XZKh;2yna0zRj+UDoX7ywkUBE)B>-60GeRxG zbt&e(^%X#uT9<{_&Sqd1JThrvikwHly!7Tm_1WE9-+1TScTb)_JhWF|zBt`);LKEs z*d;MT#x%QN36emjIej96!G&GKgh1GZof(M{pd4UfM@;eo-3g%tLx^{AUTQr65{EGa zhqIX(DkBk+EX7wzr9bbYPrRxIE{DP18a68C(SUU24TpQ!o}FMb_rI+B`RP}7{;as* zE8o5bf99{BKd=*OIKCbL?yzp%A&5)CI8-{`+?ZM0R<(X83(hEyh{R-WBE{VSAw>_V z3n?N+Bp`@rSq`Q^B!(Rz&gb*RG7y4@0JAv-Z*OjhzR1?W0GRvAMC5cjZQE8#Vd3<8 z>#7i;s&~(x@J#$mDdCQUs{L@h$=8{SX%P~jh0(2>_If-LQI_v4CEOz|Leoicwgig^ zNipI)MTiH|kz0CNY;nTA8Nznf8UUF2D6+MlH{CsX#vm4T3*zSf*~=F{eD5c%>o?wd z``PVHe|TuUBLI~$%YWSK`mu+7gv%Mz<2txM%I>cg10ulad5AqbhnWLXuG&3z18|~G ziSWQKX&_82Xdmjk$M*s3z)WH1ir;g8xWxS#e&ZTjd%U2p)^}n)*B|V2S4MiiPyUL4 zAQ-29Dqu2#kKpY-AR`a-z95Ml2dfbz|a2$jmMZLM_?WTvH-bqiHD-`0vsdFmE*abpw17W)asS zh>8A8N?=$p%zPWa)*1TG0)ry1Hr4A)K+NOdQHU^$b=$sp@v^|pvqO3E?CH(zO(bZG zL%T^tK4#`3Gan9z?R*k0nGgYFz{OG)w=ngar=yqk(-3ZKd+Ko3%21av?pG_xf8$E}B%UN8gr zD73}_71^qCS&-(g=0ZHv2^i(lD0Gn-yXv}awct^fTFDGrYv*$;%W^y%5zD%*h=IV& zHKj%nMFGtN?-OUrm;ugfvkqi-u8Hs@fq=0aaGYt^Lg{dNy zz~U%`LR5hZF+!Mi!A%egiC`fLBt#4@ke&bT3IL#4DhrOnmyy^}l>BNQz?I%Vzrv}y zB4no_sJNmECeAm|P#V&X*Ue_LppUE!VTv}t%KyEF2~7_867P5P;A*Rl=CC9d5CNi_ zd%(l#thWa>H}$P|U@S!#!K`QJt-D1?K%~Lb>2z;y%zQX3GRW0H9QpP6yqdX40pL<< zGMpm9M70!%0X3M}x^5Xva~&&u4Sj zIdEfZt*d%O(g5qaO?N8psy?+q<}<`63S>41nIe+7wi$B)rx3_}v|r{GR@GWdDTPIl z(9A?+5k4?mZo(PTvMW5#c1L>Hwhuo3SXh|&t!Gbfmx_w(wl1XAFb)vgZ{q!zUI8T zGBUb6_6rqL+jBk0C9c%{3Exja=lBZjow~zcC~2-qcE$CnkZQ|os$hiMm>I~Z9wGuP z-Tf!-zl$1X;e;^cr{NTgl(}gRK+cp7$K2k>k{?KW7Qlhgj&I|9Pv91g04{~WS~(oP z@$*0T_kZgj-oM)Z!zceK0!pcMfSlg@*g@`2#PeRnxOY2JmWJNrzUO7ToXh+20Kn`O zhH+Uj3~^tL003a_JVv%EE zvZ!78Y++z=pddsKQ#1wx2m=Gv@J=X1**ifIj7;Dh#DD@+fyB4#fiS!Ot?wMb)NB{x^vmmjQN`zbQ zbW$&Tc>071Z0*{62FD1`{qXYDtF%G`04YVJ)OzUsl#_iRa6NCmtEwRt^;iT`dy^}y zwMN3*wwda>p7Z=j7(MNM5FzcZ5XVppB&y8ErKmmvcuYFp~MDMC1Vw5ipw}^O%XT z{YaP>;jR15@$BN5tn*O>49{Ep=_emYxPJTF-~aYE3v(cLGa|<;zpZh9AG^TsCwRh@dJ|$K3Rh(O-YRLu^0C11qIw4D~j_zHV3B%E|O(MOpU>1MlB8}G)^g0aZ zNr(XAP^j#4Z6Uzif<{7<0NOw$zgRM$CQb(d0L}~&lw!Y92oXyurj{>K#Dt`KZ%rX! zS(fAB_SuuSZf>52kt!m@?ai%FMU-${n?q=og2Z6308t<#2u2}F6^|e>5V140T1iSr zhCrmcl)4BD0=XMO2&aJAARxlr1)xMo00R}062SrBK!8Zjj*QIUf^n3&V|JPsUW@p{ z8ff+kTvgAPb}$^Xf&n53(WV~swZ~pu{n?MPldc2)7X!;TSrW5av>f^#&6qaB~RAA&aF{CN8z$3C%jp*{7;@KCL367Gi29K9j57`;vkK zK<%Apzl^CuL>2)Nk(!@m*8%{UEr3cj4+~%^K2eui7dK0VC?N4Pg6?WdsihR2+5K>c zi^!IG$~LoMWzO5$GMi)0NpNEV4Dq1#a+WDGXV{Br_=Skf+|2S*B07kuHUxw~&og%h zy?nNmg@{kv_Q6LVx%T5>d3t+OnE;^MCb-~~@9p;#0@X~$plm0et%Ng-K3)TlT!0>} zmCM2Km0+D~g=RE=1R?HO#(vNi|FY_M-KSNNZn+ zh(X?lJ9*oC7u}WKV=A0iV33+I1xUK3bzM zxo{qZak;9yN@@rFuaDnR6CnhSHfw|+7+&Fd`7$zNP=vd7Krd1u4BGn5Z+-Lc{nl^( zr~mi|fAD*M^zzk1({{Vm%0$PdtF2p4(!)$+CYlTc5H{HtqFK2h5{-)i002HGzg_M& z`sh}MEAA!NnuVpPampA2ah7Q#nvDX&-PH^LnF=t6#kOg9D{@myb#+8K)Z3fo$<6Y# z(2WKWK&`@92*kRpTapJ5a9X!;fBWv~2>L9QxGY|)ia4Np^re&{2d2WvLQ;w>g^FM@ z<|HCg6Bu*{H+O4<&K!b(NZ}S#lQ;)J2V|lMV?=V21cip-Ga)%Kjgc6W=O3l>ql3Y5 z`ahcImqL^2Y?ZP>)Kx3KYa4(XK&pe z7CQ7o#z}eg-rL>Xv)qxrH@5>ams)$<%nXUbfkv}CrgmA?V)n}*x>~4eT_8q}ObQQh zSt>GnT1nTclmDfGv-5UZJ!suJ0JN?Fcsid|b!*!*ex7q<5_(EkhE!xAXEP+bdsCZc zTqRTcG^^^pyLl-?v1g{q;tfHOdz!W*v0G2_Afu1@V2P9S(+E{dxhfIuG%^uYk%Ap& zWbm}z7%>hJ37LeJcC!2P`Gb!?s?6W{<~P3e_FKo>oAva7sDw1)6e5dII=7q7Nq$Kc z2V7+dIOtNO>*DVce{tURku=kA$$p%*&p-^bWo5*I2nU2mHp`<98AatzT~x^`KW+PyqPl5OgPU zXxpKbzxYeP`0M}pH(s93KmIrWt~}w^H=2N)2(`#TC1AI=z8%y z0LHlE>;~9co&hBrkKN{F1v4uVNW{Wso?Bhj(ksG;{2zxX0w8nz9DWoLX`RT-nI?_# zc*2EvWVHbf!?`y%R{)wjM4&KqT3i3%qmRJs$<6V})2Aj&7;z6|FbHBGie0Fg1&Sx% zHcUEkY`Fk(47xNG?xYjMJR@dj9}(nPwMWIVLU#Odgy6k}Fjvxib}rB?CJA9z=;MA4 zT&>KAm>v&tRUEu_0KhC&GXi1&Mc^*koC$tRTZl~B_X8j-UDX0+^pE**zSL~tg@OLq z#CqJ_EkJ=T2Y|V$n|bd#wDyzi7=RdnN6^UKfOv{PuO3rOIkUn<*Y@lW#@P`F;205u z6&}tI5FSBkWk%o{nzydu?eRbR8^8R?$DjPs4}Z|Lonc+}DZeyu`@H_dhy)NAPK54( z5P_bdfe=%-a)~Q20L!t00+1;;L8SX1X3t2tn}vs&F*~H~_qeOcEesGcGhqNWMaX_F z781a42o|JTDvJjhl(8lGpkDTY=&GU;HKdez#be(GVvT79bsQ* z4w@(sJ28e0M^MO-VLmv;kuc5w!z=>!?RCL^_QPNY;J#XyfAu>fJDbse&%;JiG4qgI z%4T66#8P>A^5n1_7G?@ZVsv*YIiM%|9a*H9nMZV0LXH5ljIY!|Z~EVKUPfwL^g zT>${hRE4dldJ}9Eej~6VLFSd{UTXE$U#;yYpMJqlK7S}Tw|7r&i0VVn4BgBeq9Ud; zz&wE5Gq3lYUlsGv&m3%E4EyQ`$E&3SY1`_a4s7`t-_GMC-vUj9XOPYl|3%Ngx99&5 zcBwMKeVjQ1secsc_YZLL!vPmjKc+Ku48aucp#QXA-l3aJ?R}O4khYXF@!vh4lPE?X z2gAtv^W^X>=Z3<^102xCtXglq_tv#{Q_WYs3;g#4E9TyzVUzJV`d<|UBQ)TZNd_i5 z7?25nMu9;CWMvNM5a!@PmB4!dA}W>xn7;dscmM7`_&a~{gFpWF|NcMTfBuRObgVT9 zJbddt+=+x(03sYF8bSNSxe2r(5eb8*FJbuQW+4CuA*7xzoBgt$2mp7$;Ur=127oa- zfyPMFa2N!Q0{~eN1_2P4rN~ig#Un+KkYPZAw-F9podbwGV%-`5GgtJLa9yNV>5H_A zE?5{C0$c$Ck;$_PK?DXtF3f@erU4G5Ow2(Th`>V3j0Enm2A9m+g=L7L1Kb1LYRRlB z%yLK!MlcMH$%LjBj?xl+`aL`%eraee9;<+M2ndHn4+g?BYVAG6pW8Sdp9GH|ucJYi z=zpmJQa`fG4=zZ+tl9<&bw01>^Xm4*DMu;>i@3E~tC`hh0SIEs$q*5q9B=P>sE4c= zBYYhA0j)IzthK&+b+2kxNHvFNxs;;Yb~>LNp*59KGkwmK0cN<5fU?xyd(zH1PVeUC z=Eav^YU^oDnl(Xs1Z9Yy?`Kp$?!NVYcU)50sb;u?}#Lvm(r%qa6+o!Vn;WTC}wnYy0V^pF=6% zcsI(k=SIMh8H2f|fe9goo97wsfan=h=3O5%VbHU4ea3u8CmNbV#2L9S%k2?Nf1NT! z#^3!fBvRmkgTWcUe;`Kr8&etb81Q)n2hEp?bKQgoMkLCgHR=ARV@CfB{drW6@6Wa` zxIhyVxO5cX7j)A>Jz+!fZjY>cR%tO^$Z@q0@)y>2AZ8kwJ*f-MHsyI3&zUGnN zzTiMt`}T@Y&1z1r2pphG5ddI@EU?sA6~d{vgUB!Z)nESgfA|}(UfuuhXTQ5`>&@*^ zia18ouBJpewGX=X^wT9oKytW3!vFw+_YO}VHR^0k6&S?0OANd zv}A*A4g+^Ibt0aQYs+CdF3YWO2~W8AWor+v5Fm%c-ObJMaJU7K)9DPh3R*!cWfKOO zO4>k)pa611bs%JPW)XryTm(dbDKN})U{}FlazaPQ7G;!ChG|e?Mj;fL1=^g+h;vhg zLpVl|S>##Zu0n+Auu6z&{P0@bfBi7h1?da<#&TUeSs06@xwvKvNK z5p%S23F{Cg34`O!@#(Xt?bFX%Z$)d0s^;xs6tPI-c%-^$Z?EoO!G~e0%v_-eF(L(! zxq_)6kERJVB0wY{2ggU<0tlIb!Egh{>o*9Vmw4|6c=YyXHVueVkrvm%0ss*_fTlYp z&9?P@`5}UP%vRWOiRLfIomzma8YN&y*5(;9BYx)lqd6RQR44Y!c>S`+lWp(X8rCu% zm$G0A{1f?4+Q}#Rq+#749gJ0T9>lnYSHdV94V?2+qCA*iL;Gj<@BGFPK3|TJaR6fg zAp(VQ{$l%YW^cKmF*#AO6W7-+%N_9!_^p?&?yy z7MK(R5xSYrk|Z#zfCzwHVt@of0~Gb)?8}()2PC4@$fZrl{_X&PBHWD35Fs2PJP|u^ zrv&1|k+2dKrdrEksmEH6LJI_1cQbdz@GT6>Qc9_Sa5{h4w=H6;!oti5moAF2k|~e_ z$Wb_|kO-ARm9Z3(!punF?%K;zib&dhGC3zt0_anxMMJ)WW8>$CIH2sY^m7r977)wk zKf(PZyjK1==9zPe`@2*$6XW?KqYE1b4S+aIfe#}l=w>jqW7f6}5s)|{m{}3`-dz|xVHVcLQUNfsdBV4w(vRp1 z-KAZ8?@dixYsf=*e%>~g5)p(L0L&7(f51hgozK_p1!lIctD7AU$246?AD+9rJ7KP+ zm}xyMWR{oO-5*XXz4e@#m!&?O&N=yui52W3i9<{sAR;lP6I_g~E5)>pfKG@w#)`Rn z4nPNh^aiLkN+~N5_}-L{$QTe7u3;8wHIbr^*keeRdCZJZdhefq_Ickn0O03uAD%v^ zB7qcv>K+b^*(L@NK;!^Okmlu=UJaISPX9Pe)Lo|U?!EpNz z;@Pc*jzoZgn5OYTf#Osh%t3J0u6v{Jb;{59hz(X84P$doa}N2&FI#O@*4&B&;x2sA7c0@B96 zZL68J)<#l9Y+;*4A*naF-Zy5b!kA7UM2P6KmF8x-6_EhEKbax^rJGa(!K9Yh?5GH$!5kX2x`{-Kh)_Y!xhtqj&JqHrqKb+h>+re7LnW`fZbv5@K zkVNjD{2+6%lnyX(;)JKh(HZGBotHX8Nhw7{a==d7(Kdu`?h122#fWi(keT;6wLZjL z5KgDli&rn>{eX3Q!t#WPMFJ^%Ju$P%NZ2q^5}9&kpmC2eFdD4SCW~ z3S>JsQ5FggI23b=@49t8ueujJ2syWE1oNF# zp}GH)LQUWQO!s-t6xt!+5&dyAg*uO@IfDJX0+?QPxL=*w6gD<4G9suF2{DH%E`rtz z)3<){=YQ)T|D%U5PXFeA{C~YVy>f^rPo5r*iW zB4|07W3;v~q0>bgnjK;2uonXW0KgC&ArTTTh{QxvnNWmlElVj2fG`MQiQuiDd#rI- z9q&);mrUOSKB&2^tC}*A)VeHHgu&fG^QZyZH8%vvLE}ljo!bFGxOtb_FLqQVgPKF_jK@d3PBMf;<2$L?(DM>KWEMx`}f|1t~?7hCPQNcv$ zI~&YCZK9FVe<@jEoYFu*ymnNYK+sMD{LgsoIRsuE2G<2;%w`>&tepc`ggbuu;?*0s zhXc7ip(o;-O{YSjerA{Z$J zH=cqF9O{}B4Gu~hpfWPy9%CN^_8C6`(x!NreC_NDCJGM_K?IT804IclkgXC&^UBA-H_2+xJPBm1!bjy>Z0W!^$| zq1Y7pxS6S&SyyYVZLRmN=6Hd3Mozz5bC1T_3GC0O?9$JPQ~)H5P9W@sL(muyAqPy% zrZ3u)fPoEy=iSJ+nX-|H>?B7Li3n{?mK%2kQ+AD}SnFT6hmSPZBRIQ zs$rOk3NvTuujNqq08y+HL?NgS(fWOl4%7mi2&n`RcGLWeQiMyPQX)XzAdr|RB1r%o z$YJDxsIJfgmn_zpqbqccT7?tsb}P+m;icB4FcS$Ei&A7^u86{D!a>Leloy;l$U(|b zd4(AQkOU+_nZt=F!!17h|8 z==prc@Dv=G`MNds;DE#J4I=UoV!AsNDGpGiB*37mK-{%64?~{T8W1HJa6BH5$76i< zSwxs=tu^=g@Qj<5oGc^Ph0 zD$*QY7!zBDKm^{_UZhZ37;Rhr@|=`Ph>T=;l4RmH@9SXBk-E3AD8jW?Lb$(w_0vy2 z`N83)xqSoi`Lm}P^E8!lK$FH9fqYsGW?B&epoTK#9=U{QI1|U?<&m&H%y%@IgNelx z4M+1?E03PGd9I8lg*1c8;Ls8n8b5J)UjFIPd=!rehPb>icrEweum6tq&wT$v>V{|Y z7w%a9?Dv8I^DqxflW#Lq?`oO&C*##s&7DTFxCeKd7!1JlQ}Rf{bAQ%F=R<{%7=%L@ zgrP88xxvg>4`T^DF4Hl55Og~P6p@mOfUF?Bw?hE5)uw< z&>@tvI3Y%K0|5tsV3B5U?nb8|3}F_=TBsRp8jC1yDJqw0%@9KLy-8a5E98>m$#{+MlJgKNiT$$S#F z*31l$IOm1+UTbx;*4vZ2r)f+S5h*1iqA9}{B62fCT(>QY24-5f)>?}QCh}>9k_#$Z z{beZYhv&P9lN8eKvyGXE5fCC&lNext(IRB-Qbfw7XkcMdqD*#hF9C4BAvkOo84*>r ztJYFj%5pr^ZT;!zp8;Yy9+u;QQ?6v_5yx6H93o(Nc*g%R^?Qu^(Fns!^hyc-6;YW$ z&>rpq5t6!yA#jL1R?<&t7)0P$jxpF(7^{~Olp1d*0)VsPoJ`{t3x$BY|G<8f&b1u# z1;@pXZO{6r@BdZYPaG%a+KccYfVgynjb~-(H*>Rajjq0JeQT{v!q?mbpa968xy=B* z$Lg{3_Q0K`O-f7{gDJD<;R=~2^0?wd0~+(E@xlk}n5|EMcJAz1wr7})l=Hcb9{Apg zg{)Jl-}?Tyf9t>et(UJ}e)#c6=6?The|LMw!r{=jE?gil;oxy%p6C1D85(OE!+}Fp zC)Gnl7~zCqk6P15XN-GR1(opjaCmw=97?9(4iR8s;LV)_NVT`#Of4^Ec(5=~KW`fg z9m>MB0OQ)4xw$z;5YXB*QB6X|>AFDZ%+4&xR;eNrAmpuQ#v+An-RDger_KAIIgI>C}p z%cL3iM14;U_t5_59+u<=a&+)&1^eBZ(t%%fHcMk+$ zpcEfQM1q{latLjzotOh7+}zaNvY*1;GbkW&xJBf>VFVHpH(S@0q(q7V39Ts+0>ReW z@%AnPO)YbiyPI287pWPKT5GvyQmvmYz7dg5bIfdNfGEsGM9mPfbv>QW*?^YgEPC%l z#k5N{MY?Wo7C8Y1FuQuYIie78g2kprW%!roGUhB%0RX_wv%X>BYzZz{zM>BtP~3nO zIDrKq<^rJZ-nLqblyZA}yPnP;eDZOC<*Tw;m~*b+e4K7|F0eL;xkL z5020+c&-SXNYRu#B!fMOjiJeeNc@c`rv{Fd=kDnb2Pulo?7?IFQk*_Pu*-b%2Pt4Q z^jrnCR2vI$-t+KtQ93Ei+1l9oqLVoTSe)iXY^B=tT{(HaopZ@sCjYL=gFjF7?B{Isl zkrd_uk3Roegs18c5p!UwrAQV*YIXMre~pDiLMa?*2zMOs9_n#f4kCzP5gHy4hM^D` z9xT;{w}z4=sX>HD5~tI;&=Hxrh{h?CN3sBAqXJ=0z(6cz;HB2q)0}=-2&2wE3ykt z=IR*JQ?I6F;66GjKizGj^-2;tXl>k`A;t#UdEg5uvtRO=tN;IT77Xww6T_hoNzxG_ ztt}BeK;6~-)x*O#-hQ^+ENb30Dq)f0y>(SYByjT}q5zOmTi?vAx0Z);t|4Rs2t*_n zVkY5mBEh9rDFpzC#Uq%--HC+|MC5edJOYVQg?%_40MOjG)}IpRdwBKgRYdgEry@pp z@9mPX0EjRXch$U%rIfX`e6Q!VRiQNT_v{8Z3QZT%h!E!72=0MIDB>399-VvwypPW?wCVG2dXfg*=InXXF;mMFh$(RbNb7m zcLo)lgz?@KFi!>88Mheq){duLU6~!94?sttz#XuMG1CE%zzwuTHgvdwV|JO}+%3QS zH-7aWee&^_FCISo8%33 z00aUda_-7-^Z*J>W53i%U>Jr+IB_6eWT4=Pn5IC1U(*e6QGyav*ghbhzA`5gW+{yc z%3ke|SqpktuUbQc+huur6s{?W z!3c1-uxS)dt(yn*6s^}P%!IJ3>ku(bMm2&Rkcg2y+n|Z0lp>|nMa>*ww$XZWyv%G7 z%w-rnKp?Z3old8WA*sqRx1<4*2hPzO=e3(Ei_}s^JVfl~g|XIB$~Z7~U7x$VsYofD z3Li(VEJzyW5FS7R#4wJEnRsR^EetJ?sYZmE_11IL+_u^{0EieV&~4BG0dq9p2nR^2 znvg?kpD+hf5Cx(la;^94`TdVS?5=M;f8+Vx6XxRGFkCHBVxS;^zzoFmHXFoD;?o9l zUv+~Oh-4Hf9Ssm+sMNp%0*wX%kc0FjCM zh|lTH=Li8LoOh6T56H7JLwDFVL(CB~62p%bWk>3>0+=cPYyTw;a6x{qDPkNN)0z8Y z0&|aaKi8lAIl2F9!T*|D+LzPg?6F&r0i_>}SYFk(-DJ3Kr5HJ8yL`P%~ z2ad0Cc}fD-@{ z3PGs3^#Bm2^n=Xml!|p9+zkU!8`Ddg9M&9d>%5GG(f;8H)3jDY4CN!=7{!# zGaxK{di&T0{q)`MfA2T`(Qkh7`Dg#~pZ|~R+A5Xfa=H_-;mNaX8^5p3->FgZrr7#T^!5rT-!Vh+}zq&oqTgte=q3t>}xasLV;+)P`0d432*% z33>H;u0EMIa~Wx8`|kB)lpitrzdUx&pXuk2=d-&5nPcx6_j~VcYkkW)u{C#t3r>#+ z0j~25ngM!-@A(C~VEt3(@mlBC?)5oXmTU5d5RoQT0N1A->=!5kzyR|2kCD)3lu3Bf z0n8y7@~fjO55Nr}0LbAS_4XJ3`mg*i&*%5w|L7+_{LyJWfkS<=)KYq=sn6g2ka4w?oIKapb$E^Z{0P-LRBCZI5=twplwL``R8;N{I`Q!&iiNSuAAI#(b zf(!Xh6^w=e%7kmAgClL7S;wQtUJ85_^tpT`WhNu*f;@k-l$`n2l@(V8SBO9`w*WYw zSHC-w2#csWngJM!L^jGIMnEYw!;P9EQFf0Y1VRY20hp;Oi^#fe9>F3k5~h(2IDqGM zWj107z^!!)@CeM#^=WP94$OQypL~Kz>Yjk+4Ezm8A(Q( z7=)2sVTdv6Vji=aFo*ReY?9tYJjI@SCg?Lqftk~f(qT6d$z|!&r84sv$6%YmZLZO* zDPDhEB`>+-2a*Fb)t@I-Tp*zRv;6>Yo5#bn^2x}QXMj3%!;$s3@z}O)>&@L+xD;W^ zv9bW*02DFI?{W*Kk_yK-mz|wMoK^vkV?WLrz*iE|dA{stz>dCMp|Y3bHsAQn%6G{& z&iy>Tk^;>nlF$jzNB@UMz-C(Roc5YUMJeldf4 zJOcptD}hXCerX-RK?5L;aZWQW!vO^Cf&LK?^q2;CbOuCvXiaz_W;EHu{f=BmVm|Yz z+3WJ@wY4?fTH7|-Hs*o_i4hQ!ka0jXkKPr5%zbOk!_5+jO}Bbl>Oty3)pGW4z-&yN zj&Ira zU|Y9s+qP|L2TH9XOpNqs_dEhi_YrIP>SxqRY;2%CyVhqF(r2%c=WsvLTU;r3)qI-*+dT(()9qw-b-aq_J@Al9B&;RVt;Qu-~{?{m<{3VQXh!SY_6Ve zJ`e;>K@ucE1-Jm`P_Tkrhzg+)GI&b62ob?248svsT?#@Wo!}3ron6+`Ni-%@l1@VT zh6V|oG{C@ZQpvX?JQoJ|^&^2UB=E!q$1yN|6=3XP9xsBRucrZG-%GC_2$u)$|Hu6| z?Ug7Z;jB3T5!Kw>k!0K2{VB@rQmfz?7>kw&om*2SVKwXE+tzLlhawVg0Fd4+Ia!5> z4u@kqKd4&ky)d(+yAzjEJwS>ajz=I?cOdTFAI{Cp5y#$6jZNy)9WWx!=d-)_-fJld zwdc%8b5~O?Y~aM?ZbY0@0T5AD&3!4uwE6k~$Xz^u6o7E|P(s>K@`wOpAjw9vL>fl6 z7=vSEW@3U45z$*KMM|wWD~4KYCWNV{q|B>5!aYS(<6cATS)@;GMgsE+fZ)Ic;l$F- zKK|s>eqJ%)?dQ+2l#I6yPJy+Q1q7Ocxe~M`hJXbptUr^gOnMTxPZC!y7|~eo5jn&( zXe`W`m>>>k3IxKz!R1B7ssAz8c@n@dv%Xrbalw49|D8w1z4uEa z?aP;+H*JLvgp5*Zy>UlZgdi>znNzZg=rH`bAuk3I0XPe?;I{SvEky_^JUyEkJs^8^ zX>hUtL-<5Mj5L*17vyYky@&z9Frt4!E+~v5$dY%4kRWLE#vlR(h(!gjM1@F*0KtX*17AH0SzGNbtNd`g$97DowFYd zUlZ)Vatd6hHZz&IJQIew>!2kfpm*(-T5k*=#hSU3fiRPB20%3xsR6hgj@=9p4u@q~ zmg8aR_viEZe7Lzw$IViTx(mYAdJ$fjVJYs8NN$~&mZcJN>x#&&DHFMRFkspeKroj* z-~x`xj&2@2q8BnP$3qU5NlrK-T5CtS$#9=PlH_x(wYAOX=&sz%02pCrmIafil`UkG z$3z}jMxW%EOfc-A0l;jC=knIH)=TX>Fg1@~mD@%#|RWxe+acjmQfs|2BgpesG z3<%Jsoh*uQAvwQ#**{&Ex*+o#PoLe~-T;xc2E7mQP73D@KvUd|dypFm?xZC17388- zbfS3x0N`-{Otxbzr5L&nBt%GYUsAy%@Q>(su^k&K_Qz58D)e9F{-Z32H0OiIQGTj@ z#;H7ILixpR(KNQ^YwK&*R}bfGAv7Oun7jvQxcoJEocqt%8&_Lwgl{6q5I>6g%P+l> z{g0c7sVJoxwPJ?&8SSU{J0e`P0C_^hRn-oc|Hc6@+eqdYW?0C7T*x5|Tq}os``v&1 zU;j2C|BHYA|M=|1nd|Z9xa>8FnM*0P7I)j)#>7zQ%U7q<`CLj3r1R;#YG)xLx_f%_ z?zi6l?32%m9GDiSLs%(XT?G(L+4``|NMvEKXw0QBBN9bGCk!GG#(>n&qHCB&IABDn zi-<7Es6WSs7ze;a;BpC#i!!D5BuEPu1{Tc0k;H@p|G^LrmLLY4nKcmzgD_6s%10J{ zQD*{<)^;8{6=&23kjrYBI4SN}e~t)t;K}&yX8_JzDHp{~2GAG~HcNp~C+`VA?y2Z? zDR7-hU2Uq@!awY|5L^JC@rw7&wwDARv9-2s+q!PY+v)+OR=4h^2rNXIVX>=wEVV9& zV@}N_A`)Ddn%w~B)A{E1NnH-^PEvAB`U`c9ewBfjzEjoy1}&D3ZiNN?(C0#QoIVa3@OmSZCmU+@_H?yaQ}MVj9Z*-VbM zfT3D&L}DUM*c>2v;0(~ccM)E0Zi+~HTBJK(FQvEEd+)s?A~C!B=uu!6W>w1tz(j5V z;RHxH^^$wMAqusbffAT@v*TguYN1M8>&>yNZRhm|pM3nKw!iq@pLrYMW?2f6N1WGl zM=DH(1rfl)(FqZdc38s!fYJCWBVlh#|i@9w%VNe~2%HE2G4m%?_3Y3`In*j; zfx+l|jB)IUxJOw)7_|U13KL6~49rLp!pN0Gau6^DvkQfcNqvTq#1$eEp&@8E1dt;I z=Sgfh0XhHxupNwqh=7Er0Fb81Owx3j4q=SC;kp_iWFo$ZL8sQp_bLIBWf-Nvw9KVB zBYsBw|7o(HM|sRd7V%PkUM;c9TH9AFAbLb{WiMad-J9F~*^wQkNDf@gSHe;PBL}+!fDkRqftg$Hy(@3c+|OGh$p*2k1`_e3#G@dB z0tIF3j-YWINn-Ckp_`lv;O=T#Yi;QsF%N(|4>Ew1QiwRS_-udQuaCPS$sT>?$S(u{ zHw&pnWLcK{*sYnold9o+FI|XHeD|E$_$3Lb$V#Sek8?=+?Q>F zmkpds|2^X41fm}^yfd+W?W;o=blG+J_mLhM0cB~v-x>tReL(z`GU_Vtiv)rqGJ_=u zhKWfd;`ZugsRz=2Sn6N+mB0GGT8{`g7+Y$<6X++Abkbw@x|rkwsXDWLCE@1@i+`=gfK>+yR_RSqe)j2RCchApyVnGdOdm ztyr$%1Q)V)Z`G6ybHYrRAV_rgf2z;*{5cGK$KTQ8*u4!fb= zEoZ?OkvyK03NA%@<7q7BB9ap#TWhtH@rOCsNr>9KSA^`4Mc@FPM-T}>2WCDTZ)1fx z87v}8$u<};=iLPVbb?lpX`)d#o00g*=8NWdW;Q$71 zVV*|-I(Sn%uj|9<^zd*xoz|^w>Vv@}+G)eQM}Rn{*2QZZ1|QYRnWbMyT!bC^&u(8D zoF?oi0TEs>CZhcc&QyLnGL94R&*|0~U!8!^eEt>GgG0TY!oCYGj`OfBMW~iQ(EC?~ z%QwFFoqzIA|JUFB&UgNs|K@*xKCh><9gjE5&B4MR&L>2ywHywIFTVVekN%6l^jCl7 zZ~e_D$NCrk!uLM=?4u{2JbUA?3<0I^yWjrSmoL_BYufec-5UX+suZ}? zL(rKBGdMHxPT(U2$vmAPJPaZ-%>YM6DzfmHD*%z*ZGeQ=SCd2}-RY~yU4bWyy>aBH z2Rx9gn#EhhQYzQVB1G(I>M;!2XlLdF!3j+gZ=m4h@-W?NGiv6gjbwuEM0m{y!>NRN>0P(=uLjR?T=x|z902=lVkQfsXR6e2=Z zBQR$=p4TmVGSyt%i39);De>__c(4k@Zr0a^fTqT2!X5z;L}aRm!x4$XFr}ZVuvAl) zVRo45c225+z_ztoD*|}{c&M63cfOA6h`)!#d;&(W4Iz-4tGT+V zxkp%ldTgzow{|+8?;jo>PUqg$9gs+bnFVPWA4PUc5YZU$LPT^0SWQkGXMCMlYku>h z{JAKACMA%2d$s`&>*&3^b8f+}|v#r zFxTc)#LS6_*qDP6A;QeV0w7R;VE}0`8W}ukCnBOEMXCUG_1=f|4kBh5A;g4O(2vFM z7JPPqC)F3ULy_A@YE zJln*CYbmuBCUWoSZl=stL|Ej!uC4dVi$^fTc0Q+aqpj`cokLv?r@Efk4Ut8p5BNI> zL70D;d+tf_ego!-DA&`(u8J!ixU%q_#^40yzS1;}R-+%t(Y0XMF!Xwy2 zJi;?3&$WQy7(6SFL4@FFbDN+xss@B!u*7*}?gB^{jzX!CoJchc(QB3$d$KbBy|kpn zW5#vhMd>x_ju0M@Rc1H$aCPtqbzfV5`SRt%X+5p$X6UF5LQSO&zT%2)iT+%eysrMN%V1L4;;ux1A^8nb(v4Kdy z7!@!Z#;(c&3?E|2J!lM(Y;U?XzIg&jc3w+8mgmp@&TszuTW`PhFaFto_dCDyyDz@% zU0Yq2<+uPq(-sh)e)`k@#7e%YWs=4?g(tqmMuR^n>qz_uJq9 z?)QH1$3HkMH-GYbzyF{9!@vL2KmF6YOW+t7K{zIYyMqHHEHPo?kxfk{5AJ<}!~Gwa zV8T8?NYfn}_mg1U-#z_Dq_Hjc(`6_Gu6D@y(~ph;y!ziYH}nW;%oRp3q(+JN%w0P7 zs9EITt#X@FcA@oN8FIGW&d3oW*J`~2V}s4 zav*qMpzvtA_1;TaQl2j&wUn)ObB|0anIZ=lrKurv*Ae|i#C!K}W+7r?Rx?Bbq*`kN z@wtmqNSY^{sxfmZ_5S`9A|@EM=XYjSSSa~hzyJ_oF%Js_@I29ph{#OSML4HG*?gq- zwD+D6Q%*L`zivsBOqBx*nwe>LD1{b6O1I+++Bf5)nWyuZr;Aao4q@)h$fam+YPKv( zAia3`@^n50;kUo>&1X-a9u9|8t*9GXAUa)|xdH*0(+?6;bh9rOA_$wlq5&3z>7_ld z2N)@JjD?lG3?mVRiM3<0%YJ!R*DDeS2pK8*h8g%Hb}t|#P>b%7_@PBKwXL=Dx}MJ4 z%U7?~wRJUh3m6kjfY<@MIU(@m*T>%-7*?1&0*DN||2<&<;6NY0&#gFd{0Zn2j_5El z{YeYsWhdh$p-SzX6Alx@n9AC5S0_0ZfYbwkLPd>L-4Ga5dc#~0uey5$FCynJ{414(OW^U1Xbd|RGBMNq0u?hvpyAOh zJPUp1JOEUc2ilupMrxV3wPrRjqS1O5q27;6k%G+((RoaCPjgl$=9qHh*kdOXm2I&bZ#AAizR-)V2Z_2yerD>JLZt;LWVWsg-jGv`o| za8KqVhjC5XiDFt%0tI4t9)4LD>=4J`>yR+_ddjF`#=1mA1|K^Kgh2@aBBr!IW(XV3 zKQQ29j}|(Ff&c-~%pT6?m-qLt?jIf=&fB&HK-#?!XMdLjVtNh&V#>NNtyuX-rotyd zt(iYxg810egmkfZy9E79-k+rS^;Vm4*xhNIgqJ#FmKd3_2hMT_JZP^CuIhCdzwYv- z5%D7;uT=()gm8U|Nu0|EPyV<6o4@wgf93!7fBEPC z`d|OM&p-S0lTW@t zo{$;MO~GGoC-8QMTPmt$awq{9;2yab2{0@t1VMP%)}4upNTncJam=@qWQ!BKVKN~U z4xIUlkcj{b6V8f>4QgSe)_Yqw2V`CV1u&$d1-RL`2ktU?*-@Y26i(z4ng-D+rzWJB za;4vJaU(Em_hG-H`>$!C+%n78VX^{0Ey%RGFhBj96$9+yQ#ZfS*OAQH{YiU<*~n|g~zd|*mLy5u>Mo61yqwjwhTxoTV2r4*iq)s67( zSb69PGnzQgL?5pE&h~_2aNRBvBHYbe@5PPMjL(A$v#`3g-j`CJJbQkBfB)*dK78=e z!?vwz$K%c2(G)ewZ^b&LtUI4q+*mv<)j=0W7s z8JEX$hAi;+#5j#ce3wGbZ4D3@kR|_=%EPQOBP`rw3*W#4Ks{RT59jmCSFc{(Kdf8R zt^mMN_M#eyb_ZpI)OE*xUL~_PCLdgonu$RT{sS-aPuhY1-QfArgJ!L;?>LWPxC!7C zPGc-u(skp&3`c~Zn6}$jaO{Zqdx5{RcQNxmoQ4p4#|FO9i1=&x8Wj z5eSK)oubsI&!61<&0qP8zxeZi<(K~ZfAepC=TH9d2k*cCzPr8o<{NLn`~2oupFMv< z6#$ch0hLBqd!oe(gk|1IN2zNElgdn|Kxs=MKimW0o0>Z!? zM8K3N(Od!$DH;q&BNO0Z9wb;f%7Wosp)Az8nLA(y0whL@bT&<&kO=`zXdb7GXNLYv z_QNI_Fj)b(4*f)cj)X)I83_oGQl$Vh_m3S2x}pL0n7%hQ>|eP4T0;kqnky9hs})?W(vgaF3jPE*%}f7V^B4>u7_GiJaSjnQfp#(TkCZ> z9uCKt!^2A{Tneh1*}ASpEV8TXg9}0fs4;BQapK zMh|k(BiS8u@8Bzgnw`$)`_t*wtB3oChqW~bAR^%s#A(?R$;SakBsxDn#%cC9J+KF< zgF7^zglixWX4MCTk%|by*P{Q<1Ji}gBP0sCbXm?wpGeI}FTev+;tc^q=W!kO_r}lh z!`I)Oc^~em?$B>UddAJ3;gOX_gae_r4O`0fAVul?=6 z`Jevn@BZN*{L#9uFJ8R*;JqI~xjR&TczF2ZKm7ea{-Zw-=HLEb{I-5q3Cn-}+yB+N z#)t2H{?qqA32mxyC{#;P^|N;OjuZgqL)yR@#jD5S5s2oHUf@VfQn?6mO?a89O=c=>AOwLCB!bNHh@XxSF**bhk;ubO zw!PxINJ35pJsV^Q++D3B2_k2iCG)P#Vi`xJmwCqZmLQt{;-B^3I53h6&h{D~`Bl;k zvqSjLrva`Xe-`L-!4vmik_Bi?2SNly^H^JJT?zfXZe7(g0U^rHEHhKJVk(GXM#x)t zlo~p;M2Ki+ITJg>Z+I#Oky&%_ZQ};tHhr{8;3y6r+>?f%p!fJuTBsnLK z%tRP50=iw^1puVn)7^y$82Yw$({ghoOaPD`WKwEsmK;LODF+A;6Gb{s$AmnFCSWRX z@;c=^b$2c-BE&-km7VkvfkZ6QdM9&@04Bqr!adx>`@^gF7+-Dc&)hzH`*4#&+YA!X zI|7JEEkl<<;W*7`#w-kgSt{xt1OYDAC>OBY(IIDYAOjJOh#LV8?&t%=p4rD>3T8f= zfKPh-*s%7AoGkT)tq=FcJr|kXGLels4BY^;4KT5{1gH9zZ$xq&U z|Gf{MzWcp5p50o%XNq6@TYu~O-~0Zvr_a9p?8~>_d{2X_N> z2(Zu$yBS;+!5~<`0xZ2f0x=s%As`$f)Rj3bPyrM2O3BAX$J) zpb#yN;)VfIs6!A06A0k~P$1BqIlC6edW8|P0n!F{tUoxL0dfT*;$UHvf+X6_a#9RZ zqMI^!n=6xsM3#M+>BhAT^wJN!y8~T_fm~aVl`}y)z>MFG6N{aQ={Vx|Qs8lH|7ytp z3M=r46dpa1Sw_Tkgoyw(Q}eF&?8yO8&0}4+LI8j)bzRq5j{w}aetTSm*`&1AvQh>d z4MAxKEk#W2a5#tvz_4vMGZ+(QA$xHL8v((>`BUAS#UN6$&vsp#4dnp2JCU5%2UVw` zTa85gMhh5X5n0zYwcn+b1Z~n7APb063bUy8J`E%gk(s-;J%PG_3}d&D&@7;&%O0073<2!ZpopoBhw5OVTV;yH;p4psQ*Zvp_=9esTF zd;_p?3`95EdjItP%a^Bz^SZ6u77@dioWz0LcikNVVU{KV!AK5~^+(J=yziPwAPX)c zpx1KyJ;MhaZ*K;Dgu&wtJ<#P)%!CmzxdR-9^}c<_A#@euufzIldvmP3ujFx8MZk#u znO+C%O~)CUd{iSNgM^7Q?S%qitqj&-I}tFbKYRMLN_p$~n?L^xzsQH%2uJ9@^jCi2 zo9}+}-EVvofd4yv!GTLTtYvXs0wVQkFexFoE)@`=2l5cXT=7W6M1@&cYN?0HEK&nR4FRx-5HCQa z#_PE;ORbggz_^%kn!NxBA!k!za$8wh4B!(1gNXvr!nOd&E)Ze|2|+keu^_O--%Ai1TD?Bv&!c2tOq7@MUuBwn$ip+=?WxcrzUtW23cy{yMX=gr@L}Xpp-n$ejwb~dG z6(Yhch=c?IxM{ioC6vGANa4H)L=+xD0~g-gpO}aV%xvrW0{r%PhkP;Z7ay%YSr_;m3LoKz4 zq)ZeMvkAHPe!Mx}-rla;W)XlG%-y_i{ZF@#mZndhKDq1tcs!IM2q46cpsIb_4oek4 z3`b7|TzU(PS>_QAwW8e-P*agwY5+0Up(}j7&?f(X#hO>l4DZDLt;4qEsrrR5fYyD2)>3{5aXKB-4p-o ze4h|FQ?`rk1Mc!501OPtRsdL0wFBS;X%Lw*xk@9Q~3xeb*beLS|QqqH$J>M z&ANx7R719C$(9ll5CfGQ{~N&S2p~k&LsZE;tR#2K9?&`m-5x>|7li7{nHz|3fB*=f zBO0X~7$YEu>kt4EB=+PJmdLp$-Mb-@92Vi~Yfhi!(FiuVrV;&NP-n0=_g-+GOZtEH z!SINlbt9&J6l`EgvzKImm9tctk694l*B=D{u-gpo%ZR27BL)h2p_g($KtzNEHuHyV zQvhahL|`TiZGGJ~0LX$n2Xd8C0wCda$+Wi!Gb_>QbS|Z4PtoyseEITK;`OP!ucb^m zjU~jkDjnPVI=qa0@z8@5AZR&ejSu4f|3 zs(owicB$FmC?edA2y=28%o|KZVK&1D3QshB9J|P2 zU_C+t8H9i^A~f6)ut+j784IUsXeSR~AbsMA183k(ns2z(c|;HxWUH60+Rxkea6a9i zPWR{2XDhvD4ha>Iv!P zA4K=iW(cen=hN1jK6&z-2u}|W?R0wb>_*r>`RJ$R=I&>{^=)7MXMf@6*ZcM5i&yJ; zefsPvS3Da z7AYJI)QO2$s4yV7cXvP%VFoT#2}2-Qu7Erk0R8>}4)kWwNEg$$**Jr{90)GO0qx@f zFr7hZh=nhuz+AhRW8lxB0UqZTyLb%dg1}K;4N4gIDjk^vqN$%-e>kmBV%_N3?Lh>) zlD0lk3nIb*5Ektnmbz`5+}_rumQqSBy*1TtX4x-!b93|R)hn1)4ggdswbt{twbn9% zM}%qzh*E^c3%w}Qk+7>CnUhyg^J9_RCThm+HPs-LhHG>@+$@Kqn)R;P4n|Dq=$$Mk_TRv+Pi6M^(cd+^=$G$BC>GHCNDr1G3}N)Y)%wljyZHb1C5!rt(8)1 zU5dzgji$O#AtGT&a|lf>evG0tF|J6-Gd1|Sk0NPE6-GAj?qnl2mz`(XthLP^Vo_ocl9s`b>CV$x9xsi@6V@)^SSlj z-2(y3f+T=B=ykO1C*ZgnJH1~>q!d#fjPt4JwSeOc@ zvXEYn?$;sy>f`HZ4)fDJdc;>c`CtEaQ`wOu5kO9ejB(`c;7@ zQ&{W~6lP8V5RRN(t2r?gALn+cFu5CBxIwSIGx zU<)%3ha3QqQkdxW_Lhdd^Pn_|Nak#omZm+zZa#P!LUZFRNdN$V5WA{}Gn1;R8}sNS z@L@ildzV`4c`Xmi;o*Ergl7k@vt6s33vsOrvmjnPA&98fYPNMVm^M*KU*^5@$T{P~ zNUjrwse8gkz{132Zqpj5^xh-Jz<^T9)|;8f@H$1};dT-M-3)2igfX)Yxeo9&rHcr6 z1s5SUB2)EI_@kQ(bCFV{yDF4Ymny5|6qHg9Bt~Wm=meW-J6rF%ZtZkBosKt8Zf_2U zr4LaA1h=X$l#tD-HVVu+u-vFJM`c{3ITx>IlRgvx?3}A zrmgpfZCzViTRZo*b?t5rfFeK?6H2|rupOO?foo){G;*fFHRq?7P8XcW&roPwYVS*_ zJVSp<)N^1wNVY-3C4JdHxDMBUR_>3l?b6H7yp9p=#mWHC++!R}G{E0{YaV-fv!iL? z0OXI#e)sXm@hV*9)fm`2gu`K3?rxrJ&C~)>4wX-*_45xu1cY!}`|0B!|CqQ4mvVde z{H~Uwu9d9*3A8Z5PaxH;%Y1~TsVNo=fON7U`GGJ zCCsIsn=8%Q;(9UVX8W2V;591X@jAWKo--@C2%pDzCUg%o-&)s&wym|^7n0HRPnn^n zY!$PgsP`TN1_>epgk&j+sn!Akhr?l6mLcz+{`f-d?j+*whr?lEZrx&C^OdS$991;mzK&>~Vxf7!G7fTnkU!cgj!hz^It&CM}e#_};r z8$>sAGcL@G-7N!39@Q4E?yAbfh!i<+F>UpTh&k+H4q9u?S@QXti-`6X`!F&>CORxj z>)NfmJMD{VcF8i}RGn(LqgyQuF59`O^&%|9d3rHXdfP!nk+LjHH|yp(G?EC1Z5|>a zYIr-JPQ7gq4`{UBgWDTVo-M=`c@Y7JwskZhBuQuNu8E-mVsbTssA;s6AQoO=8ARAy zDLeu_L>&;_%pmd_cGXkcPV08s*3-JKy><84yXrGbVgi`b2tZ1a-Gza>Xy_wSgS0Dw zGHdpj89rPB_YC_Z^zS=m<{OCn0YD6suDetUUp)XGZ?X#_^xBc|`U!DKBQLz(z5p(@ z!hBB<*FB+QUd}iP2}Unio&jUF$)2qG3>*nMAt80OuAUQfYppls2=xe?ZL3jR)b!!u z#Fgc)*5%+tci;Wa7hgWS`Np%huD8#hp@o|&U?Ct*iX0*=sd#cH;y@IU^f+-3A|aFj zb_>LDs0srUT5qTg7(1p<69TY06R;wSv-+qO0@#Dy$tIQ<2!TQ9M8Htd0VyjE41#bs z0}Y_$4i%+HwK3EcAed-bc&StYQCjbv5SCg&iX#N9T%0Nb0y08KdYle;{`$s`l{Fxs z@t|xCql@B`_I`4L3j<7-{^5zq&t~A%1w|IqlNFfj<Ly$vC*1 zgBqk~e~NQ@R~3|Src~U4yQw*sN)gPwu4|&_5Rk3B!;BYDN(uA(`}@pcGeKr&cRwsk z>e(}2J{*rytgGf*P6iioBsmBK2(5RK;^6_%&8~BbOW~!+`N6|O zdsnr*(4~~~`FvrL2LucN;AT>RnXp^#Q%2Ghx_PK-IUb299IsFyAmU)lGa#czsJ6DH zEGjwkZQJVMAj~3z0?w3EeKcw0I?-w;+t@q6L1JRiQXWoPaSEFe- zrN}!k4M}C@2FcWhOXYj|N|7*{HuP+5A zhW}c=KXexWIBS*bpTf9&I`RJ)Az;i&_Yu$CG2Jw1dM*XP^_YpBQXU7!fQ8*mRae(q zEg9Paw*OYPRye%emQ)=`2!Q*=5Wd0O&wL4(^`aNDkpd5pHhS`>~%*=;m;E63$*6SV<@xtOux-L&aek zzo*i}UjimdvuE*_@c?6WU8Vo{dJ*ubC*U$AW}E>-CW3R>y;cf5US?zQe`Q_5)!N%3 zpcpw1(sUd`w=ndrbwuP+MMBL3z{~|X=~Dpka5Do87D;^`A|ZtgS?2&@5s8JeMA%jJ z8Ep%TFmGL3YicS*0+3}4>PAF&W8#4D7``5=x-3f;E;`LAzq%n?YsZ_TFy0(%SPR{j znK3X5Q4gS`iQ_Fw24zq|%%fuBr>9T*taaz%-T$xAf#mp}tYTu3`)#a-z0w z05G5Kz;r&J5wX;Yyt=veUJnNm5oT9IKt?hgRY*D~;zeIH9O!brGF4&G(%j5dsg%6I zh$M{KyLOeXODUz4q8jQYn4q@a8ZiTBE>nq^#XWl0u)b}5JAYQ0p5ENu9c~tuLR_UV zj4><#PGsbPeum`2SfD&{t`Ie0edxn>p5_y}bWOUb_n_6cLct4-P@ebH^lpGXy5kK5-!&B;Otp zh7;1j4j7q2b?pJbWZv5P!TVo+_C?z?07@y(zxTbnH{Xc!`TY48MdZUj`Qsn|@co~D z`2Nk!A;N{h9WaoEMT8K+39}5r7#J_m$dhBpz>3t49M-d60SF61DNub_k7%F)Gmrxs z5Cd3*I+%k|aLm5vh~YvKkW<}Lh}gh55D*xVfIQg4I1qsxvR804x7(1~RN+J4j@A~$ zsG@ZNv(=pI0cAlU4#x;H2S5-YiI_;+#A$N%?I$+QuG+bVce&?P6TsJCL4;`g5t3&B zz|MwTj6n9a^11|Ke)aKjurH~3Iy^cJBG-jSU?5E`P-{J>eIc`&4^ABh{!2tMc(?(r zY}*!*vqs16P4#-$CM31i`4!JWzIlc*QSZ&nO0BoOXP-tIi6El8XZt`vU^pSBMxG~~ zkSW{#zSAI(7<*sCju?8V(z}{!T18|}nCAonRi-hX9%jqeOxu}>R5dXTVOG=JPgyE? z_&}DJ5h=Z~Bf>qnkcfCKd0T3&4-XFn;BIDiBT`E7FjcJp!d#1}2cRW%9^v3bg7c0? z_5f!9QVll)b`~i`&aL-ozAPCD5Ft(2-My_F0aRe%MKuDDsVJ!S4gexT%m4$t%`}u) z%-h5HtX;QtJJw~9!pPu(h$7{1I2>3!z$~5GGEGNBOmF33_%cagN)wm}Rm*+|57!7~ zG6c_N0d{mGOSi?jKrfgi4we>R%2I&F$Z5FgP1kE(PJ%5T=RB>I`*0RTtx2=mvyT5%+%8zDVtkRJW|p(P@isYZiK?HFc)_+@SqI? znwf7+qc_Dx<=~|Tvs0kE=fQz;O#tuWnoE4WXgVLs{Ao6Y;k6gg)p&rtU0|YvnH7;e;1=eHXuWmqz4uZo&MYY))U=L05)gqz z)-vppS=!=55H}B1yQ!53xd=8;>$zJ>DZTY{AR@wA%4x3t)@_lTAX(j4+dUF+_(+}C zvzf6J5yq~0#WE16hXo88fY2m9Sl z4ItbAB$tVenwqC%R^8218A(`%SV*-<2>^9lgqK5w*4?ykz(_gXz~&26ceD@zM8Z5P z&;waSn9vwoI5I2vW?kL9C)**ncX##ft{iG%GV`3j#$&`ThP%1v5@kwdU-C&1k!~#H z;TGnE5eVTJK@l7d&MAAE9dIL_=P<2d?=X;`*zq5Vk^aL>W9CK}p(7B07?AVD457tH z$OhEMd+6u=AtEGvv4EaG^#7&o&$=balJii^roG)G;+&I1Rb`Dm0X<+M0D=Tai5mWa zqz5hiRNV(rT33r&SEBAErA1v4V1op@tEy|v%Ha$V?!LE~>B08)@W{-jq^Ps9;>3x; z!`;Tu%szwFly7fuyM#AVfsI$_0oS_<#oMyn#rsDn)RdwFziaDf6n=X!+T26T)`a5- zT6aMQps4;y4vB4c3V-BSGvk1$`$q)e*(G|gfNEyJKm?Nx0G$1!7U`7P0 zU=gJ#IU<2sRjWm7QK%+rS}Y}DWJe-5M1QLYyqaIch?#?96gLy*t7S*R^O8txZh(+{ zyB=VzPQ|5%Zuh-?N{bKPA&whadWU?)3#mmLArhPWxRweqUfL#pce_y}m9o^T+Q!wuq49i$lM@jujXAE?nv%HXd6{Y{ zl5ccee_mU)bqM3-4QX&*5A|Y~! zWm-nWpw{#ye5{!v8V5rJ0XWwR03Zw^Rejb{N?|hAI$3!#TtB(F4z`BC@!Z|iyhp00 zLC@OqDF847G6IjuT#Pvbu;|ZsFrzl*W(d@X;V}FL(8l_6pG%;%>$+h*7$@8S3^6u! zHQRd@{wT8HrpF)8ww>tG2eca^5;(Wh)xOSJ+F4cpEmQt`e84uqe^WDispRbhV)LQf z4>)K34zXksVptE_d1*O#~= z)LLqNIKFVl!`01C{@|CNeEKo-e+VM3&3c7sA&k$$B!Zyfr?e_X+NSORDW_o=-HvrB zt|`)9Az(tYT5AC!MKx&Qrh+N3LahIUu&&(!#7}Iy3$O1{{|!kAPpMwP4UH6oe+xK} zNetjo-CDB&0wB7WtAQ1CtO_t!7a_@#5{sZK2y~AcnQ9k>OSBE3+kNjD0Q7dCz9h1f zL&Tm)JP!x7S6RnJVNC^^DZeNKtRMX@S!@T0?@Cd<<(}>>3xO4Q^tQ(9aGXwue07?p zT|z_yBqC`m`P|$s55w`{F18@ns>~vN{ckT0`)(L>&htF0)^pkdkeGAMY94MN5``9j zAyunpO2`WA$W;k3YjI&0Q`8#tAaU5LmY7*mU}mM(oOd~AW;WA#o{cP?S|E?7QY%s-d)f4ck`m-&f`ejJ(_C~ zq6!U6tY)ZdY~E}`gYJPkt_N;fO0DJyPVR!>gf8r6#q3lUzrVY_y844hkIe0KnoiS% z0AtRzF(N^b>^I=E?IHt2r&yfrZM$TQAz=`Gfg6~5C5ph`+Dv6+)fl7{hydu07p6a` zkT-qP@P=(&6|?2%Yxs{V02}yEkHeUGbpsTwpBv178*i=Q!usH9@qbT%f2oZf({FV| zxX{Y&o8VTldlIm||E4q$tR5J%&K)*}c!ySdTi>SnMj8(X++QER{b8ERJXI!2Nly3o zr{nW^E{~o(-aUEz-bWw(?zg}B{=091=<4e5>WAlbnP0ztmGiZk3N}FA%!oKiLXhMA z?KmU^R8@BZGcb!?nj(~iuJ+efkIOvOqD%ycOeAfs#sVbnC|Ft_KB5NsbGzaMgt6)i zdJ~WX05G_yGZ-^EL*r910O&NZ-Y8!UD7Mt}p61r7gPjLA5t1WTgSr^k%FMFBFi=XM z;&OTYx_P}ofpG2STp=AIBwHr{E`|ee%?6@D`JAm{b8HybwIzCM3JARn&|-+K;GZSr z4Jm|PGCK>Ow_YH^UKe&pRlB~qSx6-%j|o**RV`55BeT;WJ8O1I72IWAi-;Ip2<}T_ z2|HU#sj9JE0|10L5jPrm0BNX?;?=5|Rn<~CNvw&dRrjEk7Fd$hQkHpU3)W04T7rxqsshbtAhC#+ zSYTyl5MxTj&8dh8cS`hSS@!$Ah_ED8HPt)}DS1xmG|z#y6QP+h$cjI>nHeB>Vsb@Q zTh!ECge9fAEYXHZgyu$+M8w?O)hwXT1_MN0E?T%a`fypAG&P_N9iY@oETl$=lthrK z)&i#U^u>4Ie)QMVK$Mq%a8LxrY$7Se z+)62kcy)cffA52r-~aH%KYort&pv#1_u}Ql{oV2Y{&01rYV&mZ`s>es`}uFb`R3ct zKHUR43ogf5cxaazT6&~aMF^lWQ_h4WED4DdQxe3(by@DG)5}tn1d&|?g@6f)7GP7f zf`xl_6A`(!j6=5oA%GwVAiFa-wbn!Q!1}$_)zBSnZ4E+*-Xx2q1(BP^*~MlC&p@N0 zX$*I=yK8lkm~`v)m$SiLEi#^AoO7E$oee$qvM}PtA8Zx|bh+(sg&pPE$ks*+-YI&Q zIRQ40;m!Y_4<0UoAnoJ&W_COtm$_VBUGH`~W{v@P2M9yoijB*HBpsnvK&fXvbS37+_%sD5WAI z69;2NY<~oB=GZ;$T6Jlw+f!s7y;F@sH3wOJ^~TP_lYW1 z%y5N;F|!x}Lqq@z76$~7oTCHJ-9`RH3F- zb$)nwxV;^B`=9;tS9u)kQa<|V!~gaF?tlB|fB7e>ORdW=q;S^}LIp0sA^@?r51A=t z2?fqS=g$J?`Jp;t5F9T3= zMXzq^hTw!oV1!QS96#=CMrIhvx`9V&N?j{d1+7pE7L74=cQa5&HB!Y|V3~bBnQYzZ z_EL2SY;E3$RSs9gpU;6mM6X}36*uq)JH>7!5NquJ#xU>-1i}9gw+nP}6D)O#?EnFK zBcr1`lu}Qp`FNV1Ts^yD6oCQ20L+1j%}N5}o&;)q7J^8c0G3ipE#Zpd zySlqb$_}Mg05H>~R`<-z`~Bf`ItHg{+bIt&Nq7q6KN>NijkGkVA(*IZKrTcS8GtG{ zhV)@%JOCgfR*M@hOIemB<&;ux<7cWYDNAAsK*kwSUD4GWQU6)Q;)5u*O{nFRB#Ej7 zn*|{wwySX7w4>2%(5^ip0{F7jV3uZ%r8>@pkqtyTV5;U8)mtZw-fluL_ZPQ!Pp@yD zJbBW&PJ3rTCVOROojvU~K-xd+IydR+*1 zY)=4vQ3S*~<+iN|0Q(jwu-1uYku$YT7f=b*&ohJ!&hDfYf+LqMuGNKt0(2k<8zE?b zO0XuL=N6#LPSCTHXbqvTCJ-%~fSw$**#AqD6oHxBE)hVIRBN5**<<7w=jhqy&KA zK9ZCvs903Nl+nkOmg%yv$}=YKgwvAkxoSm?+$OJSVtN zng|hE5HMNmFimmZR~t}`R=&af{l;`oU0T(p zsg1?Y1s4BqEqh1Q|IYZ#w+iX!sK345v3;iZ-I}&>ao*pd{j~e&fCfG4U*r8ow7o*p zt)oCymkQ>9JYGMNG#np}^EA!3cXvO$c=7!E$4?#~KKS6}GCzO*d`O9zUwr+|ba(gY z@#A#0pYCpd{OKn`_|@;eyt{i%jM3uU;82#7vWP@8K-IFUMO?w~L^+Q+jcrl41DK~^ zd?s`)sD=x00a;KAa0RyBzKTYTRne5Hc_cvE7GJr>lMPJ4ZPNwRqG@kg=S30tZ2gfC3C@xJ#RLGg$pA*w z&I!>c6XSi_dlRASoAT6A{EKOT%Q&O`79gTWw6z%q-n>`O&l@HTM53x@Fi)pxpk?6H zrjt0nmpOzUNy?}D+fqtdmKB#kL<6t2<~$ImVxYj)IJT@?cmcd+&#u*)BzEvdCAj~Mj~Mh zJWYg%a?VAymKtV0lCl+ofWGjLf)&$!u3EVwBC)7jt(6f{5=X4H=A2k4#utQ12?fE6 zM>xWSsupxc%*kpsH5bPK;t>r%)tDU{d4#!vn*-q8G`)E7!;>eE_WONLAb{u`6(J&` z6G7X)*TpMNuK+mEic330>$1Qv#fBxc! z=fjYg`NeO4`{Lz`ss+_fr-?v*{;OYp{PBlh|L(>8{oUh7d$np6L6Wc_Vk}p$b#Vs+ z3p#B?h)P)WgXcWvJl@^8RVM*(%Yui@*F^K8jNnGJGh%N&pMXTplzXCP?9;;;&8@Bitb)FvKkA3Ist1f^Y?baC6b$nJb^GM@@OoU0dGg zXV#bkTDdIR7Y%(WdOXe3JflOb2!`gZZB0DQ6ir)6)2!Ayj6)uBXnR#-!Nc{F@Fm2l zsv)SU5n-)$H;hRnGtcvMI?d6S89`wZmPi+BNokCE03xats+#qpv~0eRnuVbeNoCPe z3PQ66Whq)~ScMfgM?h=63g8Y=E!akFBCJQnTD8^~4lPw(0T7c&1-H4VsxTuFxORt% zDM$Dm3Km=aNt=FBS^5H$qw?6hkL{a`(uSkoh zR$RNyT1JZ8tba#)8tO$Ch$Cq^L2yOu6djJP>IU;^R(Lfk z;QPil{Mn!W zFaBqL{kMPryDy(VJlwti^dn~~wW?Cgi>sP&964fZeH3CAmerk!Q5#6p-DlMpesl-1T^xS!D5c0KRtHW+{a0LGg6 zCRPN7(VUM?Zh+BGj0QyR2F9%?!5rA!QM^mU8d~GFt0v^V`-2t`y~zpME5j{LX(MCN z589vXgn?2;KWNB$qlMaDVdz8OZ~K6aNUgcb=8x`<=;pxaW(F8BkeM67G?(M)^l&^q zz1ndSWa=?R;9!8@TB`$@BeNiQEk#QmIAzHQ=nlcf!wjm%BGGH9W<*@8x;ryx;#&1I zO{ddD`>|7svM?{IRtp9n9(ZhBk6Vm7xa0V2tDt1@xAI& zqB$yaBCj<@7E&5>UWR&_X03{V#A0TEu}Z7=eIdk0JDH$IUkwYfOROh$w;0O^W(yIi zRZpiAu}I1yEW&EW0JWOAt0_AoqB)qEtH*d$+oh$3;)1MXZl)2NBOK@X^{W?8pFS4Z zF@ZV6)`lJ#cgsc{;V=XTc0xb{H?rW5Tg{x~*_@lZnMMEsja_oRM%Z`2w3di9uIkV! zcB8t!A0ibn@r}{POD#RW z;U+cj#e*L1!CRyMaU`us-Bdi-BF-P6t<^V@+@N<|rs?!B*HamarQJauKRE!=)%A4$ z@chf)9UmS(`|*zt*Vl}6bG(iq>E(+TrRd@M(c?#_Pd@ta*|Ycl?mzxUgb>8l`f@i5 z`ye7_>T$8CRYt*poUi~m?Rebfq1XbBs*%E`G0QDZ2uhR?h@lEnW(vEX6Ap=nM5EBa zoP`sUaKkpu*J6YbKoXB>6H`+))5cjC)FAGJL|}x(XaNct5k&KW6F7OGaE|7U?G)VH z3FYjC+f_PW$ukgy7wZ9#&ZCGSbNr@1xL6{Fy&!xBg8Cb7gyEtNjBm-tJ7lZ>x&?w9 zG2Ej$ngXE%ArUP_mr|EfC0#RP`1Y zaYQ5xb1dHmiL^j#)kdN$(F>Gw23V>YBCv2m&}2|bWWDBIRYjPI0c+r-Od`liDW^#uz%!`zHs?)sRUzr-l zE*+xdaazi2e|4uIPoI6X|Jfhx0bcy(w^xU&Pd@$Z>Cb*Pq_n(xH4MXkxBKGr&zDku z^x2QEudmBgPREB~NNRN{^NvLtxOv2>uoMg;-e|mVG zlmXGIisWICA(6QSs~90>#w;`ljY3%{iD!YFa1a_2XC`4}M#89i#EC%ykmS|D%(c21 zm|>J2m$CpbOD2r4t!f3R2(gh~00bLs1`g;zj_&U280)f8-ncx7_=erZHTG{M(@rDP zs=J#c0M~IaTz$YMlsrUhBtJr+i~R*Ut54gk*JVZ=q))S}Xdh-O}E$w`704ctp9qZAO2Iubayx zaZ1w0&`L=;V@jfRUW!%15mS;O=hL*rIIV-5Ta3DG5WY4mAmnp}_}0TX4msVvUR0}U z4QD`{RaUU2&hGi@hp#Q+Z*`PqAvgkH@nm=L|jP#0oF7a zyH>=>!4>P@R(+XSP=rDrBG23ye?J5C&2YB+*aH8?%kZY|pC9aC(Z%tepWUC(Tch48 z)%hDQ?%z1XYV*X)jDAIcw>sZJqeybe!L{nLs5zJu3L%5(=@jgU`1J8pyt_9uSxWF= z*W1@JWV5o&)8j{vcDpf;133Qh!;4q1?!h%BUgp#7>sKGVf0ffvmjyeMSglnTWlum_ z_~d|KWTxoq#z2V3%y}FSukWZX%0yNzW!h!F-jCBvjvj5JVfqK5ESP$WKo&^x!ypXI zC~X*`DINd_!V)}&fQVv>%VN{yV-}>$oT`E&nn6`lbudI`Py$5P*t`?9^^U2LgOdZu z8h&@{e@0d=0Qf)$zakKCLoVI=`vvH~?u0qF15vbi_pZ=h)Y`b1^<&n|^i7fJq7a6D zO-K6E&Ld;k%suvcfVZ6&9Zx3&H3uSffNFEmt0B7qCXN%$Od3P{NRB*DOVw%~K`4U* zA%W{OPXHv6h-esw)5Akxg-q=3EW(Hb%cON)7B$00{fSLI%}i?os2-q=t19srH4y+$ z1lXTg%`!>!^qJODD(nyuMWigHltpW_D>PL!^LZ&_PO-6IbpX-G)Sl_CwbojQC9#;8 z*Tsv00}$0!rVoq^c_e^L=8%*wG^$@bILi*%iPuvFb!EG81P*Iz|EpJ ztq=K}1K})KF-`NSYR!;D5+L>EkvSGy60-=YMxRC{ZletCvI7*eq`Iz_G&MKF=qzHf zsu5rYMJx4TM3D4w)GuGW@cq^MSC8i7F-3wJ!E%FX0^g4mxMTd(J;J7wvTIAL5?e3A z1Asx$xt~#>)dcsDyqDKDhUwrk&Z$F>s z^3lg1{@I`X$>+cM=6HHomg#UcVyO!~1ce@ZDsv&u1frk-imvFaW=yJ$mrr)%)i_+K zA6?z56JTb!J}{Cli)SIBJTN4tL?nca(TAxBOyqw1aN02 z0w!`}VirlI4HtN+VA^CGQQM3FW<>TjL8Va{090r-fF_G)N4sK#>vhI05Yz}E5YMGx zj5Pr5@j!e4a5ED?6kJCbI2u~#1-g_c+(^b*yxvLx+&7PP@CSUgD~)gpF;;{b6RN5z z+}$0o2B->-q^4S}g>u9xk-G_#1FPCHFZ;bA#%4{184#h?T5DrQ%Q;n5CUP@M;@)V2 zGVx@(l)5Ynr&C-bMsl}=COHQd)jVUm-G^Wf*;9#mOl0?QNCkX!Oj(#-5+KokYT9T6~04&Rrl2A%X zB&L%AonqN?w;<85l={4DsYqxLE>l(7NEZYS2n0+bY|7Og+>9|Xl5mhW0I*Es;hX#W z;qLC~2hWI@%n_-2h2HxBowlLLRLH#ritoBXSDFB?i7yetI$!3|6tNn_cPuzYVlrJlKu+8&7!SBhOF1 z1cKH_d~-nV-WtaH5wxtO-47I_mCaL1ZZR^W=D19YRn3wgJ$`cd#V_@Ey#3K zG{1iJ>i+of=<(zIeka1J4nUmJ)%By{6F=74y`hSR4u~Wn3l~{JWT`3EIbIgFXp;QPW(yCg6`2i6C2q2OG_ZZqXL}X!i zj}j{&1}hu@)KcABOK#YkqINo+Vq1ORsMXrAwbmpg!T@cm7Fr(;ci5o$-E&Pd3`85L zY7QXQ+^Z@A>m~Z_7X~Q6F4K3%UvbZG-oo8=TwqH#}{)Cd?b{THg(~Z?g9Budm~?189HiBmUvE zZM|)=myRQ!D-Yh>S>vMaroTD(v#)wv0WV?Mi?)r+=VMpK@b?#b+iJfZ-dK;#8%!NP z)O5FV{^jBv-*5TMNSe44D<;c(bpU)v8a?!Nr$^@~^2G#`)0>#OU(`lo;O zSAX?aPo6xH)EfMb_xBH9ez_aQ_don(81~19)8i*Mzxd;yotC@re)#&;s~365k~n6Q zfl!hFf*T4Z78W5EV(yEvkePEz!}ay!-FOWyrdW%i!l@SEnrWc2{Q7}f? z)GS)V4KZ|sCFNmE!^oVOvdC_bF(pcZIR=gdlemZq7s8o&5}tD^`wv%=8Mw72mJx7IE0{i0q1bZ5`KHP&lGTB{xQW>8-3{&_M03E%=OLcC}hTY;d> z`$J1WHhSWh|MT^i74bjI-uZyf&q4>RYB2>?tCeb{Iv_Rc=X)XGdqBpms%nNhyzenM4-lGfQXqMBlwK)mITI*7+s-?t;ZdSovm~u`;|2RpH*i2e>{Dzl3|gnU8u>Y_0Y9u3{K9^pQTvq}_AJ}mzD`1FOI^0w z(pV?@RqkT|{n-fX*n23qfp1WzW^*yXUjue$00!jV%z5$5tq-_#ByXE|zxb2C`2Hia3H>#pv{fC^#|)SVvX^@y}FlTwV2k}s>VbjeDnB89>@9a?uYNb+wb==0T(-$ zMC1>D@r!5AJ|NVa51;+`PyclH?86xu(y%{V9j*`Ke*DpApT76>@jT7nefvEjj=K>6 z)J)At7%&j?A+ZnOU#GHh)U}6`lgbU$`X-cvT zxsF2}vyIvIBkl*<4NhPY+_ilvpaxa}34|GH|v6d zh|qj%^>H03z%w%hUQ$8e?uO7>VUMEQ^8VprHx4u)5ecFMU0NyT28iQs zcRZeiDf%f*D{(e67KrXfbMQ9m?WxCu5!RuknC>*!QtN(4%$!mxrLKiXVGb6{l#{rZ zhld27$3d8jRx@Lfa1kjdE~U2Rl7%IQRWS&5GBER?N${3!V+WPn6$uMeYBf^;ivq*E zEXz{n!YK%xt7<7s+*SnF1!LsV*A~CC&2%?JVDvDPs#?IA5D*<(s=+x$ONNL?8mocUfP&FW4!t|pvcmnHV zXXlh{bA`6Qh0J#FC+>HIrPDRBBx(IRGFNFk&PD0Ra&a3!AkyEJPG7q@`+A zAQlz@aMfz6-iTlU07N7Z`8ccQ7WAJ)XsWdq=(L*6f5Vz2wz$oq?=kNvL41)0I;aKJ zOz>kroThO6K^E=q<2dYhV@lH6E@>0pUWyijqg!A^s^$P;+yNk*C=o?ZeVk!aGgT%^ zOruaHM@9fxsxHwf)Gdkiwe_nxgAJbJ4#dJNW_o{r=N?6MBtrDoy@$OS3L9y=g>yt| zs9k&^-t1Y#%UgS71M9SBu3o51?p8n6+Hb$ZeYbnlC)liz>(>hnNFCMAWQZ5Bd`DA# zqwnts+uv5ahTC|IB)tu|Zc_)pM55mC78udm^64M_S$^{VWcK`r9~|IEzxw5${l!0}vV8Z=H}|)<-+%kfS6_bq z{nuZ=`r(IK%kcQoU;WF!`IA5S<*VD<*RNl?m0?IgJ}>iJ=717G$!cu)j+hOQ`qwu^ zNIC6yyBl!U>Q=qXi<=$xgv_udd_2!REgcZ z);e2VS{G16XSk;5m+rcS>TppI+ZgxD`z!EmjJ-~PO{oypu8MOD&+q97>e0>i z*KG_DGId5eTdEPuH2V}RkNhi>E0cf z1&Hh|a{vH@kQfY-)<*fs%mk@pq)JG&)N$8fC{>*w9;lWPk%XP~*fMMgoY$A zn7Ys6`WpE*d&nfF_5R^rRiiW!jX)u;U~Pdf0d|t!)+XB;+n^2l#wQ}t+Q{94pDw13 z)On_RS=Av5fSU>c5>zc=PgD zi5o5pn>ju|ts8Fa9Z+wo>-jL=kw9So6VckfxBjKYBjDandUx;UL34vzOSM7*a=okj z^v!oa3|EhLj~?%@Z=Sz=@#4j+a=Lr={*x^E`O9y<{`&hVU!5L5xqtnd%x|CnaJ+x` zqhI~evp@QiAOGwpfBjc~_J8_6{_EE-f4Di^++4pupLs_IfLsjGl65FjHZi4j6)^enW?V;V=I z*jgIREjUl;LXyNOnjRGih}-25juJYfGX(e!#a-PL+=&uHQZQ3fgP;sDsIm&d+EBld z9BkU(FGY{s+l&lSW2M`=0o<5=Bn)1F+1gPFN zmO6t+{Mg#@AQ1K#JF>PTj8aO$8hx7TX~1z9QWl+-WtptjoYF94cx_q=g>7qApqZ0s zRCjkFFcJi)%`;K#2#Xr3Gh)n9RT3YOi)W7UWBvLa4_r}eYcru4`Wwhj+%5_FY0vd zD|{D4sMU0aagY8(-r)W#3n(LSXO$*^b&W_~nQ$ayOGyy6Q0A*vNj_wAp#n~~- z?&jdHU%y&r9Wr_~2Dmxw(B|8_`^9(FfDlEYDiIqxGGG!JGM&ntIqfn#ntDx*&L5bF zNlGO&Ko@5Z{%j# zylD`EfmOBBG@ApG5R;lake&UTH!KJ|r{uv2g6Ie-rC}I_AFMG&lbP@L19R+JsYGN! zmMNT4B6k6Fw`pE#t+X4*aeR0yL z-F^}nb`HC$2JS=7l6UhFmf44^9aL09Vv;1WyCtkS6D8qXVNrAdH7m6e357<)W?6!D zIy%4%Tz3FE5J}98pN=O4jPHv80Xk9JzuiqgLfcv57`q(_psxVNvkhH4+-coS}EtPUYH$o`K2fckw zijN=P5Xk3We}{5gYjv~TxPSWOy{p4jlDr#shatWH(Z@HBo;<#~9+-dl?)&BRFdgr| z`G^0)#QU2^|MV~a?A7bL&%gZY(@%bU7$5DGmIpO6V`UTqi3LGsTA{|AW(=_siwLRV zknn1J^y>BptqumYltIMzyW3aKAC~(H0}E#mb%Ww=#z5nMPA=%NWvWz6q>Qs54U&h1 z%m#=_*pPyi!YLC{bwULIMN~o~sL+fkAP}GjfiO6jF78Z3Nirasfhv+;UfT`;Z!ce$ zjTO4@fzt{EHKM}a?~iZpiEPio?j(Aq+tSIicj=}a-;jfi3^YHn{dGM?Ki}=&#y4Oj zB62`L4rm5N&5>HP>)>WlwT?NaqR?iJ5L1%CPMAn*G1XWcA8>q)E+8heXidoy?UoMn z+~{(~-Hy1;azwV+k&$-lkGR;6MsG?Rj zWivD*Xi^#roe%)DF1NS7oUX60p8f16`~41GkN0<`DoLI_`!Ee7GK8p{_(GV@!4k|fA{^%A6|S9Q1^!c(HtrZOJZ=yoD!!P zz6SDW#VjY%+;r2Q}L<5soJbCS6GaS z<5cY7R8Dg(3WjWsOSPrcliEXp*9+binS57aUo_7J=Axx)End~DVO2CkH+En|0Tc&z zVDJjQpfBhrh9$9Ov9=PjLAb##P;Ujo*@)pIvNe}l%CumfG*G=&&@^h|u zoyR@a&$^I?-*dZ&Db7N8jxL)IFh}ylB$AMrMUa`3a3W60ma4Y)!#cW|DKi%}b*{DM zLDm7xc`2n7Nm*5wWkEz@s;aeCVKH+eCf1Y^m$@P=rOZoFwcR)}w=I3uEI39O!0VLI z3Yh9*AQ-H0L`-NFb>L>-MH1p%N?A%tiGvlclrm2f3^{B^^k~ji@5>^>X~-h7EK9VW zA)=}>HgdXBwUm+v$T>M^SteBt$J}{X0D^)lV&;wSjWi;tEm(NSc`2n3&DExPGP5L- zMdng!DI{_@?9}!3{li)wTp0neZ2?zMRhR;9Py{E^T4T+yyHC?Jr0n3~k^za3R7+Kj zI{ZG{i@AjGsH!3wqqp3})htE7Q|}~!vr}^bBoanlmIV-NEzCs5UMnD2#QnH2zUO#% zgQj2l0PpT4{?O70=Ky>zh~a>!uak`6DVB>dk(}E`v4i6XXLG#Ch_UbYcqsJbNB|CZg?4zIkv@XW#FJ8VPfUB#khlk_0Uw;0>ci(*W(X%1RZnyJ1%%z|^ zgJ7+8Rv1)b4Y zD;iQwoPh^GJQ^^##Ec^Wq5>GA5*d-PL6E;OArtZjv2$}oBUD0it!f2;kvP~uSL=`O zykKwigCb%_jJKQOTVFD+gy~9n zvdcLKMl>&r2&dF&N6kzDYpu1asfh>@LiMNv2MZ8O8gef6Se99}A`Ze#%mAgG*_M?N zpgF|IW1B(jT8bhPC=ziJNh#;Kl%je(old7?%HuHPX({tG<#CsjjN^EF{}3ANfDUXR zf<(~PUL%<}76*#a2RAcCgd{1aRLa7{iCN9bjEOL%5JLbEkv&>E>SE+-8dJfF!lA2Z zyes=$iaKIrPFp2;)u$CIiZcFI&)(3X^H+eg!Cl?L zg`c?e3hr;S!ksnhU1b12gH#Ae02KT*z9Eb8^H=SoYHi+aQ8$amc5+bF)BWAUak{%Z zJ$ilrDyzuDYC^NVx#`*Mc z_4w&O`6s{nn}7Jv|Nh_okH7osH=lm=+2hC82}Yk?mqke2JTf?877`R}dy^W054qzw z?mztCQ?2EAdTE+fb(yFAZv5ae|K>G1U{P07VPvA}IF+)LD#$tvDM7*GVz6_`K9~i* zpIT|gz`@lOzz~ec5sk=+jL?aI5tuOv0um5__jO2sV+J#er7{2p?&9YHK$E5R&s$Es zxom9xLQ%fMGtdu&*gOw){Kqav!!}RuH*y3-?ctz1AzJSOTI*D+kZ#1f$<6w!clY6E z`vm~rhG>X#O5<*qaz0J-K<;dZysKsipk_b`TP+I!oJEEq$3pfv=GYBMF{)=qgsM7@ zu?i}wDiT@M7z+>)FO`Nwh^~&#b0L>Qg<@D^lRI@ypp_*^b=~N@Wpy-FNHN7Fs#;1h z8^+y`Qd*X!)Z#u8QIw;XWeLK}cx_54r|b?zD*$lJxH`llfm&*Qe1=I;A}1VMwQ`)LP0ij^ovsUJ;z;Vwj8wQ4rVx zgKdHc2_(BuvA)LL8N#6ksuor2A*GN#qS)kK&9sz4h|v#PDn_UqrC>92;C33V6<1m? zZn5!xH88X+GXl)>{AjYB)lJ(@rI?vCKQ5=c`|0@)FYz~@ zUp;!CheNt~eD(Oz)hC}`efG&N=P&=uub1he%%}hG@Be+R`snJwh$6g9<#0G4({WyQ zV}1nw$G`gdH{X2sxBu}UzWV0#Pe1(0Fi5FI-GI>5O&tgT86%-)V2(VJh`1In?EBsI zdrv;Nz5QW2%{kL@nrXz3p6)-m<@>1(Jeox-BRNVnkX@dv)>;i6i5UQxfEcKvsFN^( z$7zc?J|dYDBDs@=*H$JF$ADUcp$EIvnN0Bbyrz1<$6 zh5u*SwY4VPrh<9h8{AMJL_)M^vTpt$YSpV6xNlT!Rh{2+>*cR-OL3g5hBOf}Ga$)% zxVpN!y1EugOQ~}qG}FeoO>Q+Vo?Eo%BZ23f>v9UhHD<~=rJRWg4b7A#YgLF@q@0%1 z6xX9v6``~n({6yOBJ9#&Lk@1m&1V7J_}2X05u-wXg%&(^F7003$&Nkl`WT_*zYC{5$} z2}nJeJ_r897m*Tf?DnG#?+P zlfM3kfB529?4wVA^ou|Jv%{0e^NSy*s3)^dM* zc>L(`VLyP-&;H=Y|K%Tl`;Y(lx3 ztNM`BlPB+g{q_EMyk}x?xVyW%x*k9I;O6;ly?y!IyfS30194`_MQd4<#SyTg6>wBl zs!G*GK{bmDfys6wOPG3BsOCt0x0O4XTBQcCGLa-=~=Cvw&VR zTE%Ou04^f;z{ znRPM+01#77c^Fg9$WaF-BwngkH6V5*a|far26I!>%0x@GqE>5(>EGDFi-5H%KpR<# zh8zQbm`-yor!p%74=J}%a-HXSnkMw1gr#te!H2U3bYYd6wL-L5>)ew7?acmx0!0I7 zqzlm7-l35cVI;^3ZM$jsgY9)e1XCl#pb^$ugQ`e`gLEiPaUTZW2m)fvB(HK3rmEJW zwGP9Ok`Q49K32G&wK@!A&cdzj72vSj2@^mtFSTp$y%gzyu@@=6HZ`kV-94uiOhKhq zcaJ&do^(g>=R~y6vYns>z$nj{S&*{55yozfrURm+nDP@57RG=lp+%_Ft$(YhuGmW# z#B*SO)(r^Go(nmC)2^Q*sC80|dei3`6`z&z91*VwlCOW={weCZYNjh!1cb+e^DNj# z>$g~Iwryu_8XFvM%(+^J5?RU?=emLC%DX{^lkmn>!g3uE8 zepSO>5)%hLa&>j{RbJofln-m7+zV8Y-Y68pP6hR$xVTOa@Yo<^reLPP0z4F2$>|Ixi|MU#hKp zLONgqTgYy`mT#-dYMm)oBex?p8CIz{eswpE9i_lW)1JqsKA+ebgqpCIn9IS|3s?AGXmPJyQMmxan zuC48V6a%-@H0=(uZU-Z*vD$gH8br~Iq%r7gX6(p> zHJ~AD<$;(!t8L;-gA)P(s%j~kv&37eYIQ%IreVlIZH7q9Y-@OEjfp6!P0KRRbFGCb zrIb#`sh!HcD-!|C(bk&bx-vI>67yV2DO1%k2@!fRn9b)>#!?egB(k;Elx4RYcSEjo z)wb@ayJ~PWHw1N%b~Y@;##EK+ZafUbFimqQHA^xxBL_qg(aOEA6t2d6b>N5ld+)Si zu?5@QnaCS4_$Gc{Mc%>OBxMB5L$0cgszvLXa1cbDwIe660zo}`ZWW;LF^+xp8Ej1C zs!*%r?>@=HWnfpQExdl_`Uz5Lzc854BlokxUUT67@)f(oUI6HUUJtn6H4V@N0eaMr zE2h*w|Lyz$yL~r6pcnw{TaJ5Vzdk=U)t=q;2GWI1^nXDHw{Z#8pjLHr26D5d%yX%3 zFp3nzY0*d5SAYK3|MJ-hcM~k8>WEhlSFZMaN36e_y+ay%U9pb_lwA|Ou8HN$>YbLeEiYfU72TAW1?ItQn1TGML=*f zwQ6mL9+{C>fNIL>7=s`QB|uDSfp`Qe5M*4T;|PxErPif7Gv|boQO%Kn8JS7Wg&bcm zZOd)fL=Uz}VoP+}=vJ52GxAdj`>#Y4B-fk3pMtezvo3}_GE?jVf@hNVCNOv**Kii5 zCfN~)g$}guu!c*BFpgtNnUFllv5}F46T5k-wN?{CHBBN>CIc|7l@k- zW9=#vATdFOd0EDB*DAN6Y}WR9<(!F7t7_G0n#SE;gkvuR5e2g3Ziua)fQU?8RfEN| zh3$QIskv*B=<`{Yd14xOL(bXVYc0z%GoOk^5y{cnUTc-4 zW6n2Mhs7R_i$d(}Z407e+XbotcZ@!aPLOkqgC|LaVXyOfaKjK<7R|)Ed!7wW*+k3W&p_CvoHg6Z@6WflIaJy7JvcVW0(UF zx3;c**6IWhqc8N_2o&K@Lq*T_a1;2)iaTsDj(c=Wps#)U>t>`5eG+V9I&ae=d^Yc} zR%|zz|0c7Bwiy-?@NC@Iqdnik_h83#17`}iKnaBa@}qD6{@3@I?slc1`*eJG{{1(*F+X|oC=tSZnqI%0rsF4{ee|!c{^!fAUwrZMyYGMa;O4#e zt{>}MqTdHFA%L*tlqHFoFUz9UlE8i(b|XJLyhQh1mYb{VS1-RkoyxeAX({+{8V`pL z-v1!W^y0;7Dbyw_9VKP61wavvz#AtvkeXLDT8c3!5F?Y3xv_A>*^J1H7zEGQ*Q61< z)lzFUuWn*kjmVtIm@x?ppqvE)dg&ipK6?%jy2M@B-`>qh=W(#`0Teq8&U+%ad4VHfgw=D=d7iJX zuA|Uhs}eLaeFSiXm`8U55+=ZDo+$}{gInNQ08mSnlv7GXi<&L9&T|=ujKsoHYtgF2 zJ`Te!m(%p%(LG1#U;yUW+zg@!o(tJnI~0M z)iKF147Dr)vRNl|j40S4puXmG`;M*_>?q(c4kFAV%d$j=eysF?s)R_61mI3Fn!mQI zw5X^{YBzAr2Q)0jSKzb-JJ|7}xH{1GtZt$I1_CyDG{d&)VC|AXYb+3cS&Ur+{EOPJ z0l2k&*yn8FTzEt8iq!|g-pmFrt$+J2-dVw5Rp7Plr>F0Jjx^6MgzyZFHDA=o9$J`> z4G{8n<<6jFgd`TU5VcgTl>ic9bpTUzyWa2r=;wb(;66|D={O9znrTW^%j>&46h2(v z5V6+!=)DmMR7)CnSjzLye|vv_dz_a2(+~dqU;WGZVfyud`uunQ_`C0(fBoT;XS>}1 z6_)u_4Mk)eg5}%^(2H4Bk|c~^E}RjS8S=2Zx_We)?x$%Pcf)j=@%}UnyC*kS$NQN8 zSuhC~g>g)3q*lN+#y-$7)~L8!_3G*d#3&({Y!u_-AWY6?t!Z-Y!UShxc2_lM{d^S@ zsfgw5$z6KY=Nxi(v+COk!_75s!WG3E>?Pc0$k%@V0DNfe3+ng}o+pP_8Mmertsk@} z)`;gG&^G~}jgDPB3Qd~N-U|Rqt=bK%yO&xYj;EW$zzi%2FgO*!4VXo(xFHKO;ciIt zRJE$s$}GYhry>T#Bo8nzG0C+o%Q*>92Zq3%N~!L)EK98wNK#7Ns2tJE+zbU*V;5m; ztnpH<#0?5x9l)9dEXxvuPOe_2EtgGD1<=th5yD?O+kP)#%C6l^E+9O0yh zEKA|YCLl_)vB^r+zyV`xwF88|Ng_iYmRf5msntoOmZesm=4CeziAh9CSyU^l=EVEm z@apb9z+?apTnQ1qO@bKrrS=U40xYEjb1E}0Wg(&@g91il4-4DLqA)Os4CA=WvzdY_ z#M%z?=4&0z6gNpmw~~m6(^B)eW9DHTru(~?3Uo7%OcoiC$W|rT)W_aXjdq^l%^S83 zx~6NFp>%8vx)_!0x$GG^2`XQUK8iO)zXwb`Iqt^<4o-d!?+k#qNy8f3Z?K(9En5#i zH16EW7Y}c8sZFla`#aCRXrr!(y=}ZhaLwfO@ZUSo58Gs_`N4*YqI=M1wYq>AgiA0- z)4SVOFP?u(dB_G#Gr-_K20}z)U_?YW(pbWw?!d*BD;0xOqwUtukZuRxpg;B| z_`V`S?fB044>dHZb$0-6_C#*)Z$<>x5^(?Dnp>PBqqnuFJ0NI^yB@x;vbfcZXo}Yc ziiI(qmStW_H7^S%EmeqfN@h?MO0{_@ z4w!NdgRZqO!G1UFcY|z*4Dj42fB;B+IZRt{PpHHm$F>3w^E}Ygm)9t@DW4Y?@`I&v$9K73qs=q> z2lTf)AI96?8n&iCyd89By0QMN^+8{UdK}!{+|;aARkhe{t)|sGRumH+MqrH22wF?9 zm59i&-ya@7*1P-P{q29cdGh$?{SSu6H$b8WwmTf&|6shjy1l=@Kb@{`Zk~Pg(MO+s zy59}I`TJkr-{1bppZ?J=fA#aZ)^A>Y^L_c|Hocnm_mm5Q5rPmgAh;)y!)|}IzjB9C zf**@aL3q5retPrhz0;}QKP;s}oprju_gb#Tyx&O@6I{k*iE3isCER5mGmn{v%qd|? zPF#@+;OuZRJ((A?VqQ$=Qm0a8txNQmn^|pJFA$KC6Ho#aM|R`_!(4f;bei!vyLbf5 z*gUXrF}@x{M{TvYKA($VeGUK&0UO&75jmbC{b*(IHY`T125=4`+LPT72+{jUaQpWR zLT%!y%bCQBR3I{duA%YOz`bf!)mlnT%p`~ubf`42t9nIaaAt!h;6b(reS*KllZk|7 z$a%;^DH=j4OlF$W&@%&fifFkRYrqCLlv2hWG)4(xCW>W>VW9i0ueitd_ST+DJr;(} z2@!Ujh|ILqx)rk7`ih5~ zYUb%ULoJX~MClZ>@T#h&ag9xTG039#ZJOt0p7S`4!*GB1U$RVA|8f3yO*Ub%aSB{tK(8i*|e5YM2d)q zQ>>*4OHlarP_&)xW_p`507sKiB+Jv?P#^9Fp;?1PDFXKv6XAJme)Ff*Om0lxMug9{ z7|(xdcg}z9ZCVf)#sHwzAOaujA@*v@*J@cDQ`2C?x0)sZYy;`IH_X()2{1hC_W2#v zZ}C`?g0s>&N>K9)I}q`OAO!hp%3~e)-jR@}uF$!!WwnWm$+QCka+)LWrOc z!Kc~XD*&4T%WxP{9@Dqqe>Y8vRQAZ(%XGZWl4vjUV$(DsC1OA{mJ|iq7I3%yj^?gr z1zdrm*n(cGGJ`X^5GMp7765VwB5;q;E$G`EfB@ZrwYnP`fcELmZM=Vu3w%Y~)@PiB z`yy1wO;_I7UZCUi0VGqx-2D93T93s zB7)#(il&0(3WSMeM3U&RBw}I#0M`Pswmt~hk<`4Xs#INJhZ;)(Eg4h9q2;*|Z1~3l*)!)BuQ?5LX7|-h0)qh=@o^4iMOyhhL0U z@6jJs3u$4IfTY{?jr=Ig1Vp!nBLE|iSMq{bjY=LlANHdNtfP*rV7}{yZ#{f|$!Z_B z;<}aC&2u2qsonz`c6<%c*HHSrs&m!Srl)8B)c-h-7 z9c@2!{q@Z+*$W%%Yx^l|P#{Jmv)zF||Kf}1&z~bfKA>u;rN$!h zBxx0ZoQB=7+m8pKgpio>Zg+LKdh+bU&sc`zX*r#$nV0Fobvo?jFhY_-c*?Rc%|de) z9S0u=90wdT4heGhoNP#zlMb>Z@x;nh5Ek?Wd;zcE>aGA5BdzXW9zDD0#L*qms@zi!s}Kp0T8%qWl$<537Dhcx?^{K_vsR z)`r#td>5GyY(Kggg7q6VFM00i-YmN$ax;9_E#h16*nU7ct5a#e#_}(7RgIzFcJzI7 zsr9)AkC_8-=*zB84-dcl{PR!W|M1bxA*h)5GnMWJkH47IWWS9me-QkjGEpczX5Kch}d~fBBbx@xu?V{xARL-+uf3cUL!8 zk3V|Cc>s6Qijs3mLCdIBg(RiSOzv83K~&+9P@X;e6iL4L^0%j{?nXA9=epcn?;-ou zp45Out5!fIOo?-(zkmRI2JiqJAh?NQ=-U8L$e5IXz`(#H4Ly^LHAVo)*!P12rIuYe z$Tq&;G1*fcf-y_*0gTb zB;OuI``_6`(FHNkx!M$Zvdjx2S*;B2gOKy>JsifAp`sG+a^`G=*4O7kj1U7d(^9pl z)l!CGOq~?8)T*jO8bml|vcd$b8Xavt@4?&v)Ff0hKt1b5rk6aI9it(oC&10t1 zQaKuIn5TIur3^_@Gy^S5^=c&6lO%{Tno<-pdWHz#CPZV(^Ssnjz-+(UJ)EXu?nK9_ z2+{REJ-)elKF?Z;rIg1!CfN<0c1PUw+2EHeWJOimlIt%U{p#WHk`6L=mDy1Ky_8 z`^h{{A$o(utN}nCVQn{C2+)~0-3(L}Ae!7M6Pn_(%(q|t?l2x6fB50}^*0ERhBRIs z()CpucH_4{JpAWh&+gR}g$nqqSFaTPaCMa(uoln?TIYGHRzLgEr~mwK{_2}=zWM5R zUw{6^7axB3;iKytpUF(kYpmsV11nV-ScHHaK_~$Ux-il8^^=r`m#=Q8`S@@wyKL@o ze0Z7iaCMNXI+s%x0d&>M2#5)hd(s2|6h8MCfP8Q+aZbXEXQmjR+r3kaI#3nKkL72ha%pV>eSds|mlyfR-W<}M^&BEL> zGb&=xGIwVDwpTeext~!WiFBcqGVX>zw8H_6Ik|ZzN}LsS26OWvAZC^zxCaM!6K2L} zjT=M+rXX|6S!77b^WfkHsIF{i1cVqQYq1EjQ6QkZ12YXnhC4UYWuC`zNGzwajAJUQ z57T_L9~`1*X|h@gJTZ+!p6k(7wH6}s26INTX8_QtLovQAI1nNUBS5fCRIMC+w?c?0 zBFj8eT{xG+AXxdn%mz$2sB=*mk-#wxgV(v#x~E}3?#!*4q4}pDK1*gqS?vsnAR=K$ z#Wx}UJIer`k;p83#iiUF&mmFU zXK)ciUATrfGnehH!NKkquX)qBZ>>MBs<`F~9j5aB)TW}|jJI!gMg*xSCC;QzG4T9&e0U)}ujm%sSufBnz?_3Quh-~R1yKK}Tl{gX#m z!;@uRz^ZE@5+ID#tpMOg4o-}W>`v&Ic-TGO|KbfUB3kOa?Dx1be25XH{w1vUdF=z3mK$*5To%U0r4nal;GU0}xodQ7>f@-@Mnw zzJ*S$ZU*KUAb^(&sz<9jPbc?bUM|gSS`Xd4(Z`U#bbXYBBHu2r3wRJ&`waG3GQfK0tY0bJPabI)-uadYpu%F zm{WpUma+`>;VKK%^zaaN&m5?l1@Z-sEQ|=#Jm;Lz{WMKp?Xzbe9yry-)&{L+PB4-H zZ1cYth2J-={{|OXjsC{8Z!wd%Y^fu}e0{6)cDuVekCn@FwaHyW&!|y5koNvD^AR6+ zp}(v7?3Vhh*zxZc5+f%f$M9OiFc*wq|7+5#_{h?@D2Ubt>X-abMIPiVae z&`(Sx#Dt`M1Obr&lp4suhzM4vyYF87-G47|`{=`uu0DFYXMXu``r`lnKaP){?#2Tk zmU#w**px&}B8-WbvK;Rw2ihO5udZ&?AhA4q_Q5~@>wos`x8HsF<@aCy?yINr{-eiF zB?%MNx-h%Qz<{b=OKp8K1VFwN%46b$!o!CjeBy?;ukUZ~ew)&qQkqT=*Ef4%n4#8E z#vutiKqU}DVOSY90off4BQ~<2|3r$$7XvkCBy%#yqBY2Yunmv5JeE*^0Bs4>P<)-< zZuWeGo}U{GR$IHK0$m`^66|YN=z438i<-r!)&K_EC#=5$_K|{3ZgEac-kAd6Sy0Y7 zueX6o=)DsLfaXYOhNT)f)&k^qe>?%bk3{CNK|6G;z31Aa8Ex!=xg%ogyfj1@o%^Mf z7~m3-d7ibc2WT-VGgY-1Tyir6Pm)4s`~5hM$Kz4eRW${A9%o#e`Z$myO3HZ{=6Uj}rj?K#ECuPla4Dsgf<=gl>e2`h zLI{uyK+V+*F(J4)L<&&mWypCL^1PI3niwgkoVd>FW@rSZY9>x86Op;rQaBG|N;$C> z)mkWMs}XK*|o@-E384uTeXI~aIVXy~#3YDwEC6SSV))s~*K1pGZ*FBd+b zYZ0uGU$0VhH9y<^^^vgjtz2;)F+P7>AOLT;jc(O9e68)carH-$9z??0#Mn9#+I;{5 zFo`38v%3K>AiFa-CbX33Dji(w?QegJ({l6kUp)Qnvq9nbFn{;$^VhHL-XqF})y->B zbOIK}UEb}7fse=IX>zw=%vTPGG^FvDzxw&{^uN5jfB52y-~Q(>fBotHXF2ESi(85^ z3zHBrQ)Xr}e>gtGf+;QL<~rDJxBvLVPap12U;plh*SELBZoe$2)59rc-jC___I92G zA)_hANH76=4A@3Xwskls;PiHN%#oGM*<8#3ZCT+tS5ag|24nyQCkJlD1Ly`GF9f0m z_gzw=KdWVcxOvIhSOa2T_}+1z2J-M*(~X;_HOXxWL_9}3rcgxiiJaBx*WO|zO;^P*ZRI;2P)x^&uV*6&V7Ra=&YiF3-)Y2)U=&P0e1 zzM;|atE#GS-xi4wFRSHJ*g z;tH|4szV+IUFM!6g1c+gB#D`pWj>vbNo0_8JS{~%F_-F=Fi9G6UdmkRG7dQ>*$uha zJgb=+NlZe3s)+*RgVOEP7A6JTSpN-5 z1KpmU&1>&B+nB}Pj14V<@wE(qeazDvh7Sg40OTHV^&2#wabO*(!&Pb5jQ@?2#rA|zW7T9s+7Kz7Gwod!&e1c=_;fOv6YY|A_M8>Ep+T_dlu+ll}0$Kis<3rIyn&Psif`yPLza zd~<{QeY(3dgQYA;KuE;6KkWYEum193p8oZJ^MCm7{>}gEmrsB9>4%@}vY3IXLX9ws%~)uu)4VM1KIW%~-PNa`d{&q5zWVAv+`c|aqWk;P)%EUhxH_HY`E+7( z8M2!>nS%l`IT1Jk5i+0xC`)JB1d7NN#lfo8QfoO33jtA|6;ie?886TvL@BprPpA9p?6hR^W!w#KRgV)&nTMM%#DIa<@`N~D2bT)QZ4N4Fyvv#BD^dW znu#aX8p~mbD7f1ka=b&#)<*QREK3^3B+Pvv*wji{M(<0*+pv$TT2ODAdL~5VV(#v$ z$|mbzS7aMibsWcG7-BjmE)N0{AuBHKbJ6?biP8BmB&3v*srjM~4sKc(k(9F_Kv0sV z#DqWqpv5^Bl0xkAsaj6+bee`}AhQljL?t{iSFF=%N_p4~>BVU&)hm0cs>&jmb2cqy zUU0NTyc>q8+EV9;-6&X5Yc&&qnD?oH;0Txqsyb+OQ(r8HYmxzFS8Nk6JY(sEji#e)}t%u^cS%=uqTD;~0o9O#pe|pP! zcQ3WZUCrD#JATHQ@Zwu-P8M$dT_0af|G8MwG5x0CEAPKqqx8_I0W=6``ws4oIz!~V z>nV=1XiQwH#fG(<@(*8qy?pf@9&Vm|^3(kIgE5ct$){MK!tLwVuU{2cARKqQe07y} zJ5`(KqHa^E!f6~RhVXYEy#MF_>VNX;yYK&p|NcMx`nUg42>;*@{t%s1-LX2!vP>x@ z!r&uX^vEd;%#A1=atT*05X-`c~@{&|xM^k4!nB=3!QVyzIpA!>2pXYtyG7rh`H ze$vbxysDLI_YcRVXf<=d8nnt?4FUy%bTH4P}iR*({O()d3oE!Jg!7j3^Neuoo4av1` z@nH+xZv}n5-|n1$eJi8(%V%z*tBXH%jfHo>xZ7}UdxF-7*Jh)hRoiAMbbB1YS7>MR zg!Kj)qX#wv|LObBznC5- ze{=(fJrWZ0;riTU;f!Y`)9wr zuP^@bi~sU4-y124S6>{;T#i*2A}2;+O2mwmSQy!>ogSv?I5EnnAOG}cKl`)&{=H?U zvf#^C$J^H@=3y9iwd(QVSmxPkL^B};s7j6^!@(#WO()bETx<j4VFf!8*HmBw3L&od0pXk$Y-DGv zH7zaZ@7i=|E4FLqxT325hBR*vc=d1YZl-EgP0g30w|B=#;I$M47>0ax zeQ@ymhljEhLL8E0G;PzC5M8kfjZF`c@-QHgnFGXB7Q|82QfgV4=r9i8G#4$!ma1wl zDUIVuM71n#Hsmx8DJLOtv?>6Cz{sxP7Htq14R$T9L=0YyUFtGu37a!y%ky{f%^EEBSB6K&#}xePA0$(j&jVsC`w`mb^1j*!|yk z+xoC=!C>PKZ3E!P|5p31X4Nc|C|a;g&DEfqSA%MxeHN_OTDD(vt$BwrVIpFTOUOh? z7# z0-&nS(=_J1lv!)RW%1XqK6vlRzxA#5gd*ja*kzD2ctwZqb{WUAx{Ce?i8fAOL zUZvXo!>Ke|OMuiHj@!!v&4;M&Rt*pz33JZO!c38n62EqL`UT+7SwR4Pr(@PAR1fL`$tsC@F~~Z%p{^ zX2P79t=4&-&Gax1C^FZ&)VdU{YQ&OK&N-J-t6JP2Gbc3E0%lR{f;MxYh}c(T#k4&F zc=){V!~jrAiM3yDR@*!?nVV@9!ki>AF$G+xs@2=f#IoD%=B13o@F#!#$4{R=d3d;+ zrXzs2TI!`~UXA$r>sDwljN$hFYMIt(W-I+&w9$b8ILf$D*}GJ@uly0uC%`AyvEm)WN_BEmZ*`NIJU;oR$;1NFm>Nnp!{|eYB z`BaXERlSrlt1h7zwN$Gr$b)18Ec0@FINsgfC%*dWkAL;-*^gAoji*U(Z|^y!{mmnl z{O!Z>hw5KFoSvWNSEuDTm(x;TyuAP6#ocLE!0g6m?9MSC5T1}3C1FY^2}CrBCblFd zqKQ;c8I?iN&D%nlF2o*K|M?_`d^U{ztPD}0jcV{#Bm$2@>=dEc>Hd1G(=~{C63~6X zMU=FPR=cz91$yyaqt39-dGC;8DMX4Y}1t8!m2efIPPSdg!Eoy4ScxK=-H?YP> zmPAU`rKr|QnPZrXnM+l0LuzgKu@DYhD5{=XZx6EoRu>wsuTN%HtK2LQafx}IOZJ|6GKPxA-wzXxu^kkRq( z_MV8}wxru|Bp!aNt!Deo^Pg59vCW@bpyL3Bed&U~@lrq6guuP?>v!esdU0u{(fjhs zcPh!nVYcShOCgs7V{vc`9?dcAFZJ>1T?LT`1H{u1w0mgI6k=IO{6cr{a-rdbAg^4^C(`N?#D|N7g*Hkw318lyB)Fycdq!;8zc=ypc5e`8YAYhyhgdY0~bOJ zMCND?+=eCsQV%LZUL)X`btT7c0rUuWO30 z&%&kXGS815%RWOn71gQ;_e+I^hMZ*F>Fe8CgHDTwJdAlRwboi$Fr@@wC2BHE%*0H! zF?xnIjjYI{qA*RzWm)#S9U_^lnT_L6N{R8DL@WsCE(A*CW()*~S}M9-?{`1{gFkTh zd6`RHBqz>X)ohIw&NA1jAUfl?|2|f@w~SRhi`v1rD&F}T`Prg%?K+3I*m0B<_Kc6; zT7O@k>1`Tn%@59D+gl9ktx|f4wZ3)i2*EInX9nQa=&vsfDLuC|t?#x?p~4}!gFBnE z8Fm8Cj#O<)kleQQ-nVKJ+pix1CJ_SC1-=Z3PKeRe1+^5EH0G>ebFEjqQJBm9!~J*9 zmw6t>j0C42zNfMb`@_x64FC$@ZO(1~C?bn2_xH9e0=}4m>61r~{^no&EJ+L$RP{82?SJ}{pWZ#Z_~zgJ&QD5t zhgEAWhy6H=^5Tc@6W?DQ9?#Pk*&z#|%+p=ntScyuLf#Kb#(4?FWIx;?CQ2 zTeYT~06|3NQe#Ce07TDEB(H=uxST$zK%)Qo{ zQfnNFB*3(;ag3Se#QhW9)BvzqTjo+q5CcWU^Z2j~Npj8z=)MplITQm|eX!GhNSR3_ z<(#K!HdAn{wXiB7rzBcMwUk;D6BF;)ba7Axw}o(*1epPGn&-Kcala#GtqqvMm^VvO zwBAj}D_zDkPdqL2Vm_G1uzniHG2r`oo`-Q9hG933^Rk?##VcXTM6nLiXdsC8pf;V3 z+zVIMi7^~-o@Z-KUT9|F8ch|z_WP^*d13O{GQZSn!;p!W;}J`F_R+JCKK%GmOW zpQaO0ks*QkaytEg3VX92$+9C&%$9TR;vN~fXI5rq*1}>Hi_0{%3_0D?t%fwv2pV88 z(1Y}#KdXTN0V7CgVMY|mZZ?}svB_F;jR^OZZXaDicMXaCwTu`K>f5Xz+fd8vaQ3h zRnQA4Ulz%>>S#XPOu!8D%ABDYUf=V^mrtv0wkTVGriy0dV^B9KxmCZL$;<_Gk}BAk zfs;EtA7#2sLSh7;oI=@Gq=odamM7zOgP9F#*_t~W+o_*Aox-gz^+gI6w(NypbAFQW$VnqgMcB# zap;zIu&RoIoh3pTi8x3qqK3pF#KYkzDo(4p)a;{ys04_nDn*zGz?@nlX_1_(s-Ez^ zs;5%j5+I^i8F{p4PNA8nf_WOcE8{rscDop(n&nbdwTh9L&4AT1QOO08^uxGss(n+( zs;;Uri>l$6GsjF$z8PaFsgzQfBEgQqo2QCF%D8K4q=3Xj$~kA%hB=57k!*?xR@bBu zhm!gscSB#rx^9|r7)vQkBt^h1gt*)7(m1AZB%-=*o4RT0Gz`fTC`gP*o)KgwXR9Lz zu#8|JW+I|2Ap`_WDUIXU)D;n>l*e&oW(c83E+thl+z#O|=B~R8UQBA5`!P!S@Y(aT zc4ww4#X!eAP^gT#FtDydwJI9i=<8WM+9H{gMmL(`=#xZFl8)0_!WB)yqFd*shV#N& z)%POUbL8im6L9J!&#D@y*v@()r|++;22U5>Jnrn|=-=idY+s-I4kzHa417VIIeh#C z-e~~TL^qZG8xLT4aP7DYNOcWEOtvbK`EC*t5So=zsw(xcnWGGrw6M3?DtI8=Q1{N9hg|D&J%BE5Y5m1?Q# zIP~3v2X|EU>hc$thc`qKiLSciFsAzt9+_d62GJT5w3U?_>MFzvEC86rXwwLAaApV( zgd1Uq%mGmhOrf42vQ4?u<+lNtT*>Jjdq)Cx3fG*xGV64g%2nOZSt3|@W?_D1>jW&| zXc4UW-4(U0H9+h0t#nTd%jWCSsh-%DfiS*E%}X22hB5U+8grSdfV@<6z8g~&8bS!1 zvW(gDz+BZ$yW7=GqX0#eIXJW@qKS?_XD8m6Q<#JRODnHPAvb9VYkAKs%_71?USQ*K zA@OPUo)S>=LLsqE;?aM3qCJ5XLNrp&UlRstzHPGH4mi3W9l~0}+nHz#R6wT{Zd9Qi^|v zHg!!zDJ7rYRaLQV8}<;HDEJIsM6U@Ur&eVqCib1C@yjK~xZm&Vy75{d4gmm45hALZ z#!N3SF95WztEP%paz#vH-WT`YgGcW_esb2fX&7=EV~8Qf0($6=LZEnEt`@zyK|E8n z9Ga5P7IKtzvNTpt0ug80XV?lkoM=*;Q1>?ceSKQj^~CI23uJ5kn4jaae3x~(ytUx; zcXb;NN|LgzZUw!=PcYpocKYsPiS7RPxU^b0r z5e-QdMNlU`D~_?ML#&8{8I)0uhaN3FfBNCaAAGms`pxUt$HVdJ>e4_y{?7ByzyIm} zR#Ugt+3mAtw=0HkFT3L)hfXd!xwy(#SLtxf{ZPh1$84!sQ7d9awTKpxB3U$>d8|T- zj8T|M6Ld}jUjxx|bQ9;+FkEAZn>}XO1B>>b;r9&vSM@!`-j|poz#?voNUmmMmbjZi zpMNS4H2c%OivoiB5zVGys$S@3MqYVgfRJ+O`_%PA+Ba-c2M#nB$(tEKDVdmo5d!yF z&P1z*LsivvQ&shti--XjF{x^d(Q~sYrKYZ(7KrA$KgaI~EFzJEpL7U}gd!qR{H}XhBg-b-n9Lx{{v4Vl9r6RdFrvf7+CvH+H zDut>#hEQUZJQgWxNQnE0Jwz=D6eKHSLlK93xa_5>^LgtO+azWx%4~tTih)xZ$D!Tt zn!0J4CXK1&k^`rliO7qAlFuv1xoLNGUDtIzj9GoQp8*l7sFIHdpb$b)GXn-TG7(KV z?RRz4G{e~EoKucXRfP~lM3tMSE>+bZ4^8pfH9YO#gWJ_NYr1y?I!?rU zP6OENl&H>b#k@4%nVnwn@}lvlE4j8CY^}vfkmq!*&CSd}2s|;t0}|%B-F|J`oM7er z9H$8cQ-v$6$V|1A z-46S9OkEyQRaI38-j~z1?f&uOs@d_?VeE#IGkGn4h%aQ}OM z{?89%`rrP;|NWo-?iWGh51xO|qL`K>X2`(NO&vmr%p4IYXi8>+hGo>T%jb7*ef;5f zU%vYK>iG6}byO2Gd2s*!cRqQJgfCuRRMnYkC>cdV&PZ5|2@hga1Wd^@yDnRVjNT`% z?7U@26X>2u70ti`32n^lj45XLyrSzVhBD*t6?AL?_!QbZMM)Fnpcz+eaQ$W}or>Wa z8QKJ(R+DfdhFbxfLzi>A$Rb3ixumrN#T!e+?AVWou6MG8oU<3UGeI4RNmJ_jzB4lp zJft%8y-R;p$J(7rFcBd{k;3y#E(fH3^eI@6$VjS+0N&q7I5l~B5r%sGKBAeq?k+{l z%*TkyJj|A6o@Bq7Q~R-K&UqY1BC5PN(xX*`95^5{n3uZdA}Qr@%v0$EfT|#ZX)!6_ zXB&bUID9a$Jz1+#mmCeW7yuJj%u&?FRMZfNh=WdznhIbsgcvxdlu}0GrfJMHrId3? zDR~iR+qOQ-Ea!|wA%v!BLhymLYNiP2v)jy!i9KO1B8cF<0bUOZ09C9+WEh50#4{zs zFsK>^;uy>%1R}IuQ=@1)4yo%O-M#g%jl%!A{kI)QyHP$h@4D{ZSHH_ zir^YWS;Y0a{@Os-YpK^Y6f^y8Xc6WQPiu3LcsQj)*rcFwY0&}ub_IHKo0aJb^H0r> zZFXZsMhFNIF-$bYHeK1Y+r~#(HuXurOGB?t+JAE;({4Al|1}G=tOZ|O-c`58f0har zgvCyA2tZh*AX%&!Ob@SLrjn|==k@N)fP6aB+4=4J_wPS^{9b#0PIX-Y?)Q5~U{a)E zhKozPyyQ}--sk%E6`o%uyZhnOzxsFo<4^wjr^nR){*PZ2lzLYKgMj$N8i&P96hn+$ zF-N3;$c&7_eV6m75AHqw?32%*Km8Dmy2J6+i*LU9`cGBFXU`wsyMHd4IKZ8Iw;w%z zR5eZ0o&m(6&^YSxm@Y1d%d6pd%v~>CU%FBIq+`-7Pz17AQL$+NZUHO?8F83-V%LDl z7GKY5)&KEGkWEPgm&ThgV-AU{I}T|mdK^Z^Pc>D zVo^=GIFBCjG!elAI5GHeU`gYsYHi#4MVUy+l5+;L7-Q2k0FZOiQV6h)F%UT?9~#VY zF!h_p4rXS9@A6QjxGh1%ob#AQKs1AKn&HD785w=>&VJWM!o$T|26%k$!FvxMH?fu^ zCKj1dEv515>Z-1*{n=g!lu!W;*#|0J=R#lfC7uZFyvH^DwGH^#$?MYtJ9QDJC#xi% zs`YEC{Un{c<|F18w)SQl@7bQgeAZ~E*D?9z1jYz+uO9GX=1zB>3%r+wc>{!PUOz4L znT^DDx9#0nPJ4ktYfByCG*uQ2XwfZmK7)LEErlu+l_FvYyM5aqFAr~Dhbo-iy%VZ7 zr<}(W0#~DQ;~pHF%ps6EmcKCV%8~^p49-E=buA;*g##y7(&iDXK9+oXXbej z%)?VmylYxUQqi0;nzc=H>+GzmD!(nxe~8Nv$GKwNMbnk9ssKRS?%JjWfMFbA?!I=% z0vU)Yl@u%9w`~kDa(MjU(bFeS9zJ;3AA1BKKrI@Wt%#)(a^b#j(qLu^Xn3m2*96n` z2nv4NV_d{@O{r}G%?)gG-2>Q&^osYdDc|+!yc{=H(Rqlg`w~I!=b;tJbv}n@YPp#IOLST660{bE6=}k zFYHvttKoRrbq6hD6{u~hU?$^8k^lLh|Kwl(H~%u}`14==f4}+SABrW4piqit_lzgc z(fs_7D27niu^|YOp+DrBch8=F^yKj~hQJV{lvgiaj6-+-{`vb)A4(a&{Noqjy!etC zVhl}F@6OIG)CyD-hAiDsu8!&Im=9g)2OARfgY|h(K5ja?VKTv3~FJ_0s;jsu95rKwZ}&qVp(6 zNA6)m7n(~6A?)`%n<#Bb=M0z|0GJe<`lMf1cDOM7n0a}!8-Tj5iD)6Fj#Y(3-XaSC zA%uwNk$RuhIF4z|8O++I+P6($LQpAraX2^xSMi>biwy2XcX@?_5V0E= zzwhn=R8>@wH0CPArfEDl1AwL`B4F0kH3ER?(04f(A8b(95fM!V)yq1Z2V24CwfT&I zS%=!}W7SQ=%;UroK`~aUN=O(O2#VyKQwR(yySBZ5@BZ`mpVzS-x~}95U}`1`1l(|p zfRGC+E>QZN-skKAoGPY`=3OEGCX}+sBYe*b$deqq}z>VO;a@EUr%?a4L0#hJ} z0wd$XEjBlEFR4Xy!Hz!L0sur+hT-_;t3P!^-#&Wp&a-Fje&2V)<(sREH^)-o>f(@7 zsw%3_8rs!(e!F_|w7q{X5Q3I6b|v-ozPYn&b9YE(_~5gT|Cj&ufBqML`P0|OxBu_o z|MKnOjglovV;+hYab_R!{1$}}>!z)mcE3N{?`~D0)&i34R(t;Jy$>JXf1*jRE)FG) zU;g7C0>OvRpFVzc7a@<`Wga_3i2>_6a#LeqKoY|wHYDwa(hbrL){oea)+ZaXjahRs zKM9WoA?n;EwV{HzP9CxZS2m%4m{#CadVsLi$k$v#!zKN@7=txLG-rdSpLVejr#|%} zte%`+Efid@3R+HZtMX_2rdyu?0Ex`dbLA6>ZO}j^mhE4}CGnYKoO4b%lZkqi$yK~-a5oa(4(4~+wWk{r zL^QRM%c9?%lsuP`b2hW8iXN0NQ*BR(g!N1z2Z&=Dudc3gPF^lC7n_y^;0F%O5pb%7 ztq6ULfwL(A0CON>HJG}BiG6>VvwtygT~%f}rj&AV6Q!bV5!0BChr<-VGAKEhoIM~` z$0`teI0+31R|1TgRdD*r$w?zhNht*mRaHrmVI18JQdKER$jGEBDUDr!OewWZ_4wiK zNB8%`@dew$R#7)=4;5^%a`HTRTAnf$ghFOnho4o+d`Q+z) zh=}Ri4!qa^`j(9;EHh|l?Z5shn7VahmwCuJ1)__Kx0m1i$-1lR)}vb={E+W`Fi71c zJa*}L>;&NK&fWIGW2_qJ2D`Y_W7oD#6*!Om;q{C0^*6)eZU6Rdcf6?1n%&cf|Kh*< z%Lng2{?#9T`};5d(B)&*1|$>9Vnrm|3{cv(ZQ8bJ+IH98y?uAzoCS=UL4}^a_w4(h z{qU?kzkG9*hf>D$`o)V#_UR`dJh*>uI^=v6s4yv7My6O-Tvrr=0cN!!TQ^EKXg_E- z*qCrkFlHN*j7f8rB1Kfplx7cY6{AH~=HF@K@y@{*vr`e7g<%8s=aSrw3ZK;cs`sa7 zTNmL(R1x4h_+O@eK%X=3@ zO37JrVZ^42(Pv(Yni&(rY|#BKozTZ3ctaTg)O8)JDwmQ=@!K_yBN&FrOb}6`_K(l$ zv-i*b*(Z-8^eTg&>IK;aq=zeN#l6dZ086WoaW%tAhE>NO5&%Np5U%Y1OawFUy$ zwK-R2oFcS$1cGkZrB$!HjdnA$S)|uMx}AI!=lkLR(&+(g(E!t$nXXaQwf|}l5ZwSw znO~V${U{_N?4T|;F&qNacaEt!>A#}ESrHX<>~3S+i@kgk`mEB&bB zsQn=QP{vfol+)ZhO13J{IgGc7_FPl*n_A;7NU+Vni_C0m?54A^Iw6bTY%dKqmdryCB4S`pP*+5+j$PLeV=jkoIQGMs%f!e@6Ilxa z)>VbfLrVQH0GNk*nyMyPr-PM}5wU3+XJ{}poQJ)H5F!WH`{3m_034>-hcrjDVaEGj z)$aFwX*{0v;Dn^iW_D7hlwZ+F~&%y#Y2FA3aIAnJ{&@*gA+5oB8Psp@@1ZEHT^sBbo}B2IjVHO!aU$0>G_Xw|2XosY#jY zokXOr>$YuGRZ7k&nOR*|byWptbv7j;CiaVUpUL^1-Nh%GrV)s!{~W5C#t{Gks1$>2 zm1+yJXR9pT-d7)g{K4%zcXCcCjSd@hBRzKV=l5xnpi@YGBJ0-zIIBEQ>RV@pi(6T} zmS0Z2%f>*76N+!Cl@J*7+#jDd4jPVJ06LkY;j= zRTC@$;%k=5DL;#pDH?s61D;|?>vrv|d|Mi8u4#XOIR!mKgcIB4;VmFDQX?&fDW9RX znxZ6-qN@4DAJeNBY|`AlbMN`H`|m$JzjG(XIu|*l7{~EH{<22yLZn1{I7oU-~XF`)s^lSzxlhX;j%q%+I`IouvBLfG9jXkJ zB_ZOj-8JeP|iSA`yx_o%|_QQMUXyZ6sh;&RDpa26! zq{tWoFd2|2TPWTL2&vbF4}f$7|d?S&h`g` zMd`!(TTD;4+8<%l2XHO+b6qZPZVNDa_d6?e(oHVP%+zgwdS!|gg;>>NmN82=q<&0e zE@D&b4j?*#P>2(8bQ(tlsH>`}>$Yt|42YoON-P6FUDqK5kN6X<$=*2WHAAO-0B}Y( zPae!;&?j~8QobmC1Isy0Iaud92QZubgAf7$rks5&<;2WfN-jlIVioJaGe>#=B%R$O z_W)uDo`)#KGY3dS#M3|mFbiZc1Vr#G2Ee58#jH$}(Wn5$Nqr~UYHteTlyXjqh|bQ= z5HY6=9_cEDiT1nwq_|U(Qkc1}D<{TqCxFOfYZLYDwZ-oW!KT*e2tcX8#buw8nUNF> zNJP{fkH^EIuBsY3b_fdUJ% zB3~mNTDHUMufP7?ug9REX_9)Qyj5XzD6d3?K|z zM-BkTtGAagzAoM6lMkN$5MAu4QBe0tyr& zhgeN>&JBw~Q5>_3N&3;c5e_{aM;;RAOhss!34F>4y1l2Hp}}Cbh2W0K-5Wq$MITpD z*kb=pBowc|z6lVyd04;7pW$?{$r9mqI@oj;vn>blN3 z7ZE=r|FJ#bqpC=(KFI`t17j}4G;`$|5jpGqR0nAqh!L^OF(1FTW>!iT$?jetlB;j` z9>Uy{oO5Zas)-s(Dn>x0rln{ul1r&VAVOvefs;5foo51wnad0y16qiY1{5$+VGBfF z53bYtH6uVZL!C)k445bo_qlXk-_-Sfw;zXL==+p1GZ&E{8bWC6<|tjsIp-Xzs;aBH zuKQu|NEs1%;=wg16PhWikkjK(TA3aeUB6ZO+kZFOz=K@`wvsPxe+m1Rls*8E4C(6C%hcVv zd$+!Q7tYQq4xw#h*q6Q^C(d9sF%hjoh%w4IITuty?S{kd&g0Mj+2^lb zz5Vz9{{Q&-uYY;_?!8Yx`ix@$u%Zb82+-6JImEyr5)*(H$s)zfq*zYMh>ssUC4!&* zyj&e$A<5;NH+2>7KYa4!(cQ1UzUYT5#t?QuhDwY;K!|LJR6vS}sR6QL=!+0qQ7!5n zHh_Qz80LG2vt0V`2Wmi*#>ctnbDbmVw;X1Et_@7GO>HRR^mOrPaV;f-*~eY%03bRA z!vcofjt=5M~vL%q@Zwk#}wdHmU=!EJ8a+$=4#HC#BP{POkXqx-k(vsQpSJR}Me zPyw(~kTL-XQqE}{hM7tsEUtYB!6Qdz7GoTSA>&Rm6{T;^LnCN1)Q+01Erba zrw~z2*@?RYv)EJ!R9xGcnN5Mwxf@^_tl`}NJUIb**R)j(F_m0E6;w(B9LChvu?ifR ze1v+IX{dcEC5Av8{C80I04RouU?+Q;A%p;uMNQPyrt+l8VKhS@OUcB6$1!zXclYky zrfy0~xs;d-6DL4KY?>yQ()B}5sjBN3V$;;|7;+I)WnS(ukO#;Wh!6}7Nex5`Achbc zB9AbL`XC2ZEmF85jKudI-~Zs*^B6+cb!8l5jA$TJjS;v~QZPjyyyc^J6@hOmrsb)J z<#Yph7yL47(#bEpTn~MQ3Ina<>JM8BH(>7G=Ls&Vj1mGC%)i zz1Xt+PmF;heRN~8ptUuLwh2&8>4kOWnk$fL6>PP?&2(@6VCnsuSJ>%&nv?w3u>W;}nWU2$L- zxXjyXhbmfR}UWD`)~j4zy6Ou`}rS!{rkWD&EGzL{P@xB$C#ny z1jUF!L71x$W79UCRLFT8hQ6d!g-8lxUn15&`uxYB1FsKwtv~*_xdzvwyc7=EnrstOP1!uD_HQyoF};Pg(t#sb33UBT4Pr8buHrV=Z}+P3Vr$bN-v8o~fn5szeNjSvJApoMPqLzv=#^~oWog}dVX3^rF ziw_x{et9DVMr5MEK@5(^BO=yyT~)QIW{;QUTuKQc?Aq2@98yY5R9979Ro*O(6B;lk zH&{)TQNRl!EzW?5nySHyg;&B*kGsJC?m)UwP5`=J_ zkbIWIo3DI}zTUiU3$|Wd@7PYF^NU}wIo#I8 zSi?IwP1;uXdOYj9tnL$KVJivOvMmk3>j22pkQQ#X1@hkv`una%sD#2O0X2{XGG51) zUHdtf$2@P>0*U@^%eoJ?V};yZy!`s|^-CECW7d991K@~N8}C2dzxO`x&)i>(bxllM z#mF3qdr>W_I>-}>}BpMUnr_s-gV8PesOi{zn5>UpJ;ndF>`h=`@4LoP!q-H?w%KK3~*(uXjqMR2*_42PMa#B?WZ4hs2zfF)zJ ziQ7!REuO9uT*M4vsgH0<)x|vc=Wu3LIr;Ds1A6DShJH@=!VR>sRXEYql%^puxd?(q zgra(J)$OWiNd1`4nivQbktb&e%#I`%2I9a}6r_lgo>B<0s?DQO%>R?C(FoF&2lfQbRY3YaMvgun)na&pSJScSgt#x&G*h*cPlJ$2o~hYugz zyVusWN@-#MwmjyNbBxtSZ_fJ2F^N-0!JHpB-IhSmqF;QU6Y=w%ZkPt!=*qdz%$c-w z0Y;NB+gdk46W#r!sqI_F!uV4?A(ZeFnHYrT$MdRRp!!y0PBu}U$b2?i(^nh#)Ufqd zOlo6Bh%hI3=M^`hegK%>TgLlrf@#YG++60WqX`XB5hoS%Em-j6w>aJBnz~Ojt?*F)-N0+5oKqqY!sBqI~bq ze(>e*zW5LS=l}Jq-~9dETX#Nw@-a{m)gr-N*)$F*^@gNsku>KdT8Jt{G{vgg4PCx{ zcJF(i{ov)RFaPxQ?+@L@>lZKUYPV}|J-%O`HPx|uUDd;wg5n;K49p9S0wVww(Uc3A z5&?punu>^u7^?9}70@-GY0j7~B61RkneD#yO^qKe@#@)2=YV zuoXtFGWYInn}?gO1lGit%^KR_zd8{)A`tdNdG+RMNZE)&Rg=%w1oEY1W@Rp_;Sgu~ zHV7d!Qy*4Iy2-(rA*Yl=%-&V2sws^kN@i6IfddhX+L+QXj^j9br#f{| zM*vMjHykb)%B_99t0SX{6hWC)s=#C4oRdgd(~M)h_nKJ$rTo(>1NV9_5Ayuzt@CAig>4$zcQnWTdVCv7X{OL11ySue&7I zoD`VPb4>{>w{D)43;;-oOb{3%Yh(!|jOs+K->&^{T8?Xa-?!BT*?bmEvGgcI(9}k3 zYUW*yr5HjfQViO=_lCEZ{h`CA<*KHtE=7T2ymhB}^pu)gh!}`&J-8pv_9*$zt^JSw z%OC#W$KU_z^;f_6)i1{~gvcR~X*SJfl2U&-T#aLY?7LFNx`|CwBcdvlEKITQ#_l*i zdHno`-~X>4KllXlxu)IgudlxO)0e2})>*sX@69Z7X`5JAAu36%G(q>-GG8+Wc0<}Gi?9u^Y^7oI*x~q^NZQsrPq%ya0$^htrq51Z-u18D z{#4((T|eRF+9rFpnuUqH#%6CC5K_)bbV#Ke)5YcSaO_hlYDP>>^vPZ`Q%_Z%IVD7F z>N>=L1XG6rPJv73y771@F_{@NH&s=~3JfDh0)F-4_2H_UQvgiNKEc4p_D-e?J$&bF zw4(FO6E^@kmr_JkV~osUiaX^@h%r_nMpaEYt0|}u7yv3j7AZ2Nf=ltD9b_gj^JMHe zj!ym@LX5E@W(FlhpNFX`r4(nl0K_a>#If!~1TcmBrlHWt9O|mcp5~3QuIs?TrwNba zAX20KV*pf<&>QH$01-lDHT5-KRel>NmvJ1sQWB8efAHYF z2M=#GJ5)nwBud#t&(6+5U^tO;gjvR2TnMkXCM{UxCd^#rd3k-xp)A_iSJfOMKlxh6 zA;dn{cBPy_t)3#)qlmdCc?k6%)j#NgLET~Y& zs%;R#RQt=zTj#g$J$+v}498A^>N|Iv$4}ZvPwIP*s67Y6;czTSicp3wGJO2q@BHL1 zeu@?U?C*a5;_V9|soIES;)9l?^vA<6bY0&c`z}V>pY37{rsh6YDJBwzj-NmM{Il=; z_|C0ILzlX)fBWX*a5xh2`K{YktN{9^;yO|lFtQN}fiNmF@Kk5+KOTx<7ARsvQd$B2 zHbSzo=1V@nHg9dj5mvAHL{6t03!BK#QvJD!4sG3nWr!HAz<&isCpO~SwreM{cs1LT zUt*&$Rqsu`P9iF1-8de);n0nTZn!#jead186p+FsLC(V8G^#2^iWFwr?RIV3#8|nE z(L83=4S<=66FjIEg&`M)$PfZWC!a>Z5P~l@AAO~&fKI=&7F38TAc}fnPo7RkJqVsf z#94rtLlH?S0U$AZh{K6R0U;8F7>R;UQS#O7WXs+SFoEEy;R}6=yR%X=sHw~WD9h8x z8YX6+`dieT5W>6v5vO5ZZV5uHRCP=Vh`6q)t|3OQt0Z|C2UTs`wytZh4)Vl6(=_d_ z4I$uEfi{uW(TwsuOu>Yvf(TU|+g(irfk{g4yH3jZ@c!Lr&!4~d@S)_ChCYUXfVm_$ zC!PbIRQVc*_oTyyCt(BLw+~i9zX3n538hWI>U!V^%p779uCueJFPS0JEPC&P={HVq zqj$e82e7_1f9k}GxnX$BTOt5;ru$!)z0CmMDf~=4^narL^<mraXGEg0Aeeo)P{Z04gje+iOpMU;mKYIVeXaD%sm%se`zr8qIgo=q# zq?i^2NogoKi%c0YQ8k3RiMP)8byGX|J03eJ`s~^B?|=UXyWLse4Hs`O-@JZ1^g{@7 zx7!1bDkDJ&q>(M4G8BYNsDZ%=fqfdgAqj9+9E;HmhOY;Lu4(zz6bl-H zb&L^-5Lp4IFF;Q$$6edT5F8YHF(^3egXh2{gBC(^9&C3th?zKKFJTi!oKS_=zx(tS zLNG0gkPN#l1(9Nf%svAIiJT+agJwPrEsdj3aoX>8bzKvp%>}iNHNgxChyVf96wwF` zRL8yt)4GaPgS&S%7cC+sn-&jb*+fCMQF*giPn9sv3AL<_(+4L` z@2SQ)2}EviE-ZMHrg`_*JmmpMJfQ!Vk=OWg?gn^NHx+$Lfomp6J*8jY7+nyTUuw*B{ z%f>uxzjkA-5LFm7GLZ^gUBS?^>2AM^A*d9PY?qgP*LA~CRCAVm=q_Hq`sRy2UcEl% zQ9`U#?egv0SFc}-76|bCqvt>PvmZQt{^*N8{{By2|H+3l8Hh+`Mn*cX&K_3x8$0j& za&_oMl>@hJdvYpMC$s4?bk#Y0GIFI{<Hesq7BH*p$}5*Vk?YKHK0m;DwhX|lf|#kQnb}mcY5~HO^X0{*GaV6;8B|rx zM1DnHS?h;RH-y$WzG?`gdT-8Nuo*<}_ZseKQW-S=-#dcjxiL`yW1k z|M}DRIL0?`-x2`NzXHG#++VVn>nPHxc~o&iz3+k_h$}F-k9vD zs;jd#UET>)U#}Y9q4aMS?cDf9-eDNt`H^eZbh(p6WE=nyMIsZZXm_y7OOxE}+ugn~ z5k`>XvEH>0KKbswM~`yX4_60M1gZ*#ZctN;Oq;-Rz%7(oViR|DyN_+l4GHH`oaGk)W2~B{ZR(aem_p9db;EAA|MZj39zA$m zayedgFJHcT^X84J+`fHY*QRAK8Bxaog|Q$Oglw7t6ure|w(1Hf2LC?^g4mb+86_S7 O0000Ml{cK2o2e-|=R4ma%6|CQ{~z+y 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

bnYjka~M|0UaL4Bn{SB76BQe@Sky#7`Ud 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

  • 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

    XDV!vLGBc^D(w=#~`qKWBio0*%klkNdZ8{$(Ol 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 Date: Tue, 14 Mar 2023 15:45:07 -0700 Subject: [PATCH 077/136] Add InteractiveSegmenter Web API PiperOrigin-RevId: 516654090 --- mediapipe/tasks/web/vision/BUILD | 1 + mediapipe/tasks/web/vision/README.md | 18 ++ mediapipe/tasks/web/vision/index.ts | 3 + .../web/vision/interactive_segmenter/BUILD | 62 ++++ .../interactive_segmenter.ts | 306 ++++++++++++++++++ .../interactive_segmenter_options.d.ts | 36 +++ .../interactive_segmenter_test.ts | 214 ++++++++++++ mediapipe/tasks/web/vision/types.ts | 1 + 8 files changed, 641 insertions(+) create mode 100644 mediapipe/tasks/web/vision/interactive_segmenter/BUILD create mode 100644 mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter.ts create mode 100644 mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_options.d.ts create mode 100644 mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_test.ts diff --git a/mediapipe/tasks/web/vision/BUILD b/mediapipe/tasks/web/vision/BUILD index a229cbd2a..37709c055 100644 --- a/mediapipe/tasks/web/vision/BUILD +++ b/mediapipe/tasks/web/vision/BUILD @@ -24,6 +24,7 @@ VISION_LIBS = [ "//mediapipe/tasks/web/vision/image_classifier", "//mediapipe/tasks/web/vision/image_embedder", "//mediapipe/tasks/web/vision/image_segmenter", + "//mediapipe/tasks/web/vision/interactive_segmenter", "//mediapipe/tasks/web/vision/object_detector", ] diff --git a/mediapipe/tasks/web/vision/README.md b/mediapipe/tasks/web/vision/README.md index c1f15ec26..2ca4ff64e 100644 --- a/mediapipe/tasks/web/vision/README.md +++ b/mediapipe/tasks/web/vision/README.md @@ -75,6 +75,24 @@ imageSegmenter.segment(image, (masks, width, height) => { }); ``` +## Interactive Segmentation + +The MediaPipe Interactive Segmenter lets you select a region of interest to +segment an image by. + +``` +const vision = await FilesetResolver.forVisionTasks( + "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm" +); +const interactiveSegmenter = await InteractiveSegmenter.createFromModelPath( + vision, "model.tflite" +); +const image = document.getElementById("image") as HTMLImageElement; +interactiveSegmenter.segment(image, { keypoint: { x: 0.1, y: 0.2 } }, + (masks, width, height) => { ... } +); +``` + ## Object Detection The MediaPipe Object Detector task lets you detect the presence and location of diff --git a/mediapipe/tasks/web/vision/index.ts b/mediapipe/tasks/web/vision/index.ts index 5a87c7a82..fdbb1a65a 100644 --- a/mediapipe/tasks/web/vision/index.ts +++ b/mediapipe/tasks/web/vision/index.ts @@ -20,6 +20,7 @@ import {HandLandmarker as HandLandmarkerImpl} from '../../../tasks/web/vision/ha import {ImageClassifier as ImageClassifierImpl} from '../../../tasks/web/vision/image_classifier/image_classifier'; import {ImageEmbedder as ImageEmbedderImpl} from '../../../tasks/web/vision/image_embedder/image_embedder'; import {ImageSegmenter as ImageSegementerImpl} from '../../../tasks/web/vision/image_segmenter/image_segmenter'; +import {InteractiveSegmenter as InteractiveSegmenterImpl} from '../../../tasks/web/vision/interactive_segmenter/interactive_segmenter'; import {ObjectDetector as ObjectDetectorImpl} from '../../../tasks/web/vision/object_detector/object_detector'; // Declare the variables locally so that Rollup in OSS includes them explicitly @@ -30,6 +31,7 @@ const HandLandmarker = HandLandmarkerImpl; const ImageClassifier = ImageClassifierImpl; const ImageEmbedder = ImageEmbedderImpl; const ImageSegmenter = ImageSegementerImpl; +const InteractiveSegmenter = InteractiveSegmenterImpl; const ObjectDetector = ObjectDetectorImpl; export { @@ -39,5 +41,6 @@ export { ImageClassifier, ImageEmbedder, ImageSegmenter, + InteractiveSegmenter, ObjectDetector }; diff --git a/mediapipe/tasks/web/vision/interactive_segmenter/BUILD b/mediapipe/tasks/web/vision/interactive_segmenter/BUILD new file mode 100644 index 000000000..a4a3f27c9 --- /dev/null +++ b/mediapipe/tasks/web/vision/interactive_segmenter/BUILD @@ -0,0 +1,62 @@ +# This contains the MediaPipe Interactive Segmenter Task. + +load("//mediapipe/framework/port:build_config.bzl", "mediapipe_ts_declaration", "mediapipe_ts_library") +load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") + +package(default_visibility = ["//mediapipe/tasks:internal"]) + +licenses(["notice"]) + +mediapipe_ts_library( + name = "interactive_segmenter", + srcs = ["interactive_segmenter.ts"], + deps = [ + ":interactive_segmenter_types", + "//mediapipe/framework:calculator_jspb_proto", + "//mediapipe/framework:calculator_options_jspb_proto", + "//mediapipe/tasks/cc/core/proto:base_options_jspb_proto", + "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_jspb_proto", + "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_jspb_proto", + "//mediapipe/tasks/web/components/containers:keypoint", + "//mediapipe/tasks/web/core", + "//mediapipe/tasks/web/vision/core:image_processing_options", + "//mediapipe/tasks/web/vision/core:types", + "//mediapipe/tasks/web/vision/core:vision_task_runner", + "//mediapipe/util:color_jspb_proto", + "//mediapipe/util:render_data_jspb_proto", + "//mediapipe/web/graph_runner:graph_runner_ts", + ], +) + +mediapipe_ts_declaration( + name = "interactive_segmenter_types", + srcs = ["interactive_segmenter_options.d.ts"], + deps = [ + "//mediapipe/tasks/web/core", + "//mediapipe/tasks/web/core:classifier_options", + "//mediapipe/tasks/web/vision/core:vision_task_options", + ], +) + +mediapipe_ts_library( + name = "interactive_segmenter_test_lib", + testonly = True, + srcs = [ + "interactive_segmenter_test.ts", + ], + deps = [ + ":interactive_segmenter", + ":interactive_segmenter_types", + "//mediapipe/framework:calculator_jspb_proto", + "//mediapipe/tasks/web/core", + "//mediapipe/tasks/web/core:task_runner_test_utils", + "//mediapipe/util:render_data_jspb_proto", + "//mediapipe/web/graph_runner:graph_runner_image_lib_ts", + ], +) + +jasmine_node_test( + name = "interactive_segmenter_test", + tags = ["nomsan"], + deps = [":interactive_segmenter_test_lib"], +) diff --git a/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter.ts b/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter.ts new file mode 100644 index 000000000..1499a4c0c --- /dev/null +++ b/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter.ts @@ -0,0 +1,306 @@ +/** + * Copyright 2023 The MediaPipe Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CalculatorGraphConfig} from '../../../../framework/calculator_pb'; +import {CalculatorOptions} from '../../../../framework/calculator_options_pb'; +import {BaseOptions as BaseOptionsProto} from '../../../../tasks/cc/core/proto/base_options_pb'; +import {ImageSegmenterGraphOptions as ImageSegmenterGraphOptionsProto} from '../../../../tasks/cc/vision/image_segmenter/proto/image_segmenter_graph_options_pb'; +import {SegmenterOptions as SegmenterOptionsProto} from '../../../../tasks/cc/vision/image_segmenter/proto/segmenter_options_pb'; +import {WasmFileset} from '../../../../tasks/web/core/wasm_fileset'; +import {ImageProcessingOptions} from '../../../../tasks/web/vision/core/image_processing_options'; +import {RegionOfInterest, SegmentationMask, SegmentationMaskCallback} from '../../../../tasks/web/vision/core/types'; +import {VisionGraphRunner, VisionTaskRunner} from '../../../../tasks/web/vision/core/vision_task_runner'; +import {Color as ColorProto} from '../../../../util/color_pb'; +import {RenderAnnotation as RenderAnnotationProto, RenderData as RenderDataProto} from '../../../../util/render_data_pb'; +import {ImageSource, WasmModule} from '../../../../web/graph_runner/graph_runner'; +// Placeholder for internal dependency on trusted resource url + +import {InteractiveSegmenterOptions} from './interactive_segmenter_options'; + +export * from './interactive_segmenter_options'; +export {SegmentationMask, SegmentationMaskCallback, RegionOfInterest}; +export {ImageSource}; + +const IMAGE_IN_STREAM = 'image_in'; +const NORM_RECT_IN_STREAM = 'norm_rect_in'; +const ROI_IN_STREAM = 'roi_in'; +const IMAGE_OUT_STREAM = 'image_out'; +const IMAGEA_SEGMENTER_GRAPH = + 'mediapipe.tasks.vision.interactive_segmenter.InteractiveSegmenterGraph'; + +// The OSS JS API does not support the builder pattern. +// tslint:disable:jspb-use-builder-pattern + +/** + * Performs interactive segmentation on images. + * + * Users can represent user interaction through `RegionOfInterest`, which gives + * a hint to InteractiveSegmenter to perform segmentation focusing on the given + * region of interest. + * + * The API expects a TFLite model with mandatory TFLite Model Metadata. + * + * Input tensor: + * (kTfLiteUInt8/kTfLiteFloat32) + * - image input of size `[batch x height x width x channels]`. + * - batch inference is not supported (`batch` is required to be 1). + * - RGB inputs is supported (`channels` is required to be 3). + * - if type is kTfLiteFloat32, NormalizationOptions are required to be + * attached to the metadata for input normalization. + * Output tensors: + * (kTfLiteUInt8/kTfLiteFloat32) + * - list of segmented masks. + * - if `output_type` is CATEGORY_MASK, uint8 Image, Image vector of size 1. + * - if `output_type` is CONFIDENCE_MASK, float32 Image list of size + * `channels`. + * - batch is always 1 + */ +export class InteractiveSegmenter extends VisionTaskRunner { + private userCallback: SegmentationMaskCallback = () => {}; + private readonly options: ImageSegmenterGraphOptionsProto; + private readonly segmenterOptions: SegmenterOptionsProto; + + /** + * Initializes the Wasm runtime and creates a new interactive segmenter from + * the provided options. + * @param wasmFileset A configuration object that provides the location of + * the Wasm binary and its loader. + * @param interactiveSegmenterOptions The options for the Interactive + * Segmenter. Note that either a path to the model asset or a model buffer + * needs to be provided (via `baseOptions`). + * @return A new `InteractiveSegmenter`. + */ + static createFromOptions( + wasmFileset: WasmFileset, + interactiveSegmenterOptions: InteractiveSegmenterOptions): + Promise { + return VisionTaskRunner.createInstance( + InteractiveSegmenter, /* initializeCanvas= */ true, wasmFileset, + interactiveSegmenterOptions); + } + + /** + * Initializes the Wasm runtime and creates a new interactive segmenter based + * on the provided model asset buffer. + * @param wasmFileset A configuration object that provides the location of + * the Wasm binary and its loader. + * @param modelAssetBuffer A binary representation of the model. + * @return A new `InteractiveSegmenter`. + */ + static createFromModelBuffer( + wasmFileset: WasmFileset, + modelAssetBuffer: Uint8Array): Promise { + return VisionTaskRunner.createInstance( + InteractiveSegmenter, /* initializeCanvas= */ true, wasmFileset, + {baseOptions: {modelAssetBuffer}}); + } + + /** + * Initializes the Wasm runtime and creates a new interactive segmenter based + * on the path to the model asset. + * @param wasmFileset A configuration object that provides the location of + * the Wasm binary and its loader. + * @param modelAssetPath The path to the model asset. + * @return A new `InteractiveSegmenter`. + */ + static createFromModelPath( + wasmFileset: WasmFileset, + modelAssetPath: string): Promise { + return VisionTaskRunner.createInstance( + InteractiveSegmenter, /* initializeCanvas= */ true, wasmFileset, + {baseOptions: {modelAssetPath}}); + } + + /** @hideconstructor */ + constructor( + wasmModule: WasmModule, + glCanvas?: HTMLCanvasElement|OffscreenCanvas|null) { + super( + new VisionGraphRunner(wasmModule, glCanvas), IMAGE_IN_STREAM, + NORM_RECT_IN_STREAM, /* roiAllowed= */ false); + this.options = new ImageSegmenterGraphOptionsProto(); + this.segmenterOptions = new SegmenterOptionsProto(); + this.options.setSegmenterOptions(this.segmenterOptions); + this.options.setBaseOptions(new BaseOptionsProto()); + } + + + protected override get baseOptions(): BaseOptionsProto { + return this.options.getBaseOptions()!; + } + + protected override set baseOptions(proto: BaseOptionsProto) { + this.options.setBaseOptions(proto); + } + + /** + * Sets new options for the interactive segmenter. + * + * Calling `setOptions()` with a subset of options only affects those + * options. You can reset an option back to its default value by + * explicitly setting it to `undefined`. + * + * @param options The options for the interactive segmenter. + * @return A Promise that resolves when the settings have been applied. + */ + override setOptions(options: InteractiveSegmenterOptions): Promise { + if (options.outputType === 'CONFIDENCE_MASK') { + this.segmenterOptions.setOutputType( + SegmenterOptionsProto.OutputType.CONFIDENCE_MASK); + } else { + this.segmenterOptions.setOutputType( + SegmenterOptionsProto.OutputType.CATEGORY_MASK); + } + + return super.applyOptions(options); + } + + /** + * Performs interactive segmentation on the provided single image and invokes + * the callback with the response. The `roi` parameter is used to represent a + * user's region of interest for segmentation. + * + * If the output_type is `CATEGORY_MASK`, the callback is invoked with vector + * of images that represent per-category segmented image mask. If the + * output_type is `CONFIDENCE_MASK`, the callback is invoked with a vector of + * images that contains only one confidence image mask. The method returns + * synchronously once the callback returns. + * + * @param image An image to process. + * @param roi The region of interest for segmentation. + * @param callback The callback that is invoked with the segmented masks. The + * lifetime of the returned data is only guaranteed for the duration of the + * callback. + */ + segment( + image: ImageSource, roi: RegionOfInterest, + callback: SegmentationMaskCallback): void; + /** + * Performs interactive segmentation on the provided single image and invokes + * the callback with the response. The `roi` parameter is used to represent a + * user's region of interest for segmentation. + * + * The 'image_processing_options' parameter can be used to specify the + * rotation to apply to the image before performing segmentation, by setting + * its 'rotationDegrees' field. Note that specifying a region-of-interest + * using the 'regionOfInterest' field is NOT supported and will result in an + * error. + * + * If the output_type is `CATEGORY_MASK`, the callback is invoked with vector + * of images that represent per-category segmented image mask. If the + * output_type is `CONFIDENCE_MASK`, the callback is invoked with a vector of + * images that contains only one confidence image mask. The method returns + * synchronously once the callback returns. + * + * @param image An image to process. + * @param roi The region of interest for segmentation. + * @param imageProcessingOptions the `ImageProcessingOptions` specifying how + * to process the input image before running inference. + * @param callback The callback that is invoked with the segmented masks. The + * lifetime of the returned data is only guaranteed for the duration of the + * callback. + */ + segment( + image: ImageSource, roi: RegionOfInterest, + imageProcessingOptions: ImageProcessingOptions, + callback: SegmentationMaskCallback): void; + segment( + image: ImageSource, roi: RegionOfInterest, + imageProcessingOptionsOrCallback: ImageProcessingOptions| + SegmentationMaskCallback, + callback?: SegmentationMaskCallback): void { + const imageProcessingOptions = + typeof imageProcessingOptionsOrCallback !== 'function' ? + imageProcessingOptionsOrCallback : + {}; + + this.userCallback = typeof imageProcessingOptionsOrCallback === 'function' ? + imageProcessingOptionsOrCallback : + callback!; + + this.processRenderData(roi, this.getSynctheticTimestamp()); + this.processImageData(image, imageProcessingOptions); + this.userCallback = () => {}; + } + + /** Updates the MediaPipe graph configuration. */ + protected override refreshGraph(): void { + const graphConfig = new CalculatorGraphConfig(); + graphConfig.addInputStream(IMAGE_IN_STREAM); + graphConfig.addInputStream(ROI_IN_STREAM); + graphConfig.addInputStream(NORM_RECT_IN_STREAM); + graphConfig.addOutputStream(IMAGE_OUT_STREAM); + + const calculatorOptions = new CalculatorOptions(); + calculatorOptions.setExtension( + ImageSegmenterGraphOptionsProto.ext, this.options); + + const segmenterNode = new CalculatorGraphConfig.Node(); + segmenterNode.setCalculator(IMAGEA_SEGMENTER_GRAPH); + segmenterNode.addInputStream('IMAGE:' + IMAGE_IN_STREAM); + segmenterNode.addInputStream('ROI:' + ROI_IN_STREAM); + segmenterNode.addInputStream('NORM_RECT:' + NORM_RECT_IN_STREAM); + segmenterNode.addOutputStream('GROUPED_SEGMENTATION:' + IMAGE_OUT_STREAM); + segmenterNode.setOptions(calculatorOptions); + + graphConfig.addNode(segmenterNode); + + this.graphRunner.attachImageVectorListener( + IMAGE_OUT_STREAM, (masks, timestamp) => { + if (masks.length === 0) { + this.userCallback([], 0, 0); + } else { + this.userCallback( + masks.map(m => m.data), masks[0].width, masks[0].height); + } + this.setLatestOutputTimestamp(timestamp); + }); + this.graphRunner.attachEmptyPacketListener(IMAGE_OUT_STREAM, timestamp => { + this.setLatestOutputTimestamp(timestamp); + }); + + const binaryGraph = graphConfig.serializeBinary(); + this.setGraph(new Uint8Array(binaryGraph), /* isBinary= */ true); + } + + /** + * Converts the user-facing RegionOfInterest message to the RenderData proto + * and sends it to the graph + */ + private processRenderData(roi: RegionOfInterest, timestamp: number): void { + const renderData = new RenderDataProto(); + + const renderAnnotation = new RenderAnnotationProto(); + + const color = new ColorProto(); + color.setR(255); + renderAnnotation.setColor(color); + + const point = new RenderAnnotationProto.Point(); + point.setNormalized(true); + point.setX(roi.keypoint.x); + point.setY(roi.keypoint.y); + renderAnnotation.setPoint(point); + + renderData.addRenderAnnotations(renderAnnotation); + + this.graphRunner.addProtoToStream( + renderData.serializeBinary(), 'mediapipe.RenderData', ROI_IN_STREAM, + timestamp); + } +} + + diff --git a/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_options.d.ts b/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_options.d.ts new file mode 100644 index 000000000..beb43cd81 --- /dev/null +++ b/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_options.d.ts @@ -0,0 +1,36 @@ +/** + * Copyright 2023 The MediaPipe Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import {TaskRunnerOptions} from '../../../../tasks/web/core/task_runner_options'; + +/** Options to configure the MediaPipe Interactive Segmenter Task */ +export interface InteractiveSegmenterOptions extends TaskRunnerOptions { + /** + * The output type of segmentation results. + * + * The two supported modes are: + * - Category Mask: Gives a single output mask where each pixel represents + * the class which the pixel in the original image was + * predicted to belong to. + * - Confidence Mask: Gives a list of output masks (one for each class). For + * each mask, the pixel represents the prediction + * confidence, usually in the [0.0, 0.1] range. + * + * Defaults to `CATEGORY_MASK`. + */ + outputType?: 'CATEGORY_MASK'|'CONFIDENCE_MASK'|undefined; +} diff --git a/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_test.ts b/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_test.ts new file mode 100644 index 000000000..4be9f7d37 --- /dev/null +++ b/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_test.ts @@ -0,0 +1,214 @@ +/** + * Copyright 2023 The MediaPipe Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'jasmine'; + +// Placeholder for internal dependency on encodeByteArray +import {CalculatorGraphConfig} from '../../../../framework/calculator_pb'; +import {addJasmineCustomFloatEqualityTester, createSpyWasmModule, MediapipeTasksFake, SpyWasmModule, verifyGraph, verifyListenersRegistered} from '../../../../tasks/web/core/task_runner_test_utils'; +import {RenderData as RenderDataProto} from '../../../../util/render_data_pb'; +import {WasmImage} from '../../../../web/graph_runner/graph_runner_image_lib'; + +import {InteractiveSegmenter, RegionOfInterest} from './interactive_segmenter'; + + +const ROI: RegionOfInterest = { + keypoint: {x: 0.1, y: 0.2} +}; + +class InteractiveSegmenterFake extends InteractiveSegmenter implements + MediapipeTasksFake { + calculatorName = + 'mediapipe.tasks.vision.interactive_segmenter.InteractiveSegmenterGraph'; + attachListenerSpies: jasmine.Spy[] = []; + graph: CalculatorGraphConfig|undefined; + + fakeWasmModule: SpyWasmModule; + imageVectorListener: + ((images: WasmImage[], timestamp: number) => void)|undefined; + lastRoi?: RenderDataProto; + + constructor() { + super(createSpyWasmModule(), /* glCanvas= */ null); + this.fakeWasmModule = + this.graphRunner.wasmModule as unknown as SpyWasmModule; + + this.attachListenerSpies[0] = + spyOn(this.graphRunner, 'attachImageVectorListener') + .and.callFake((stream, listener) => { + expect(stream).toEqual('image_out'); + this.imageVectorListener = listener; + }); + spyOn(this.graphRunner, 'setGraph').and.callFake(binaryGraph => { + this.graph = CalculatorGraphConfig.deserializeBinary(binaryGraph); + }); + spyOn(this.graphRunner, 'addGpuBufferAsImageToStream'); + + spyOn(this.graphRunner, 'addProtoToStream') + .and.callFake((data, protoName, stream) => { + if (stream === 'roi_in') { + expect(protoName).toEqual('mediapipe.RenderData'); + this.lastRoi = RenderDataProto.deserializeBinary(data); + } + }); + } +} + +describe('InteractiveSegmenter', () => { + let interactiveSegmenter: InteractiveSegmenterFake; + + beforeEach(async () => { + addJasmineCustomFloatEqualityTester(); + interactiveSegmenter = new InteractiveSegmenterFake(); + await interactiveSegmenter.setOptions( + {baseOptions: {modelAssetBuffer: new Uint8Array([])}}); + }); + + it('initializes graph', async () => { + verifyGraph(interactiveSegmenter); + verifyListenersRegistered(interactiveSegmenter); + }); + + it('reloads graph when settings are changed', async () => { + await interactiveSegmenter.setOptions({outputType: 'CATEGORY_MASK'}); + verifyGraph(interactiveSegmenter, [['segmenterOptions', 'outputType'], 1]); + verifyListenersRegistered(interactiveSegmenter); + + await interactiveSegmenter.setOptions({outputType: 'CONFIDENCE_MASK'}); + verifyGraph(interactiveSegmenter, [['segmenterOptions', 'outputType'], 2]); + verifyListenersRegistered(interactiveSegmenter); + }); + + it('can use custom models', async () => { + const newModel = new Uint8Array([0, 1, 2, 3, 4]); + const newModelBase64 = Buffer.from(newModel).toString('base64'); + await interactiveSegmenter.setOptions({ + baseOptions: { + modelAssetBuffer: newModel, + } + }); + + verifyGraph( + interactiveSegmenter, + /* expectedCalculatorOptions= */ undefined, + /* expectedBaseOptions= */ + [ + 'modelAsset', { + fileContent: newModelBase64, + fileName: undefined, + fileDescriptorMeta: undefined, + filePointerMeta: undefined + } + ]); + }); + + + describe('setOptions()', () => { + const fieldPath = ['segmenterOptions', 'outputType']; + + it(`can set outputType`, async () => { + await interactiveSegmenter.setOptions({outputType: 'CONFIDENCE_MASK'}); + verifyGraph(interactiveSegmenter, [fieldPath, 2]); + }); + + it(`can clear outputType`, async () => { + await interactiveSegmenter.setOptions({outputType: 'CONFIDENCE_MASK'}); + verifyGraph(interactiveSegmenter, [fieldPath, 2]); + await interactiveSegmenter.setOptions({outputType: undefined}); + verifyGraph(interactiveSegmenter, [fieldPath, 1]); + }); + }); + + it('doesn\'t support region of interest', () => { + expect(() => { + interactiveSegmenter.segment( + {} as HTMLImageElement, ROI, + {regionOfInterest: {left: 0, right: 0, top: 0, bottom: 0}}, () => {}); + }).toThrowError('This task doesn\'t support region-of-interest.'); + }); + + it('sends region-of-interest', (done) => { + interactiveSegmenter.fakeWasmModule._waitUntilIdle.and.callFake(() => { + expect(interactiveSegmenter.lastRoi).toBeDefined(); + expect(interactiveSegmenter.lastRoi!.toObject().renderAnnotationsList![0]) + .toEqual(jasmine.objectContaining({ + color: {r: 255, b: undefined, g: undefined}, + })); + done(); + }); + + interactiveSegmenter.segment({} as HTMLImageElement, ROI, () => {}); + }); + + it('supports category masks', (done) => { + const mask = new Uint8Array([1, 2, 3, 4]); + + // Pass the test data to our listener + interactiveSegmenter.fakeWasmModule._waitUntilIdle.and.callFake(() => { + verifyListenersRegistered(interactiveSegmenter); + interactiveSegmenter.imageVectorListener!( + [ + {data: mask, width: 2, height: 2}, + ], + /* timestamp= */ 1337); + }); + + // Invoke the image segmenter + interactiveSegmenter.segment( + {} as HTMLImageElement, ROI, (masks, width, height) => { + expect(interactiveSegmenter.fakeWasmModule._waitUntilIdle) + .toHaveBeenCalled(); + expect(masks).toHaveSize(1); + expect(masks[0]).toEqual(mask); + expect(width).toEqual(2); + expect(height).toEqual(2); + done(); + }); + }); + + it('supports confidence masks', async () => { + const mask1 = new Float32Array([0.1, 0.2, 0.3, 0.4]); + const mask2 = new Float32Array([0.5, 0.6, 0.7, 0.8]); + + await interactiveSegmenter.setOptions({outputType: 'CONFIDENCE_MASK'}); + + // Pass the test data to our listener + interactiveSegmenter.fakeWasmModule._waitUntilIdle.and.callFake(() => { + verifyListenersRegistered(interactiveSegmenter); + interactiveSegmenter.imageVectorListener!( + [ + {data: mask1, width: 2, height: 2}, + {data: mask2, width: 2, height: 2}, + ], + 1337); + }); + + return new Promise(resolve => { + // Invoke the image segmenter + interactiveSegmenter.segment( + {} as HTMLImageElement, ROI, (masks, width, height) => { + expect(interactiveSegmenter.fakeWasmModule._waitUntilIdle) + .toHaveBeenCalled(); + expect(masks).toHaveSize(2); + expect(masks[0]).toEqual(mask1); + expect(masks[1]).toEqual(mask2); + expect(width).toEqual(2); + expect(height).toEqual(2); + resolve(); + }); + }); + }); +}); diff --git a/mediapipe/tasks/web/vision/types.ts b/mediapipe/tasks/web/vision/types.ts index b9d951f60..fa6939460 100644 --- a/mediapipe/tasks/web/vision/types.ts +++ b/mediapipe/tasks/web/vision/types.ts @@ -20,4 +20,5 @@ export * from '../../../tasks/web/vision/hand_landmarker/hand_landmarker'; export * from '../../../tasks/web/vision/image_classifier/image_classifier'; export * from '../../../tasks/web/vision/image_embedder/image_embedder'; export * from '../../../tasks/web/vision/image_segmenter/image_segmenter'; +export * from '../../../tasks/web/vision/interactive_segmenter/interactive_segmenter'; export * from '../../../tasks/web/vision/object_detector/object_detector'; From 3e952a5f1f96b0de602ebdf07020cc4322a34c73 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 14 Mar 2023 15:49:05 -0700 Subject: [PATCH 078/136] Update Node version to 16.19.0 PiperOrigin-RevId: 516654979 --- WORKSPACE | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WORKSPACE b/WORKSPACE index 8cb2b9d04..ea12f439e 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -499,8 +499,8 @@ cc_crosstool(name = "crosstool") # Node dependencies http_archive( name = "build_bazel_rules_nodejs", - sha256 = "5aae76dced38f784b58d9776e4ab12278bc156a9ed2b1d9fcd3e39921dc88fda", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.7.1/rules_nodejs-5.7.1.tar.gz"], + sha256 = "94070eff79305be05b7699207fbac5d2608054dd53e6109f7d00d923919ff45a", + urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.2/rules_nodejs-5.8.2.tar.gz"], ) load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies") From f60fe739d383c480085e2eabd1f8daee1bf4393e Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 14 Mar 2023 15:53:21 -0700 Subject: [PATCH 079/136] Internal change PiperOrigin-RevId: 516656029 --- third_party/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/third_party/BUILD b/third_party/BUILD index 034243b3e..ea037dce1 100644 --- a/third_party/BUILD +++ b/third_party/BUILD @@ -171,6 +171,7 @@ cmake_external( "-lrt", ] + select({ "//mediapipe:ios": ["-framework Cocoa"], + "//mediapipe:macos": ["-framework Cocoa"], "//conditions:default": [], }), shared_libraries = select({ From cd2cc971bb16f8ba466972c3670e0ba70b9b9f43 Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Tue, 14 Mar 2023 16:24:56 -0700 Subject: [PATCH 080/136] Registering FaceGeometry proto. PiperOrigin-RevId: 516663848 --- mediapipe/python/packet_test.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mediapipe/python/packet_test.py b/mediapipe/python/packet_test.py index 93c8601bb..16fc37c87 100644 --- a/mediapipe/python/packet_test.py +++ b/mediapipe/python/packet_test.py @@ -28,7 +28,6 @@ from mediapipe.python._framework_bindings import calculator_graph from mediapipe.python._framework_bindings import image from mediapipe.python._framework_bindings import image_frame from mediapipe.python._framework_bindings import packet -from mediapipe.tasks.cc.vision.face_geometry.proto import face_geometry_pb2 CalculatorGraph = calculator_graph.CalculatorGraph Image = image.Image @@ -178,11 +177,6 @@ class PacketTest(absltest.TestCase): text_format.Parse('score: 0.5', detection) p = packet_creator.create_proto(detection).at(100) - def test_face_geometry_proto_packet(self): - face_geometry_in = face_geometry_pb2.FaceGeometry() - p = packet_creator.create_proto(face_geometry_in).at(100) - face_geometry_out = packet_getter.get_proto(p) - def test_string_packet(self): p = packet_creator.create_string('abc').at(100) self.assertEqual(packet_getter.get_str(p), 'abc') From 9a89b47572a9c6732386a00c55b5951292900430 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 16:40:05 -0700 Subject: [PATCH 081/136] Rename *ModelFile to *File for methods of ModelAssetBundleResources. PiperOrigin-RevId: 516667461 --- .../cc/core/model_asset_bundle_resources.cc | 35 ++++++++-------- .../cc/core/model_asset_bundle_resources.h | 31 +++++++------- .../core/model_asset_bundle_resources_test.cc | 40 +++++++++---------- .../face_landmarker/face_landmarker_graph.cc | 8 ++-- .../gesture_recognizer_graph.cc | 7 ++-- .../hand_gesture_recognizer_graph.cc | 9 ++--- .../hand_landmarker/hand_landmarker_graph.cc | 4 +- 7 files changed, 62 insertions(+), 72 deletions(-) diff --git a/mediapipe/tasks/cc/core/model_asset_bundle_resources.cc b/mediapipe/tasks/cc/core/model_asset_bundle_resources.cc index 5867be49b..2f53ff2d5 100644 --- a/mediapipe/tasks/cc/core/model_asset_bundle_resources.cc +++ b/mediapipe/tasks/cc/core/model_asset_bundle_resources.cc @@ -51,12 +51,11 @@ ModelAssetBundleResources::Create( auto model_bundle_resources = absl::WrapUnique( new ModelAssetBundleResources(tag, std::move(model_asset_bundle_file))); MP_RETURN_IF_ERROR( - model_bundle_resources->ExtractModelFilesFromExternalFileProto()); + model_bundle_resources->ExtractFilesFromExternalFileProto()); return model_bundle_resources; } -absl::Status -ModelAssetBundleResources::ExtractModelFilesFromExternalFileProto() { +absl::Status ModelAssetBundleResources::ExtractFilesFromExternalFileProto() { if (model_asset_bundle_file_->has_file_name()) { // If the model asset bundle file name is a relative path, searches the file // in a platform-specific location and returns the absolute path on success. @@ -72,34 +71,32 @@ ModelAssetBundleResources::ExtractModelFilesFromExternalFileProto() { model_asset_bundle_file_handler_->GetFileContent().data(); size_t buffer_size = model_asset_bundle_file_handler_->GetFileContent().size(); - return metadata::ExtractFilesfromZipFile(buffer_data, buffer_size, - &model_files_); + return metadata::ExtractFilesfromZipFile(buffer_data, buffer_size, &files_); } -absl::StatusOr ModelAssetBundleResources::GetModelFile( +absl::StatusOr ModelAssetBundleResources::GetFile( const std::string& filename) const { - auto it = model_files_.find(filename); - if (it == model_files_.end()) { - auto model_files = ListModelFiles(); - std::string all_model_files = - absl::StrJoin(model_files.begin(), model_files.end(), ", "); + auto it = files_.find(filename); + if (it == files_.end()) { + auto files = ListFiles(); + std::string all_files = absl::StrJoin(files.begin(), files.end(), ", "); return CreateStatusWithPayload( StatusCode::kNotFound, - absl::StrFormat("No model file with name: %s. All model files in the " - "model asset bundle are: %s.", - filename, all_model_files), + absl::StrFormat("No file with name: %s. All files in the model asset " + "bundle are: %s.", + filename, all_files), MediaPipeTasksStatus::kFileNotFoundError); } return it->second; } -std::vector ModelAssetBundleResources::ListModelFiles() const { - std::vector model_names; - for (const auto& [model_name, _] : model_files_) { - model_names.push_back(model_name); +std::vector ModelAssetBundleResources::ListFiles() const { + std::vector file_names; + for (const auto& [file_name, _] : files_) { + file_names.push_back(file_name); } - return model_names; + return file_names; } } // namespace core diff --git a/mediapipe/tasks/cc/core/model_asset_bundle_resources.h b/mediapipe/tasks/cc/core/model_asset_bundle_resources.h index 61474d3ad..02d989d4b 100644 --- a/mediapipe/tasks/cc/core/model_asset_bundle_resources.h +++ b/mediapipe/tasks/cc/core/model_asset_bundle_resources.h @@ -28,8 +28,8 @@ namespace core { // The mediapipe task model asset bundle resources class. // A ModelAssetBundleResources object, created from an external file proto, // contains model asset bundle related resources and the method to extract the -// tflite models or model asset bundles for the mediapipe sub-tasks. As the -// resources are owned by the ModelAssetBundleResources object +// tflite models, resource files or model asset bundles for the mediapipe +// sub-tasks. As the resources are owned by the ModelAssetBundleResources object // callers must keep ModelAssetBundleResources alive while using any of the // resources. class ModelAssetBundleResources { @@ -50,14 +50,13 @@ class ModelAssetBundleResources { // Returns the model asset bundle resources tag. std::string GetTag() const { return tag_; } - // Gets the contents of the model file (either tflite model file or model - // bundle file) with the provided name. An error is returned if there is no - // such model file. - absl::StatusOr GetModelFile( - const std::string& filename) const; + // Gets the contents of the model file (either tflite model file, resource + // file or model bundle file) with the provided name. An error is returned if + // there is no such model file. + absl::StatusOr GetFile(const std::string& filename) const; - // Lists all the model file names in the model asset model. - std::vector ListModelFiles() const; + // Lists all the file names in the model asset model. + std::vector ListFiles() const; private: // Constructor. @@ -65,9 +64,9 @@ class ModelAssetBundleResources { const std::string& tag, std::unique_ptr model_asset_bundle_file); - // Extracts the model files (either tflite model file or model bundle file) - // from the external file proto. - absl::Status ExtractModelFilesFromExternalFileProto(); + // Extracts the model files (either tflite model file, resource file or model + // bundle file) from the external file proto. + absl::Status ExtractFilesFromExternalFileProto(); // The model asset bundle resources tag. const std::string tag_; @@ -78,11 +77,11 @@ class ModelAssetBundleResources { // The ExternalFileHandler for the model asset bundle. std::unique_ptr model_asset_bundle_file_handler_; - // The model files bundled in model asset bundle, as a map with the filename + // The files bundled in model asset bundle, as a map with the filename // (corresponding to a basename, e.g. "hand_detector.tflite") as key and - // a pointer to the file contents as value. Each model file can be either - // a TFLite model file or a model bundle file for sub-task. - absl::flat_hash_map model_files_; + // a pointer to the file contents as value. Each file can be either a TFLite + // model file, resource file or a model bundle file for sub-task. + absl::flat_hash_map files_; }; } // namespace core diff --git a/mediapipe/tasks/cc/core/model_asset_bundle_resources_test.cc b/mediapipe/tasks/cc/core/model_asset_bundle_resources_test.cc index 359deef91..85a94ccc7 100644 --- a/mediapipe/tasks/cc/core/model_asset_bundle_resources_test.cc +++ b/mediapipe/tasks/cc/core/model_asset_bundle_resources_test.cc @@ -66,10 +66,9 @@ TEST(ModelAssetBundleResourcesTest, CreateFromBinaryContent) { ModelAssetBundleResources::Create(kTestModelBundleResourcesTag, std::move(model_file))); MP_EXPECT_OK( - model_bundle_resources->GetModelFile("dummy_hand_landmarker.task") - .status()); + model_bundle_resources->GetFile("dummy_hand_landmarker.task").status()); MP_EXPECT_OK( - model_bundle_resources->GetModelFile("dummy_gesture_recognizer.tflite") + model_bundle_resources->GetFile("dummy_gesture_recognizer.tflite") .status()); } @@ -81,10 +80,9 @@ TEST(ModelAssetBundleResourcesTest, CreateFromFile) { ModelAssetBundleResources::Create(kTestModelBundleResourcesTag, std::move(model_file))); MP_EXPECT_OK( - model_bundle_resources->GetModelFile("dummy_hand_landmarker.task") - .status()); + model_bundle_resources->GetFile("dummy_hand_landmarker.task").status()); MP_EXPECT_OK( - model_bundle_resources->GetModelFile("dummy_gesture_recognizer.tflite") + model_bundle_resources->GetFile("dummy_gesture_recognizer.tflite") .status()); } @@ -98,10 +96,9 @@ TEST(ModelAssetBundleResourcesTest, CreateFromFileDescriptor) { ModelAssetBundleResources::Create(kTestModelBundleResourcesTag, std::move(model_file))); MP_EXPECT_OK( - model_bundle_resources->GetModelFile("dummy_hand_landmarker.task") - .status()); + model_bundle_resources->GetFile("dummy_hand_landmarker.task").status()); MP_EXPECT_OK( - model_bundle_resources->GetModelFile("dummy_gesture_recognizer.tflite") + model_bundle_resources->GetFile("dummy_gesture_recognizer.tflite") .status()); } #endif // _WIN32 @@ -115,10 +112,9 @@ TEST(ModelAssetBundleResourcesTest, CreateFromFilePointer) { ModelAssetBundleResources::Create(kTestModelBundleResourcesTag, std::move(model_file))); MP_EXPECT_OK( - model_bundle_resources->GetModelFile("dummy_hand_landmarker.task") - .status()); + model_bundle_resources->GetFile("dummy_hand_landmarker.task").status()); MP_EXPECT_OK( - model_bundle_resources->GetModelFile("dummy_gesture_recognizer.tflite") + model_bundle_resources->GetFile("dummy_gesture_recognizer.tflite") .status()); } @@ -147,7 +143,7 @@ TEST(ModelAssetBundleResourcesTest, ExtractValidModelBundleFile) { ModelAssetBundleResources::Create(kTestModelBundleResourcesTag, std::move(model_file))); auto status_or_model_bundle_file = - model_bundle_resources->GetModelFile("dummy_hand_landmarker.task"); + model_bundle_resources->GetFile("dummy_hand_landmarker.task"); MP_EXPECT_OK(status_or_model_bundle_file.status()); // Creates sub-task model asset bundle resources. @@ -159,10 +155,10 @@ TEST(ModelAssetBundleResourcesTest, ExtractValidModelBundleFile) { ModelAssetBundleResources::Create(kTestModelBundleResourcesTag, std::move(hand_landmaker_model_file))); MP_EXPECT_OK(hand_landmaker_model_bundle_resources - ->GetModelFile("dummy_hand_detector.tflite") + ->GetFile("dummy_hand_detector.tflite") .status()); MP_EXPECT_OK(hand_landmaker_model_bundle_resources - ->GetModelFile("dummy_hand_landmarker.tflite") + ->GetFile("dummy_hand_landmarker.tflite") .status()); } @@ -175,7 +171,7 @@ TEST(ModelAssetBundleResourcesTest, ExtractValidTFLiteModelFile) { ModelAssetBundleResources::Create(kTestModelBundleResourcesTag, std::move(model_file))); auto status_or_model_bundle_file = - model_bundle_resources->GetModelFile("dummy_gesture_recognizer.tflite"); + model_bundle_resources->GetFile("dummy_gesture_recognizer.tflite"); MP_EXPECT_OK(status_or_model_bundle_file.status()); // Verify tflite model works. @@ -200,12 +196,12 @@ TEST(ModelAssetBundleResourcesTest, ExtractInvalidModelFile) { auto model_bundle_resources, ModelAssetBundleResources::Create(kTestModelBundleResourcesTag, std::move(model_file))); - auto status = model_bundle_resources->GetModelFile("not_found.task").status(); + auto status = model_bundle_resources->GetFile("not_found.task").status(); EXPECT_EQ(status.code(), absl::StatusCode::kNotFound); - EXPECT_THAT(status.message(), - testing::HasSubstr( - "No model file with name: not_found.task. All model files in " - "the model asset bundle are: ")); + EXPECT_THAT( + status.message(), + testing::HasSubstr("No file with name: not_found.task. All files in " + "the model asset bundle are: ")); EXPECT_THAT(status.GetPayload(kMediaPipeTasksPayload), testing::Optional(absl::Cord( absl::StrCat(MediaPipeTasksStatus::kFileNotFoundError)))); @@ -219,7 +215,7 @@ TEST(ModelAssetBundleResourcesTest, ListModelFiles) { auto model_bundle_resources, ModelAssetBundleResources::Create(kTestModelBundleResourcesTag, std::move(model_file))); - auto model_files = model_bundle_resources->ListModelFiles(); + auto model_files = model_bundle_resources->ListFiles(); std::vector expected_model_files = { "dummy_gesture_recognizer.tflite", "dummy_hand_landmarker.task"}; std::sort(model_files.begin(), model_files.end()); diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc index d6cc630b2..78927f27b 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc @@ -116,7 +116,7 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, options->mutable_face_detector_graph_options(); if (!face_detector_graph_options->base_options().has_model_asset()) { ASSIGN_OR_RETURN(const auto face_detector_file, - resources.GetModelFile(kFaceDetectorTFLiteName)); + resources.GetFile(kFaceDetectorTFLiteName)); SetExternalFile(face_detector_file, face_detector_graph_options->mutable_base_options() ->mutable_model_asset(), @@ -132,7 +132,7 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, if (!face_landmarks_detector_graph_options->base_options() .has_model_asset()) { ASSIGN_OR_RETURN(const auto face_landmarks_detector_file, - resources.GetModelFile(kFaceLandmarksDetectorTFLiteName)); + resources.GetFile(kFaceLandmarksDetectorTFLiteName)); SetExternalFile( face_landmarks_detector_file, face_landmarks_detector_graph_options->mutable_base_options() @@ -146,7 +146,7 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, ->set_use_stream_mode(options->base_options().use_stream_mode()); absl::StatusOr face_blendshape_model = - resources.GetModelFile(kFaceBlendshapeTFLiteName); + resources.GetFile(kFaceBlendshapeTFLiteName); if (face_blendshape_model.ok()) { SetExternalFile(*face_blendshape_model, face_landmarks_detector_graph_options @@ -327,7 +327,7 @@ class FaceLandmarkerGraph : public core::ModelTaskGraph { // Set the face geometry metdata file for // FaceGeometryFromLandmarksGraph. ASSIGN_OR_RETURN(auto face_geometry_pipeline_metadata_file, - model_asset_bundle_resources->GetModelFile( + model_asset_bundle_resources->GetFile( kFaceGeometryPipelineMetadataName)); SetExternalFile(face_geometry_pipeline_metadata_file, sc->MutableOptions() diff --git a/mediapipe/tasks/cc/vision/gesture_recognizer/gesture_recognizer_graph.cc b/mediapipe/tasks/cc/vision/gesture_recognizer/gesture_recognizer_graph.cc index 11b2d12c4..55db07cb8 100644 --- a/mediapipe/tasks/cc/vision/gesture_recognizer/gesture_recognizer_graph.cc +++ b/mediapipe/tasks/cc/vision/gesture_recognizer/gesture_recognizer_graph.cc @@ -92,7 +92,7 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, GestureRecognizerGraphOptions* options, bool is_copy) { ASSIGN_OR_RETURN(const auto hand_landmarker_file, - resources.GetModelFile(kHandLandmarkerBundleAssetName)); + resources.GetFile(kHandLandmarkerBundleAssetName)); auto* hand_landmarker_graph_options = options->mutable_hand_landmarker_graph_options(); SetExternalFile(hand_landmarker_file, @@ -105,9 +105,8 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, hand_landmarker_graph_options->mutable_base_options()->set_use_stream_mode( options->base_options().use_stream_mode()); - ASSIGN_OR_RETURN( - const auto hand_gesture_recognizer_file, - resources.GetModelFile(kHandGestureRecognizerBundleAssetName)); + ASSIGN_OR_RETURN(const auto hand_gesture_recognizer_file, + resources.GetFile(kHandGestureRecognizerBundleAssetName)); auto* hand_gesture_recognizer_graph_options = options->mutable_hand_gesture_recognizer_graph_options(); SetExternalFile(hand_gesture_recognizer_file, diff --git a/mediapipe/tasks/cc/vision/gesture_recognizer/hand_gesture_recognizer_graph.cc b/mediapipe/tasks/cc/vision/gesture_recognizer/hand_gesture_recognizer_graph.cc index 4db57e85b..3fe999937 100644 --- a/mediapipe/tasks/cc/vision/gesture_recognizer/hand_gesture_recognizer_graph.cc +++ b/mediapipe/tasks/cc/vision/gesture_recognizer/hand_gesture_recognizer_graph.cc @@ -207,7 +207,7 @@ class SingleHandGestureRecognizerGraph : public core::ModelTaskGraph { HandGestureRecognizerGraphOptions* options, bool is_copy) { ASSIGN_OR_RETURN(const auto gesture_embedder_file, - resources.GetModelFile(kGestureEmbedderTFLiteName)); + resources.GetFile(kGestureEmbedderTFLiteName)); auto* gesture_embedder_graph_options = options->mutable_gesture_embedder_graph_options(); SetExternalFile(gesture_embedder_file, @@ -218,9 +218,8 @@ class SingleHandGestureRecognizerGraph : public core::ModelTaskGraph { options->base_options(), gesture_embedder_graph_options->mutable_base_options()); - ASSIGN_OR_RETURN( - const auto canned_gesture_classifier_file, - resources.GetModelFile(kCannedGestureClassifierTFLiteName)); + ASSIGN_OR_RETURN(const auto canned_gesture_classifier_file, + resources.GetFile(kCannedGestureClassifierTFLiteName)); auto* canned_gesture_classifier_graph_options = options->mutable_canned_gesture_classifier_graph_options(); SetExternalFile( @@ -233,7 +232,7 @@ class SingleHandGestureRecognizerGraph : public core::ModelTaskGraph { canned_gesture_classifier_graph_options->mutable_base_options()); const auto custom_gesture_classifier_file = - resources.GetModelFile(kCustomGestureClassifierTFLiteName); + resources.GetFile(kCustomGestureClassifierTFLiteName); if (custom_gesture_classifier_file.ok()) { has_custom_gesture_classifier = true; auto* custom_gesture_classifier_graph_options = diff --git a/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker_graph.cc b/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker_graph.cc index 21e43fc82..b37141005 100644 --- a/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker_graph.cc +++ b/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker_graph.cc @@ -97,7 +97,7 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, options->mutable_hand_detector_graph_options(); if (!hand_detector_graph_options->base_options().has_model_asset()) { ASSIGN_OR_RETURN(const auto hand_detector_file, - resources.GetModelFile(kHandDetectorTFLiteName)); + resources.GetFile(kHandDetectorTFLiteName)); SetExternalFile(hand_detector_file, hand_detector_graph_options->mutable_base_options() ->mutable_model_asset(), @@ -113,7 +113,7 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, if (!hand_landmarks_detector_graph_options->base_options() .has_model_asset()) { ASSIGN_OR_RETURN(const auto hand_landmarks_detector_file, - resources.GetModelFile(kHandLandmarksDetectorTFLiteName)); + resources.GetFile(kHandLandmarksDetectorTFLiteName)); SetExternalFile( hand_landmarks_detector_file, hand_landmarks_detector_graph_options->mutable_base_options() From 51d9640d887eca3fcb2c0baf3b5fc075f6c03e7c Mon Sep 17 00:00:00 2001 From: Yuqi Li Date: Tue, 14 Mar 2023 16:58:07 -0700 Subject: [PATCH 082/136] Add metadata writer for image segmentation. PiperOrigin-RevId: 516671364 --- mediapipe/tasks/metadata/BUILD | 14 +- .../image_segmenter_metadata_schema.fbs | 59 +++++++ mediapipe/tasks/python/metadata/metadata.py | 66 ++++++- .../python/metadata/metadata_writers/BUILD | 14 ++ .../metadata_writers/image_segmenter.py | 161 ++++++++++++++++++ .../metadata_writers/metadata_info.py | 46 +++++ .../metadata_writers/metadata_writer.py | 30 ++++ .../test/metadata/metadata_writers/BUILD | 15 ++ .../metadata_writers/image_segmenter_test.py | 98 +++++++++++ .../metadata_writers/metadata_info_test.py | 21 +++ mediapipe/tasks/testdata/metadata/BUILD | 12 ++ .../tasks/testdata/metadata/deeplabv3.json | 66 +++++++ .../metadata/deeplabv3_with_activation.json | 67 ++++++++ .../metadata/deeplabv3_without_labels.json | 59 +++++++ .../metadata/segmentation_mask_meta.json | 24 +++ .../testdata/metadata/segmenter_labelmap.txt | 21 +++ third_party/external_files.bzl | 36 ++++ 17 files changed, 805 insertions(+), 4 deletions(-) create mode 100644 mediapipe/tasks/metadata/image_segmenter_metadata_schema.fbs create mode 100644 mediapipe/tasks/python/metadata/metadata_writers/image_segmenter.py create mode 100644 mediapipe/tasks/python/test/metadata/metadata_writers/image_segmenter_test.py create mode 100644 mediapipe/tasks/testdata/metadata/deeplabv3.json create mode 100644 mediapipe/tasks/testdata/metadata/deeplabv3_with_activation.json create mode 100644 mediapipe/tasks/testdata/metadata/deeplabv3_without_labels.json create mode 100644 mediapipe/tasks/testdata/metadata/segmentation_mask_meta.json create mode 100644 mediapipe/tasks/testdata/metadata/segmenter_labelmap.txt diff --git a/mediapipe/tasks/metadata/BUILD b/mediapipe/tasks/metadata/BUILD index abd948809..de6350685 100644 --- a/mediapipe/tasks/metadata/BUILD +++ b/mediapipe/tasks/metadata/BUILD @@ -7,7 +7,9 @@ package( licenses = ["notice"], # Apache 2.0 ) -exports_files(["metadata_schema.fbs"]) +exports_files(glob([ + "*.fbs", +])) # Generic schema for model metadata. flatbuffer_cc_library( @@ -24,3 +26,13 @@ flatbuffer_py_library( name = "metadata_schema_py", srcs = ["metadata_schema.fbs"], ) + +flatbuffer_cc_library( + name = "image_segmenter_metadata_schema_cc", + srcs = ["image_segmenter_metadata_schema.fbs"], +) + +flatbuffer_py_library( + name = "image_segmenter_metadata_schema_py", + srcs = ["image_segmenter_metadata_schema.fbs"], +) diff --git a/mediapipe/tasks/metadata/image_segmenter_metadata_schema.fbs b/mediapipe/tasks/metadata/image_segmenter_metadata_schema.fbs new file mode 100644 index 000000000..e120c64aa --- /dev/null +++ b/mediapipe/tasks/metadata/image_segmenter_metadata_schema.fbs @@ -0,0 +1,59 @@ +// Copyright 2023 The MediaPipe Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace mediapipe.tasks; + +// Image segmenter metadata contains information specific for the image +// segmentation task. The metadata can be added in +// SubGraphMetadata.custom_metadata [1] in model metadata. +// [1]: https://github.com/google/mediapipe/blob/46b5c4012d2ef76c9d92bb0d88a6b107aee83814/mediapipe/tasks/metadata/metadata_schema.fbs#L685 + +// ImageSegmenterOptions.min_parser_version indicates the minimum necessary +// image segmenter metadata parser version to fully understand all fields in a +// given metadata flatbuffer. This min_parser_version is specific for the +// image segmenter metadata defined in this schema file. +// +// New fields and types will have associated comments with the schema version +// for which they were added. +// +// Schema Semantic version: 1.0.0 + +// This indicates the flatbuffer compatibility. The number will bump up when a +// break change is applied to the schema, such as removing fields or adding new +// fields to the middle of a table. +file_identifier "V001"; + +// History: +// 1.0.0 - Initial version. + +// Supported activation functions. +enum Activation: byte { + NONE = 0, + SIGMOID = 1, + SOFTMAX = 2 +} + +table ImageSegmenterOptions { + // The activation function of the output layer in the image segmenter. + activation: Activation; + + // The minimum necessary image segmenter metadata parser version to fully + // understand all fields in a given metadata flatbuffer. This field is + // automaticaly populated by the MetadataPopulator when the metadata is + // populated into a TFLite model. This min_parser_version is specific for the + // image segmenter metadata defined in this schema file. + min_parser_version:string; +} + +root_type ImageSegmenterOptions; diff --git a/mediapipe/tasks/python/metadata/metadata.py b/mediapipe/tasks/python/metadata/metadata.py index 6afb5a3fa..e294bfc8c 100644 --- a/mediapipe/tasks/python/metadata/metadata.py +++ b/mediapipe/tasks/python/metadata/metadata.py @@ -17,10 +17,13 @@ import copy import inspect import io +import json +import logging import os import shutil import sys import tempfile +from typing import Dict, Optional import warnings import zipfile @@ -789,13 +792,43 @@ class MetadataDisplayer(object): return [] +def _get_custom_metadata(metadata_buffer: bytes, name: str): + """Gets the custom metadata in metadata_buffer based on the name. + + Args: + metadata_buffer: valid metadata buffer in bytes. + name: custom metadata name. + + Returns: + Index of custom metadata, custom metadata flatbuffer. Returns (None, None) + if the custom metadata is not found. + """ + model_metadata = _metadata_fb.ModelMetadata.GetRootAs(metadata_buffer) + subgraph = model_metadata.SubgraphMetadata(0) + if subgraph is None or subgraph.CustomMetadataIsNone(): + return None, None + + for i in range(subgraph.CustomMetadataLength()): + custom_metadata = subgraph.CustomMetadata(i) + if custom_metadata.Name().decode("utf-8") == name: + return i, custom_metadata.DataAsNumpy().tobytes() + return None, None + + # Create an individual method for getting the metadata json file, so that it can # be used as a standalone util. -def convert_to_json(metadata_buffer): +def convert_to_json( + metadata_buffer, custom_metadata_schema: Optional[Dict[str, str]] = None +) -> str: """Converts the metadata into a json string. Args: metadata_buffer: valid metadata buffer in bytes. + custom_metadata_schema: A dict of custom metadata schema, in which key is + custom metadata name [1], value is the filepath that defines custom + metadata schema. For intance, custom_metadata_schema = + {"SEGMENTER_METADATA": "metadata/vision_tasks_metadata_schema.fbs"}. [1]: + https://github.com/google/mediapipe/blob/46b5c4012d2ef76c9d92bb0d88a6b107aee83814/mediapipe/tasks/metadata/metadata_schema.fbs#L612 Returns: Metadata in JSON format. @@ -803,7 +836,6 @@ def convert_to_json(metadata_buffer): Raises: ValueError: error occured when parsing the metadata schema file. """ - opt = _pywrap_flatbuffers.IDLOptions() opt.strict_json = True parser = _pywrap_flatbuffers.Parser(opt) @@ -811,7 +843,35 @@ def convert_to_json(metadata_buffer): metadata_schema_content = f.read() if not parser.parse(metadata_schema_content): raise ValueError("Cannot parse metadata schema. Reason: " + parser.error) - return _pywrap_flatbuffers.generate_text(parser, metadata_buffer) + # Json content which may contain binary custom metadata. + raw_json_content = _pywrap_flatbuffers.generate_text(parser, metadata_buffer) + if not custom_metadata_schema: + return raw_json_content + + json_data = json.loads(raw_json_content) + # Gets the custom metadata by name and parse the binary custom metadata into + # human readable json content. + for name, schema_file in custom_metadata_schema.items(): + idx, custom_metadata = _get_custom_metadata(metadata_buffer, name) + if not custom_metadata: + logging.info( + "No custom metadata with name %s in metadata flatbuffer.", name + ) + continue + _assert_file_exist(schema_file) + with _open_file(schema_file, "rb") as f: + custom_metadata_schema_content = f.read() + if not parser.parse(custom_metadata_schema_content): + raise ValueError( + "Cannot parse custom metadata schema. Reason: " + parser.error + ) + custom_metadata_json = _pywrap_flatbuffers.generate_text( + parser, custom_metadata + ) + json_meta = json_data["subgraph_metadata"][0]["custom_metadata"][idx] + json_meta["name"] = name + json_meta["data"] = json.loads(custom_metadata_json) + return json.dumps(json_data, indent=2) def _assert_file_exist(filename): diff --git a/mediapipe/tasks/python/metadata/metadata_writers/BUILD b/mediapipe/tasks/python/metadata/metadata_writers/BUILD index ce572283f..1f126c30b 100644 --- a/mediapipe/tasks/python/metadata/metadata_writers/BUILD +++ b/mediapipe/tasks/python/metadata/metadata_writers/BUILD @@ -50,6 +50,20 @@ py_library( deps = [":metadata_writer"], ) +py_library( + name = "image_segmenter", + srcs = ["image_segmenter.py"], + data = ["//mediapipe/tasks/metadata:image_segmenter_metadata_schema.fbs"], + deps = [ + ":metadata_info", + ":metadata_writer", + "//mediapipe/tasks/metadata:image_segmenter_metadata_schema_py", + "//mediapipe/tasks/metadata:metadata_schema_py", + "//mediapipe/tasks/python/metadata", + "@flatbuffers//:runtime_py", + ], +) + py_library( name = "object_detector", srcs = ["object_detector.py"], diff --git a/mediapipe/tasks/python/metadata/metadata_writers/image_segmenter.py b/mediapipe/tasks/python/metadata/metadata_writers/image_segmenter.py new file mode 100644 index 000000000..8e215437e --- /dev/null +++ b/mediapipe/tasks/python/metadata/metadata_writers/image_segmenter.py @@ -0,0 +1,161 @@ +# Copyright 2022 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Writes metadata and label file to the image segmenter models.""" +import enum +from typing import List, Optional + +import flatbuffers +from mediapipe.tasks.metadata import image_segmenter_metadata_schema_py_generated as _segmenter_metadata_fb +from mediapipe.tasks.metadata import metadata_schema_py_generated as _metadata_fb +from mediapipe.tasks.python.metadata import metadata +from mediapipe.tasks.python.metadata.metadata_writers import metadata_info +from mediapipe.tasks.python.metadata.metadata_writers import metadata_writer + + +_MODEL_NAME = "ImageSegmenter" +_MODEL_DESCRIPTION = ( + "Semantic image segmentation predicts whether each pixel " + "of an image is associated with a certain class." +) + +# Metadata Schema file for image segmenter. +_FLATC_METADATA_SCHEMA_FILE = metadata.get_path_to_datafile( + "../../../metadata/image_segmenter_metadata_schema.fbs", +) + +# Metadata name in custom metadata field. The metadata name is used to get +# image segmenter metadata from SubGraphMetadata.custom_metadata and +# shouldn't be changed. +_METADATA_NAME = "SEGMENTER_METADATA" + + +class Activation(enum.Enum): + NONE = 0 + SIGMOID = 1 + SOFTMAX = 2 + + +# Create an individual method for getting the metadata json file, so that it can +# be used as a standalone util. +def convert_to_json(metadata_buffer: bytearray) -> str: + """Converts the metadata into a json string. + + Args: + metadata_buffer: valid metadata buffer in bytes. + + Returns: + Metadata in JSON format. + + Raises: + ValueError: error occured when parsing the metadata schema file. + """ + return metadata.convert_to_json( + metadata_buffer, + custom_metadata_schema={_METADATA_NAME: _FLATC_METADATA_SCHEMA_FILE}, + ) + + +class ImageSegmenterOptionsMd(metadata_info.CustomMetadataMd): + """Image segmenter options metadata.""" + + _METADATA_FILE_IDENTIFIER = b"V001" + + def __init__(self, activation: Activation) -> None: + """Creates an ImageSegmenterOptionsMd object. + + Args: + activation: activation function of the output layer in the image + segmenter. + """ + self.activation = activation + super().__init__(name=_METADATA_NAME) + + def create_metadata(self) -> _metadata_fb.CustomMetadataT: + """Creates the image segmenter options metadata. + + Returns: + A Flatbuffers Python object of the custom metadata including image + segmenter options metadata. + """ + segmenter_options = _segmenter_metadata_fb.ImageSegmenterOptionsT() + segmenter_options.activation = self.activation.value + + # Get the image segmenter options flatbuffer. + b = flatbuffers.Builder(0) + b.Finish(segmenter_options.Pack(b), self._METADATA_FILE_IDENTIFIER) + segmenter_options_buf = b.Output() + + # Add the image segmenter options flatbuffer in custom metadata. + custom_metadata = _metadata_fb.CustomMetadataT() + custom_metadata.name = self.name + custom_metadata.data = segmenter_options_buf + return custom_metadata + + +class MetadataWriter(metadata_writer.MetadataWriterBase): + """MetadataWriter to write the metadata for image segmenter.""" + + @classmethod + def create( + cls, + model_buffer: bytearray, + input_norm_mean: List[float], + input_norm_std: List[float], + labels: Optional[metadata_writer.Labels] = None, + activation: Optional[Activation] = None, + ) -> "MetadataWriter": + """Creates MetadataWriter to write the metadata for image segmenter. + + The parameters required in this method are mandatory when using MediaPipe + Tasks. + + Example usage: + metadata_writer = image_segmenter.Metadatawriter.create(model_buffer, ...) + tflite_content, json_content = metadata_writer.populate() + + When calling `populate` function in this class, it returns TfLite content + and JSON content. Note that only the output TFLite is used for deployment. + The output JSON content is used to interpret the metadata content. + + Args: + model_buffer: A valid flatbuffer loaded from the TFLite model file. + input_norm_mean: the mean value used in the input tensor normalization + [1]. + input_norm_std: the std value used in the input tensor normalizarion [1]. + labels: an instance of Labels helper class used in the output category + tensor [2]. + activation: activation function for the output layer. + [1]: + https://github.com/google/mediapipe/blob/f8af41b1eb49ff4bdad756ff19d1d36f486be614/mediapipe/tasks/metadata/metadata_schema.fbs#L389 + [2]: + https://github.com/google/mediapipe/blob/f8af41b1eb49ff4bdad756ff19d1d36f486be614/mediapipe/tasks/metadata/metadata_schema.fbs#L116 + + Returns: + A MetadataWriter object. + """ + writer = metadata_writer.MetadataWriter(model_buffer) + writer.add_general_info(_MODEL_NAME, _MODEL_DESCRIPTION) + writer.add_image_input(input_norm_mean, input_norm_std) + writer.add_segmentation_output(labels=labels) + if activation is not None: + option_md = ImageSegmenterOptionsMd(activation) + writer.add_custom_metadata(option_md) + return cls(writer) + + def populate(self) -> tuple[bytearray, str]: + model_buf, _ = super().populate() + metadata_buf = metadata.get_metadata_buffer(model_buf) + json_content = convert_to_json(metadata_buf) + return model_buf, json_content diff --git a/mediapipe/tasks/python/metadata/metadata_writers/metadata_info.py b/mediapipe/tasks/python/metadata/metadata_writers/metadata_info.py index 4794a12fc..f201ab7e0 100644 --- a/mediapipe/tasks/python/metadata/metadata_writers/metadata_info.py +++ b/mediapipe/tasks/python/metadata/metadata_writers/metadata_info.py @@ -1030,6 +1030,52 @@ class TensorGroupMd: return group +class SegmentationMaskMd(TensorMd): + """A container for the segmentation mask metadata information.""" + + # The output tensor is in the shape of [1, ImageHeight, ImageWidth, N], where + # N is the number of objects that the segmentation model can recognize. The + # output tensor is essentially a list of grayscale bitmaps, where each value + # is the probability of the corresponding pixel belonging to a certain object + # type. Therefore, the content dimension range of the output tensor is [1, 2]. + _CONTENT_DIM_MIN = 1 + _CONTENT_DIM_MAX = 2 + + def __init__( + self, + name: Optional[str] = None, + description: Optional[str] = None, + label_files: Optional[List[LabelFileMd]] = None, + ): + self.name = name + self.description = description + associated_files = label_files or [] + super().__init__( + name=name, description=description, associated_files=associated_files + ) + + def create_metadata(self) -> _metadata_fb.TensorMetadataT: + """Creates the metadata for the segmentation masks tensor.""" + masks_metadata = super().create_metadata() + + # Create tensor content information. + content = _metadata_fb.ContentT() + content.contentProperties = _metadata_fb.ImagePropertiesT() + content.contentProperties.colorSpace = _metadata_fb.ColorSpaceType.GRAYSCALE + content.contentPropertiesType = ( + _metadata_fb.ContentProperties.ImageProperties + ) + # Add the content range. See + # https://github.com/google/mediapipe/blob/f8af41b1eb49ff4bdad756ff19d1d36f486be614/mediapipe/tasks/metadata/metadata_schema.fbs#L323-L385 + dim_range = _metadata_fb.ValueRangeT() + dim_range.min = self._CONTENT_DIM_MIN + dim_range.max = self._CONTENT_DIM_MAX + content.range = dim_range + masks_metadata.content = content + + return masks_metadata + + class CustomMetadataMd(abc.ABC): """An abstract class of a container for the custom metadata information.""" diff --git a/mediapipe/tasks/python/metadata/metadata_writers/metadata_writer.py b/mediapipe/tasks/python/metadata/metadata_writers/metadata_writer.py index fda6a64d3..e0be9beea 100644 --- a/mediapipe/tasks/python/metadata/metadata_writers/metadata_writer.py +++ b/mediapipe/tasks/python/metadata/metadata_writers/metadata_writer.py @@ -34,6 +34,10 @@ _INPUT_REGEX_TEXT_DESCRIPTION = ('Embedding vectors representing the input ' 'text to be processed.') _OUTPUT_CLASSIFICATION_NAME = 'score' _OUTPUT_CLASSIFICATION_DESCRIPTION = 'Score of the labels respectively.' +_OUTPUT_SEGMENTATION_MASKS_NAME = 'segmentation_masks' +_OUTPUT_SEGMENTATION_MASKS_DESCRIPTION = ( + 'Masks over the target objects with high accuracy.' +) # Detection tensor result to be grouped together. _DETECTION_GROUP_NAME = 'detection_result' # File name to export score calibration parameters. @@ -657,6 +661,32 @@ class MetadataWriter(object): self._output_group_mds.append(group_md) return self + def add_segmentation_output( + self, + labels: Optional[Labels] = None, + name: str = _OUTPUT_SEGMENTATION_MASKS_NAME, + description: str = _OUTPUT_SEGMENTATION_MASKS_DESCRIPTION, + ) -> 'MetadataWriter': + """Adds a segmentation head metadata for segmentation output tensor. + + Args: + labels: an instance of Labels helper class. + name: Metadata name of the tensor. Note that this is different from tensor + name in the flatbuffer. + description: human readable description of what the output is. + + Returns: + The current Writer instance to allow chained operation. + """ + label_files = self._create_label_file_md(labels) + output_md = metadata_info.SegmentationMaskMd( + name=name, + description=description, + label_files=label_files, + ) + self._output_mds.append(output_md) + return self + def add_feature_output(self, name: Optional[str] = None, description: Optional[str] = None) -> 'MetadataWriter': diff --git a/mediapipe/tasks/python/test/metadata/metadata_writers/BUILD b/mediapipe/tasks/python/test/metadata/metadata_writers/BUILD index 417e3e10c..976ddc9d2 100644 --- a/mediapipe/tasks/python/test/metadata/metadata_writers/BUILD +++ b/mediapipe/tasks/python/test/metadata/metadata_writers/BUILD @@ -91,3 +91,18 @@ py_test( "//mediapipe/tasks/python/test:test_utils", ], ) + +py_test( + name = "image_segmenter_test", + srcs = ["image_segmenter_test.py"], + data = [ + "//mediapipe/tasks/testdata/metadata:data_files", + "//mediapipe/tasks/testdata/metadata:model_files", + ], + deps = [ + "//mediapipe/tasks/python/metadata", + "//mediapipe/tasks/python/metadata/metadata_writers:image_segmenter", + "//mediapipe/tasks/python/metadata/metadata_writers:metadata_writer", + "//mediapipe/tasks/python/test:test_utils", + ], +) diff --git a/mediapipe/tasks/python/test/metadata/metadata_writers/image_segmenter_test.py b/mediapipe/tasks/python/test/metadata/metadata_writers/image_segmenter_test.py new file mode 100644 index 000000000..a12f009cd --- /dev/null +++ b/mediapipe/tasks/python/test/metadata/metadata_writers/image_segmenter_test.py @@ -0,0 +1,98 @@ +# Copyright 2022 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Tests for metadata_writer.image_segmenter.""" + +import os + +from absl.testing import absltest + +from mediapipe.tasks.python.metadata import metadata +from mediapipe.tasks.python.metadata.metadata_writers import image_segmenter +from mediapipe.tasks.python.metadata.metadata_writers import metadata_writer +from mediapipe.tasks.python.test import test_utils + +_TEST_DATA_DIR = "mediapipe/tasks/testdata/metadata" +_MODEL_FILE = test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, "deeplabv3_without_metadata.tflite") +) +_LABEL_FILE_NAME = "labels.txt" +_LABEL_FILE = test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, "segmenter_labelmap.txt") +) +_NORM_MEAN = 127.5 +_NORM_STD = 127.5 +_JSON_FILE = test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, "deeplabv3.json") +) +_JSON_FILE_WITHOUT_LABELS = test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, "deeplabv3_without_labels.json") +) +_JSON_FILE_WITH_ACTIVATION = test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, "deeplabv3_with_activation.json") +) + + +class ImageSegmenterTest(absltest.TestCase): + + def test_write_metadata(self): + with open(_MODEL_FILE, "rb") as f: + model_buffer = f.read() + writer = image_segmenter.MetadataWriter.create( + bytearray(model_buffer), + [_NORM_MEAN], + [_NORM_STD], + labels=metadata_writer.Labels().add_from_file(_LABEL_FILE), + ) + tflite_content, metadata_json = writer.populate() + with open(_JSON_FILE, "r") as f: + expected_json = f.read().strip() + self.assertEqual(metadata_json, expected_json) + + displayer = metadata.MetadataDisplayer.with_model_buffer(tflite_content) + label_file_buffer = displayer.get_associated_file_buffer(_LABEL_FILE_NAME) + with open(_LABEL_FILE, "rb") as f: + expected_labelfile_buffer = f.read() + self.assertEqual(label_file_buffer, expected_labelfile_buffer) + + def test_write_metadata_without_labels(self): + with open(_MODEL_FILE, "rb") as f: + model_buffer = f.read() + writer = image_segmenter.MetadataWriter.create( + bytearray(model_buffer), + [_NORM_MEAN], + [_NORM_STD], + ) + _, metadata_json = writer.populate() + with open(_JSON_FILE_WITHOUT_LABELS, "r") as f: + expected_json = f.read().strip() + self.assertEqual(metadata_json, expected_json) + + def test_write_metadata_with_activation(self): + with open(_MODEL_FILE, "rb") as f: + model_buffer = f.read() + writer = image_segmenter.MetadataWriter.create( + bytearray(model_buffer), + [_NORM_MEAN], + [_NORM_STD], + activation=image_segmenter.Activation.SIGMOID, + ) + _, metadata_json = writer.populate() + with open(_JSON_FILE_WITH_ACTIVATION, "r") as f: + expected_json = f.read().strip() + self.assertEqual(metadata_json, expected_json) + + +if __name__ == "__main__": + absltest.main() diff --git a/mediapipe/tasks/python/test/metadata/metadata_writers/metadata_info_test.py b/mediapipe/tasks/python/test/metadata/metadata_writers/metadata_info_test.py index bcb384a34..fd4462631 100644 --- a/mediapipe/tasks/python/test/metadata/metadata_writers/metadata_info_test.py +++ b/mediapipe/tasks/python/test/metadata/metadata_writers/metadata_info_test.py @@ -455,6 +455,27 @@ class TensorGroupMdMdTest(absltest.TestCase): self.assertEqual(metadata_json, expected_json) +class SegmentationMaskMdTest(absltest.TestCase): + _NAME = "segmentation_masks" + _DESCRIPTION = "Masks over the target objects." + _EXPECTED_JSON = test_utils.get_test_data_path( + os.path.join(_TEST_DATA_DIR, "segmentation_mask_meta.json") + ) + + def test_create_metadata_should_succeed(self): + segmentation_mask_md = metadata_info.SegmentationMaskMd( + name=self._NAME, description=self._DESCRIPTION + ) + metadata = segmentation_mask_md.create_metadata() + + metadata_json = _metadata.convert_to_json( + _create_dummy_model_metadata_with_tensor(metadata) + ) + with open(self._EXPECTED_JSON, "r") as f: + expected_json = f.read() + self.assertEqual(metadata_json, expected_json) + + def _create_dummy_model_metadata_with_tensor( tensor_metadata: _metadata_fb.TensorMetadataT) -> bytes: # Create a dummy model using the tensor metadata. diff --git a/mediapipe/tasks/testdata/metadata/BUILD b/mediapipe/tasks/testdata/metadata/BUILD index 0ac06caac..e335831aa 100644 --- a/mediapipe/tasks/testdata/metadata/BUILD +++ b/mediapipe/tasks/testdata/metadata/BUILD @@ -28,6 +28,10 @@ mediapipe_files(srcs = [ "category_tensor_float_meta.json", "coco_ssd_mobilenet_v1_1.0_quant_2018_06_29_no_metadata.tflite", "coco_ssd_mobilenet_v1_score_calibration.json", + "deeplabv3.json", + "deeplabv3_with_activation.json", + "deeplabv3_without_labels.json", + "deeplabv3_without_metadata.tflite", "efficientdet_lite0_v1.json", "efficientdet_lite0_v1.tflite", "labelmap.txt", @@ -44,6 +48,8 @@ mediapipe_files(srcs = [ "mobilenet_v2_1.0_224_without_metadata.tflite", "movie_review.tflite", "score_calibration.csv", + "segmentation_mask_meta.json", + "segmenter_labelmap.txt", "ssd_mobilenet_v1_no_metadata.json", "ssd_mobilenet_v1_no_metadata.tflite", "tensor_group_meta.json", @@ -87,6 +93,7 @@ filegroup( "30k-clean.model", "bert_text_classifier_no_metadata.tflite", "coco_ssd_mobilenet_v1_1.0_quant_2018_06_29_no_metadata.tflite", + "deeplabv3_without_metadata.tflite", "efficientdet_lite0_v1.tflite", "mobile_ica_8bit-with-custom-metadata.tflite", "mobile_ica_8bit-with-large-min-parser-version.tflite", @@ -116,6 +123,9 @@ filegroup( "classification_tensor_uint8_meta.json", "classification_tensor_unsupported_meta.json", "coco_ssd_mobilenet_v1_score_calibration.json", + "deeplabv3.json", + "deeplabv3_with_activation.json", + "deeplabv3_without_labels.json", "efficientdet_lite0_v1.json", "external_file", "feature_tensor_meta.json", @@ -140,6 +150,8 @@ filegroup( "score_calibration_file_meta.json", "score_calibration_tensor_meta.json", "score_thresholding_meta.json", + "segmentation_mask_meta.json", + "segmenter_labelmap.txt", "sentence_piece_tokenizer_meta.json", "ssd_mobilenet_v1_no_metadata.json", "tensor_group_meta.json", diff --git a/mediapipe/tasks/testdata/metadata/deeplabv3.json b/mediapipe/tasks/testdata/metadata/deeplabv3.json new file mode 100644 index 000000000..1ae982200 --- /dev/null +++ b/mediapipe/tasks/testdata/metadata/deeplabv3.json @@ -0,0 +1,66 @@ +{ + "name": "ImageSegmenter", + "description": "Semantic image segmentation predicts whether each pixel of an image is associated with a certain class.", + "subgraph_metadata": [ + { + "input_tensor_metadata": [ + { + "name": "image", + "description": "Input image to be processed.", + "content": { + "content_properties_type": "ImageProperties", + "content_properties": { + "color_space": "RGB" + } + }, + "process_units": [ + { + "options_type": "NormalizationOptions", + "options": { + "mean": [ + 127.5 + ], + "std": [ + 127.5 + ] + } + } + ], + "stats": { + "max": [ + 1.0 + ], + "min": [ + -1.0 + ] + } + } + ], + "output_tensor_metadata": [ + { + "name": "segmentation_masks", + "description": "Masks over the target objects with high accuracy.", + "content": { + "content_properties_type": "ImageProperties", + "content_properties": { + "color_space": "GRAYSCALE" + }, + "range": { + "min": 1, + "max": 2 + } + }, + "stats": {}, + "associated_files": [ + { + "name": "labels.txt", + "description": "Labels for categories that the model can recognize.", + "type": "TENSOR_AXIS_LABELS" + } + ] + } + ] + } + ], + "min_parser_version": "1.0.0" +} diff --git a/mediapipe/tasks/testdata/metadata/deeplabv3_with_activation.json b/mediapipe/tasks/testdata/metadata/deeplabv3_with_activation.json new file mode 100644 index 000000000..4fb32bab3 --- /dev/null +++ b/mediapipe/tasks/testdata/metadata/deeplabv3_with_activation.json @@ -0,0 +1,67 @@ +{ + "name": "ImageSegmenter", + "description": "Semantic image segmentation predicts whether each pixel of an image is associated with a certain class.", + "subgraph_metadata": [ + { + "input_tensor_metadata": [ + { + "name": "image", + "description": "Input image to be processed.", + "content": { + "content_properties_type": "ImageProperties", + "content_properties": { + "color_space": "RGB" + } + }, + "process_units": [ + { + "options_type": "NormalizationOptions", + "options": { + "mean": [ + 127.5 + ], + "std": [ + 127.5 + ] + } + } + ], + "stats": { + "max": [ + 1.0 + ], + "min": [ + -1.0 + ] + } + } + ], + "output_tensor_metadata": [ + { + "name": "segmentation_masks", + "description": "Masks over the target objects with high accuracy.", + "content": { + "content_properties_type": "ImageProperties", + "content_properties": { + "color_space": "GRAYSCALE" + }, + "range": { + "min": 1, + "max": 2 + } + }, + "stats": {} + } + ], + "custom_metadata": [ + { + "name": "SEGMENTER_METADATA", + "data": { + "activation": "SIGMOID" + } + } + ] + } + ], + "min_parser_version": "1.5.0" +} diff --git a/mediapipe/tasks/testdata/metadata/deeplabv3_without_labels.json b/mediapipe/tasks/testdata/metadata/deeplabv3_without_labels.json new file mode 100644 index 000000000..d7a1a1c25 --- /dev/null +++ b/mediapipe/tasks/testdata/metadata/deeplabv3_without_labels.json @@ -0,0 +1,59 @@ +{ + "name": "ImageSegmenter", + "description": "Semantic image segmentation predicts whether each pixel of an image is associated with a certain class.", + "subgraph_metadata": [ + { + "input_tensor_metadata": [ + { + "name": "image", + "description": "Input image to be processed.", + "content": { + "content_properties_type": "ImageProperties", + "content_properties": { + "color_space": "RGB" + } + }, + "process_units": [ + { + "options_type": "NormalizationOptions", + "options": { + "mean": [ + 127.5 + ], + "std": [ + 127.5 + ] + } + } + ], + "stats": { + "max": [ + 1.0 + ], + "min": [ + -1.0 + ] + } + } + ], + "output_tensor_metadata": [ + { + "name": "segmentation_masks", + "description": "Masks over the target objects with high accuracy.", + "content": { + "content_properties_type": "ImageProperties", + "content_properties": { + "color_space": "GRAYSCALE" + }, + "range": { + "min": 1, + "max": 2 + } + }, + "stats": {} + } + ] + } + ], + "min_parser_version": "1.0.0" +} diff --git a/mediapipe/tasks/testdata/metadata/segmentation_mask_meta.json b/mediapipe/tasks/testdata/metadata/segmentation_mask_meta.json new file mode 100644 index 000000000..9252d9573 --- /dev/null +++ b/mediapipe/tasks/testdata/metadata/segmentation_mask_meta.json @@ -0,0 +1,24 @@ +{ + "subgraph_metadata": [ + { + "input_tensor_metadata": [ + { + "name": "segmentation_masks", + "description": "Masks over the target objects.", + "content": { + "content_properties_type": "ImageProperties", + "content_properties": { + "color_space": "GRAYSCALE" + }, + "range": { + "min": 1, + "max": 2 + } + }, + "stats": { + } + } + ] + } + ] +} diff --git a/mediapipe/tasks/testdata/metadata/segmenter_labelmap.txt b/mediapipe/tasks/testdata/metadata/segmenter_labelmap.txt new file mode 100644 index 000000000..204608ce5 --- /dev/null +++ b/mediapipe/tasks/testdata/metadata/segmenter_labelmap.txt @@ -0,0 +1,21 @@ +background +aeroplane +bicycle +bird +boat +bottle +bus +car +cat +chair +cow +dining table +dog +horse +motorbike +person +potted plant +sheep +sofa +train +tv diff --git a/third_party/external_files.bzl b/third_party/external_files.bzl index f6c2e8ce1..b290fbcbe 100644 --- a/third_party/external_files.bzl +++ b/third_party/external_files.bzl @@ -208,12 +208,36 @@ def external_files(): urls = ["https://storage.googleapis.com/mediapipe-assets/corrupted_mobilenet_v1_0.25_224_1_default_1.tflite?generation=1661875706780536"], ) + http_file( + name = "com_google_mediapipe_deeplabv3_json", + sha256 = "f299835bd9ea1cceb25fdf40a761a22716cbd20025cd67c365a860527f178b7f", + urls = ["https://storage.googleapis.com/mediapipe-assets/deeplabv3.json?generation=1678818040715103"], + ) + http_file( name = "com_google_mediapipe_deeplabv3_tflite", sha256 = "5faed2c653905d3e22a8f6f29ee198da84e9b0e7936a207bf431f17f6b4d87ff", urls = ["https://storage.googleapis.com/mediapipe-assets/deeplabv3.tflite?generation=1678775085237701"], ) + http_file( + name = "com_google_mediapipe_deeplabv3_with_activation_json", + sha256 = "a7633476d02f970db3cc30f5f027bcb608149e02207b2ccae36a4b69d730c82c", + urls = ["https://storage.googleapis.com/mediapipe-assets/deeplabv3_with_activation.json?generation=1678818047050984"], + ) + + http_file( + name = "com_google_mediapipe_deeplabv3_without_labels_json", + sha256 = "7d045a583a4046f17a52d2078b0175607a45ed0cc187558325f9c66534c08401", + urls = ["https://storage.googleapis.com/mediapipe-assets/deeplabv3_without_labels.json?generation=1678818050191996"], + ) + + http_file( + name = "com_google_mediapipe_deeplabv3_without_metadata_tflite", + sha256 = "68a539782c2c6a72f8aac3724600124a85ed977162b44e84cbae5db717c933c6", + urls = ["https://storage.googleapis.com/mediapipe-assets/deeplabv3_without_metadata.tflite?generation=1678818053623010"], + ) + http_file( name = "com_google_mediapipe_dense_tflite", sha256 = "be9323068461b1cbf412692ee916be30dcb1a5fb59a9ee875d470bc340d9e869", @@ -976,6 +1000,18 @@ def external_files(): urls = ["https://storage.googleapis.com/mediapipe-assets/segmentation_input_rotation0.jpg?generation=1661875914048401"], ) + http_file( + name = "com_google_mediapipe_segmentation_mask_meta_json", + sha256 = "4294d53b309c1fbe38a5184de4057576c3dec14e07d16491f1dd459ac9116ab3", + urls = ["https://storage.googleapis.com/mediapipe-assets/segmentation_mask_meta.json?generation=1678818065134737"], + ) + + http_file( + name = "com_google_mediapipe_segmenter_labelmap_txt", + sha256 = "d9efa78274f1799ddbcab1f87263e19dae338c1697de47a5b270c9526c45d364", + urls = ["https://storage.googleapis.com/mediapipe-assets/segmenter_labelmap.txt?generation=1678818068181025"], + ) + http_file( name = "com_google_mediapipe_selfie_segm_128_128_3_expected_mask_jpg", sha256 = "a295f3ab394a5e0caff2db5041337da58341ec331f1413ef91f56e0d650b4a1e", From 141cf843ae22c4f1279347b31468209957798ef2 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 18:01:49 -0700 Subject: [PATCH 083/136] Add getLabels to ImageSegmeter Java API PiperOrigin-RevId: 516683339 --- .../tensors_to_segmentation_calculator.proto | 3 ++ .../mediapipe/tasks/core/TaskRunner.java | 5 ++ .../mediapipe/tasks/mediapipe_tasks_aar.bzl | 1 + .../com/google/mediapipe/tasks/vision/BUILD | 1 + .../vision/imagesegmenter/ImageSegmenter.java | 52 ++++++++++++++++++- mediapipe/tasks/java/version_script.lds | 1 + .../imagesegmenter/ImageSegmenterTest.java | 40 ++++++++++++++ 7 files changed, 102 insertions(+), 1 deletion(-) diff --git a/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.proto b/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.proto index b0fdfdd32..f267bf09b 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.proto +++ b/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.proto @@ -22,6 +22,9 @@ import "mediapipe/framework/calculator.proto"; import "mediapipe/tasks/cc/vision/image_segmenter/proto/segmenter_options.proto"; import "mediapipe/util/label_map.proto"; +option java_package = "com.google.mediapipe.tasks"; +option java_outer_classname = "TensorsToSegmentationCalculatorOptionsProto"; + message TensorsToSegmentationCalculatorOptions { extend mediapipe.CalculatorOptions { optional TensorsToSegmentationCalculatorOptions ext = 458105876; diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/core/TaskRunner.java b/mediapipe/tasks/java/com/google/mediapipe/tasks/core/TaskRunner.java index 1a128c538..11d385890 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/core/TaskRunner.java +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/core/TaskRunner.java @@ -16,6 +16,7 @@ package com.google.mediapipe.tasks.core; import android.content.Context; import android.util.Log; +import com.google.mediapipe.proto.CalculatorProto.CalculatorGraphConfig; import com.google.mediapipe.framework.AndroidAssetUtil; import com.google.mediapipe.framework.AndroidPacketCreator; import com.google.mediapipe.framework.Graph; @@ -201,6 +202,10 @@ public class TaskRunner implements AutoCloseable { } } + public CalculatorGraphConfig getCalculatorGraphConfig() { + return graph.getCalculatorGraphConfig(); + } + private synchronized void addPackets(Map inputs, long inputTimestamp) { if (!graphStarted.get()) { reportError( diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl index 5c5a154d8..d8a237e8d 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl @@ -41,6 +41,7 @@ _VISION_TASKS_JAVA_PROTO_LITE_TARGETS = [ "//mediapipe/tasks/cc/vision/gesture_recognizer/proto:hand_gesture_recognizer_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_classifier/proto:image_classifier_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_embedder/proto:image_embedder_graph_options_java_proto_lite", + "//mediapipe/tasks/cc/vision/image_segmenter/calculators:tensors_to_segmentation_calculator_java_proto_lite", "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_java_proto_lite", "//mediapipe/tasks/cc/vision/hand_detector/proto:hand_detector_graph_options_java_proto_lite", diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD index bd57ffadb..a5b036924 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD @@ -197,6 +197,7 @@ android_library( "//mediapipe/java/com/google/mediapipe/framework:android_framework", "//mediapipe/java/com/google/mediapipe/framework/image", "//mediapipe/tasks/cc/core/proto:base_options_java_proto_lite", + "//mediapipe/tasks/cc/vision/image_segmenter/calculators:tensors_to_segmentation_calculator_java_proto_lite", "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_java_proto_lite", "//mediapipe/tasks/java/com/google/mediapipe/tasks/core", diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenter.java b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenter.java index 76b33fb97..299423003 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenter.java +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenter.java @@ -17,6 +17,7 @@ package com.google.mediapipe.tasks.vision.imagesegmenter; import android.content.Context; import com.google.auto.value.AutoValue; import com.google.mediapipe.proto.CalculatorOptionsProto.CalculatorOptions; +import com.google.mediapipe.proto.CalculatorProto.CalculatorGraphConfig; import com.google.mediapipe.framework.AndroidPacketGetter; import com.google.mediapipe.framework.MediaPipeException; import com.google.mediapipe.framework.Packet; @@ -24,6 +25,7 @@ import com.google.mediapipe.framework.PacketGetter; import com.google.mediapipe.framework.image.BitmapImageBuilder; import com.google.mediapipe.framework.image.ByteBufferImageBuilder; import com.google.mediapipe.framework.image.MPImage; +import com.google.mediapipe.tasks.TensorsToSegmentationCalculatorOptionsProto; import com.google.mediapipe.tasks.core.BaseOptions; import com.google.mediapipe.tasks.core.ErrorListener; import com.google.mediapipe.tasks.core.OutputHandler; @@ -88,8 +90,10 @@ public final class ImageSegmenter extends BaseVisionTaskApi { private static final int SEGMENTATION_OUT_STREAM_INDEX = 2; private static final String TASK_GRAPH_NAME = "mediapipe.tasks.vision.image_segmenter.ImageSegmenterGraph"; - + private static final String TENSORS_TO_SEGMENTATION_CALCULATOR_NAME = + "mediapipe.tasks.TensorsToSegmentationCalculator"; private boolean hasResultListener = false; + private List labels = new ArrayList<>(); /** * Creates an {@link ImageSegmenter} instance from an {@link ImageSegmenterOptions}. @@ -190,6 +194,41 @@ public final class ImageSegmenter extends BaseVisionTaskApi { TaskRunner taskRunner, RunningMode runningMode, boolean hasResultListener) { super(taskRunner, runningMode, IMAGE_IN_STREAM_NAME, NORM_RECT_IN_STREAM_NAME); this.hasResultListener = hasResultListener; + populateLabels(); + } + /** + * Populate the labelmap in TensorsToSegmentationCalculator to labels field. + * + * @throws MediaPipeException if there is an error during finding TensorsToSegmentationCalculator. + */ + private void populateLabels() { + CalculatorGraphConfig graphConfig = this.runner.getCalculatorGraphConfig(); + + boolean foundTensorsToSegmentation = false; + for (CalculatorGraphConfig.Node node : graphConfig.getNodeList()) { + if (node.getName().contains(TENSORS_TO_SEGMENTATION_CALCULATOR_NAME)) { + if (foundTensorsToSegmentation) { + throw new MediaPipeException( + MediaPipeException.StatusCode.INTERNAL.ordinal(), + "The graph has more than one mediapipe.tasks.TensorsToSegmentationCalculator."); + } + foundTensorsToSegmentation = true; + TensorsToSegmentationCalculatorOptionsProto.TensorsToSegmentationCalculatorOptions options = + node.getOptions() + .getExtension( + TensorsToSegmentationCalculatorOptionsProto + .TensorsToSegmentationCalculatorOptions.ext); + for (int i = 0; i < options.getLabelItemsMap().size(); i++) { + Long labelKey = Long.valueOf(i); + if (!options.getLabelItemsMap().containsKey(labelKey)) { + throw new MediaPipeException( + MediaPipeException.StatusCode.INTERNAL.ordinal(), + "The lablemap have no expected key: " + labelKey); + } + labels.add(options.getLabelItemsMap().get(labelKey).getName()); + } + } + } } /** @@ -473,6 +512,17 @@ public final class ImageSegmenter extends BaseVisionTaskApi { sendLiveStreamData(image, imageProcessingOptions, timestampMs); } + /** + * Get the category label list of the ImageSegmenter can recognize. For CATEGORY_MASK type, the + * index in the category mask corresponds to the category in the label list. For CONFIDENCE_MASK + * type, the output mask list at index corresponds to the category in the label list. + * + *

    If there is no labelmap provided in the model file, empty label list is returned. + */ + List getLabels() { + return labels; + } + /** Options for setting up an {@link ImageSegmenter}. */ @AutoValue public abstract static class ImageSegmenterOptions extends TaskOptions { diff --git a/mediapipe/tasks/java/version_script.lds b/mediapipe/tasks/java/version_script.lds index 08577b101..13f36f21e 100644 --- a/mediapipe/tasks/java/version_script.lds +++ b/mediapipe/tasks/java/version_script.lds @@ -7,6 +7,7 @@ VERS_1.0 { Java_com_google_mediapipe_framework_Graph_nativeAddPacketToInputStream; Java_com_google_mediapipe_framework_Graph_nativeCloseAllPacketSources; Java_com_google_mediapipe_framework_Graph_nativeCreateGraph; + Java_com_google_mediapipe_framework_Graph_nativeGetCalculatorGraphConfig; Java_com_google_mediapipe_framework_Graph_nativeLoadBinaryGraph*; Java_com_google_mediapipe_framework_Graph_nativeMovePacketToInputStream; Java_com_google_mediapipe_framework_Graph_nativeReleaseGraph; diff --git a/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenterTest.java b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenterTest.java index 16f591c40..3b35c21bc 100644 --- a/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenterTest.java +++ b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenterTest.java @@ -34,6 +34,7 @@ import com.google.mediapipe.tasks.vision.imagesegmenter.ImageSegmenter.ImageSegm import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.FloatBuffer; +import java.util.Arrays; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; @@ -135,6 +136,45 @@ public class ImageSegmenterTest { // MPImage expectedMaskBuffer = getImageFromAsset(goldenImageName); // verifyConfidenceMask(actualMaskBuffer, expectedMaskBuffer, GOLDEN_MASK_SIMILARITY); // } + + @Test + public void getLabels_success() throws Exception { + final List expectedLabels = + Arrays.asList( + "background", + "aeroplane", + "bicycle", + "bird", + "boat", + "bottle", + "bus", + "car", + "cat", + "chair", + "cow", + "dining table", + "dog", + "horse", + "motorbike", + "person", + "potted plant", + "sheep", + "sofa", + "train", + "tv"); + ImageSegmenterOptions options = + ImageSegmenterOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(DEEPLAB_MODEL_FILE).build()) + .setOutputType(ImageSegmenterOptions.OutputType.CONFIDENCE_MASK) + .build(); + ImageSegmenter imageSegmenter = + ImageSegmenter.createFromOptions(ApplicationProvider.getApplicationContext(), options); + List actualLabels = imageSegmenter.getLabels(); + assertThat(actualLabels.size()).isEqualTo(expectedLabels.size()); + for (int i = 0; i < actualLabels.size(); i++) { + assertThat(actualLabels.get(i)).isEqualTo(expectedLabels.get(i)); + } + } } @RunWith(AndroidJUnit4.class) From cafff14135f422b7eb6be3b72a64fbe6559f8763 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 20:00:59 -0700 Subject: [PATCH 084/136] GeometryPipelineCalculator support single face landmarks input. PiperOrigin-RevId: 516701488 --- .../cc/vision/face_geometry/calculators/BUILD | 2 + .../geometry_pipeline_calculator.cc | 134 ++++++++++++++---- 2 files changed, 106 insertions(+), 30 deletions(-) diff --git a/mediapipe/tasks/cc/vision/face_geometry/calculators/BUILD b/mediapipe/tasks/cc/vision/face_geometry/calculators/BUILD index b3d4e604a..3f2833f3b 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/calculators/BUILD +++ b/mediapipe/tasks/cc/vision/face_geometry/calculators/BUILD @@ -60,6 +60,7 @@ cc_library( "//mediapipe/framework/port:ret_check", "//mediapipe/framework/port:status", "//mediapipe/framework/port:statusor", + "//mediapipe/tasks/cc:common", "//mediapipe/tasks/cc/core:external_file_handler", "//mediapipe/tasks/cc/core/proto:external_file_cc_proto", "//mediapipe/tasks/cc/vision/face_geometry/libs:geometry_pipeline", @@ -69,6 +70,7 @@ cc_library( "//mediapipe/tasks/cc/vision/face_geometry/proto:geometry_pipeline_metadata_cc_proto", "//mediapipe/util:resource_util", "@com_google_absl//absl/memory", + "@com_google_absl//absl/strings:str_format", ], alwayslink = 1, ) diff --git a/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc b/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc index 78cb1146a..4fbbf08e0 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc +++ b/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc @@ -18,12 +18,14 @@ #include #include "absl/memory/memory.h" +#include "absl/strings/str_format.h" #include "mediapipe/framework/calculator_framework.h" #include "mediapipe/framework/formats/landmark.pb.h" #include "mediapipe/framework/port/ret_check.h" #include "mediapipe/framework/port/status.h" #include "mediapipe/framework/port/status_macros.h" #include "mediapipe/framework/port/statusor.h" +#include "mediapipe/tasks/cc/common.h" #include "mediapipe/tasks/cc/core/external_file_handler.h" #include "mediapipe/tasks/cc/core/proto/external_file.pb.h" #include "mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.pb.h" @@ -41,13 +43,50 @@ static constexpr char kEnvironmentTag[] = "ENVIRONMENT"; static constexpr char kImageSizeTag[] = "IMAGE_SIZE"; static constexpr char kMultiFaceGeometryTag[] = "MULTI_FACE_GEOMETRY"; static constexpr char kMultiFaceLandmarksTag[] = "MULTI_FACE_LANDMARKS"; +static constexpr char kFaceGeometryTag[] = "FACE_GEOMETRY"; +static constexpr char kFaceLandmarksTag[] = "FACE_LANDMARKS"; using ::mediapipe::tasks::vision::face_geometry::proto::Environment; using ::mediapipe::tasks::vision::face_geometry::proto::FaceGeometry; using ::mediapipe::tasks::vision::face_geometry::proto:: GeometryPipelineMetadata; -// A calculator that renders a visual effect for multiple faces. +absl::Status SanityCheck(CalculatorContract* cc) { + if (!(cc->Inputs().HasTag(kFaceLandmarksTag) ^ + cc->Inputs().HasTag(kMultiFaceLandmarksTag))) { + return CreateStatusWithPayload( + absl::StatusCode::kInvalidArgument, + absl::StrFormat("Only one of %s and %s can be set at a time.", + kFaceLandmarksTag, kMultiFaceLandmarksTag)); + } + if (!(cc->Outputs().HasTag(kFaceGeometryTag) ^ + cc->Outputs().HasTag(kMultiFaceGeometryTag))) { + return CreateStatusWithPayload( + absl::StatusCode::kInvalidArgument, + absl::StrFormat("Only one of %s and %s can be set at a time.", + kFaceGeometryTag, kMultiFaceGeometryTag)); + } + if (cc->Inputs().HasTag(kFaceLandmarksTag) != + cc->Outputs().HasTag(kFaceGeometryTag)) { + return CreateStatusWithPayload( + absl::StatusCode::kInvalidArgument, + absl::StrFormat( + "%s and %s must both be set or neither be set and a time.", + kFaceLandmarksTag, kFaceGeometryTag)); + } + if (cc->Inputs().HasTag(kMultiFaceLandmarksTag) != + cc->Outputs().HasTag(kMultiFaceGeometryTag)) { + return CreateStatusWithPayload( + absl::StatusCode::kInvalidArgument, + absl::StrFormat( + "%s and %s must both be set or neither be set and a time.", + kMultiFaceLandmarksTag, kMultiFaceGeometryTag)); + } + return absl::OkStatus(); +} + +// A calculator that renders a visual effect for multiple faces. Support single +// face landmarks or multiple face landmarks. // // Inputs: // IMAGE_SIZE (`std::pair`, required): @@ -58,8 +97,12 @@ using ::mediapipe::tasks::vision::face_geometry::proto:: // ratio. If used as-is, the resulting face geometry visualization should be // happening on a frame with the same ratio as well. // -// MULTI_FACE_LANDMARKS (`std::vector`, required): -// A vector of face landmark lists. +// MULTI_FACE_LANDMARKS (`std::vector`, optional): +// A vector of face landmark lists. If connected, the output stream +// MULTI_FACE_GEOMETRY must be connected. +// FACE_LANDMARKS (NormalizedLandmarkList, optional): +// A NormalizedLandmarkList of single face landmark lists. If connected, the +// output stream FACE_GEOMETRY must be connected. // // Input side packets: // ENVIRONMENT (`proto::Environment`, required) @@ -67,8 +110,10 @@ using ::mediapipe::tasks::vision::face_geometry::proto:: // as well as virtual camera parameters. // // Output: -// MULTI_FACE_GEOMETRY (`std::vector`, required): -// A vector of face geometry data. +// MULTI_FACE_GEOMETRY (`std::vector`, optional): +// A vector of face geometry data if MULTI_FACE_LANDMARKS is connected . +// FACE_GEOMETRY (FaceGeometry, optional): +// A FaceGeometry of the face landmarks if FACE_LANDMARKS is connected. // // Options: // metadata_file (`ExternalFile`, optional): @@ -81,13 +126,21 @@ class GeometryPipelineCalculator : public CalculatorBase { public: static absl::Status GetContract(CalculatorContract* cc) { cc->InputSidePackets().Tag(kEnvironmentTag).Set(); + MP_RETURN_IF_ERROR(SanityCheck(cc)); cc->Inputs().Tag(kImageSizeTag).Set>(); - cc->Inputs() - .Tag(kMultiFaceLandmarksTag) - .Set>(); - cc->Outputs().Tag(kMultiFaceGeometryTag).Set>(); - - return absl::OkStatus(); + if (cc->Inputs().HasTag(kMultiFaceLandmarksTag)) { + cc->Inputs() + .Tag(kMultiFaceLandmarksTag) + .Set>(); + cc->Outputs().Tag(kMultiFaceGeometryTag).Set>(); + return absl::OkStatus(); + } else { + cc->Inputs() + .Tag(kFaceLandmarksTag) + .Set(); + cc->Outputs().Tag(kFaceGeometryTag).Set(); + return absl::OkStatus(); + } } absl::Status Open(CalculatorContext* cc) override { @@ -112,7 +165,6 @@ class GeometryPipelineCalculator : public CalculatorBase { ASSIGN_OR_RETURN(geometry_pipeline_, CreateGeometryPipeline(environment, metadata), _ << "Failed to create a geometry pipeline!"); - return absl::OkStatus(); } @@ -121,32 +173,54 @@ class GeometryPipelineCalculator : public CalculatorBase { // to have a non-empty packet. In case this requirement is not met, there's // nothing to be processed at the current timestamp. if (cc->Inputs().Tag(kImageSizeTag).IsEmpty() || - cc->Inputs().Tag(kMultiFaceLandmarksTag).IsEmpty()) { + (cc->Inputs().Tag(kMultiFaceLandmarksTag).IsEmpty() && + cc->Inputs().Tag(kFaceLandmarksTag).IsEmpty())) { return absl::OkStatus(); } const auto& image_size = cc->Inputs().Tag(kImageSizeTag).Get>(); - const auto& multi_face_landmarks = - cc->Inputs() - .Tag(kMultiFaceLandmarksTag) - .Get>(); - auto multi_face_geometry = absl::make_unique>(); + if (cc->Inputs().HasTag(kMultiFaceLandmarksTag)) { + const auto& multi_face_landmarks = + cc->Inputs() + .Tag(kMultiFaceLandmarksTag) + .Get>(); - ASSIGN_OR_RETURN( - *multi_face_geometry, - geometry_pipeline_->EstimateFaceGeometry( - multi_face_landmarks, // - /*frame_width*/ image_size.first, - /*frame_height*/ image_size.second), - _ << "Failed to estimate face geometry for multiple faces!"); + auto multi_face_geometry = absl::make_unique>(); - cc->Outputs() - .Tag(kMultiFaceGeometryTag) - .AddPacket(mediapipe::Adopt>( - multi_face_geometry.release()) - .At(cc->InputTimestamp())); + ASSIGN_OR_RETURN( + *multi_face_geometry, + geometry_pipeline_->EstimateFaceGeometry( + multi_face_landmarks, // + /*frame_width*/ image_size.first, + /*frame_height*/ image_size.second), + _ << "Failed to estimate face geometry for multiple faces!"); + + cc->Outputs() + .Tag(kMultiFaceGeometryTag) + .AddPacket(mediapipe::Adopt>( + multi_face_geometry.release()) + .At(cc->InputTimestamp())); + } else { + const auto& face_landmarks = + cc->Inputs() + .Tag(kMultiFaceLandmarksTag) + .Get(); + + ASSIGN_OR_RETURN( + std::vector multi_face_geometry, + geometry_pipeline_->EstimateFaceGeometry( + {face_landmarks}, // + /*frame_width*/ image_size.first, + /*frame_height*/ image_size.second), + _ << "Failed to estimate face geometry for multiple faces!"); + + cc->Outputs() + .Tag(kFaceGeometryTag) + .AddPacket(mediapipe::MakePacket(multi_face_geometry[0]) + .At(cc->InputTimestamp())); + } return absl::OkStatus(); } From f517eddce10e2259d823a1fd25e35c4ed8c2852c Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 21:10:43 -0700 Subject: [PATCH 085/136] API for c++ ImageSegmenter to get labels PiperOrigin-RevId: 516714139 --- .../tasks/cc/vision/image_segmenter/BUILD | 4 ++ .../vision/image_segmenter/image_segmenter.cc | 62 ++++++++++++++++--- .../vision/image_segmenter/image_segmenter.h | 12 ++++ .../image_segmenter/image_segmenter_test.cc | 24 +++++++ 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/mediapipe/tasks/cc/vision/image_segmenter/BUILD b/mediapipe/tasks/cc/vision/image_segmenter/BUILD index b084331c8..1123204ce 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/BUILD +++ b/mediapipe/tasks/cc/vision/image_segmenter/BUILD @@ -25,6 +25,7 @@ cc_library( visibility = ["//visibility:public"], deps = [ ":image_segmenter_graph", + "//mediapipe/framework:calculator_cc_proto", "//mediapipe/framework/api2:builder", "//mediapipe/framework/formats:image", "//mediapipe/framework/formats:rect_cc_proto", @@ -34,10 +35,13 @@ cc_library( "//mediapipe/tasks/cc/vision/core:image_processing_options", "//mediapipe/tasks/cc/vision/core:running_mode", "//mediapipe/tasks/cc/vision/core:vision_task_api_factory", + "//mediapipe/tasks/cc/vision/image_segmenter/calculators:tensors_to_segmentation_calculator_cc_proto", "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_cc_proto", "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_cc_proto", + "//mediapipe/util:label_map_cc_proto", "@com_google_absl//absl/memory", "@com_google_absl//absl/status:statusor", + "@com_google_absl//absl/strings:str_format", "@org_tensorflow//tensorflow/lite/kernels:builtin_ops", ], ) diff --git a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.cc b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.cc index 7130c72e2..9769b47d5 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.cc +++ b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.cc @@ -15,15 +15,21 @@ limitations under the License. #include "mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h" +#include + +#include "absl/strings/str_format.h" #include "mediapipe/framework/api2/builder.h" +#include "mediapipe/framework/calculator.pb.h" #include "mediapipe/framework/formats/image.h" #include "mediapipe/framework/formats/rect.pb.h" #include "mediapipe/tasks/cc/core/utils.h" #include "mediapipe/tasks/cc/vision/core/image_processing_options.h" #include "mediapipe/tasks/cc/vision/core/running_mode.h" #include "mediapipe/tasks/cc/vision/core/vision_task_api_factory.h" +#include "mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.pb.h" #include "mediapipe/tasks/cc/vision/image_segmenter/proto/image_segmenter_graph_options.pb.h" #include "mediapipe/tasks/cc/vision/image_segmenter/proto/segmenter_options.pb.h" +#include "mediapipe/util/label_map.pb.h" namespace mediapipe { namespace tasks { @@ -112,6 +118,39 @@ ConvertImageSegmenterOptionsToProto(ImageSegmenterOptions* options) { return options_proto; } +absl::StatusOr> GetLabelsFromGraphConfig( + const CalculatorGraphConfig& graph_config) { + bool found_tensor_to_segmentation_calculator = false; + std::vector labels; + for (const auto& node : graph_config.node()) { + if (node.calculator() == + "mediapipe.tasks.TensorsToSegmentationCalculator") { + if (!found_tensor_to_segmentation_calculator) { + found_tensor_to_segmentation_calculator = true; + } else { + return absl::Status(CreateStatusWithPayload( + absl::StatusCode::kFailedPrecondition, + "The graph has more than one " + "mediapipe.tasks.TensorsToSegmentationCalculator.")); + } + TensorsToSegmentationCalculatorOptions options = + node.options().GetExtension( + TensorsToSegmentationCalculatorOptions::ext); + if (!options.label_items().empty()) { + for (int i = 0; i < options.label_items_size(); ++i) { + if (!options.label_items().contains(i)) { + return absl::Status(CreateStatusWithPayload( + absl::StatusCode::kFailedPrecondition, + absl::StrFormat("The lablemap have no expected key: %d.", i))); + } + labels.push_back(options.label_items().at(i).name()); + } + } + } + } + return labels; +} + } // namespace absl::StatusOr> ImageSegmenter::Create( @@ -140,13 +179,22 @@ absl::StatusOr> ImageSegmenter::Create( kMicroSecondsPerMilliSecond); }; } - return core::VisionTaskApiFactory::Create( - CreateGraphConfig( - std::move(options_proto), - options->running_mode == core::RunningMode::LIVE_STREAM), - std::move(options->base_options.op_resolver), options->running_mode, - std::move(packets_callback)); + + auto image_segmenter = + core::VisionTaskApiFactory::Create( + CreateGraphConfig( + std::move(options_proto), + options->running_mode == core::RunningMode::LIVE_STREAM), + std::move(options->base_options.op_resolver), options->running_mode, + std::move(packets_callback)); + if (!image_segmenter.ok()) { + return image_segmenter.status(); + } + ASSIGN_OR_RETURN( + (*image_segmenter)->labels_, + GetLabelsFromGraphConfig((*image_segmenter)->runner_->GetGraphConfig())); + return image_segmenter; } absl::StatusOr> ImageSegmenter::Segment( diff --git a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h index 511d3b9c1..c757296e4 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h +++ b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h @@ -189,6 +189,18 @@ class ImageSegmenter : tasks::vision::core::BaseVisionTaskApi { // Shuts down the ImageSegmenter when all works are done. absl::Status Close() { return runner_->Close(); } + + // Get the category label list of the ImageSegmenter can recognize. For + // CATEGORY_MASK type, the index in the category mask corresponds to the + // category in the label list. For CONFIDENCE_MASK type, the output mask list + // at index corresponds to the category in the label list. + // + // If there is no labelmap provided in the model file, empty label list is + // returned. + std::vector GetLabels() { return labels_; } + + private: + std::vector labels_; }; } // namespace image_segmenter diff --git a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc index d1fe20182..ab5d184db 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc +++ b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc @@ -15,6 +15,7 @@ limitations under the License. #include "mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h" +#include #include #include @@ -71,6 +72,13 @@ constexpr float kGoldenMaskSimilarity = 0.98; // 20 means class index 2, etc. constexpr int kGoldenMaskMagnificationFactor = 10; +constexpr std::array kDeeplabLabelNames = { + "background", "aeroplane", "bicycle", "bird", "boat", + "bottle", "bus", "car", "cat", "chair", + "cow", "dining table", "dog", "horse", "motorbike", + "person", "potted plant", "sheep", "sofa", "train", + "tv"}; + // Intentionally converting output into CV_8UC1 and then again into CV_32FC1 // as expected outputs are stored in CV_8UC1, so this conversion allows to do // fair comparison. @@ -244,6 +252,22 @@ TEST_F(CreateFromOptionsTest, FailsWithInputChannelOneModel) { "channels = 3 or 4.")); } +TEST(GetLabelsTest, SucceedsWithLabelsInModel) { + auto options = std::make_unique(); + options->base_options.model_asset_path = + JoinPath("./", kTestDataDirectory, kDeeplabV3WithMetadata); + options->output_type = ImageSegmenterOptions::OutputType::CATEGORY_MASK; + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, + ImageSegmenter::Create(std::move(options))); + const auto& labels = segmenter->GetLabels(); + ASSERT_FALSE(labels.empty()); + ASSERT_EQ(labels.size(), kDeeplabLabelNames.size()); + for (int i = 0; i < labels.size(); ++i) { + EXPECT_EQ(labels[i], kDeeplabLabelNames[i]); + } +} + class ImageModeTest : public tflite_shims::testing::Test {}; TEST_F(ImageModeTest, SucceedsWithCategoryMask) { From d83f400b0860001dcca0952c6762bb2e4cc62d3e Mon Sep 17 00:00:00 2001 From: kinaryml Date: Tue, 14 Mar 2023 22:32:39 -0700 Subject: [PATCH 086/136] Updated API and tests --- .../test/vision/face_landmarker_test.py | 28 +++++++++---------- .../tasks/python/vision/face_landmarker.py | 1 - 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py index 49cdacbfe..eec19d589 100644 --- a/mediapipe/tasks/python/test/vision/face_landmarker_test.py +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -207,20 +207,20 @@ class HandLandmarkerTest(parameterized.TestCase): _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), _get_expected_face_blendshapes( _PORTRAIT_EXPECTED_BLENDSHAPES), None), - # (ModelFileType.FILE_NAME, - # _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - # _get_expected_face_landmarks( - # _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - # _get_expected_face_blendshapes( - # _PORTRAIT_EXPECTED_BLENDSHAPES), - # _make_expected_facial_transformation_matrixes()), - # (ModelFileType.FILE_CONTENT, - # _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - # _get_expected_face_landmarks( - # _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - # _get_expected_face_blendshapes( - # _PORTRAIT_EXPECTED_BLENDSHAPES), - # _make_expected_facial_transformation_matrixes()) + (ModelFileType.FILE_NAME, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), + _make_expected_facial_transformation_matrixes()), + (ModelFileType.FILE_CONTENT, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), + _make_expected_facial_transformation_matrixes()) ) def test_detect(self, model_file_type, model_name, expected_face_landmarks, expected_face_blendshapes, expected_facial_transformation_matrix): diff --git a/mediapipe/tasks/python/vision/face_landmarker.py b/mediapipe/tasks/python/vision/face_landmarker.py index 519a78dfb..c109c646a 100644 --- a/mediapipe/tasks/python/vision/face_landmarker.py +++ b/mediapipe/tasks/python/vision/face_landmarker.py @@ -162,7 +162,6 @@ def _build_landmarker_result( facial_transformation_matrixes_results = [] if _FACE_GEOMETRY_STREAM_NAME in output_packets: - print(output_packets[_FACE_GEOMETRY_STREAM_NAME]) facial_transformation_matrixes_proto_list = packet_getter.get_proto_list( output_packets[_FACE_GEOMETRY_STREAM_NAME]) for proto in facial_transformation_matrixes_proto_list: From c8b56439afe4bc72258367d88cc2b13cc8a0be8f Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 15 Mar 2023 01:36:09 -0700 Subject: [PATCH 087/136] Fix typo PiperOrigin-RevId: 516758040 --- mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts b/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts index 85c222a28..f8ff0dcca 100644 --- a/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts +++ b/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts @@ -35,7 +35,7 @@ export {ImageSource}; // Used in the public API const IMAGE_STREAM = 'image_in'; const NORM_RECT_STREAM = 'norm_rect'; const GROUPED_SEGMENTATIONS_STREAM = 'segmented_masks'; -const IMAGEA_SEGMENTER_GRAPH = +const IMAGE_SEGMENTER_GRAPH = 'mediapipe.tasks.vision.image_segmenter.ImageSegmenterGraph'; // The OSS JS API does not support the builder pattern. @@ -255,7 +255,7 @@ export class ImageSegmenter extends VisionTaskRunner { ImageSegmenterGraphOptionsProto.ext, this.options); const segmenterNode = new CalculatorGraphConfig.Node(); - segmenterNode.setCalculator(IMAGEA_SEGMENTER_GRAPH); + segmenterNode.setCalculator(IMAGE_SEGMENTER_GRAPH); segmenterNode.addInputStream('IMAGE:' + IMAGE_STREAM); segmenterNode.addInputStream('NORM_RECT:' + NORM_RECT_STREAM); segmenterNode.addOutputStream( From 04ffb8432e42e14532c08dddaa1e8ad85d0dfc3a Mon Sep 17 00:00:00 2001 From: Fergus Henderson Date: Wed, 15 Mar 2023 08:39:14 -0700 Subject: [PATCH 088/136] Fix typo. PiperOrigin-RevId: 516834369 --- mediapipe/tasks/cc/vision/image_embedder/image_embedder_test.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/tasks/cc/vision/image_embedder/image_embedder_test.cc b/mediapipe/tasks/cc/vision/image_embedder/image_embedder_test.cc index 8fa036b7d..6aa0b85bc 100644 --- a/mediapipe/tasks/cc/vision/image_embedder/image_embedder_test.cc +++ b/mediapipe/tasks/cc/vision/image_embedder/image_embedder_test.cc @@ -52,7 +52,7 @@ constexpr char kMobileNetV3Embedder[] = constexpr double kSimilarityTolerancy = 1e-6; // Utility function to check the sizes, head_index and head_names of a result -// procduced by kMobileNetV3Embedder. +// produced by kMobileNetV3Embedder. void CheckMobileNetV3Result(const ImageEmbedderResult& result, bool quantized) { EXPECT_EQ(result.embeddings.size(), 1); EXPECT_EQ(result.embeddings[0].head_index, 0); From 06c37c6442d058589d44fe2e076351279ec8fffc Mon Sep 17 00:00:00 2001 From: kinaryml Date: Wed, 15 Mar 2023 09:11:06 -0700 Subject: [PATCH 089/136] Updated mediapipe/python/BUILD and tests --- mediapipe/python/BUILD | 1 + mediapipe/tasks/python/test/vision/face_landmarker_test.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mediapipe/python/BUILD b/mediapipe/python/BUILD index 8aa11f5f3..879f82819 100644 --- a/mediapipe/python/BUILD +++ b/mediapipe/python/BUILD @@ -37,6 +37,7 @@ pybind_extension( deps = [ ":builtin_calculators", ":builtin_task_graphs", + "//mediapipe/tasks/cc/vision/face_geometry/calculators:geometry_pipeline_calculator", "//mediapipe/python/pybind:calculator_graph", "//mediapipe/python/pybind:image", "//mediapipe/python/pybind:image_frame", diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py index eec19d589..fe1891288 100644 --- a/mediapipe/tasks/python/test/vision/face_landmarker_test.py +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -105,7 +105,7 @@ class ModelFileType(enum.Enum): FILE_NAME = 2 -class HandLandmarkerTest(parameterized.TestCase): +class FaceLandmarkerTest(parameterized.TestCase): def setUp(self): super().setUp() From 4a6015e65cf9ad3f64b6c70776b7bdce1db57534 Mon Sep 17 00:00:00 2001 From: kinaryml Date: Wed, 15 Mar 2023 10:41:36 -0700 Subject: [PATCH 090/136] Fixed some issues in the MatrixData container, revised the implementation and added more tests --- .../components/containers/matrix_data.py | 17 +- .../test/vision/face_landmarker_test.py | 333 +++++++++++++++++- mediapipe/tasks/python/vision/BUILD | 1 + .../tasks/python/vision/face_landmarker.py | 17 +- 4 files changed, 341 insertions(+), 27 deletions(-) diff --git a/mediapipe/tasks/python/components/containers/matrix_data.py b/mediapipe/tasks/python/components/containers/matrix_data.py index 2cef4a5c6..ded3a9b4f 100644 --- a/mediapipe/tasks/python/components/containers/matrix_data.py +++ b/mediapipe/tasks/python/components/containers/matrix_data.py @@ -24,6 +24,11 @@ from mediapipe.tasks.python.core.optional_dependencies import doc_controls _MatrixDataProto = matrix_data_pb2.MatrixData +class Layout(enum.Enum): + COLUMN_MAJOR = 0 + ROW_MAJOR = 1 + + @dataclasses.dataclass class MatrixData: """This stores the Matrix data. @@ -37,10 +42,6 @@ class MatrixData: layout: The order in which the data are stored. Defaults to COLUMN_MAJOR. """ - class Layout(enum.Enum): - COLUMN_MAJOR = 0 - ROW_MAJOR = 1 - rows: int = None cols: int = None data: np.ndarray = None @@ -52,8 +53,8 @@ class MatrixData: return _MatrixDataProto( rows=self.rows, cols=self.cols, - data=self.data.tolist(), - layout=self.layout) + packed_data=self.data, + layout=self.layout.value) @classmethod @doc_controls.do_not_generate_docs @@ -62,8 +63,8 @@ class MatrixData: return MatrixData( rows=pb2_obj.rows, cols=pb2_obj.cols, - data=np.array(pb2_obj.data), - layout=pb2_obj.layout) + data=np.array(pb2_obj.packed_data), + layout=Layout(pb2_obj.layout)) def __eq__(self, other: Any) -> bool: """Checks if this object is equal to the given object. diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py index fe1891288..a6b6e02f6 100644 --- a/mediapipe/tasks/python/test/vision/face_landmarker_test.py +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -50,12 +50,13 @@ _ImageProcessingOptions = image_processing_options_module.ImageProcessingOptions _FACE_LANDMARKER_BUNDLE_ASSET_FILE = 'face_landmarker.task' _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE = 'face_landmarker_with_blendshapes.task' _PORTRAIT_IMAGE = 'portrait.jpg' +_CAT_IMAGE = 'cat.jpg' _PORTRAIT_EXPECTED_FACE_LANDMARKS = 'portrait_expected_face_landmarks.pbtxt' _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION = 'portrait_expected_face_landmarks_with_attention.pbtxt' _PORTRAIT_EXPECTED_BLENDSHAPES = 'portrait_expected_blendshapes_with_attention.pbtxt' _PORTRAIT_EXPECTED_FACE_GEOMETRY = 'portrait_expected_face_geometry_with_attention.pbtxt' _LANDMARKS_DIFF_MARGIN = 0.03 -_BLENDSHAPES_DIFF_MARGIN = 0.1 +_BLENDSHAPES_DIFF_MARGIN = 0.12 _FACIAL_TRANSFORMATION_MATRIX_DIFF_MARGIN = 0.02 @@ -90,12 +91,12 @@ def _get_expected_face_blendshapes(file_path: str): def _make_expected_facial_transformation_matrixes(): data = np.array([[0.9995292, -0.005092691, 0.030254554, -0.37340546], - [0.0072318087, 0.99744856, -0.07102106, 22.212194], - [-0.029815676, 0.07120642, 0.9970159, -64.76358], - [0, 0, 0, 1]]) + [0.0072318087, 0.99744856, -0.07102106, 22.212194], + [-0.029815676, 0.07120642, 0.9970159, -64.76358], + [0, 0, 0, 1]]) rows, cols = len(data), len(data[0]) facial_transformation_matrixes_results = [] - facial_transformation_matrix = _MatrixData(rows, cols, data) + facial_transformation_matrix = _MatrixData(rows, cols, data.flatten()) facial_transformation_matrixes_results.append(facial_transformation_matrix) return facial_transformation_matrixes_results @@ -147,8 +148,8 @@ class FaceLandmarkerTest(parameterized.TestCase): self.assertEqual(rename_me.rows, expected_matrix_list[i].rows) self.assertEqual(rename_me.cols, expected_matrix_list[i].cols) self.assertAlmostEqual( - rename_me.data, - expected_matrix_list[i].data, + rename_me.data.all(), + expected_matrix_list[i].data.all(), delta=_FACIAL_TRANSFORMATION_MATRIX_DIFF_MARGIN) def test_create_from_file_succeeds_with_valid_model_path(self): @@ -220,10 +221,10 @@ class FaceLandmarkerTest(parameterized.TestCase): _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), _get_expected_face_blendshapes( _PORTRAIT_EXPECTED_BLENDSHAPES), - _make_expected_facial_transformation_matrixes()) - ) - def test_detect(self, model_file_type, model_name, expected_face_landmarks, - expected_face_blendshapes, expected_facial_transformation_matrix): + _make_expected_facial_transformation_matrixes())) + def test_detect( + self, model_file_type, model_name, expected_face_landmarks, + expected_face_blendshapes, expected_facial_transformation_matrixes): # Creates face landmarker. model_path = test_utils.get_test_data_path(model_name) if model_file_type is ModelFileType.FILE_NAME: @@ -240,7 +241,7 @@ class FaceLandmarkerTest(parameterized.TestCase): base_options=base_options, output_face_blendshapes=True if expected_face_blendshapes else False, output_facial_transformation_matrixes=True - if expected_facial_transformation_matrix else False) + if expected_facial_transformation_matrixes else False) landmarker = _FaceLandmarker.create_from_options(options) # Performs face landmarks detection on the input. @@ -252,15 +253,317 @@ class FaceLandmarkerTest(parameterized.TestCase): if expected_face_blendshapes is not None: self._expect_blendshapes_correct(detection_result.face_blendshapes[0], expected_face_blendshapes) - if expected_facial_transformation_matrix is not None: + if expected_facial_transformation_matrixes is not None: self._expect_facial_transformation_matrix_correct( - detection_result.facial_transformation_matrixes[0], - expected_facial_transformation_matrix) + detection_result.facial_transformation_matrixes, + expected_facial_transformation_matrixes) # Closes the face landmarker explicitly when the face landmarker is not used # in a context. landmarker.close() + @parameterized.parameters( + (ModelFileType.FILE_NAME, _FACE_LANDMARKER_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), + (ModelFileType.FILE_CONTENT, _FACE_LANDMARKER_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), + (ModelFileType.FILE_NAME, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), + (ModelFileType.FILE_CONTENT, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), + (ModelFileType.FILE_NAME, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), None), + (ModelFileType.FILE_CONTENT, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), None), + (ModelFileType.FILE_NAME, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), + _make_expected_facial_transformation_matrixes()), + (ModelFileType.FILE_CONTENT, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), + _make_expected_facial_transformation_matrixes())) + def test_detect_in_context( + self, model_file_type, model_name, expected_face_landmarks, + expected_face_blendshapes, expected_facial_transformation_matrixes): + # Creates face landmarker. + model_path = test_utils.get_test_data_path(model_name) + if model_file_type is ModelFileType.FILE_NAME: + base_options = _BaseOptions(model_asset_path=model_path) + elif model_file_type is ModelFileType.FILE_CONTENT: + with open(model_path, 'rb') as f: + model_content = f.read() + base_options = _BaseOptions(model_asset_buffer=model_content) + else: + # Should never happen + raise ValueError('model_file_type is invalid.') + + options = _FaceLandmarkerOptions( + base_options=base_options, + output_face_blendshapes=True if expected_face_blendshapes else False, + output_facial_transformation_matrixes=True + if expected_facial_transformation_matrixes else False) + + with _FaceLandmarker.create_from_options(options) as landmarker: + # Performs face landmarks detection on the input. + detection_result = landmarker.detect(self.test_image) + # Comparing results. + if expected_face_landmarks is not None: + self._expect_landmarks_correct(detection_result.face_landmarks[0], + expected_face_landmarks) + if expected_face_blendshapes is not None: + self._expect_blendshapes_correct(detection_result.face_blendshapes[0], + expected_face_blendshapes) + if expected_facial_transformation_matrixes is not None: + self._expect_facial_transformation_matrix_correct( + detection_result.facial_transformation_matrixes, + expected_facial_transformation_matrixes) + + def test_detect_succeeds_with_num_faces(self): + # Creates face landmarker. + model_path = test_utils.get_test_data_path( + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE) + base_options = _BaseOptions(model_asset_path=model_path) + options = _FaceLandmarkerOptions(base_options=base_options, num_faces=1, + output_face_blendshapes=True) + with _FaceLandmarker.create_from_options(options) as landmarker: + # Load the portrait image. + test_image = _Image.create_from_file( + test_utils.get_test_data_path(_PORTRAIT_IMAGE)) + # Performs face landmarks detection on the input. + detection_result = landmarker.detect(test_image) + # Comparing results. + self.assertLen(detection_result.face_blendshapes, 1) + + def test_empty_detection_outputs(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path)) + with _FaceLandmarker.create_from_options(options) as landmarker: + # Load the image with no faces. + no_faces_test_image = _Image.create_from_file( + test_utils.get_test_data_path(_CAT_IMAGE)) + # Performs face landmarks detection on the input. + detection_result = landmarker.detect(no_faces_test_image) + self.assertEmpty(detection_result.face_landmarks) + self.assertEmpty(detection_result.face_blendshapes) + self.assertEmpty(detection_result.facial_transformation_matrixes) + + def test_missing_result_callback(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM) + with self.assertRaisesRegex(ValueError, + r'result callback must be provided'): + with _FaceLandmarker.create_from_options(options) as unused_landmarker: + pass + + @parameterized.parameters((_RUNNING_MODE.IMAGE), (_RUNNING_MODE.VIDEO)) + def test_illegal_result_callback(self, running_mode): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=running_mode, + result_callback=mock.MagicMock()) + with self.assertRaisesRegex(ValueError, + r'result callback should not be provided'): + with _FaceLandmarker.create_from_options(options) as unused_landmarker: + pass + + def test_calling_detect_for_video_in_image_mode(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.IMAGE) + with _FaceLandmarker.create_from_options(options) as landmarker: + with self.assertRaisesRegex(ValueError, + r'not initialized with the video mode'): + landmarker.detect_for_video(self.test_image, 0) + + def test_calling_detect_async_in_image_mode(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.IMAGE) + with _FaceLandmarker.create_from_options(options) as landmarker: + with self.assertRaisesRegex(ValueError, + r'not initialized with the live stream mode'): + landmarker.detect_async(self.test_image, 0) + + def test_calling_detect_in_video_mode(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _FaceLandmarker.create_from_options(options) as landmarker: + with self.assertRaisesRegex(ValueError, + r'not initialized with the image mode'): + landmarker.detect(self.test_image) + + def test_calling_detect_async_in_video_mode(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _FaceLandmarker.create_from_options(options) as landmarker: + with self.assertRaisesRegex(ValueError, + r'not initialized with the live stream mode'): + landmarker.detect_async(self.test_image, 0) + + def test_detect_for_video_with_out_of_order_timestamp(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.VIDEO) + with _FaceLandmarker.create_from_options(options) as landmarker: + unused_result = landmarker.detect_for_video(self.test_image, 1) + with self.assertRaisesRegex( + ValueError, r'Input timestamp must be monotonically increasing'): + landmarker.detect_for_video(self.test_image, 0) + + @parameterized.parameters( + (_FACE_LANDMARKER_BUNDLE_ASSET_FILE, _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), + (_FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), + (_FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), None), + (_FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), + _make_expected_facial_transformation_matrixes())) + def test_detect_for_video( + self, model_name, expected_face_landmarks, expected_face_blendshapes, + expected_facial_transformation_matrixes): + # Creates face landmarker. + model_path = test_utils.get_test_data_path(model_name) + base_options = _BaseOptions(model_asset_path=model_path) + + options = _FaceLandmarkerOptions( + base_options=base_options, + running_mode=_RUNNING_MODE.VIDEO, + output_face_blendshapes=True if expected_face_blendshapes else False, + output_facial_transformation_matrixes=True + if expected_facial_transformation_matrixes else False) + + with _FaceLandmarker.create_from_options(options) as landmarker: + for timestamp in range(0, 300, 30): + # Performs face landmarks detection on the input. + detection_result = landmarker.detect_for_video(self.test_image, + timestamp) + # Comparing results. + if expected_face_landmarks is not None: + self._expect_landmarks_correct(detection_result.face_landmarks[0], + expected_face_landmarks) + if expected_face_blendshapes is not None: + self._expect_blendshapes_correct(detection_result.face_blendshapes[0], + expected_face_blendshapes) + if expected_facial_transformation_matrixes is not None: + self._expect_facial_transformation_matrix_correct( + detection_result.facial_transformation_matrixes, + expected_facial_transformation_matrixes) + + def test_calling_detect_in_live_stream_mode(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=mock.MagicMock()) + with _FaceLandmarker.create_from_options(options) as landmarker: + with self.assertRaisesRegex(ValueError, + r'not initialized with the image mode'): + landmarker.detect(self.test_image) + + def test_calling_detect_for_video_in_live_stream_mode(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=mock.MagicMock()) + with _FaceLandmarker.create_from_options(options) as landmarker: + with self.assertRaisesRegex(ValueError, + r'not initialized with the video mode'): + landmarker.detect_for_video(self.test_image, 0) + + def test_detect_async_calls_with_illegal_timestamp(self): + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=self.model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + result_callback=mock.MagicMock()) + with _FaceLandmarker.create_from_options(options) as landmarker: + landmarker.detect_async(self.test_image, 100) + with self.assertRaisesRegex( + ValueError, r'Input timestamp must be monotonically increasing'): + landmarker.detect_async(self.test_image, 0) + + @parameterized.parameters( + (_PORTRAIT_IMAGE, _FACE_LANDMARKER_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), + (_PORTRAIT_IMAGE, _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), + (_PORTRAIT_IMAGE, _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), None), + (_PORTRAIT_IMAGE, _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), + _make_expected_facial_transformation_matrixes())) + def test_detect_async_calls( + self, image_path, model_name, expected_face_landmarks, + expected_face_blendshapes, expected_facial_transformation_matrixes): + test_image = _Image.create_from_file( + test_utils.get_test_data_path(image_path)) + observed_timestamp_ms = -1 + + def check_result(result: FaceLandmarkerResult, output_image: _Image, + timestamp_ms: int): + # Comparing results. + if expected_face_landmarks is not None: + self._expect_landmarks_correct(result.face_landmarks[0], + expected_face_landmarks) + if expected_face_blendshapes is not None: + self._expect_blendshapes_correct(result.face_blendshapes[0], + expected_face_blendshapes) + if expected_facial_transformation_matrixes is not None: + self._expect_facial_transformation_matrix_correct( + result.facial_transformation_matrixes, + expected_facial_transformation_matrixes) + self.assertTrue( + np.array_equal(output_image.numpy_view(), test_image.numpy_view())) + self.assertLess(observed_timestamp_ms, timestamp_ms) + self.observed_timestamp_ms = timestamp_ms + + model_path = test_utils.get_test_data_path(model_name) + options = _FaceLandmarkerOptions( + base_options=_BaseOptions(model_asset_path=model_path), + running_mode=_RUNNING_MODE.LIVE_STREAM, + output_face_blendshapes=True if expected_face_blendshapes else False, + output_facial_transformation_matrixes=True + if expected_facial_transformation_matrixes else False, + result_callback=check_result) + with _FaceLandmarker.create_from_options(options) as landmarker: + for timestamp in range(0, 300, 30): + landmarker.detect_async(test_image, timestamp) + if __name__ == '__main__': absltest.main() diff --git a/mediapipe/tasks/python/vision/BUILD b/mediapipe/tasks/python/vision/BUILD index 62b760561..e488bbb74 100644 --- a/mediapipe/tasks/python/vision/BUILD +++ b/mediapipe/tasks/python/vision/BUILD @@ -166,6 +166,7 @@ py_library( "//mediapipe/python:packet_creator", "//mediapipe/python:packet_getter", "//mediapipe/tasks/cc/vision/face_landmarker/proto:face_landmarker_graph_options_py_pb2", + "//mediapipe/tasks/cc/vision/face_geometry/proto:face_geometry_py_pb2", "//mediapipe/tasks/python/components/containers:category", "//mediapipe/tasks/python/components/containers:landmark", "//mediapipe/tasks/python/components/containers:matrix_data", diff --git a/mediapipe/tasks/python/vision/face_landmarker.py b/mediapipe/tasks/python/vision/face_landmarker.py index c109c646a..a053d936a 100644 --- a/mediapipe/tasks/python/vision/face_landmarker.py +++ b/mediapipe/tasks/python/vision/face_landmarker.py @@ -25,6 +25,8 @@ from mediapipe.python import packet_getter from mediapipe.python._framework_bindings import image as image_module from mediapipe.python._framework_bindings import packet as packet_module from mediapipe.tasks.cc.vision.face_landmarker.proto import face_landmarker_graph_options_pb2 +# TODO: Remove later. +from mediapipe.tasks.cc.vision.face_geometry.proto import face_geometry_pb2 from mediapipe.tasks.python.components.containers import category as category_module from mediapipe.tasks.python.components.containers import landmark as landmark_module from mediapipe.tasks.python.components.containers import matrix_data as matrix_data_module @@ -160,15 +162,22 @@ def _build_landmarker_result( category_name=face_blendshapes.label)) face_blendshapes_results.append(face_blendshapes_categories) + # Creates a dummy FaceGeometry packet to initialize the symbol database. + # TODO: Remove later. + face_geometry_in = face_geometry_pb2.FaceGeometry() + p = packet_creator.create_proto(face_geometry_in).at(100) + face_geometry_out = packet_getter.get_proto(p) + facial_transformation_matrixes_results = [] if _FACE_GEOMETRY_STREAM_NAME in output_packets: facial_transformation_matrixes_proto_list = packet_getter.get_proto_list( output_packets[_FACE_GEOMETRY_STREAM_NAME]) for proto in facial_transformation_matrixes_proto_list: - matrix_data = matrix_data_pb2.MatrixData() - matrix_data.MergeFrom(proto) - matrix = matrix_data_module.MatrixData.create_from_pb2(matrix_data) - facial_transformation_matrixes_results.append(matrix) + if proto.pose_transform_matrix: + matrix_data = matrix_data_pb2.MatrixData() + matrix_data.MergeFrom(proto.pose_transform_matrix) + matrix = matrix_data_module.MatrixData.create_from_pb2(matrix_data) + facial_transformation_matrixes_results.append(matrix) return FaceLandmarkerResult(face_landmarks_results, face_blendshapes_results, facial_transformation_matrixes_results) From 80dd764605d3ffb48edc30d70fe127a08394043e Mon Sep 17 00:00:00 2001 From: kinaryml Date: Wed, 15 Mar 2023 10:52:16 -0700 Subject: [PATCH 091/136] Removed dummy packet creation and preserved face_geometry protobuf import --- mediapipe/tasks/python/vision/face_landmarker.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/mediapipe/tasks/python/vision/face_landmarker.py b/mediapipe/tasks/python/vision/face_landmarker.py index a053d936a..6862818ce 100644 --- a/mediapipe/tasks/python/vision/face_landmarker.py +++ b/mediapipe/tasks/python/vision/face_landmarker.py @@ -25,7 +25,7 @@ from mediapipe.python import packet_getter from mediapipe.python._framework_bindings import image as image_module from mediapipe.python._framework_bindings import packet as packet_module from mediapipe.tasks.cc.vision.face_landmarker.proto import face_landmarker_graph_options_pb2 -# TODO: Remove later. +# TODO: Remove this later. from mediapipe.tasks.cc.vision.face_geometry.proto import face_geometry_pb2 from mediapipe.tasks.python.components.containers import category as category_module from mediapipe.tasks.python.components.containers import landmark as landmark_module @@ -162,12 +162,6 @@ def _build_landmarker_result( category_name=face_blendshapes.label)) face_blendshapes_results.append(face_blendshapes_categories) - # Creates a dummy FaceGeometry packet to initialize the symbol database. - # TODO: Remove later. - face_geometry_in = face_geometry_pb2.FaceGeometry() - p = packet_creator.create_proto(face_geometry_in).at(100) - face_geometry_out = packet_getter.get_proto(p) - facial_transformation_matrixes_results = [] if _FACE_GEOMETRY_STREAM_NAME in output_packets: facial_transformation_matrixes_proto_list = packet_getter.get_proto_list( From ce3cd94f457970502adb855fc723d2d13ae47980 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Wed, 15 Mar 2023 10:54:21 -0700 Subject: [PATCH 092/136] Internal change PiperOrigin-RevId: 516871638 --- LICENSE | 17 ++ .../language_detector/custom_ops/utils/BUILD | 42 ++++ .../custom_ops/utils/ngram_hash_ops_utils.cc | 96 ++++++++ .../custom_ops/utils/ngram_hash_ops_utils.h | 56 +++++ .../utils/ngram_hash_ops_utils_test.cc | 135 ++++++++++ .../custom_ops/utils/utf/BUILD | 27 ++ .../custom_ops/utils/utf/rune.c | 233 ++++++++++++++++++ .../custom_ops/utils/utf/runetype.c | 54 ++++ .../custom_ops/utils/utf/runetypebody.h | 212 ++++++++++++++++ .../custom_ops/utils/utf/utf.h | 98 ++++++++ 10 files changed, 970 insertions(+) create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/BUILD create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.cc create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.h create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils_test.cc create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/BUILD create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/rune.c create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/runetype.c create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/runetypebody.h create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h diff --git a/LICENSE b/LICENSE index 261eeb9e9..0e03e3911 100644 --- a/LICENSE +++ b/LICENSE @@ -199,3 +199,20 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +=========================================================================== +For files under tasks/cc/text/language_detector/custom_ops/utils/utf/ +=========================================================================== +/* + * The authors of this software are Rob Pike and Ken Thompson. + * Copyright (c) 2002 by Lucent Technologies. + * Permission to use, copy, modify, and distribute this software for any + * purpose without fee is hereby granted, provided that this entire notice + * is included in all copies of any software which is or includes a copy + * or modification of this software and in all copies of the supporting + * documentation for such software. + * THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED + * WARRANTY. IN PARTICULAR, NEITHER THE AUTHORS NOR LUCENT TECHNOLOGIES MAKE ANY + * REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY + * OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. + */ diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/BUILD b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/BUILD new file mode 100644 index 000000000..9f2fe298a --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/BUILD @@ -0,0 +1,42 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package(default_visibility = ["//mediapipe/tasks:internal"]) + +licenses(["notice"]) + +cc_library( + name = "ngram_hash_ops_utils", + srcs = [ + "ngram_hash_ops_utils.cc", + ], + hdrs = [ + "ngram_hash_ops_utils.h", + ], + deps = [ + "//mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf", + ], +) + +cc_test( + name = "ngram_hash_ops_utils_test", + size = "small", + srcs = [ + "ngram_hash_ops_utils_test.cc", + ], + deps = [ + ":ngram_hash_ops_utils", + "//mediapipe/framework/port:gtest_main", + ], +) diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.cc b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.cc new file mode 100644 index 000000000..f1ad71fc1 --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.cc @@ -0,0 +1,96 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.h" + +#include +#include +#include + +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h" + +namespace mediapipe::tasks::text::language_detector::custom_ops { + +TokenizedOutput Tokenize(const char* input_str, int len, int max_tokens, + bool exclude_nonalphaspace_tokens) { + const std::string kPrefix = "^"; + const std::string kSuffix = "$"; + const std::string kReplacementToken = " "; + + TokenizedOutput output; + + size_t token_start = 0; + output.str.reserve(len + 2); + output.tokens.reserve(len + 2); + + output.str.append(kPrefix); + output.tokens.push_back(std::make_pair(token_start, kPrefix.size())); + token_start += kPrefix.size(); + + Rune token; + for (int i = 0; i < len && output.tokens.size() + 1 < max_tokens;) { + // Use the standard UTF-8 library to find the next token. + size_t bytes_read = utf_charntorune(&token, input_str + i, len - i); + + // Stop processing, if we can't read any more tokens, or we have reached + // maximum allowed tokens, allocating one token for the suffix. + if (bytes_read == 0) { + break; + } + + // If `exclude_nonalphaspace_tokens` is set to true, and the token is not + // alphanumeric, replace it with a replacement token. + if (exclude_nonalphaspace_tokens && !utf_isalpharune(token)) { + output.str.append(kReplacementToken); + output.tokens.push_back( + std::make_pair(token_start, kReplacementToken.size())); + token_start += kReplacementToken.size(); + i += bytes_read; + continue; + } + + // Append the token in the output string, and note its position and the + // number of bytes that token consumed. + output.str.append(input_str + i, bytes_read); + output.tokens.push_back(std::make_pair(token_start, bytes_read)); + token_start += bytes_read; + i += bytes_read; + } + output.str.append(kSuffix); + output.tokens.push_back(std::make_pair(token_start, kSuffix.size())); + token_start += kSuffix.size(); + + return output; +} + +void LowercaseUnicodeStr(const char* input_str, int len, + std::string* output_str) { + for (int i = 0; i < len;) { + Rune token; + + // Tokenize the given string, and get the appropriate lowercase token. + size_t bytes_read = utf_charntorune(&token, input_str + i, len - i); + token = utf_isalpharune(token) ? utf_tolowerrune(token) : token; + + // Write back the token to the output string. + char token_buf[UTFmax]; + size_t bytes_to_write = utf_runetochar(token_buf, &token); + output_str->append(token_buf, bytes_to_write); + + i += bytes_read; + } +} + +} // namespace mediapipe::tasks::text::language_detector::custom_ops diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.h b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.h new file mode 100644 index 000000000..9a80554c8 --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.h @@ -0,0 +1,56 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#ifndef MEDIAPIPE_TASKS_CC_TEXT_LANGUAGE_DETECTOR_CUSTOM_OPS_UTILS_NGRAM_HASH_OPS_UTILS_H_ +#define MEDIAPIPE_TASKS_CC_TEXT_LANGUAGE_DETECTOR_CUSTOM_OPS_UTILS_NGRAM_HASH_OPS_UTILS_H_ + +#include +#include +#include + +namespace mediapipe::tasks::text::language_detector::custom_ops { + +struct TokenizedOutput { + // The processed string (with necessary prefix, suffix, skipped tokens, etc.). + std::string str; + + // This vector contains pairs, where each pair has two members. The first + // denoting the starting index of the token in the `str` string, and the + // second denoting the length of that token in bytes. + std::vector> tokens; +}; + +// Tokenizes the given input string on Unicode token boundaries, with a maximum +// of `max_tokens` tokens. +// +// If `exclude_nonalphaspace_tokens` is enabled, the tokenization ignores +// non-alphanumeric tokens, and replaces them with a replacement token (" "). +// +// The method returns the output in the `TokenizedOutput` struct, which stores +// both, the processed input string, and the indices and sizes of each token +// within that string. +TokenizedOutput Tokenize(const char* input_str, int len, int max_tokens, + bool exclude_nonalphaspace_tokens); + +// Converts the given unicode string (`input_str`) with the specified length +// (`len`) to a lowercase string. +// +// The method populates the lowercased string in `output_str`. +void LowercaseUnicodeStr(const char* input_str, int len, + std::string* output_str); + +} // namespace mediapipe::tasks::text::language_detector::custom_ops + +#endif // MEDIAPIPE_TASKS_CC_TEXT_LANGUAGE_DETECTOR_CUSTOM_OPS_UTILS_NGRAM_HASH_OPS_UTILS_H_ diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils_test.cc b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils_test.cc new file mode 100644 index 000000000..d22af1c95 --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils_test.cc @@ -0,0 +1,135 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.h" + +#include + +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" + +namespace mediapipe::tasks::text::language_detector::custom_ops { + +namespace { + +using ::testing::Values; + +std::string ReconstructStringFromTokens(TokenizedOutput output) { + std::string reconstructed_str; + for (int i = 0; i < output.tokens.size(); i++) { + reconstructed_str.append( + output.str.c_str() + output.tokens[i].first, + output.str.c_str() + output.tokens[i].first + output.tokens[i].second); + } + return reconstructed_str; +} + +struct TokenizeTestParams { + std::string input_str; + size_t max_tokens; + bool exclude_nonalphaspace_tokens; + std::string expected_output_str; +}; + +class TokenizeParameterizedTest + : public ::testing::Test, + public testing::WithParamInterface {}; + +TEST_P(TokenizeParameterizedTest, Tokenize) { + // Checks that the Tokenize method returns the expected value. + const TokenizeTestParams params = TokenizeParameterizedTest::GetParam(); + const TokenizedOutput output = Tokenize( + /*input_str=*/params.input_str.c_str(), + /*len=*/params.input_str.size(), + /*max_tokens=*/params.max_tokens, + /*exclude_nonalphaspace_tokens=*/params.exclude_nonalphaspace_tokens); + + // The output string should have the necessary prefixes, and the "!" token + // should have been replaced with a " ". + EXPECT_EQ(output.str, params.expected_output_str); + EXPECT_EQ(ReconstructStringFromTokens(output), params.expected_output_str); +} + +INSTANTIATE_TEST_SUITE_P( + TokenizeParameterizedTests, TokenizeParameterizedTest, + Values( + // Test including non-alphanumeric characters. + TokenizeTestParams({/*input_str=*/"hi!", /*max_tokens=*/100, + /*exclude_alphanonspace=*/false, + /*expected_output_str=*/"^hi!$"}), + // Test not including non-alphanumeric characters. + TokenizeTestParams({/*input_str=*/"hi!", /*max_tokens=*/100, + /*exclude_alphanonspace=*/true, + /*expected_output_str=*/"^hi $"}), + // Test with a maximum of 3 tokens. + TokenizeTestParams({/*input_str=*/"hi!", /*max_tokens=*/3, + /*exclude_alphanonspace=*/true, + /*expected_output_str=*/"^h$"}), + // Test with non-latin characters. + TokenizeTestParams({/*input_str=*/"ありがと", /*max_tokens=*/100, + /*exclude_alphanonspace=*/true, + /*expected_output_str=*/"^ありがと$"}))); + +TEST(LowercaseUnicodeTest, TestLowercaseUnicode) { + { + // Check that the method is a no-op when the string is lowercase. + std::string input_str = "hello"; + std::string output_str; + LowercaseUnicodeStr( + /*input_str=*/input_str.c_str(), + /*len=*/input_str.size(), + /*output_str=*/&output_str); + + EXPECT_EQ(output_str, "hello"); + } + { + // Check that the method has uppercase characters. + std::string input_str = "hElLo"; + std::string output_str; + LowercaseUnicodeStr( + /*input_str=*/input_str.c_str(), + /*len=*/input_str.size(), + /*output_str=*/&output_str); + + EXPECT_EQ(output_str, "hello"); + } + { + // Check that the method works with non-latin scripts. + // Cyrillic has the concept of cases, so it should change the input. + std::string input_str = "БЙп"; + std::string output_str; + LowercaseUnicodeStr( + /*input_str=*/input_str.c_str(), + /*len=*/input_str.size(), + /*output_str=*/&output_str); + + EXPECT_EQ(output_str, "бйп"); + } + { + // Check that the method works with non-latin scripts. + // Japanese doesn't have the concept of cases, so it should not change. + std::string input_str = "ありがと"; + std::string output_str; + LowercaseUnicodeStr( + /*input_str=*/input_str.c_str(), + /*len=*/input_str.size(), + /*output_str=*/&output_str); + + EXPECT_EQ(output_str, "ありがと"); + } +} + +} // namespace +} // namespace mediapipe::tasks::text::language_detector::custom_ops diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/BUILD b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/BUILD new file mode 100644 index 000000000..a71845305 --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/BUILD @@ -0,0 +1,27 @@ +# Copyright 2022 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package(default_visibility = ["//mediapipe/tasks:internal"]) + +licenses(["notice"]) + +cc_library( + name = "utf", + srcs = [ + "rune.c", + "runetype.c", + "runetypebody.h", + ], + hdrs = ["utf.h"], +) diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/rune.c b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/rune.c new file mode 100644 index 000000000..b74450f44 --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/rune.c @@ -0,0 +1,233 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ +// Forked from a library written by Rob Pike and Ken Thompson. Original +// copyright message below. +/* + * The authors of this software are Rob Pike and Ken Thompson. + * Copyright (c) 2002 by Lucent Technologies. + * Permission to use, copy, modify, and distribute this software for any + * purpose without fee is hereby granted, provided that this entire notice + * is included in all copies of any software which is or includes a copy + * or modification of this software and in all copies of the supporting + * documentation for such software. + * THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED + * WARRANTY. IN PARTICULAR, NEITHER THE AUTHORS NOR LUCENT TECHNOLOGIES MAKE ANY + * REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY + * OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. + */ +#include +#include +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h" + +enum +{ + Bit1 = 7, + Bitx = 6, + Bit2 = 5, + Bit3 = 4, + Bit4 = 3, + Bit5 = 2, + + T1 = ((1<<(Bit1+1))-1) ^ 0xFF, /* 0000 0000 */ + Tx = ((1<<(Bitx+1))-1) ^ 0xFF, /* 1000 0000 */ + T2 = ((1<<(Bit2+1))-1) ^ 0xFF, /* 1100 0000 */ + T3 = ((1<<(Bit3+1))-1) ^ 0xFF, /* 1110 0000 */ + T4 = ((1<<(Bit4+1))-1) ^ 0xFF, /* 1111 0000 */ + T5 = ((1<<(Bit5+1))-1) ^ 0xFF, /* 1111 1000 */ + + Rune1 = (1<<(Bit1+0*Bitx))-1, /* 0000 0000 0111 1111 */ + Rune2 = (1<<(Bit2+1*Bitx))-1, /* 0000 0111 1111 1111 */ + Rune3 = (1<<(Bit3+2*Bitx))-1, /* 1111 1111 1111 1111 */ + Rune4 = (1<<(Bit4+3*Bitx))-1, + /* 0001 1111 1111 1111 1111 1111 */ + + Maskx = (1< T1 + */ + c = *(uchar*)str; + if(c < Tx) { + *rune = c; + return 1; + } + + // If we can't read more than one character we must stop + if(length <= 1) { + goto badlen; + } + + /* + * two character sequence (11-bit value) + * 0080-07FF => T2 Tx + */ + c1 = *(uchar*)(str+1) ^ Tx; + if(c1 & Testx) + goto bad; + if(c < T3) { + if(c < T2) + goto bad; + l = ((c << Bitx) | c1) & Rune2; + if(l <= Rune1) + goto bad; + *rune = l; + return 2; + } + + // If we can't read more than two characters we must stop + if(length <= 2) { + goto badlen; + } + + /* + * three character sequence (16-bit value) + * 0800-FFFF => T3 Tx Tx + */ + c2 = *(uchar*)(str+2) ^ Tx; + if(c2 & Testx) + goto bad; + if(c < T4) { + l = ((((c << Bitx) | c1) << Bitx) | c2) & Rune3; + if(l <= Rune2) + goto bad; + *rune = l; + return 3; + } + + if (length <= 3) + goto badlen; + + /* + * four character sequence (21-bit value) + * 10000-1FFFFF => T4 Tx Tx Tx + */ + c3 = *(uchar*)(str+3) ^ Tx; + if (c3 & Testx) + goto bad; + if (c < T5) { + l = ((((((c << Bitx) | c1) << Bitx) | c2) << Bitx) | c3) & Rune4; + if (l <= Rune3) + goto bad; + if (l > Runemax) + goto bad; + *rune = l; + return 4; + } + + // Support for 5-byte or longer UTF-8 would go here, but + // since we don't have that, we'll just fall through to bad. + + /* + * bad decoding + */ +bad: + *rune = Bad; + return 1; +badlen: + *rune = Bad; + return 0; + +} + +int +utf_runetochar(char *str, const Rune *rune) +{ + /* Runes are signed, so convert to unsigned for range check. */ + unsigned long c; + + /* + * one character sequence + * 00000-0007F => 00-7F + */ + c = *rune; + if(c <= Rune1) { + str[0] = c; + return 1; + } + + /* + * two character sequence + * 0080-07FF => T2 Tx + */ + if(c <= Rune2) { + str[0] = T2 | (c >> 1*Bitx); + str[1] = Tx | (c & Maskx); + return 2; + } + + /* + * If the Rune is out of range, convert it to the error rune. + * Do this test here because the error rune encodes to three bytes. + * Doing it earlier would duplicate work, since an out of range + * Rune wouldn't have fit in one or two bytes. + */ + if (c > Runemax) + c = Runeerror; + + /* + * three character sequence + * 0800-FFFF => T3 Tx Tx + */ + if (c <= Rune3) { + str[0] = T3 | (c >> 2*Bitx); + str[1] = Tx | ((c >> 1*Bitx) & Maskx); + str[2] = Tx | (c & Maskx); + return 3; + } + + /* + * four character sequence (21-bit value) + * 10000-1FFFFF => T4 Tx Tx Tx + */ + str[0] = T4 | (c >> 3*Bitx); + str[1] = Tx | ((c >> 2*Bitx) & Maskx); + str[2] = Tx | ((c >> 1*Bitx) & Maskx); + str[3] = Tx | (c & Maskx); + return 4; +} diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/runetype.c b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/runetype.c new file mode 100644 index 000000000..1dd8abdbd --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/runetype.c @@ -0,0 +1,54 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ +// Forked from a library written by Rob Pike and Ken Thompson. Original +// copyright message below. +/* + * The authors of this software are Rob Pike and Ken Thompson. + * Copyright (c) 2002 by Lucent Technologies. + * Permission to use, copy, modify, and distribute this software for any + * purpose without fee is hereby granted, provided that this entire notice + * is included in all copies of any software which is or includes a copy + * or modification of this software and in all copies of the supporting + * documentation for such software. + * THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED + * WARRANTY. IN PARTICULAR, NEITHER THE AUTHORS NOR LUCENT TECHNOLOGIES MAKE ANY + * REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY + * OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. + */ +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h" + +static +Rune* +rbsearch(Rune c, Rune *t, int n, int ne) +{ + Rune *p; + int m; + + while(n > 1) { + m = n >> 1; + p = t + m*ne; + if(c >= p[0]) { + t = p; + n = n-m; + } else + n = m; + } + if(n && c >= t[0]) + return t; + return 0; +} + +#define RUNETYPEBODY +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/runetypebody.h" diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/runetypebody.h b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/runetypebody.h new file mode 100644 index 000000000..66d1dfc19 --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/runetypebody.h @@ -0,0 +1,212 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#ifdef RUNETYPEBODY + +static Rune __isalphar[] = { + 0x0041, 0x005a, 0x0061, 0x007a, 0x00c0, 0x00d6, 0x00d8, 0x00f6, + 0x00f8, 0x02c1, 0x02c6, 0x02d1, 0x02e0, 0x02e4, 0x0370, 0x0374, + 0x0376, 0x0377, 0x037a, 0x037d, 0x0388, 0x038a, 0x038e, 0x03a1, + 0x03a3, 0x03f5, 0x03f7, 0x0481, 0x048a, 0x0527, 0x0531, 0x0556, + 0x0561, 0x0587, 0x05d0, 0x05ea, 0x05f0, 0x05f2, 0x0620, 0x064a, + 0x066e, 0x066f, 0x0671, 0x06d3, 0x06e5, 0x06e6, 0x06ee, 0x06ef, + 0x06fa, 0x06fc, 0x0712, 0x072f, 0x074d, 0x07a5, 0x07ca, 0x07ea, + 0x07f4, 0x07f5, 0x0800, 0x0815, 0x0840, 0x0858, 0x08a2, 0x08ac, + 0x0904, 0x0939, 0x0958, 0x0961, 0x0971, 0x0977, 0x0979, 0x097f, + 0x0985, 0x098c, 0x098f, 0x0990, 0x0993, 0x09a8, 0x09aa, 0x09b0, + 0x09b6, 0x09b9, 0x09dc, 0x09dd, 0x09df, 0x09e1, 0x09f0, 0x09f1, + 0x0a05, 0x0a0a, 0x0a0f, 0x0a10, 0x0a13, 0x0a28, 0x0a2a, 0x0a30, + 0x0a32, 0x0a33, 0x0a35, 0x0a36, 0x0a38, 0x0a39, 0x0a59, 0x0a5c, + 0x0a72, 0x0a74, 0x0a85, 0x0a8d, 0x0a8f, 0x0a91, 0x0a93, 0x0aa8, + 0x0aaa, 0x0ab0, 0x0ab2, 0x0ab3, 0x0ab5, 0x0ab9, 0x0ae0, 0x0ae1, + 0x0b05, 0x0b0c, 0x0b0f, 0x0b10, 0x0b13, 0x0b28, 0x0b2a, 0x0b30, + 0x0b32, 0x0b33, 0x0b35, 0x0b39, 0x0b5c, 0x0b5d, 0x0b5f, 0x0b61, + 0x0b85, 0x0b8a, 0x0b8e, 0x0b90, 0x0b92, 0x0b95, 0x0b99, 0x0b9a, + 0x0b9e, 0x0b9f, 0x0ba3, 0x0ba4, 0x0ba8, 0x0baa, 0x0bae, 0x0bb9, + 0x0c05, 0x0c0c, 0x0c0e, 0x0c10, 0x0c12, 0x0c28, 0x0c2a, 0x0c33, + 0x0c35, 0x0c39, 0x0c58, 0x0c59, 0x0c60, 0x0c61, 0x0c85, 0x0c8c, + 0x0c8e, 0x0c90, 0x0c92, 0x0ca8, 0x0caa, 0x0cb3, 0x0cb5, 0x0cb9, + 0x0ce0, 0x0ce1, 0x0cf1, 0x0cf2, 0x0d05, 0x0d0c, 0x0d0e, 0x0d10, + 0x0d12, 0x0d3a, 0x0d60, 0x0d61, 0x0d7a, 0x0d7f, 0x0d85, 0x0d96, + 0x0d9a, 0x0db1, 0x0db3, 0x0dbb, 0x0dc0, 0x0dc6, 0x0e01, 0x0e30, + 0x0e32, 0x0e33, 0x0e40, 0x0e46, 0x0e81, 0x0e82, 0x0e87, 0x0e88, + 0x0e94, 0x0e97, 0x0e99, 0x0e9f, 0x0ea1, 0x0ea3, 0x0eaa, 0x0eab, + 0x0ead, 0x0eb0, 0x0eb2, 0x0eb3, 0x0ec0, 0x0ec4, 0x0edc, 0x0edf, + 0x0f40, 0x0f47, 0x0f49, 0x0f6c, 0x0f88, 0x0f8c, 0x1000, 0x102a, + 0x1050, 0x1055, 0x105a, 0x105d, 0x1065, 0x1066, 0x106e, 0x1070, + 0x1075, 0x1081, 0x10a0, 0x10c5, 0x10d0, 0x10fa, 0x10fc, 0x1248, + 0x124a, 0x124d, 0x1250, 0x1256, 0x125a, 0x125d, 0x1260, 0x1288, + 0x128a, 0x128d, 0x1290, 0x12b0, 0x12b2, 0x12b5, 0x12b8, 0x12be, + 0x12c2, 0x12c5, 0x12c8, 0x12d6, 0x12d8, 0x1310, 0x1312, 0x1315, + 0x1318, 0x135a, 0x1380, 0x138f, 0x13a0, 0x13f4, 0x1401, 0x166c, + 0x166f, 0x167f, 0x1681, 0x169a, 0x16a0, 0x16ea, 0x1700, 0x170c, + 0x170e, 0x1711, 0x1720, 0x1731, 0x1740, 0x1751, 0x1760, 0x176c, + 0x176e, 0x1770, 0x1780, 0x17b3, 0x1820, 0x1877, 0x1880, 0x18a8, + 0x18b0, 0x18f5, 0x1900, 0x191c, 0x1950, 0x196d, 0x1970, 0x1974, + 0x1980, 0x19ab, 0x19c1, 0x19c7, 0x1a00, 0x1a16, 0x1a20, 0x1a54, + 0x1b05, 0x1b33, 0x1b45, 0x1b4b, 0x1b83, 0x1ba0, 0x1bae, 0x1baf, + 0x1bba, 0x1be5, 0x1c00, 0x1c23, 0x1c4d, 0x1c4f, 0x1c5a, 0x1c7d, + 0x1ce9, 0x1cec, 0x1cee, 0x1cf1, 0x1cf5, 0x1cf6, 0x1d00, 0x1dbf, + 0x1e00, 0x1f15, 0x1f18, 0x1f1d, 0x1f20, 0x1f45, 0x1f48, 0x1f4d, + 0x1f50, 0x1f57, 0x1f5f, 0x1f7d, 0x1f80, 0x1fb4, 0x1fb6, 0x1fbc, + 0x1fc2, 0x1fc4, 0x1fc6, 0x1fcc, 0x1fd0, 0x1fd3, 0x1fd6, 0x1fdb, + 0x1fe0, 0x1fec, 0x1ff2, 0x1ff4, 0x1ff6, 0x1ffc, 0x2090, 0x209c, + 0x210a, 0x2113, 0x2119, 0x211d, 0x212a, 0x212d, 0x212f, 0x2139, + 0x213c, 0x213f, 0x2145, 0x2149, 0x2183, 0x2184, 0x2c00, 0x2c2e, + 0x2c30, 0x2c5e, 0x2c60, 0x2ce4, 0x2ceb, 0x2cee, 0x2cf2, 0x2cf3, + 0x2d00, 0x2d25, 0x2d30, 0x2d67, 0x2d80, 0x2d96, 0x2da0, 0x2da6, + 0x2da8, 0x2dae, 0x2db0, 0x2db6, 0x2db8, 0x2dbe, 0x2dc0, 0x2dc6, + 0x2dc8, 0x2dce, 0x2dd0, 0x2dd6, 0x2dd8, 0x2dde, 0x3005, 0x3006, + 0x3031, 0x3035, 0x303b, 0x303c, 0x3041, 0x3096, 0x309d, 0x309f, + 0x30a1, 0x30fa, 0x30fc, 0x30ff, 0x3105, 0x312d, 0x3131, 0x318e, + 0x31a0, 0x31ba, 0x31f0, 0x31ff, 0x3400, 0x4db5, 0x4e00, 0x9fcc, + 0xa000, 0xa48c, 0xa4d0, 0xa4fd, 0xa500, 0xa60c, 0xa610, 0xa61f, + 0xa62a, 0xa62b, 0xa640, 0xa66e, 0xa67f, 0xa697, 0xa6a0, 0xa6e5, + 0xa717, 0xa71f, 0xa722, 0xa788, 0xa78b, 0xa78e, 0xa790, 0xa793, + 0xa7a0, 0xa7aa, 0xa7f8, 0xa801, 0xa803, 0xa805, 0xa807, 0xa80a, + 0xa80c, 0xa822, 0xa840, 0xa873, 0xa882, 0xa8b3, 0xa8f2, 0xa8f7, + 0xa90a, 0xa925, 0xa930, 0xa946, 0xa960, 0xa97c, 0xa984, 0xa9b2, + 0xaa00, 0xaa28, 0xaa40, 0xaa42, 0xaa44, 0xaa4b, 0xaa60, 0xaa76, + 0xaa80, 0xaaaf, 0xaab5, 0xaab6, 0xaab9, 0xaabd, 0xaadb, 0xaadd, + 0xaae0, 0xaaea, 0xaaf2, 0xaaf4, 0xab01, 0xab06, 0xab09, 0xab0e, + 0xab11, 0xab16, 0xab20, 0xab26, 0xab28, 0xab2e, 0xabc0, 0xabe2, + 0xac00, 0xd7a3, 0xd7b0, 0xd7c6, 0xd7cb, 0xd7fb, 0xf900, 0xfa6d, + 0xfa70, 0xfad9, 0xfb00, 0xfb06, 0xfb13, 0xfb17, 0xfb1f, 0xfb28, + 0xfb2a, 0xfb36, 0xfb38, 0xfb3c, 0xfb40, 0xfb41, 0xfb43, 0xfb44, + 0xfb46, 0xfbb1, 0xfbd3, 0xfd3d, 0xfd50, 0xfd8f, 0xfd92, 0xfdc7, + 0xfdf0, 0xfdfb, 0xfe70, 0xfe74, 0xfe76, 0xfefc, 0xff21, 0xff3a, + 0xff41, 0xff5a, 0xff66, 0xffbe, 0xffc2, 0xffc7, 0xffca, 0xffcf, + 0xffd2, 0xffd7, 0xffda, 0xffdc, 0x10000, 0x1000b, 0x1000d, 0x10026, + 0x10028, 0x1003a, 0x1003c, 0x1003d, 0x1003f, 0x1004d, 0x10050, 0x1005d, + 0x10080, 0x100fa, 0x10280, 0x1029c, 0x102a0, 0x102d0, 0x10300, 0x1031e, + 0x10330, 0x10340, 0x10342, 0x10349, 0x10380, 0x1039d, 0x103a0, 0x103c3, + 0x103c8, 0x103cf, 0x10400, 0x1049d, 0x10800, 0x10805, 0x1080a, 0x10835, + 0x10837, 0x10838, 0x1083f, 0x10855, 0x10900, 0x10915, 0x10920, 0x10939, + 0x10980, 0x109b7, 0x109be, 0x109bf, 0x10a10, 0x10a13, 0x10a15, 0x10a17, + 0x10a19, 0x10a33, 0x10a60, 0x10a7c, 0x10b00, 0x10b35, 0x10b40, 0x10b55, + 0x10b60, 0x10b72, 0x10c00, 0x10c48, 0x11003, 0x11037, 0x11083, 0x110af, + 0x110d0, 0x110e8, 0x11103, 0x11126, 0x11183, 0x111b2, 0x111c1, 0x111c4, + 0x11680, 0x116aa, 0x12000, 0x1236e, 0x13000, 0x1342e, 0x16800, 0x16a38, + 0x16f00, 0x16f44, 0x16f93, 0x16f9f, 0x1b000, 0x1b001, 0x1d400, 0x1d454, + 0x1d456, 0x1d49c, 0x1d49e, 0x1d49f, 0x1d4a5, 0x1d4a6, 0x1d4a9, 0x1d4ac, + 0x1d4ae, 0x1d4b9, 0x1d4bd, 0x1d4c3, 0x1d4c5, 0x1d505, 0x1d507, 0x1d50a, + 0x1d50d, 0x1d514, 0x1d516, 0x1d51c, 0x1d51e, 0x1d539, 0x1d53b, 0x1d53e, + 0x1d540, 0x1d544, 0x1d54a, 0x1d550, 0x1d552, 0x1d6a5, 0x1d6a8, 0x1d6c0, + 0x1d6c2, 0x1d6da, 0x1d6dc, 0x1d6fa, 0x1d6fc, 0x1d714, 0x1d716, 0x1d734, + 0x1d736, 0x1d74e, 0x1d750, 0x1d76e, 0x1d770, 0x1d788, 0x1d78a, 0x1d7a8, + 0x1d7aa, 0x1d7c2, 0x1d7c4, 0x1d7cb, 0x1ee00, 0x1ee03, 0x1ee05, 0x1ee1f, + 0x1ee21, 0x1ee22, 0x1ee29, 0x1ee32, 0x1ee34, 0x1ee37, 0x1ee4d, 0x1ee4f, + 0x1ee51, 0x1ee52, 0x1ee61, 0x1ee62, 0x1ee67, 0x1ee6a, 0x1ee6c, 0x1ee72, + 0x1ee74, 0x1ee77, 0x1ee79, 0x1ee7c, 0x1ee80, 0x1ee89, 0x1ee8b, 0x1ee9b, + 0x1eea1, 0x1eea3, 0x1eea5, 0x1eea9, 0x1eeab, 0x1eebb, 0x20000, 0x2a6d6, + 0x2a700, 0x2b734, 0x2b740, 0x2b81d, 0x2f800, 0x2fa1d, +}; + +static Rune __isalphas[] = { + 0x00aa, 0x00b5, 0x00ba, 0x02ec, 0x02ee, 0x0386, 0x038c, 0x0559, + 0x06d5, 0x06ff, 0x0710, 0x07b1, 0x07fa, 0x081a, 0x0824, 0x0828, + 0x08a0, 0x093d, 0x0950, 0x09b2, 0x09bd, 0x09ce, 0x0a5e, 0x0abd, + 0x0ad0, 0x0b3d, 0x0b71, 0x0b83, 0x0b9c, 0x0bd0, 0x0c3d, 0x0cbd, + 0x0cde, 0x0d3d, 0x0d4e, 0x0dbd, 0x0e84, 0x0e8a, 0x0e8d, 0x0ea5, + 0x0ea7, 0x0ebd, 0x0ec6, 0x0f00, 0x103f, 0x1061, 0x108e, 0x10c7, + 0x10cd, 0x1258, 0x12c0, 0x17d7, 0x17dc, 0x18aa, 0x1aa7, 0x1f59, + 0x1f5b, 0x1f5d, 0x1fbe, 0x2071, 0x207f, 0x2102, 0x2107, 0x2115, + 0x2124, 0x2126, 0x2128, 0x214e, 0x2d27, 0x2d2d, 0x2d6f, 0x2e2f, + 0xa8fb, 0xa9cf, 0xaa7a, 0xaab1, 0xaac0, 0xaac2, 0xfb1d, 0xfb3e, + 0x10808, 0x1083c, 0x10a00, 0x16f50, 0x1d4a2, 0x1d4bb, 0x1d546, 0x1ee24, + 0x1ee27, 0x1ee39, 0x1ee3b, 0x1ee42, 0x1ee47, 0x1ee49, 0x1ee4b, 0x1ee54, + 0x1ee57, 0x1ee59, 0x1ee5b, 0x1ee5d, 0x1ee5f, 0x1ee64, 0x1ee7e, +}; + +int utf_isalpharune(Rune c) { + Rune *p; + + p = rbsearch(c, __isalphar, nelem(__isalphar) / 2, 2); + if (p && c >= p[0] && c <= p[1]) return 1; + p = rbsearch(c, __isalphas, nelem(__isalphas), 1); + if (p && c == p[0]) return 1; + return 0; +} + +static Rune __tolowerr[] = { + 0x0041, 0x005a, 1048608, 0x00c0, 0x00d6, 1048608, 0x00d8, 0x00de, 1048608, + 0x0189, 0x018a, 1048781, 0x01b1, 0x01b2, 1048793, 0x0388, 0x038a, 1048613, + 0x038e, 0x038f, 1048639, 0x0391, 0x03a1, 1048608, 0x03a3, 0x03ab, 1048608, + 0x03fd, 0x03ff, 1048446, 0x0400, 0x040f, 1048656, 0x0410, 0x042f, 1048608, + 0x0531, 0x0556, 1048624, 0x10a0, 0x10c5, 1055840, 0x1f08, 0x1f0f, 1048568, + 0x1f18, 0x1f1d, 1048568, 0x1f28, 0x1f2f, 1048568, 0x1f38, 0x1f3f, 1048568, + 0x1f48, 0x1f4d, 1048568, 0x1f68, 0x1f6f, 1048568, 0x1f88, 0x1f8f, 1048568, + 0x1f98, 0x1f9f, 1048568, 0x1fa8, 0x1faf, 1048568, 0x1fb8, 0x1fb9, 1048568, + 0x1fba, 0x1fbb, 1048502, 0x1fc8, 0x1fcb, 1048490, 0x1fd8, 0x1fd9, 1048568, + 0x1fda, 0x1fdb, 1048476, 0x1fe8, 0x1fe9, 1048568, 0x1fea, 0x1feb, 1048464, + 0x1ff8, 0x1ff9, 1048448, 0x1ffa, 0x1ffb, 1048450, 0x2160, 0x216f, 1048592, + 0x24b6, 0x24cf, 1048602, 0x2c00, 0x2c2e, 1048624, 0x2c7e, 0x2c7f, 1037761, + 0xff21, 0xff3a, 1048608, 0x10400, 0x10427, 1048616, +}; + +static Rune __tolowerp[] = { + 0x0100, 0x012e, 1048577, 0x0132, 0x0136, 1048577, 0x0139, 0x0147, 1048577, + 0x014a, 0x0176, 1048577, 0x017b, 0x017d, 1048577, 0x01a2, 0x01a4, 1048577, + 0x01b3, 0x01b5, 1048577, 0x01cd, 0x01db, 1048577, 0x01de, 0x01ee, 1048577, + 0x01f8, 0x021e, 1048577, 0x0222, 0x0232, 1048577, 0x0248, 0x024e, 1048577, + 0x0370, 0x0372, 1048577, 0x03d8, 0x03ee, 1048577, 0x0460, 0x0480, 1048577, + 0x048a, 0x04be, 1048577, 0x04c3, 0x04cd, 1048577, 0x04d0, 0x0526, 1048577, + 0x1e00, 0x1e94, 1048577, 0x1ea0, 0x1efe, 1048577, 0x1f59, 0x1f5f, 1048568, + 0x2c67, 0x2c6b, 1048577, 0x2c80, 0x2ce2, 1048577, 0x2ceb, 0x2ced, 1048577, + 0xa640, 0xa66c, 1048577, 0xa680, 0xa696, 1048577, 0xa722, 0xa72e, 1048577, + 0xa732, 0xa76e, 1048577, 0xa779, 0xa77b, 1048577, 0xa780, 0xa786, 1048577, + 0xa790, 0xa792, 1048577, 0xa7a0, 0xa7a8, 1048577, +}; + +static Rune __tolowers[] = { + 0x0130, 1048377, 0x0178, 1048455, 0x0179, 1048577, 0x0181, 1048786, + 0x0182, 1048577, 0x0184, 1048577, 0x0186, 1048782, 0x0187, 1048577, + 0x018b, 1048577, 0x018e, 1048655, 0x018f, 1048778, 0x0190, 1048779, + 0x0191, 1048577, 0x0193, 1048781, 0x0194, 1048783, 0x0196, 1048787, + 0x0197, 1048785, 0x0198, 1048577, 0x019c, 1048787, 0x019d, 1048789, + 0x019f, 1048790, 0x01a0, 1048577, 0x01a6, 1048794, 0x01a7, 1048577, + 0x01a9, 1048794, 0x01ac, 1048577, 0x01ae, 1048794, 0x01af, 1048577, + 0x01b7, 1048795, 0x01b8, 1048577, 0x01bc, 1048577, 0x01c4, 1048578, + 0x01c5, 1048577, 0x01c7, 1048578, 0x01c8, 1048577, 0x01ca, 1048578, + 0x01cb, 1048577, 0x01f1, 1048578, 0x01f2, 1048577, 0x01f4, 1048577, + 0x01f6, 1048479, 0x01f7, 1048520, 0x0220, 1048446, 0x023a, 1059371, + 0x023b, 1048577, 0x023d, 1048413, 0x023e, 1059368, 0x0241, 1048577, + 0x0243, 1048381, 0x0244, 1048645, 0x0245, 1048647, 0x0246, 1048577, + 0x0376, 1048577, 0x0386, 1048614, 0x038c, 1048640, 0x03cf, 1048584, + 0x03f4, 1048516, 0x03f7, 1048577, 0x03f9, 1048569, 0x03fa, 1048577, + 0x04c0, 1048591, 0x04c1, 1048577, 0x10c7, 1055840, 0x10cd, 1055840, + 0x1e9e, 1040961, 0x1fbc, 1048567, 0x1fcc, 1048567, 0x1fec, 1048569, + 0x1ffc, 1048567, 0x2126, 1041059, 0x212a, 1040193, 0x212b, 1040314, + 0x2132, 1048604, 0x2183, 1048577, 0x2c60, 1048577, 0x2c62, 1037833, + 0x2c63, 1044762, 0x2c64, 1037849, 0x2c6d, 1037796, 0x2c6e, 1037827, + 0x2c6f, 1037793, 0x2c70, 1037794, 0x2c72, 1048577, 0x2c75, 1048577, + 0x2cf2, 1048577, 0xa77d, 1013244, 0xa77e, 1048577, 0xa78b, 1048577, + 0xa78d, 1006296, 0xa7aa, 1006268, +}; + +Rune utf_tolowerrune(Rune c) { + Rune *p; + + p = rbsearch(c, __tolowerr, nelem(__tolowerr) / 3, 3); + if (p && c >= p[0] && c <= p[1]) return c + p[2] - 1048576; + p = rbsearch(c, __tolowerp, nelem(__tolowerp) / 3, 3); + if (p && c >= p[0] && c <= p[1] && !((c - p[0]) & 1)) + return c + p[2] - 1048576; + p = rbsearch(c, __tolowers, nelem(__tolowers) / 2, 2); + if (p && c == p[0]) return c + p[1] - 1048576; + return c; +} + +#endif diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h new file mode 100644 index 000000000..f3b14772e --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h @@ -0,0 +1,98 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +// Fork of several UTF utils originally written by Rob Pike and Ken Thompson. +#ifndef MEDIAPIPE_TASKS_CC_TEXT_LANGUAGE_DETECTOR_CUSTOM_OPS_UTILS_UTF_UTF_H_ +#define MEDIAPIPE_TASKS_CC_TEXT_LANGUAGE_DETECTOR_CUSTOM_OPS_UTILS_UTF_UTF_H_ 1 + +#include + +// Code-point values in Unicode 4.0 are 21 bits wide. +typedef signed int Rune; + +#define uchar _utfuchar + +typedef unsigned char uchar; + +#define nelem(x) (sizeof(x) / sizeof((x)[0])) + +enum { + UTFmax = 4, // maximum bytes per rune + Runeerror = 0xFFFD, // decoding error in UTF + Runemax = 0x10FFFF, // maximum rune value +}; + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * rune routines + */ + +/* + * These routines were written by Rob Pike and Ken Thompson + * and first appeared in Plan 9. + * SEE ALSO + * utf (7) + * tcs (1) + */ + +// utf_runetochar copies (encodes) one rune, pointed to by r, to at most +// UTFmax bytes starting at s and returns the number of bytes generated. + +int utf_runetochar(char* s, const Rune* r); + +// utf_charntorune copies (decodes) at most UTFmax bytes starting at `str` to +// one rune, pointed to by `rune`, accesss at most `length` bytes of `str`, and +// returns the number of bytes consumed. +// If the UTF sequence is incomplete within n bytes, +// utf_charntorune will set *r to Runeerror and return 0. If it is complete +// but not in UTF format, it will set *r to Runeerror and return 1. +// +// Added 2004-09-24 by Wei-Hwa Huang + +int utf_charntorune(Rune* rune, const char* str, int length); + +// Unicode defines some characters as letters and +// specifies three cases: upper, lower, and title. Mappings among the +// cases are also defined, although they are not exhaustive: some +// upper case letters have no lower case mapping, and so on. Unicode +// also defines several character properties, a subset of which are +// checked by these routines. These routines are based on Unicode +// version 3.0.0. +// +// NOTE: The routines are implemented in C, so isalpharrune returns 0 for false +// and 1 for true. +// +// utf_tolowerrune is the Unicode case mapping. It returns the character +// unchanged if it has no defined mapping. + +Rune utf_tolowerrune(Rune r); + +// utf_isalpharune tests for Unicode letters; this includes ideographs in +// addition to alphabetic characters. + +int utf_isalpharune(Rune r); + +// (The comments in this file were copied from the manpage files rune.3, +// isalpharune.3, and runestrcat.3. Some formatting changes were also made +// to conform to Google style. /JRM 11/11/05) + +#ifdef __cplusplus +} +#endif + +#endif // MEDIAPIPE_TASKS_CC_TEXT_LANGUAGE_DETECTOR_CUSTOM_OPS_UTILS_UTF_UTF_H_ From 18d88c531ac54b6369dc45f558b23cda20895efc Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Wed, 15 Mar 2023 11:29:16 -0700 Subject: [PATCH 093/136] Internal MediaPipe Tasks change. PiperOrigin-RevId: 516881879 --- .../text/language_detector/custom_ops/BUILD | 33 ++ .../custom_ops/ngram_hash.cc | 264 +++++++++++++++ .../language_detector/custom_ops/ngram_hash.h | 27 ++ .../custom_ops/ngram_hash_test.cc | 313 ++++++++++++++++++ 4 files changed, 637 insertions(+) create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.cc create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.h create mode 100644 mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash_test.cc diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/BUILD b/mediapipe/tasks/cc/text/language_detector/custom_ops/BUILD index 5e7c5afa5..090f528ef 100644 --- a/mediapipe/tasks/cc/text/language_detector/custom_ops/BUILD +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/BUILD @@ -42,3 +42,36 @@ cc_test( "@org_tensorflow//tensorflow/lite/kernels:test_util", ], ) + +cc_library( + name = "ngram_hash", + srcs = ["ngram_hash.cc"], + hdrs = ["ngram_hash.h"], + copts = tflite_copts(), + deps = [ + "//mediapipe/tasks/cc/text/language_detector/custom_ops/utils:ngram_hash_ops_utils", + "//mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash:murmur", + "@flatbuffers", + "@org_tensorflow//tensorflow/lite:string_util", + "@org_tensorflow//tensorflow/lite/kernels:builtin_ops", + "@org_tensorflow//tensorflow/lite/kernels:kernel_util", + ], + alwayslink = 1, +) + +cc_test( + name = "ngram_hash_test", + srcs = ["ngram_hash_test.cc"], + deps = [ + ":ngram_hash", + "//mediapipe/framework/port:gtest_main", + "//mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash:murmur", + "@com_google_absl//absl/types:optional", + "@flatbuffers", + "@org_tensorflow//tensorflow/lite:framework", + "@org_tensorflow//tensorflow/lite:string_util", + "@org_tensorflow//tensorflow/lite/c:common", + "@org_tensorflow//tensorflow/lite/kernels:builtin_ops", + "@org_tensorflow//tensorflow/lite/kernels:test_util", + ], +) diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.cc b/mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.cc new file mode 100644 index 000000000..738fa1128 --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.cc @@ -0,0 +1,264 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.h" + +#include +#include +#include + +#include "flatbuffers/flexbuffers.h" +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.h" +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/ngram_hash_ops_utils.h" +#include "tensorflow/lite/kernels/kernel_util.h" +#include "tensorflow/lite/string_util.h" + +namespace tflite::ops::custom { + +namespace ngram_op { + +namespace { + +using ::flexbuffers::GetRoot; +using ::flexbuffers::Map; +using ::flexbuffers::TypedVector; +using ::mediapipe::tasks::text::language_detector::custom_ops:: + LowercaseUnicodeStr; +using ::mediapipe::tasks::text::language_detector::custom_ops::Tokenize; +using ::mediapipe::tasks::text::language_detector::custom_ops::TokenizedOutput; +using ::mediapipe::tasks::text::language_detector::custom_ops::hash:: + MurmurHash64WithSeed; +using ::tflite::GetString; +using ::tflite::StringRef; + +constexpr int kInputMessage = 0; +constexpr int kOutputLabel = 0; +constexpr int kDefaultMaxSplits = 128; + +// This op takes in a string, finds the character ngrams for it and then +// maps each of these ngrams to an index using the specified vocabulary sizes. + +// Input(s): +// - input: Input string. +// - seeds: Seed for the random number generator. +// - ngram_lengths: Lengths of each of the ngrams. For example [1, 2, 3] would +// be interpreted as generating unigrams, bigrams, and trigrams. +// - vocab_sizes: Size of the vocabulary for each of the ngram features +// respectively. The op would generate vocab ids to be less than or equal to +// the vocab size. The index 0 implies an invalid ngram. +// - max_splits: Maximum number of tokens in the output. If this is unset, the +// limit is `kDefaultMaxSplits`. +// - lower_case_input: If this is set to true, the input string would be +// lower-cased before any processing. + +// Output(s): +// - output: A tensor of size [number of ngrams, number of tokens + 2], +// where 2 tokens are reserved for the padding. If `max_splits` is set, this +// length is <= max_splits, otherwise it is <= `kDefaultMaxSplits`. + +// Helper class used for pre-processing the input. +class NGramHashParams { + public: + NGramHashParams(const uint64_t seed, const std::vector& ngram_lengths, + const std::vector& vocab_sizes, int max_splits, + bool lower_case_input) + : seed_(seed), + ngram_lengths_(ngram_lengths), + vocab_sizes_(vocab_sizes), + max_splits_(max_splits), + lower_case_input_(lower_case_input) {} + + TfLiteStatus PreprocessInput(const TfLiteTensor* input_t, + TfLiteContext* context) { + if (input_t->bytes == 0) { + context->ReportError(context, "Empty input not supported."); + return kTfLiteError; + } + + // Do sanity checks on the input. + if (ngram_lengths_.empty()) { + context->ReportError(context, "`ngram_lengths` must be non-empty."); + return kTfLiteError; + } + + if (vocab_sizes_.empty()) { + context->ReportError(context, "`vocab_sizes` must be non-empty."); + return kTfLiteError; + } + + if (ngram_lengths_.size() != vocab_sizes_.size()) { + context->ReportError( + context, + "Sizes of `ngram_lengths` and `vocab_sizes` must be the same."); + return kTfLiteError; + } + + if (max_splits_ <= 0) { + context->ReportError(context, "`max_splits` must be > 0."); + return kTfLiteError; + } + + // Obtain and tokenize the input. + StringRef inputref = GetString(input_t, /*string_index=*/0); + if (lower_case_input_) { + std::string lower_cased_str; + LowercaseUnicodeStr(inputref.str, inputref.len, &lower_cased_str); + + tokenized_output_ = + Tokenize(lower_cased_str.c_str(), inputref.len, max_splits_, + /*exclude_nonalphaspace_tokens=*/true); + } else { + tokenized_output_ = Tokenize(inputref.str, inputref.len, max_splits_, + /*exclude_nonalphaspace_tokens=*/true); + } + return kTfLiteOk; + } + uint64_t GetSeed() const { return seed_; } + + int GetNumTokens() const { return tokenized_output_.tokens.size(); } + + int GetNumNGrams() const { return ngram_lengths_.size(); } + + std::vector GetNGramLengths() const { return ngram_lengths_; } + + std::vector GetVocabSizes() const { return vocab_sizes_; } + + const TokenizedOutput& GetTokenizedOutput() const { + return tokenized_output_; + } + + TokenizedOutput tokenized_output_; + + private: + const uint64_t seed_; + std::vector ngram_lengths_; + std::vector vocab_sizes_; + const int max_splits_; + const bool lower_case_input_; +}; + +// Convert the TypedVector into a regular std::vector. +std::vector GetIntVector(TypedVector typed_vec) { + std::vector vec(typed_vec.size()); + for (int j = 0; j < typed_vec.size(); j++) { + vec[j] = typed_vec[j].AsInt32(); + } + return vec; +} + +void GetNGramHashIndices(NGramHashParams* params, int32_t* data) { + const int max_unicode_length = params->GetNumTokens(); + const auto ngram_lengths = params->GetNGramLengths(); + const auto vocab_sizes = params->GetVocabSizes(); + const auto& tokenized_output = params->GetTokenizedOutput(); + const auto seed = params->GetSeed(); + + // Compute for each ngram. + for (int ngram = 0; ngram < ngram_lengths.size(); ngram++) { + const int vocab_size = vocab_sizes[ngram]; + const int ngram_length = ngram_lengths[ngram]; + + // Compute for each token within the input. + for (int start = 0; start < tokenized_output.tokens.size(); start++) { + // Compute the number of bytes for the ngram starting at the given + // token. + int num_bytes = 0; + for (int i = start; + i < tokenized_output.tokens.size() && i < (start + ngram_length); + i++) { + num_bytes += tokenized_output.tokens[i].second; + } + + // Compute the hash for the ngram starting at the token. + const auto str_hash = MurmurHash64WithSeed( + tokenized_output.str.c_str() + tokenized_output.tokens[start].first, + num_bytes, seed); + + // Map the hash to an index in the vocab. + data[ngram * max_unicode_length + start] = (str_hash % vocab_size) + 1; + } + } +} + +} // namespace + +void* Init(TfLiteContext* context, const char* buffer, size_t length) { + const uint8_t* buffer_t = reinterpret_cast(buffer); + const Map& m = GetRoot(buffer_t, length).AsMap(); + + const uint64_t seed = m["seed"].AsUInt64(); + const std::vector ngram_lengths = + GetIntVector(m["ngram_lengths"].AsTypedVector()); + const std::vector vocab_sizes = + GetIntVector(m["vocab_sizes"].AsTypedVector()); + const int max_splits = + m["max_splits"].IsNull() ? kDefaultMaxSplits : m["max_splits"].AsInt32(); + const bool lowercase_input = + m["lowercase_input"].IsNull() ? true : m["lowercase_input"].AsBool(); + + return new NGramHashParams(seed, ngram_lengths, vocab_sizes, max_splits, + lowercase_input); +} + +void Free(TfLiteContext* context, void* buffer) { + delete reinterpret_cast(buffer); +} + +TfLiteStatus Resize(TfLiteContext* context, TfLiteNode* node) { + TfLiteTensor* output = GetOutput(context, node, kOutputLabel); + TF_LITE_ENSURE(context, output != nullptr); + SetTensorToDynamic(output); + return kTfLiteOk; +} + +TfLiteStatus Eval(TfLiteContext* context, TfLiteNode* node) { + NGramHashParams* params = reinterpret_cast(node->user_data); + TF_LITE_ENSURE_OK( + context, + params->PreprocessInput(GetInput(context, node, kInputMessage), context)); + + TfLiteTensor* output = GetOutput(context, node, kOutputLabel); + TF_LITE_ENSURE(context, output != nullptr); + if (IsDynamicTensor(output)) { + TfLiteIntArray* output_size = TfLiteIntArrayCreate(3); + output_size->data[0] = 1; + output_size->data[1] = params->GetNumNGrams(); + output_size->data[2] = params->GetNumTokens(); + TF_LITE_ENSURE_OK(context, + context->ResizeTensor(context, output, output_size)); + } else { + context->ReportError(context, "Output must by dynamic."); + return kTfLiteError; + } + + if (output->type == kTfLiteInt32) { + GetNGramHashIndices(params, output->data.i32); + } else { + context->ReportError(context, "Output type must be Int32."); + return kTfLiteError; + } + + return kTfLiteOk; +} + +} // namespace ngram_op + +TfLiteRegistration* Register_NGRAM_HASH() { + static TfLiteRegistration r = {ngram_op::Init, ngram_op::Free, + ngram_op::Resize, ngram_op::Eval}; + return &r; +} + +} // namespace tflite::ops::custom diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.h b/mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.h new file mode 100644 index 000000000..a061357bd --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.h @@ -0,0 +1,27 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#ifndef MEDIAPIPE_TASKS_CC_TEXT_LANGUAGE_DETECTOR_CUSTOM_OPS_NGRAM_HASH_H_ +#define MEDIAPIPE_TASKS_CC_TEXT_LANGUAGE_DETECTOR_CUSTOM_OPS_NGRAM_HASH_H_ + +#include "tensorflow/lite/kernels/register.h" + +namespace tflite::ops::custom { + +TfLiteRegistration* Register_NGRAM_HASH(); + +} // namespace tflite::ops::custom + +#endif // MEDIAPIPE_TASKS_CC_TEXT_LANGUAGE_DETECTOR_CUSTOM_OPS_NGRAM_HASH_H_ diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash_test.cc b/mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash_test.cc new file mode 100644 index 000000000..28d2dea6e --- /dev/null +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash_test.cc @@ -0,0 +1,313 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/ngram_hash.h" + +#include +#include +#include +#include + +#include "absl/types/optional.h" +#include "flatbuffers/flexbuffers.h" +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/tasks/cc/text/language_detector/custom_ops/utils/hash/murmur.h" +#include "tensorflow/lite/c/common.h" +#include "tensorflow/lite/interpreter.h" +#include "tensorflow/lite/kernels/register.h" +#include "tensorflow/lite/kernels/test_util.h" +#include "tensorflow/lite/model.h" +#include "tensorflow/lite/string_util.h" + +namespace tflite::ops::custom { +namespace { + +using ::flexbuffers::Builder; +using ::mediapipe::tasks::text::language_detector::custom_ops::hash:: + MurmurHash64WithSeed; +using ::testing::ElementsAreArray; +using ::testing::Message; + +// Helper class for testing the op. +class NGramHashModel : public SingleOpModel { + public: + explicit NGramHashModel(const uint64_t seed, + const std::vector& ngram_lengths, + const std::vector& vocab_sizes, + const absl::optional max_splits = std::nullopt) { + // Setup the model inputs. + Builder fbb; + size_t start = fbb.StartMap(); + fbb.UInt("seed", seed); + { + size_t start = fbb.StartVector("ngram_lengths"); + for (const int& ngram_len : ngram_lengths) { + fbb.Int(ngram_len); + } + fbb.EndVector(start, /*typed=*/true, /*fixed=*/false); + } + { + size_t start = fbb.StartVector("vocab_sizes"); + for (const int& vocab_size : vocab_sizes) { + fbb.Int(vocab_size); + } + fbb.EndVector(start, /*typed=*/true, /*fixed=*/false); + } + if (max_splits) { + fbb.Int("max_splits", *max_splits); + } + fbb.EndMap(start); + fbb.Finish(); + output_ = AddOutput({TensorType_INT32, {}}); + SetCustomOp("NGramHash", fbb.GetBuffer(), Register_NGRAM_HASH); + BuildInterpreter({GetShape(input_)}); + } + + void SetupInputTensor(const std::string& input) { + PopulateStringTensor(input_, {input}); + CHECK(interpreter_->AllocateTensors() == kTfLiteOk) + << "Cannot allocate tensors"; + } + + void Invoke(const std::string& input) { + SetupInputTensor(input); + CHECK_EQ(SingleOpModel::Invoke(), kTfLiteOk); + } + + TfLiteStatus InvokeUnchecked(const std::string& input) { + SetupInputTensor(input); + return SingleOpModel::Invoke(); + } + + template + std::vector GetOutput() { + return ExtractVector(output_); + } + + std::vector GetOutputShape() { return GetTensorShape(output_); } + + private: + int input_ = AddInput(TensorType_STRING); + int output_; +}; + +TEST(NGramHashTest, ReturnsExpectedValueWhenInputIsSane) { + // Checks that the op returns the expected value when the input is sane. + // Also checks that when `max_splits` is not specified, the entire string is + // tokenized. + const uint64_t kSeed = 123; + const std::vector vocab_sizes({100, 200}); + std::vector ngram_lengths({1, 2}); + const std::vector testcase_inputs({ + "hi", + "wow", + "!", + "HI", + }); + + // A hash function that maps the given string to an index in the embedding + // table denoted by `vocab_idx`. + auto hash = [vocab_sizes](std::string str, const int vocab_idx) { + const auto hash_value = + MurmurHash64WithSeed(str.c_str(), str.size(), kSeed); + return static_cast((hash_value % vocab_sizes[vocab_idx]) + 1); + }; + const std::vector> expected_testcase_outputs( + {{ + // Unigram & Bigram output for "hi". + hash("^", 0), + hash("h", 0), + hash("i", 0), + hash("$", 0), + hash("^h", 1), + hash("hi", 1), + hash("i$", 1), + hash("$", 1), + }, + { + // Unigram & Bigram output for "wow". + hash("^", 0), + hash("w", 0), + hash("o", 0), + hash("w", 0), + hash("$", 0), + hash("^w", 1), + hash("wo", 1), + hash("ow", 1), + hash("w$", 1), + hash("$", 1), + }, + { + // Unigram & Bigram output for "!" (which will get replaced by " "). + hash("^", 0), + hash(" ", 0), + hash("$", 0), + hash("^ ", 1), + hash(" $", 1), + hash("$", 1), + }, + { + // Unigram & Bigram output for "HI" (which will get lower-cased). + hash("^", 0), + hash("h", 0), + hash("i", 0), + hash("$", 0), + hash("^h", 1), + hash("hi", 1), + hash("i$", 1), + hash("$", 1), + }}); + + NGramHashModel m(kSeed, ngram_lengths, vocab_sizes); + for (int test_idx = 0; test_idx < testcase_inputs.size(); test_idx++) { + const string& testcase_input = testcase_inputs[test_idx]; + m.Invoke(testcase_input); + SCOPED_TRACE(Message() << "Where the testcases' input is: " + << testcase_input); + EXPECT_THAT(m.GetOutput(), + ElementsAreArray(expected_testcase_outputs[test_idx])); + EXPECT_THAT(m.GetOutputShape(), + ElementsAreArray( + {/*batch_size=*/1, static_cast(ngram_lengths.size()), + static_cast(testcase_input.size()) + /*padding*/ 2})); + } +} + +TEST(NGramHashTest, ReturnsExpectedValueWhenMaxSplitsIsSpecified) { + // Checks that the op returns the expected value when the input is correct + // when `max_splits` is specified. + const uint64_t kSeed = 123; + const std::vector vocab_sizes({100, 200}); + std::vector ngram_lengths({1, 2}); + + const std::string testcase_input = "wow"; + const std::vector max_splits({2, 3, 4, 5, 6}); + + // A hash function that maps the given string to an index in the embedding + // table denoted by `vocab_idx`. + auto hash = [vocab_sizes](std::string str, const int vocab_idx) { + const auto hash_value = + MurmurHash64WithSeed(str.c_str(), str.size(), kSeed); + return static_cast((hash_value % vocab_sizes[vocab_idx]) + 1); + }; + + const std::vector> expected_testcase_outputs( + {{ + // Unigram & Bigram output for "wow", when `max_splits` == 2. + // We cannot include any of the actual tokens, since `max_splits` + // only allows enough space for the delimiters. + hash("^", 0), + hash("$", 0), + hash("^$", 1), + hash("$", 1), + }, + { + // Unigram & Bigram output for "wow", when `max_splits` == 3. + // We can start to include some tokens from the input string. + hash("^", 0), + hash("w", 0), + hash("$", 0), + hash("^w", 1), + hash("w$", 1), + hash("$", 1), + }, + { + // Unigram & Bigram output for "wow", when `max_splits` == 4. + hash("^", 0), + hash("w", 0), + hash("o", 0), + hash("$", 0), + hash("^w", 1), + hash("wo", 1), + hash("o$", 1), + hash("$", 1), + }, + { + // Unigram & Bigram output for "wow", when `max_splits` == 5. + // We can include the full input string. + hash("^", 0), + hash("w", 0), + hash("o", 0), + hash("w", 0), + hash("$", 0), + hash("^w", 1), + hash("wo", 1), + hash("ow", 1), + hash("w$", 1), + hash("$", 1), + }, + { + // Unigram & Bigram output for "wow", when `max_splits` == 6. + // `max_splits` is more than the full input string. + hash("^", 0), + hash("w", 0), + hash("o", 0), + hash("w", 0), + hash("$", 0), + hash("^w", 1), + hash("wo", 1), + hash("ow", 1), + hash("w$", 1), + hash("$", 1), + }}); + + for (int test_idx = 0; test_idx < max_splits.size(); test_idx++) { + const int testcase_max_splits = max_splits[test_idx]; + NGramHashModel m(kSeed, ngram_lengths, vocab_sizes, testcase_max_splits); + m.Invoke(testcase_input); + SCOPED_TRACE(Message() << "Where `max_splits` is: " << testcase_max_splits); + EXPECT_THAT(m.GetOutput(), + ElementsAreArray(expected_testcase_outputs[test_idx])); + EXPECT_THAT( + m.GetOutputShape(), + ElementsAreArray( + {/*batch_size=*/1, static_cast(ngram_lengths.size()), + std::min( + // Longest possible tokenization when using the entire + // input. + static_cast(testcase_input.size()) + /*padding*/ 2, + // Longest possible string when the `max_splits` value + // is < testcase_input.size() + 2 for padding. + testcase_max_splits)})); + } +} + +TEST(NGramHashTest, InvalidMaxSplitsValue) { + // Check that the op errors out when given an invalid max splits value. + const std::vector invalid_max_splits({0, -1, -5, -100}); + for (const int max_splits : invalid_max_splits) { + NGramHashModel m(/*seed=*/123, /*ngram_lengths=*/{100, 200}, + /*vocab_sizes=*/{1, 2}, /*max_splits=*/max_splits); + EXPECT_EQ(m.InvokeUnchecked("hi"), kTfLiteError); + } +} + +TEST(NGramHashTest, MismatchNgramLengthsAndVocabSizes) { + // Check that the op errors out when ngram lengths and vocab sizes mistmatch. + { + NGramHashModel m(/*seed=*/123, /*ngram_lengths=*/{100, 200, 300}, + /*vocab_sizes=*/{1, 2}); + EXPECT_EQ(m.InvokeUnchecked("hi"), kTfLiteError); + } + { + NGramHashModel m(/*seed=*/123, /*ngram_lengths=*/{100, 200}, + /*vocab_sizes=*/{1, 2, 3}); + EXPECT_EQ(m.InvokeUnchecked("hi"), kTfLiteError); + } +} + +} // namespace +} // namespace tflite::ops::custom From a32382513468d6d0ea2888c0fce0e754df57329a Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Wed, 15 Mar 2023 11:31:28 -0700 Subject: [PATCH 094/136] Internal change PiperOrigin-RevId: 516882513 --- .../tensors_to_segmentation_calculator.cc | 209 ++++++++++++------ .../image_segmenter/image_segmenter_graph.cc | 2 +- .../image_segmenter/image_segmenter_test.cc | 91 +++++++- mediapipe/tasks/testdata/vision/BUILD | 6 + third_party/external_files.bzl | 26 ++- 5 files changed, 250 insertions(+), 84 deletions(-) diff --git a/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.cc b/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.cc index 091e4d6c9..b6c1fe6b0 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.cc +++ b/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.cc @@ -15,6 +15,7 @@ limitations under the License. #include #include +#include #include #include #include @@ -79,6 +80,133 @@ void Sigmoid(absl::Span values, [](float value) { return 1. / (1 + std::exp(-value)); }); } +std::vector ProcessForCategoryMaskCpu(const Shape& input_shape, + const Shape& output_shape, + const SegmenterOptions& options, + const float* tensors_buffer) { + cv::Mat resized_tensors_mat; + cv::Mat tensors_mat_view( + input_shape.height, input_shape.width, CV_32FC(input_shape.channels), + reinterpret_cast(const_cast(tensors_buffer))); + if (output_shape.height == input_shape.height && + output_shape.width == input_shape.width) { + resized_tensors_mat = tensors_mat_view; + } else { + // Resize input tensors to output size. + // TOOD(b/273633027) Use an efficient way to find values for category mask + // instead of resizing the whole tensor . + cv::resize(tensors_mat_view, resized_tensors_mat, + {output_shape.width, output_shape.height}, 0, 0, + cv::INTER_LINEAR); + } + + // Category mask Image. + ImageFrameSharedPtr image_frame_ptr = std::make_shared( + ImageFormat::GRAY8, output_shape.width, output_shape.height, 1); + Image category_mask(image_frame_ptr); + + // Fill in the maximum category in the category mask image. + cv::Mat category_mask_mat_view = + mediapipe::formats::MatView(image_frame_ptr.get()); + const int input_channels = input_shape.channels; + category_mask_mat_view.forEach( + [&resized_tensors_mat, &input_channels, &options](uint8_t& pixel, + const int position[]) { + float* tensors_buffer = + resized_tensors_mat.ptr(position[0], position[1]); + absl::Span confidence_scores(tensors_buffer, input_channels); + // Only process the activation function if it is SIGMOID. If NONE, + // we do nothing for activation, If SOFTMAX, it is required + // to have input_channels > 1, and for input_channels > 1, we don't need + // activation to find the maximum value. + if (options.activation() == SegmenterOptions::SIGMOID) { + Sigmoid(confidence_scores, confidence_scores); + } + if (input_channels == 1) { + // if the input tensor is a single mask, it is assumed to be a binary + // foreground segmentation mask. For such a mask, we make foreground + // category 1, and background category 0. + pixel = static_cast(*tensors_buffer > 0.5f); + } else { + const int maximum_category_idx = + std::max_element(confidence_scores.begin(), + confidence_scores.end()) - + confidence_scores.begin(); + pixel = maximum_category_idx; + } + }); + return {category_mask}; +} + +std::vector ProcessForConfidenceMaskCpu(const Shape& input_shape, + const Shape& output_shape, + const SegmenterOptions& options, + const float* tensors_buffer) { + std::function values, + absl::Span activated_values)> + activation_fn; + switch (options.activation()) { + case SegmenterOptions::SIGMOID: + activation_fn = &Sigmoid; + break; + case SegmenterOptions::SOFTMAX: + activation_fn = &StableSoftmax; + break; + case SegmenterOptions::NONE: + // Just copying for NONE activation. + activation_fn = [](absl::Span values, + absl::Span activated_values) { + std::copy(values.begin(), values.end(), activated_values.begin()); + }; + break; + } + + // TODO Use libyuv for resizing instead. + std::vector confidence_masks; + std::vector confidence_mask_mats; + confidence_masks.reserve(input_shape.channels); + confidence_mask_mats.reserve(input_shape.channels); + for (int i = 0; i < input_shape.channels; ++i) { + confidence_masks.push_back(Image(std::make_shared( + ImageFormat::VEC32F1, input_shape.width, input_shape.height, 1))); + confidence_mask_mats.push_back(mediapipe::formats::MatView( + confidence_masks.back().GetImageFrameSharedPtr().get())); + } + + // Applies activation function. + const int tensor_size = input_shape.height * input_shape.width; + std::vector activated_values(input_shape.channels); + absl::Span activated_values_span(activated_values); + for (int i = 0; i < tensor_size; ++i) { + activation_fn(absl::MakeConstSpan(&tensors_buffer[i * input_shape.channels], + input_shape.channels), + activated_values_span); + for (int j = 0; j < input_shape.channels; ++j) { + confidence_mask_mats[j].at( + i / input_shape.width, i % input_shape.width) = activated_values[j]; + } + } + if (output_shape.height == input_shape.height && + output_shape.width == input_shape.width) { + return confidence_masks; + } + std::vector resized_confidence_masks; + resized_confidence_masks.reserve(confidence_mask_mats.size()); + // Resizes segmented masks to required output size. + for (int i = 0; i < confidence_mask_mats.size(); i++) { + // Pre-allocates ImageFrame memory to avoid copying from cv::Mat + // afterward. + ImageFrameSharedPtr image_frame_ptr = std::make_shared( + ImageFormat::VEC32F1, output_shape.width, output_shape.height, 1); + cv::Mat resized_mask_mat_view = + mediapipe::formats::MatView(image_frame_ptr.get()); + cv::resize(confidence_mask_mats[i], resized_mask_mat_view, + resized_mask_mat_view.size(), 0, 0, cv::INTER_LINEAR); + resized_confidence_masks.push_back(Image(image_frame_ptr)); + } + return resized_confidence_masks; +} + } // namespace // Converts Tensors from a vector of Tensor to Segmentation. @@ -222,81 +350,16 @@ absl::Status TensorsToSegmentationCalculator::Process( std::vector TensorsToSegmentationCalculator::GetSegmentationResultCpu( const Shape& input_shape, const Shape& output_shape, const float* tensors_buffer) { - std::function values, - absl::Span activated_values)> - activation_fn; - switch (options_.segmenter_options().activation()) { - case SegmenterOptions::SIGMOID: - activation_fn = &Sigmoid; - break; - case SegmenterOptions::SOFTMAX: - activation_fn = &StableSoftmax; - break; - case SegmenterOptions::NONE: - // Just copying for NONE activation. - activation_fn = [](absl::Span values, - absl::Span activated_values) { - std::copy(values.begin(), values.end(), activated_values.begin()); - }; - break; - } - - const bool is_category_mask = options_.segmenter_options().output_type() == - SegmenterOptions::CATEGORY_MASK; - const int cv_mat_type = is_category_mask ? CV_8UC1 : CV_32FC1; - const int output_masks_num = output_shape.channels; - - // TODO Use libyuv for resizing instead. - std::vector segmented_mask_mats; - segmented_mask_mats.reserve(output_masks_num); - for (int i = 0; i < output_masks_num; ++i) { - segmented_mask_mats.push_back( - cv::Mat(input_shape.height, input_shape.width, cv_mat_type)); - } - - // Applies activation function. - const int tensor_size = input_shape.height * input_shape.width; - if (is_category_mask) { - for (int i = 0; i < tensor_size; ++i) { - absl::Span confidence_scores( - &tensors_buffer[i * input_shape.channels], input_shape.channels); - const int maximum_category_idx = - std::max_element(confidence_scores.begin(), confidence_scores.end()) - - confidence_scores.begin(); - segmented_mask_mats[0].at( - i / input_shape.width, i % input_shape.width) = maximum_category_idx; - } + if (options_.segmenter_options().output_type() == + SegmenterOptions::CATEGORY_MASK) { + return ProcessForCategoryMaskCpu(input_shape, output_shape, + options_.segmenter_options(), + tensors_buffer); } else { - std::vector activated_values(input_shape.channels); - absl::Span activated_values_span(activated_values); - for (int i = 0; i < tensor_size; ++i) { - activation_fn( - absl::MakeConstSpan(&tensors_buffer[i * input_shape.channels], - input_shape.channels), - activated_values_span); - for (int j = 0; j < input_shape.channels; ++j) { - segmented_mask_mats[j].at( - i / input_shape.width, i % input_shape.width) = activated_values[j]; - } - } + return ProcessForConfidenceMaskCpu(input_shape, output_shape, + options_.segmenter_options(), + tensors_buffer); } - - std::vector segmented_masks; - segmented_masks.reserve(output_masks_num); - // Resizes segmented masks to required output size. - for (int i = 0; i < segmented_mask_mats.size(); i++) { - // Pre-allocates ImageFrame memory to avoid copying from cv::Mat afterward. - ImageFrameSharedPtr image_frame_ptr = std::make_shared( - is_category_mask ? ImageFormat::GRAY8 : ImageFormat::VEC32F1, - output_shape.width, output_shape.height, 1); - cv::Mat resized_mask_mat_view = - mediapipe::formats::MatView(image_frame_ptr.get()); - cv::resize(segmented_mask_mats[i], resized_mask_mat_view, - resized_mask_mat_view.size(), 0, 0, - cv_mat_type == CV_8UC1 ? cv::INTER_NEAREST : cv::INTER_LINEAR); - segmented_masks.push_back(Image(image_frame_ptr)); - } - return segmented_masks; } MEDIAPIPE_REGISTER_NODE(::mediapipe::tasks::TensorsToSegmentationCalculator); diff --git a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_graph.cc b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_graph.cc index c4a4065c6..6a7e08626 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_graph.cc +++ b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_graph.cc @@ -401,7 +401,7 @@ class ImageSegmenterGraph : public core::ModelTaskGraph { } else { ASSIGN_OR_RETURN(const tflite::Tensor* output_tensor, GetOutputTensor(model_resources)); - const int segmentation_streams_num = *output_tensor->shape()->rbegin(); + int segmentation_streams_num = *output_tensor->shape()->rbegin(); for (int i = 0; i < segmentation_streams_num; ++i) { segmented_masks.push_back(Source( tensor_to_images[Output::Multiple(kSegmentationTag)][i])); diff --git a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc index ab5d184db..d063ca87a 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc +++ b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc @@ -62,6 +62,11 @@ constexpr char kSelfie128x128WithMetadata[] = "selfie_segm_128_128_3.tflite"; constexpr char kSelfie144x256WithMetadata[] = "selfie_segm_144_256_3.tflite"; +constexpr char kSelfieSegmentation[] = "selfie_segmentation.tflite"; + +constexpr char kSelfieSegmentationLandscape[] = + "selfie_segmentation_landscape.tflite"; + constexpr char kHairSegmentationWithMetadata[] = "hair_segmentation.tflite"; constexpr float kGoldenMaskSimilarity = 0.98; @@ -90,13 +95,8 @@ cv::Mat PostProcessResultMask(const cv::Mat& mask) { } Image GetSRGBImage(const std::string& image_path) { - // TODO: fix test so RGB really is used and not BGR/BGRA. - // mediapipe/app/aimatter/segmentation/segmenter_test_common.cc - // golden masks are generated with BGR image. To align with the unittest of - // aimatter segmenter, here reads image as BGR as well (opencv reads image as - // BGR). Once the correctness of mediapipe tasks segmenter is verified, change - // the golden masks to be generated by RGB image. cv::Mat image_mat = cv::imread(image_path); + cv::cvtColor(image_mat, image_mat, cv::COLOR_BGR2RGB); mediapipe::ImageFrame image_frame( mediapipe::ImageFormat::SRGB, image_mat.cols, image_mat.rows, image_mat.step, image_mat.data, [image_mat](uint8_t[]) {}); @@ -435,6 +435,85 @@ TEST_F(ImageModeTest, SucceedsSelfie144x256Segmentations) { SimilarToFloatMask(expected_mask_float, kGoldenMaskSimilarity)); } +TEST_F(ImageModeTest, SucceedsPortraitSelfieSegmentationConfidenceMask) { + Image image = + GetSRGBImage(JoinPath("./", kTestDataDirectory, "portrait.jpg")); + auto options = std::make_unique(); + options->base_options.model_asset_path = + JoinPath("./", kTestDataDirectory, kSelfieSegmentation); + options->output_type = ImageSegmenterOptions::OutputType::CONFIDENCE_MASK; + options->activation = ImageSegmenterOptions::Activation::NONE; + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, + ImageSegmenter::Create(std::move(options))); + MP_ASSERT_OK_AND_ASSIGN(auto confidence_masks, segmenter->Segment(image)); + EXPECT_EQ(confidence_masks.size(), 1); + MP_ASSERT_OK(segmenter->Close()); + + cv::Mat expected_mask = cv::imread( + JoinPath("./", kTestDataDirectory, + "portrait_selfie_segmentation_expected_confidence_mask.jpg"), + cv::IMREAD_GRAYSCALE); + cv::Mat expected_mask_float; + expected_mask.convertTo(expected_mask_float, CV_32FC1, 1 / 255.f); + + cv::Mat selfie_mask = mediapipe::formats::MatView( + confidence_masks[0].GetImageFrameSharedPtr().get()); + EXPECT_THAT(selfie_mask, + SimilarToFloatMask(expected_mask_float, kGoldenMaskSimilarity)); +} + +TEST_F(ImageModeTest, SucceedsPortraitSelfieSegmentationCategoryMask) { + Image image = + GetSRGBImage(JoinPath("./", kTestDataDirectory, "portrait.jpg")); + auto options = std::make_unique(); + options->base_options.model_asset_path = + JoinPath("./", kTestDataDirectory, kSelfieSegmentation); + options->output_type = ImageSegmenterOptions::OutputType::CATEGORY_MASK; + options->activation = ImageSegmenterOptions::Activation::NONE; + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, + ImageSegmenter::Create(std::move(options))); + MP_ASSERT_OK_AND_ASSIGN(auto category_mask, segmenter->Segment(image)); + EXPECT_EQ(category_mask.size(), 1); + MP_ASSERT_OK(segmenter->Close()); + + cv::Mat selfie_mask = mediapipe::formats::MatView( + category_mask[0].GetImageFrameSharedPtr().get()); + cv::Mat expected_mask = cv::imread( + JoinPath("./", kTestDataDirectory, + "portrait_selfie_segmentation_expected_category_mask.jpg"), + cv::IMREAD_GRAYSCALE); + EXPECT_THAT(selfie_mask, + SimilarToUint8Mask(expected_mask, kGoldenMaskSimilarity, 255)); +} + +TEST_F(ImageModeTest, SucceedsPortraitSelfieSegmentationLandscapeCategoryMask) { + Image image = + GetSRGBImage(JoinPath("./", kTestDataDirectory, "portrait.jpg")); + auto options = std::make_unique(); + options->base_options.model_asset_path = + JoinPath("./", kTestDataDirectory, kSelfieSegmentationLandscape); + options->output_type = ImageSegmenterOptions::OutputType::CATEGORY_MASK; + options->activation = ImageSegmenterOptions::Activation::NONE; + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, + ImageSegmenter::Create(std::move(options))); + MP_ASSERT_OK_AND_ASSIGN(auto category_mask, segmenter->Segment(image)); + EXPECT_EQ(category_mask.size(), 1); + MP_ASSERT_OK(segmenter->Close()); + + cv::Mat selfie_mask = mediapipe::formats::MatView( + category_mask[0].GetImageFrameSharedPtr().get()); + cv::Mat expected_mask = cv::imread( + JoinPath( + "./", kTestDataDirectory, + "portrait_selfie_segmentation_landscape_expected_category_mask.jpg"), + cv::IMREAD_GRAYSCALE); + EXPECT_THAT(selfie_mask, + SimilarToUint8Mask(expected_mask, kGoldenMaskSimilarity, 255)); +} + TEST_F(ImageModeTest, SucceedsHairSegmentation) { Image image = GetSRGBAImage(JoinPath("./", kTestDataDirectory, "portrait.jpg")); diff --git a/mediapipe/tasks/testdata/vision/BUILD b/mediapipe/tasks/testdata/vision/BUILD index 087d0ea75..ac76bfa23 100644 --- a/mediapipe/tasks/testdata/vision/BUILD +++ b/mediapipe/tasks/testdata/vision/BUILD @@ -70,6 +70,9 @@ mediapipe_files(srcs = [ "portrait.jpg", "portrait_hair_expected_mask.jpg", "portrait_rotated.jpg", + "portrait_selfie_segmentation_expected_category_mask.jpg", + "portrait_selfie_segmentation_expected_confidence_mask.jpg", + "portrait_selfie_segmentation_landscape_expected_category_mask.jpg", "pose.jpg", "pose_detection.tflite", "right_hands.jpg", @@ -129,6 +132,9 @@ filegroup( "portrait.jpg", "portrait_hair_expected_mask.jpg", "portrait_rotated.jpg", + "portrait_selfie_segmentation_expected_category_mask.jpg", + "portrait_selfie_segmentation_expected_confidence_mask.jpg", + "portrait_selfie_segmentation_landscape_expected_category_mask.jpg", "pose.jpg", "right_hands.jpg", "right_hands_rotated.jpg", diff --git a/third_party/external_files.bzl b/third_party/external_files.bzl index b290fbcbe..52636f427 100644 --- a/third_party/external_files.bzl +++ b/third_party/external_files.bzl @@ -886,6 +886,24 @@ def external_files(): urls = ["https://storage.googleapis.com/mediapipe-assets/portrait_rotated.jpg?generation=1677194680138164"], ) + http_file( + name = "com_google_mediapipe_portrait_selfie_segmentation_expected_category_mask_jpg", + sha256 = "d8f20fa746e14067f668dd293f21bbc50ec81196d186386a6ded1278c3ec8f46", + urls = ["https://storage.googleapis.com/mediapipe-assets/portrait_selfie_segmentation_expected_category_mask.jpg?generation=1678606935088873"], + ) + + http_file( + name = "com_google_mediapipe_portrait_selfie_segmentation_expected_confidence_mask_jpg", + sha256 = "25b723e90608edaf6ed92f382da703dc904a59c87525b6d271e60d9eed7a90e9", + urls = ["https://storage.googleapis.com/mediapipe-assets/portrait_selfie_segmentation_expected_confidence_mask.jpg?generation=1678606937358235"], + ) + + http_file( + name = "com_google_mediapipe_portrait_selfie_segmentation_landscape_expected_category_mask_jpg", + sha256 = "f5c3fa3d93f8e7289b69b8a89c2519276dfa5014dcc50ed6e86e8cd4d4ae7f27", + urls = ["https://storage.googleapis.com/mediapipe-assets/portrait_selfie_segmentation_landscape_expected_category_mask.jpg?generation=1678606939469429"], + ) + http_file( name = "com_google_mediapipe_pose_detection_tflite", sha256 = "9ba9dd3d42efaaba86b4ff0122b06f29c4122e756b329d89dca1e297fd8f866c", @@ -1014,8 +1032,8 @@ def external_files(): http_file( name = "com_google_mediapipe_selfie_segm_128_128_3_expected_mask_jpg", - sha256 = "a295f3ab394a5e0caff2db5041337da58341ec331f1413ef91f56e0d650b4a1e", - urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segm_128_128_3_expected_mask.jpg?generation=1661875916766416"], + sha256 = "1a2a068287d8bcd4184492485b3dbb95a09b763f4653fd729d14a836147eb383", + urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segm_128_128_3_expected_mask.jpg?generation=1678606942616777"], ) http_file( @@ -1026,8 +1044,8 @@ def external_files(): http_file( name = "com_google_mediapipe_selfie_segm_144_256_3_expected_mask_jpg", - sha256 = "cfc699db9670585c04414d0d1a07b289a027ba99d6903d2219f897d34e2c9952", - urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segm_144_256_3_expected_mask.jpg?generation=1661875922646736"], + sha256 = "2de433b6e8adabec2aaf80135232db900903ead4f2811c0c9378a6792b2a68b5", + urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segm_144_256_3_expected_mask.jpg?generation=1678606945085676"], ) http_file( From 59962bed27cde01045756778850400e770ed4486 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Wed, 15 Mar 2023 12:08:28 -0700 Subject: [PATCH 095/136] ImageSegmenterGraph set activation type from metadata, and remove the activation config in C++ ImageSegmenterOptions. PiperOrigin-RevId: 516893115 --- .../tasks/cc/vision/image_segmenter/BUILD | 1 + .../vision/image_segmenter/image_segmenter.cc | 14 ------ .../vision/image_segmenter/image_segmenter.h | 9 ---- .../image_segmenter/image_segmenter_graph.cc | 48 +++++++++++++++++-- .../image_segmenter/image_segmenter_test.cc | 9 ---- .../vision/imagesegmenter/ImageSegmenter.java | 3 -- 6 files changed, 46 insertions(+), 38 deletions(-) diff --git a/mediapipe/tasks/cc/vision/image_segmenter/BUILD b/mediapipe/tasks/cc/vision/image_segmenter/BUILD index 1123204ce..69833a5f6 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/BUILD +++ b/mediapipe/tasks/cc/vision/image_segmenter/BUILD @@ -80,6 +80,7 @@ cc_library( "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_cc_proto", "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_cc_proto", "//mediapipe/tasks/cc/vision/utils:image_tensor_specs", + "//mediapipe/tasks/metadata:image_segmenter_metadata_schema_cc", "//mediapipe/tasks/metadata:metadata_schema_cc", "//mediapipe/util:label_map_cc_proto", "//mediapipe/util:label_map_util", diff --git a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.cc b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.cc index 9769b47d5..c12fe7f7e 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.cc +++ b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.cc @@ -101,20 +101,6 @@ ConvertImageSegmenterOptionsToProto(ImageSegmenterOptions* options) { SegmenterOptions::CONFIDENCE_MASK); break; } - switch (options->activation) { - case ImageSegmenterOptions::Activation::NONE: - options_proto->mutable_segmenter_options()->set_activation( - SegmenterOptions::NONE); - break; - case ImageSegmenterOptions::Activation::SIGMOID: - options_proto->mutable_segmenter_options()->set_activation( - SegmenterOptions::SIGMOID); - break; - case ImageSegmenterOptions::Activation::SOFTMAX: - options_proto->mutable_segmenter_options()->set_activation( - SegmenterOptions::SOFTMAX); - break; - } return options_proto; } diff --git a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h index c757296e4..076a5016c 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h +++ b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter.h @@ -64,15 +64,6 @@ struct ImageSegmenterOptions { OutputType output_type = OutputType::CATEGORY_MASK; - // The activation function used on the raw segmentation model output. - enum Activation { - NONE = 0, // No activation function is used. - SIGMOID = 1, // Assumes 1-channel input tensor. - SOFTMAX = 2, // Assumes multi-channel input tensor. - }; - - Activation activation = Activation::NONE; - // The user-defined result callback for processing live stream data. // The result callback should only be specified when the running mode is set // to RunningMode::LIVE_STREAM. diff --git a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_graph.cc b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_graph.cc index 6a7e08626..fe6265b73 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_graph.cc +++ b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_graph.cc @@ -40,6 +40,7 @@ limitations under the License. #include "mediapipe/tasks/cc/vision/image_segmenter/proto/image_segmenter_graph_options.pb.h" #include "mediapipe/tasks/cc/vision/image_segmenter/proto/segmenter_options.pb.h" #include "mediapipe/tasks/cc/vision/utils/image_tensor_specs.h" +#include "mediapipe/tasks/metadata/image_segmenter_metadata_schema_generated.h" #include "mediapipe/tasks/metadata/metadata_schema_generated.h" #include "mediapipe/util/label_map.pb.h" #include "mediapipe/util/label_map_util.h" @@ -74,6 +75,7 @@ constexpr char kImageGpuTag[] = "IMAGE_GPU"; constexpr char kNormRectTag[] = "NORM_RECT"; constexpr char kTensorsTag[] = "TENSORS"; constexpr char kOutputSizeTag[] = "OUTPUT_SIZE"; +constexpr char kSegmentationMetadataName[] = "SEGMENTER_METADATA"; // Struct holding the different output streams produced by the image segmenter // subgraph. @@ -130,7 +132,49 @@ absl::Status ConfigureTensorsToSegmentationCalculator( const ImageSegmenterGraphOptions& segmenter_option, const core::ModelResources& model_resources, TensorsToSegmentationCalculatorOptions* options) { - *options->mutable_segmenter_options() = segmenter_option.segmenter_options(); + // Set default activation function NONE + options->mutable_segmenter_options()->set_output_type( + segmenter_option.segmenter_options().output_type()); + options->mutable_segmenter_options()->set_activation(SegmenterOptions::NONE); + // Find the custom metadata of ImageSegmenterOptions type in model metadata. + const auto* metadata_extractor = model_resources.GetMetadataExtractor(); + bool found_activation_in_metadata = false; + if (metadata_extractor->GetCustomMetadataList() != nullptr && + metadata_extractor->GetCustomMetadataList()->size() > 0) { + for (const auto& custom_metadata : + *metadata_extractor->GetCustomMetadataList()) { + if (custom_metadata->name()->str() == kSegmentationMetadataName) { + found_activation_in_metadata = true; + auto activation_fb = + GetImageSegmenterOptions(custom_metadata->data()->data()) + ->activation(); + switch (activation_fb) { + case Activation_NONE: + options->mutable_segmenter_options()->set_activation( + SegmenterOptions::NONE); + break; + case Activation_SIGMOID: + options->mutable_segmenter_options()->set_activation( + SegmenterOptions::SIGMOID); + break; + case Activation_SOFTMAX: + options->mutable_segmenter_options()->set_activation( + SegmenterOptions::SOFTMAX); + break; + default: + return CreateStatusWithPayload( + absl::StatusCode::kInvalidArgument, + "Invalid activation type found in CustomMetadata of " + "ImageSegmenterOptions type."); + } + } + } + } + if (!found_activation_in_metadata) { + LOG(WARNING) + << "No activation type is found in model metadata. Use NONE for " + "ImageSegmenterGraph."; + } const tflite::Model& model = *model_resources.GetTfLiteModel(); if (model.subgraphs()->size() != 1) { return CreateStatusWithPayload( @@ -146,8 +190,6 @@ absl::Status ConfigureTensorsToSegmentationCalculator( MediaPipeTasksStatus::kInvalidArgumentError); } - const ModelMetadataExtractor* metadata_extractor = - model_resources.GetMetadataExtractor(); ASSIGN_OR_RETURN( *options->mutable_label_items(), GetLabelItemsIfAny(*metadata_extractor, diff --git a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc index d063ca87a..1d75a3fb7 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc +++ b/mediapipe/tasks/cc/vision/image_segmenter/image_segmenter_test.cc @@ -304,7 +304,6 @@ TEST_F(ImageModeTest, SucceedsWithConfidenceMask) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kDeeplabV3WithMetadata); options->output_type = ImageSegmenterOptions::OutputType::CONFIDENCE_MASK; - options->activation = ImageSegmenterOptions::Activation::SOFTMAX; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, ImageSegmenter::Create(std::move(options))); @@ -333,7 +332,6 @@ TEST_F(ImageModeTest, DISABLED_SucceedsWithRotation) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kDeeplabV3WithMetadata); options->output_type = ImageSegmenterOptions::OutputType::CONFIDENCE_MASK; - options->activation = ImageSegmenterOptions::Activation::SOFTMAX; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, ImageSegmenter::Create(std::move(options))); @@ -364,7 +362,6 @@ TEST_F(ImageModeTest, FailsWithRegionOfInterest) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kDeeplabV3WithMetadata); options->output_type = ImageSegmenterOptions::OutputType::CONFIDENCE_MASK; - options->activation = ImageSegmenterOptions::Activation::SOFTMAX; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, ImageSegmenter::Create(std::move(options))); @@ -388,7 +385,6 @@ TEST_F(ImageModeTest, SucceedsSelfie128x128Segmentation) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kSelfie128x128WithMetadata); options->output_type = ImageSegmenterOptions::OutputType::CONFIDENCE_MASK; - options->activation = ImageSegmenterOptions::Activation::SOFTMAX; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, ImageSegmenter::Create(std::move(options))); @@ -416,7 +412,6 @@ TEST_F(ImageModeTest, SucceedsSelfie144x256Segmentations) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kSelfie144x256WithMetadata); options->output_type = ImageSegmenterOptions::OutputType::CONFIDENCE_MASK; - options->activation = ImageSegmenterOptions::Activation::NONE; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, ImageSegmenter::Create(std::move(options))); MP_ASSERT_OK_AND_ASSIGN(auto confidence_masks, segmenter->Segment(image)); @@ -442,7 +437,6 @@ TEST_F(ImageModeTest, SucceedsPortraitSelfieSegmentationConfidenceMask) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kSelfieSegmentation); options->output_type = ImageSegmenterOptions::OutputType::CONFIDENCE_MASK; - options->activation = ImageSegmenterOptions::Activation::NONE; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, ImageSegmenter::Create(std::move(options))); @@ -470,7 +464,6 @@ TEST_F(ImageModeTest, SucceedsPortraitSelfieSegmentationCategoryMask) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kSelfieSegmentation); options->output_type = ImageSegmenterOptions::OutputType::CATEGORY_MASK; - options->activation = ImageSegmenterOptions::Activation::NONE; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, ImageSegmenter::Create(std::move(options))); @@ -495,7 +488,6 @@ TEST_F(ImageModeTest, SucceedsPortraitSelfieSegmentationLandscapeCategoryMask) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kSelfieSegmentationLandscape); options->output_type = ImageSegmenterOptions::OutputType::CATEGORY_MASK; - options->activation = ImageSegmenterOptions::Activation::NONE; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, ImageSegmenter::Create(std::move(options))); @@ -521,7 +513,6 @@ TEST_F(ImageModeTest, SucceedsHairSegmentation) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kHairSegmentationWithMetadata); options->output_type = ImageSegmenterOptions::OutputType::CONFIDENCE_MASK; - options->activation = ImageSegmenterOptions::Activation::SOFTMAX; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, ImageSegmenter::Create(std::move(options))); MP_ASSERT_OK_AND_ASSIGN(auto confidence_masks, segmenter->Segment(image)); diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenter.java b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenter.java index 299423003..931740c8e 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenter.java +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenter.java @@ -641,9 +641,6 @@ public final class ImageSegmenter extends BaseVisionTaskApi { SegmenterOptionsProto.SegmenterOptions.OutputType.CATEGORY_MASK); } - // TODO: remove this once activation is handled in metadata and grpah level. - segmenterOptionsBuilder.setActivation( - SegmenterOptionsProto.SegmenterOptions.Activation.SOFTMAX); taskOptionsBuilder.setSegmenterOptions(segmenterOptionsBuilder); return CalculatorOptions.newBuilder() .setExtension( From 43082482f85321607987b083a356fa8962a6f8c8 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 15 Mar 2023 14:21:50 -0700 Subject: [PATCH 096/136] Remove framework:Cocoa again PiperOrigin-RevId: 516928735 --- third_party/BUILD | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/third_party/BUILD b/third_party/BUILD index ea037dce1..7522bab1b 100644 --- a/third_party/BUILD +++ b/third_party/BUILD @@ -169,11 +169,7 @@ cmake_external( "-lm", "-lpthread", "-lrt", - ] + select({ - "//mediapipe:ios": ["-framework Cocoa"], - "//mediapipe:macos": ["-framework Cocoa"], - "//conditions:default": [], - }), + ], shared_libraries = select({ "@bazel_tools//src/conditions:darwin": ["libopencv_%s.%s.dylib" % (module, OPENCV_SO_VERSION) for module in OPENCV_MODULES], # Only the shared objects listed here will be linked in the directory From 61bcddc6711a2b9d1a2a7676bb5fca1360360cd4 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Wed, 15 Mar 2023 16:02:48 -0700 Subject: [PATCH 097/136] Add Interactive Segmenter MediaPipe Task PiperOrigin-RevId: 516954589 --- .../cc/vision/interactive_segmenter/BUILD | 76 +++++ .../interactive_segmenter.cc | 163 +++++++++++ .../interactive_segmenter.h | 136 +++++++++ .../interactive_segmenter_graph.cc | 198 +++++++++++++ .../interactive_segmenter_test.cc | 261 ++++++++++++++++++ 5 files changed, 834 insertions(+) create mode 100644 mediapipe/tasks/cc/vision/interactive_segmenter/BUILD create mode 100644 mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.cc create mode 100644 mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.h create mode 100644 mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_graph.cc create mode 100644 mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_test.cc diff --git a/mediapipe/tasks/cc/vision/interactive_segmenter/BUILD b/mediapipe/tasks/cc/vision/interactive_segmenter/BUILD new file mode 100644 index 000000000..ea72d3d99 --- /dev/null +++ b/mediapipe/tasks/cc/vision/interactive_segmenter/BUILD @@ -0,0 +1,76 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package(default_visibility = ["//mediapipe/tasks:internal"]) + +licenses(["notice"]) + +# Docs for Mediapipe Tasks Interactive Segmenter +# TODO: add doc link. +cc_library( + name = "interactive_segmenter", + srcs = ["interactive_segmenter.cc"], + hdrs = ["interactive_segmenter.h"], + deps = [ + ":interactive_segmenter_graph", + "//mediapipe/framework/api2:builder", + "//mediapipe/framework/formats:image", + "//mediapipe/framework/formats:rect_cc_proto", + "//mediapipe/tasks/cc/components/containers:keypoint", + "//mediapipe/tasks/cc/core:base_options", + "//mediapipe/tasks/cc/vision/core:base_vision_task_api", + "//mediapipe/tasks/cc/vision/core:image_processing_options", + "//mediapipe/tasks/cc/vision/core:running_mode", + "//mediapipe/tasks/cc/vision/core:vision_task_api_factory", + "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_cc_proto", + "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_cc_proto", + "//mediapipe/util:color_cc_proto", + "//mediapipe/util:render_data_cc_proto", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + ], +) + +cc_library( + name = "interactive_segmenter_graph", + srcs = ["interactive_segmenter_graph.cc"], + deps = [ + "@com_google_absl//absl/strings", + "//mediapipe/calculators/image:set_alpha_calculator", + "//mediapipe/calculators/util:annotation_overlay_calculator", + "//mediapipe/calculators/util:flat_color_image_calculator", + "//mediapipe/calculators/util:flat_color_image_calculator_cc_proto", + "//mediapipe/calculators/util:from_image_calculator", + "//mediapipe/calculators/util:to_image_calculator", + "//mediapipe/framework:calculator_framework", + "//mediapipe/framework/api2:builder", + "//mediapipe/framework/api2:port", + "//mediapipe/framework/formats:image", + "//mediapipe/framework/formats:rect_cc_proto", + "//mediapipe/tasks/cc/components/processors:image_preprocessing_graph", + "//mediapipe/tasks/cc/core:model_task_graph", + "//mediapipe/tasks/cc/vision/image_segmenter:image_segmenter_graph", + "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_cc_proto", + "//mediapipe/util:color_cc_proto", + "//mediapipe/util:label_map_cc_proto", + "//mediapipe/util:render_data_cc_proto", + ] + select({ + "//mediapipe/gpu:disable_gpu": [], + "//conditions:default": [ + "//mediapipe/gpu:gpu_buffer_to_image_frame_calculator", + "//mediapipe/gpu:image_frame_to_gpu_buffer_calculator", + ], + }), + alwayslink = 1, +) diff --git a/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.cc b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.cc new file mode 100644 index 000000000..4298d4a19 --- /dev/null +++ b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.cc @@ -0,0 +1,163 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.h" + +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "mediapipe/framework/api2/builder.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/formats/rect.pb.h" +#include "mediapipe/tasks/cc/vision/core/image_processing_options.h" +#include "mediapipe/tasks/cc/vision/core/running_mode.h" +#include "mediapipe/tasks/cc/vision/core/vision_task_api_factory.h" +#include "mediapipe/tasks/cc/vision/image_segmenter/proto/image_segmenter_graph_options.pb.h" +#include "mediapipe/tasks/cc/vision/image_segmenter/proto/segmenter_options.pb.h" +#include "mediapipe/util/color.pb.h" +#include "mediapipe/util/render_data.pb.h" + +namespace mediapipe { +namespace tasks { +namespace vision { +namespace interactive_segmenter { +namespace { + +constexpr char kSegmentationStreamName[] = "segmented_mask_out"; +constexpr char kImageInStreamName[] = "image_in"; +constexpr char kImageOutStreamName[] = "image_out"; +constexpr char kRoiStreamName[] = "roi_in"; +constexpr char kNormRectStreamName[] = "norm_rect_in"; + +constexpr char kGroupedSegmentationTag[] = "GROUPED_SEGMENTATION"; +constexpr char kImageTag[] = "IMAGE"; +constexpr char kRoiTag[] = "ROI"; +constexpr char kNormRectTag[] = "NORM_RECT"; + +constexpr char kSubgraphTypeName[] = + "mediapipe.tasks.vision.interactive_segmenter.InteractiveSegmenterGraph"; + +using ::mediapipe::CalculatorGraphConfig; +using ::mediapipe::Image; +using ::mediapipe::NormalizedRect; +using ::mediapipe::tasks::vision::image_segmenter::proto::SegmenterOptions; +using ImageSegmenterGraphOptionsProto = ::mediapipe::tasks::vision:: + image_segmenter::proto::ImageSegmenterGraphOptions; + +// Creates a MediaPipe graph config that only contains a single subgraph node of +// "mediapipe.tasks.vision.image_segmenter.ImageSegmenterGraph". +CalculatorGraphConfig CreateGraphConfig( + std::unique_ptr options) { + api2::builder::Graph graph; + auto& task_subgraph = graph.AddNode(kSubgraphTypeName); + task_subgraph.GetOptions().Swap( + options.get()); + graph.In(kImageTag).SetName(kImageInStreamName); + graph.In(kRoiTag).SetName(kRoiStreamName); + graph.In(kNormRectTag).SetName(kNormRectStreamName); + task_subgraph.Out(kGroupedSegmentationTag).SetName(kSegmentationStreamName) >> + graph.Out(kGroupedSegmentationTag); + task_subgraph.Out(kImageTag).SetName(kImageOutStreamName) >> + graph.Out(kImageTag); + graph.In(kImageTag) >> task_subgraph.In(kImageTag); + graph.In(kRoiTag) >> task_subgraph.In(kRoiTag); + graph.In(kNormRectTag) >> task_subgraph.In(kNormRectTag); + return graph.GetConfig(); +} + +// Converts the user-facing InteractiveSegmenterOptions struct to the internal +// ImageSegmenterOptions proto. +std::unique_ptr +ConvertImageSegmenterOptionsToProto(InteractiveSegmenterOptions* options) { + auto options_proto = std::make_unique(); + auto base_options_proto = std::make_unique( + tasks::core::ConvertBaseOptionsToProto(&(options->base_options))); + options_proto->mutable_base_options()->Swap(base_options_proto.get()); + switch (options->output_type) { + case InteractiveSegmenterOptions::OutputType::CATEGORY_MASK: + options_proto->mutable_segmenter_options()->set_output_type( + SegmenterOptions::CATEGORY_MASK); + break; + case InteractiveSegmenterOptions::OutputType::CONFIDENCE_MASK: + options_proto->mutable_segmenter_options()->set_output_type( + SegmenterOptions::CONFIDENCE_MASK); + break; + } + return options_proto; +} + +// Converts the user-facing RegionOfInterest struct to the RenderData proto that +// is used in subgraph. +absl::StatusOr ConvertRoiToRenderData(const RegionOfInterest& roi) { + RenderData result; + switch (roi.format) { + case RegionOfInterest::UNSPECIFIED: + return absl::InvalidArgumentError( + "RegionOfInterest format not specified"); + case RegionOfInterest::KEYPOINT: + RET_CHECK(roi.keypoint.has_value()); + auto* annotation = result.add_render_annotations(); + annotation->mutable_color()->set_r(255); + auto* point = annotation->mutable_point(); + point->set_normalized(true); + point->set_x(roi.keypoint->x); + point->set_y(roi.keypoint->y); + return result; + } + return absl::UnimplementedError("Unrecognized format"); +} + +} // namespace + +absl::StatusOr> +InteractiveSegmenter::Create( + std::unique_ptr options) { + auto options_proto = ConvertImageSegmenterOptionsToProto(options.get()); + return core::VisionTaskApiFactory::Create( + CreateGraphConfig(std::move(options_proto)), + std::move(options->base_options.op_resolver), core::RunningMode::IMAGE, + /*packets_callback=*/nullptr); +} + +absl::StatusOr> InteractiveSegmenter::Segment( + mediapipe::Image image, const RegionOfInterest& roi, + std::optional image_processing_options) { + if (image.UsesGpu()) { + return CreateStatusWithPayload( + absl::StatusCode::kInvalidArgument, + absl::StrCat("GPU input images are currently not supported."), + MediaPipeTasksStatus::kRunnerUnexpectedInputError); + } + ASSIGN_OR_RETURN( + NormalizedRect norm_rect, + ConvertToNormalizedRect(image_processing_options, /*roi_allowed=*/false)); + ASSIGN_OR_RETURN(RenderData roi_as_render_data, ConvertRoiToRenderData(roi)); + ASSIGN_OR_RETURN( + auto output_packets, + ProcessImageData( + {{kImageInStreamName, mediapipe::MakePacket(std::move(image))}, + {kRoiStreamName, + mediapipe::MakePacket(std::move(roi_as_render_data))}, + {kNormRectStreamName, + MakePacket(std::move(norm_rect))}})); + return output_packets[kSegmentationStreamName].Get>(); +} + +} // namespace interactive_segmenter +} // namespace vision +} // namespace tasks +} // namespace mediapipe diff --git a/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.h b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.h new file mode 100644 index 000000000..420b22462 --- /dev/null +++ b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.h @@ -0,0 +1,136 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#ifndef MEDIAPIPE_TASKS_CC_VISION_INTERACTIVE_SEGMENTER_INTERACTIVE_SEGMENTER_H_ +#define MEDIAPIPE_TASKS_CC_VISION_INTERACTIVE_SEGMENTER_INTERACTIVE_SEGMENTER_H_ + +#include +#include +#include +#include + +#include "absl/status/statusor.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/tasks/cc/components/containers/keypoint.h" +#include "mediapipe/tasks/cc/core/base_options.h" +#include "mediapipe/tasks/cc/vision/core/base_vision_task_api.h" +#include "mediapipe/tasks/cc/vision/core/image_processing_options.h" + +namespace mediapipe { +namespace tasks { +namespace vision { +namespace interactive_segmenter { + +// The options for configuring a mediapipe interactive segmenter task. +struct InteractiveSegmenterOptions { + // Base options for configuring MediaPipe Tasks, such as specifying the model + // file with metadata, accelerator options, op resolver, etc. + tasks::core::BaseOptions base_options; + + // The output type of segmentation results. + enum OutputType { + // Gives a single output mask where each pixel represents the class which + // the pixel in the original image was predicted to belong to. + CATEGORY_MASK = 0, + // Gives a list of output masks where, for each mask, each pixel represents + // the prediction confidence, usually in the [0, 1] range. + CONFIDENCE_MASK = 1, + }; + + OutputType output_type = OutputType::CATEGORY_MASK; +}; + +// The Region-Of-Interest (ROI) to interact with. +struct RegionOfInterest { + enum Format { + UNSPECIFIED = 0, // Format not specified. + KEYPOINT = 1, // Using keypoint to represent ROI. + }; + + // Specifies the format used to specify the region-of-interest. Note that + // using `UNSPECIFIED` is invalid and will lead to an `InvalidArgument` status + // being returned. + Format format = Format::UNSPECIFIED; + + // Represents the ROI in keypoint format, this should be non-nullopt if + // `format` is `KEYPOINT`. + std::optional keypoint; +}; + +// Performs interactive segmentation on images. +// +// Users can represent user interaction through `RegionOfInterest`, which gives +// a hint to InteractiveSegmenter to perform segmentation focusing on the given +// region of interest. +// +// The API expects a TFLite model with mandatory TFLite Model Metadata. +// +// Input tensor: +// (kTfLiteUInt8/kTfLiteFloat32) +// - image input of size `[batch x height x width x channels]`. +// - batch inference is not supported (`batch` is required to be 1). +// - RGB inputs is supported (`channels` is required to be 3). +// - if type is kTfLiteFloat32, NormalizationOptions are required to be +// attached to the metadata for input normalization. +// Output tensors: +// (kTfLiteUInt8/kTfLiteFloat32) +// - list of segmented masks. +// - if `output_type` is CATEGORY_MASK, uint8 Image, Image vector of size 1. +// - if `output_type` is CONFIDENCE_MASK, float32 Image list of size +// `channels`. +// - batch is always 1 +class InteractiveSegmenter : tasks::vision::core::BaseVisionTaskApi { + public: + using BaseVisionTaskApi::BaseVisionTaskApi; + + // Creates an InteractiveSegmenter from the provided options. A non-default + // OpResolver can be specified in the BaseOptions of + // InteractiveSegmenterOptions, to support custom Ops of the segmentation + // model. + static absl::StatusOr> Create( + std::unique_ptr options); + + // Performs image segmentation on the provided single image. + // + // The image can be of any size with format RGB. + // + // The `roi` parameter is used to represent user's region of interest for + // segmentation. + // + // The optional 'image_processing_options' parameter can be used to specify + // the rotation to apply to the image before performing segmentation, by + // setting its 'rotation_degrees' field. Note that specifying a + // region-of-interest using the 'region_of_interest' field is NOT supported + // and will result in an invalid argument error being returned. + // + // If the output_type is CATEGORY_MASK, the returned vector of images is + // per-category segmented image mask. + // If the output_type is CONFIDENCE_MASK, the returned vector of images + // contains only one confidence image mask. + absl::StatusOr> Segment( + mediapipe::Image image, const RegionOfInterest& roi, + std::optional image_processing_options = + std::nullopt); + + // Shuts down the InteractiveSegmenter when all works are done. + absl::Status Close() { return runner_->Close(); } +}; + +} // namespace interactive_segmenter +} // namespace vision +} // namespace tasks +} // namespace mediapipe + +#endif // MEDIAPIPE_TASKS_CC_VISION_INTERACTIVE_SEGMENTER_INTERACTIVE_SEGMENTER_H_ diff --git a/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_graph.cc b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_graph.cc new file mode 100644 index 000000000..4c0cd2a88 --- /dev/null +++ b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_graph.cc @@ -0,0 +1,198 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "absl/strings/string_view.h" +#include "mediapipe/calculators/util/flat_color_image_calculator.pb.h" +#include "mediapipe/framework/api2/builder.h" +#include "mediapipe/framework/api2/port.h" +#include "mediapipe/framework/calculator_framework.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/formats/rect.pb.h" +#include "mediapipe/tasks/cc/components/processors/image_preprocessing_graph.h" +#include "mediapipe/tasks/cc/core/model_task_graph.h" +#include "mediapipe/tasks/cc/vision/image_segmenter/proto/image_segmenter_graph_options.pb.h" +#include "mediapipe/util/color.pb.h" +#include "mediapipe/util/label_map.pb.h" +#include "mediapipe/util/render_data.pb.h" + +namespace mediapipe { +namespace tasks { +namespace vision { +namespace interactive_segmenter { + +namespace { + +using image_segmenter::proto::ImageSegmenterGraphOptions; +using ::mediapipe::Image; +using ::mediapipe::NormalizedRect; +using ::mediapipe::api2::Input; +using ::mediapipe::api2::Output; +using ::mediapipe::api2::builder::Graph; +using ::mediapipe::api2::builder::Source; + +constexpr char kSegmentationTag[] = "SEGMENTATION"; +constexpr char kGroupedSegmentationTag[] = "GROUPED_SEGMENTATION"; +constexpr char kImageTag[] = "IMAGE"; +constexpr char kImageCpuTag[] = "IMAGE_CPU"; +constexpr char kImageGpuTag[] = "IMAGE_GPU"; +constexpr char kAlphaTag[] = "ALPHA"; +constexpr char kAlphaGpuTag[] = "ALPHA_GPU"; +constexpr char kNormRectTag[] = "NORM_RECT"; +constexpr char kRoiTag[] = "ROI"; +constexpr char kVideoTag[] = "VIDEO"; + +// Updates the graph to return `roi` stream which has same dimension as +// `image`, and rendered with `roi`. If `use_gpu` is true, returned `Source` is +// in GpuBuffer format, otherwise using ImageFrame. +Source<> RoiToAlpha(Source image, Source roi, bool use_gpu, + Graph& graph) { + // TODO: Replace with efficient implementation. + const absl::string_view image_tag_with_suffix = + use_gpu ? kImageGpuTag : kImageCpuTag; + + // Generates a blank canvas with same size as input image. + auto& flat_color = graph.AddNode("FlatColorImageCalculator"); + auto& flat_color_options = + flat_color.GetOptions(); + // SetAlphaCalculator only takes 1st channel. + flat_color_options.mutable_color()->set_r(0); + image >> flat_color.In(kImageTag)[0]; + auto blank_canvas = flat_color.Out(kImageTag)[0]; + + auto& from_mp_image = graph.AddNode("FromImageCalculator"); + blank_canvas >> from_mp_image.In(kImageTag); + auto blank_canvas_in_cpu_or_gpu = from_mp_image.Out(image_tag_with_suffix); + + auto& roi_to_alpha = graph.AddNode("AnnotationOverlayCalculator"); + blank_canvas_in_cpu_or_gpu >> + roi_to_alpha.In(use_gpu ? kImageGpuTag : kImageTag); + roi >> roi_to_alpha.In(0); + auto alpha = roi_to_alpha.Out(use_gpu ? kImageGpuTag : kImageTag); + + return alpha; +} + +} // namespace + +// An "mediapipe.tasks.vision.interactive_segmenter.InteractiveSegmenterGraph" +// performs semantic segmentation given user's region-of-interest. Two kinds of +// outputs are provided: SEGMENTATION and GROUPED_SEGMENTATION. Users can +// retrieve segmented mask of only particular category/channel from +// SEGMENTATION, and users can also get all segmented masks from +// GROUPED_SEGMENTATION. +// - Accepts CPU input images and outputs segmented masks on CPU. +// +// Inputs: +// IMAGE - Image +// Image to perform segmentation on. +// ROI - RenderData proto +// Region of interest based on user interaction. Currently only support +// Point format, and Color has to be (255, 255, 255). +// NORM_RECT - NormalizedRect @Optional +// Describes image rotation and region of image to perform detection +// on. +// @Optional: rect covering the whole image is used if not specified. +// +// Outputs: +// SEGMENTATION - mediapipe::Image @Multiple +// Segmented masks for individual category. Segmented mask of single +// category can be accessed by index based output stream. +// GROUPED_SEGMENTATION - std::vector +// The output segmented masks grouped in a vector. +// IMAGE - mediapipe::Image +// The image that image segmenter runs on. +// +// Example: +// node { +// calculator: +// "mediapipe.tasks.vision.interactive_segmenter.InteractiveSegmenterGraph" +// input_stream: "IMAGE:image" +// input_stream: "ROI:region_of_interest" +// output_stream: "SEGMENTATION:segmented_masks" +// options { +// [mediapipe.tasks.vision.image_segmenter.proto.ImageSegmenterGraphOptions.ext] +// { +// base_options { +// model_asset { +// file_name: "/path/to/model.tflite" +// } +// } +// segmenter_options { +// output_type: CONFIDENCE_MASK +// } +// } +// } +// } +class InteractiveSegmenterGraph : public core::ModelTaskGraph { + public: + absl::StatusOr GetConfig( + mediapipe::SubgraphContext* sc) override { + Graph graph; + const auto& task_options = sc->Options(); + bool use_gpu = + components::processors::DetermineImagePreprocessingGpuBackend( + task_options.base_options().acceleration()); + + Source image = graph[Input(kImageTag)]; + Source roi = graph[Input(kRoiTag)]; + Source norm_rect = + graph[Input(kNormRectTag)]; + const absl::string_view image_tag_with_suffix = + use_gpu ? kImageGpuTag : kImageCpuTag; + const absl::string_view alpha_tag_with_suffix = + use_gpu ? kAlphaGpuTag : kAlphaTag; + + auto& from_mp_image = graph.AddNode("FromImageCalculator"); + image >> from_mp_image.In(kImageTag); + auto image_in_cpu_or_gpu = from_mp_image.Out(image_tag_with_suffix); + + auto alpha_in_cpu_or_gpu = RoiToAlpha(image, roi, use_gpu, graph); + + auto& set_alpha = graph.AddNode("SetAlphaCalculator"); + image_in_cpu_or_gpu >> set_alpha.In(use_gpu ? kImageGpuTag : kImageTag); + alpha_in_cpu_or_gpu >> set_alpha.In(alpha_tag_with_suffix); + auto image_in_cpu_or_gpu_with_set_alpha = + set_alpha.Out(use_gpu ? kImageGpuTag : kImageTag); + + auto& to_mp_image = graph.AddNode("ToImageCalculator"); + image_in_cpu_or_gpu_with_set_alpha >> to_mp_image.In(image_tag_with_suffix); + auto image_with_set_alpha = to_mp_image.Out(kImageTag); + + auto& image_segmenter = graph.AddNode( + "mediapipe.tasks.vision.image_segmenter.ImageSegmenterGraph"); + image_segmenter.GetOptions() = task_options; + image_with_set_alpha >> image_segmenter.In(kImageTag); + norm_rect >> image_segmenter.In(kNormRectTag); + + image_segmenter.Out(kSegmentationTag) >> + graph[Output(kSegmentationTag)]; + image_segmenter.Out(kGroupedSegmentationTag) >> + graph[Output>(kGroupedSegmentationTag)]; + image_segmenter.Out(kImageTag) >> graph[Output(kImageTag)]; + + return graph.GetConfig(); + } +}; + +// REGISTER_MEDIAPIPE_GRAPH argument has to fit on one line to work properly. +// clang-format off +REGISTER_MEDIAPIPE_GRAPH( + ::mediapipe::tasks::vision::interactive_segmenter::InteractiveSegmenterGraph); +// clang-format on + +} // namespace interactive_segmenter +} // namespace vision +} // namespace tasks +} // namespace mediapipe diff --git a/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_test.cc b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_test.cc new file mode 100644 index 000000000..dbe021dce --- /dev/null +++ b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_test.cc @@ -0,0 +1,261 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.h" + +#include +#include + +#include "absl/flags/flag.h" +#include "mediapipe/framework/deps/file_path.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/formats/image_frame.h" +#include "mediapipe/framework/formats/image_frame_opencv.h" +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/framework/port/opencv_core_inc.h" +#include "mediapipe/framework/port/opencv_imgcodecs_inc.h" +#include "mediapipe/framework/port/status_matchers.h" +#include "mediapipe/tasks/cc/components/containers/rect.h" +#include "mediapipe/tasks/cc/core/proto/base_options.pb.h" +#include "mediapipe/tasks/cc/core/proto/external_file.pb.h" +#include "mediapipe/tasks/cc/vision/core/image_processing_options.h" +#include "mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.pb.h" +#include "mediapipe/tasks/cc/vision/image_segmenter/proto/image_segmenter_graph_options.pb.h" +#include "mediapipe/tasks/cc/vision/utils/image_utils.h" +#include "tensorflow/lite/core/shims/cc/shims_test_util.h" +#include "tensorflow/lite/kernels/builtin_op_kernels.h" +#include "tensorflow/lite/mutable_op_resolver.h" + +namespace mediapipe { +namespace tasks { +namespace vision { +namespace interactive_segmenter { +namespace { + +using ::mediapipe::Image; +using ::mediapipe::file::JoinPath; +using ::mediapipe::tasks::components::containers::RectF; +using ::mediapipe::tasks::vision::core::ImageProcessingOptions; +using ::testing::HasSubstr; +using ::testing::Optional; + +constexpr char kTestDataDirectory[] = "/mediapipe/tasks/testdata/vision/"; +constexpr char kPtmModel[] = "ptm_512_hdt_ptm_woid.tflite"; +constexpr char kCatsAndDogsJpg[] = "cats_and_dogs.jpg"; + +constexpr float kGoldenMaskSimilarity = 0.98; + +// Magnification factor used when creating the golden category masks to make +// them more human-friendly. Each pixel in the golden masks has its value +// multiplied by this factor, i.e. a value of 10 means class index 1, a value of +// 20 means class index 2, etc. +constexpr int kGoldenMaskMagnificationFactor = 10; + +// Intentionally converting output into CV_8UC1 and then again into CV_32FC1 +// as expected outputs are stored in CV_8UC1, so this conversion allows to do +// fair comparison. +cv::Mat PostProcessResultMask(const cv::Mat& mask) { + cv::Mat mask_float; + mask.convertTo(mask_float, CV_8UC1, 255); + mask_float.convertTo(mask_float, CV_32FC1, 1 / 255.f); + return mask_float; +} + +double CalculateSum(const cv::Mat& m) { + double sum = 0.0; + cv::Scalar s = cv::sum(m); + for (int i = 0; i < m.channels(); ++i) { + sum += s.val[i]; + } + return sum; +} + +double CalculateSoftIOU(const cv::Mat& m1, const cv::Mat& m2) { + cv::Mat intersection; + cv::multiply(m1, m2, intersection); + double intersection_value = CalculateSum(intersection); + double union_value = + CalculateSum(m1.mul(m1)) + CalculateSum(m2.mul(m2)) - intersection_value; + return union_value > 0.0 ? intersection_value / union_value : 0.0; +} + +MATCHER_P2(SimilarToFloatMask, expected_mask, similarity_threshold, "") { + cv::Mat actual_mask = PostProcessResultMask(arg); + return arg.rows == expected_mask.rows && arg.cols == expected_mask.cols && + CalculateSoftIOU(arg, expected_mask) > similarity_threshold; +} + +MATCHER_P3(SimilarToUint8Mask, expected_mask, similarity_threshold, + magnification_factor, "") { + if (arg.rows != expected_mask.rows || arg.cols != expected_mask.cols) { + return false; + } + int consistent_pixels = 0; + const int num_pixels = expected_mask.rows * expected_mask.cols; + for (int i = 0; i < num_pixels; ++i) { + consistent_pixels += + (arg.data[i] * magnification_factor == expected_mask.data[i]); + } + return static_cast(consistent_pixels) / num_pixels >= + similarity_threshold; +} + +class CreateFromOptionsTest : public tflite_shims::testing::Test {}; + +class DeepLabOpResolverMissingOps : public ::tflite::MutableOpResolver { + public: + DeepLabOpResolverMissingOps() { + AddBuiltin(::tflite::BuiltinOperator_ADD, + ::tflite::ops::builtin::Register_ADD()); + } + + DeepLabOpResolverMissingOps(const DeepLabOpResolverMissingOps& r) = delete; +}; + +TEST_F(CreateFromOptionsTest, FailsWithSelectiveOpResolverMissingOps) { + auto options = std::make_unique(); + options->base_options.model_asset_path = + JoinPath("./", kTestDataDirectory, kPtmModel); + options->base_options.op_resolver = + absl::make_unique(); + auto segmenter_or = InteractiveSegmenter::Create(std::move(options)); + // TODO: Make MediaPipe InferenceCalculator report the detailed + // interpreter errors (e.g., "Encountered unresolved custom op"). + EXPECT_EQ(segmenter_or.status().code(), absl::StatusCode::kInternal); + EXPECT_THAT( + segmenter_or.status().message(), + testing::HasSubstr("interpreter_builder(&interpreter) == kTfLiteOk")); +} + +TEST_F(CreateFromOptionsTest, FailsWithMissingModel) { + absl::StatusOr> segmenter_or = + InteractiveSegmenter::Create( + std::make_unique()); + + EXPECT_EQ(segmenter_or.status().code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT( + segmenter_or.status().message(), + HasSubstr("ExternalFile must specify at least one of 'file_content', " + "'file_name', 'file_pointer_meta' or 'file_descriptor_meta'.")); + EXPECT_THAT(segmenter_or.status().GetPayload(kMediaPipeTasksPayload), + Optional(absl::Cord(absl::StrCat( + MediaPipeTasksStatus::kRunnerInitializationError)))); +} + +class ImageModeTest : public tflite_shims::testing::Test {}; + +TEST_F(ImageModeTest, SucceedsWithCategoryMask) { + MP_ASSERT_OK_AND_ASSIGN( + Image image, + DecodeImageFromFile(JoinPath("./", kTestDataDirectory, kCatsAndDogsJpg))); + RegionOfInterest interaction_roi; + interaction_roi.format = RegionOfInterest::KEYPOINT; + interaction_roi.keypoint = + components::containers::NormalizedKeypoint{0.25, 0.9}; + auto options = std::make_unique(); + options->base_options.model_asset_path = + JoinPath("./", kTestDataDirectory, kPtmModel); + options->output_type = InteractiveSegmenterOptions::OutputType::CATEGORY_MASK; + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, + InteractiveSegmenter::Create(std::move(options))); + MP_ASSERT_OK_AND_ASSIGN(auto category_masks, + segmenter->Segment(image, interaction_roi)); + EXPECT_EQ(category_masks.size(), 1); +} + +TEST_F(ImageModeTest, SucceedsWithConfidenceMask) { + MP_ASSERT_OK_AND_ASSIGN( + Image image, + DecodeImageFromFile(JoinPath("./", kTestDataDirectory, kCatsAndDogsJpg))); + RegionOfInterest interaction_roi; + interaction_roi.format = RegionOfInterest::KEYPOINT; + interaction_roi.keypoint = + components::containers::NormalizedKeypoint{0.25, 0.9}; + auto options = std::make_unique(); + options->base_options.model_asset_path = + JoinPath("./", kTestDataDirectory, kPtmModel); + options->output_type = + InteractiveSegmenterOptions::OutputType::CONFIDENCE_MASK; + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, + InteractiveSegmenter::Create(std::move(options))); + MP_ASSERT_OK_AND_ASSIGN(auto confidence_masks, + segmenter->Segment(image, interaction_roi)); + EXPECT_EQ(confidence_masks.size(), 2); +} + +// TODO: fix this unit test after image segmenter handled post +// processing correctly with rotated image. +TEST_F(ImageModeTest, DISABLED_SucceedsWithRotation) { + MP_ASSERT_OK_AND_ASSIGN( + Image image, + DecodeImageFromFile(JoinPath("./", kTestDataDirectory, kCatsAndDogsJpg))); + RegionOfInterest interaction_roi; + interaction_roi.format = RegionOfInterest::KEYPOINT; + interaction_roi.keypoint = + components::containers::NormalizedKeypoint{0.25, 0.9}; + auto options = std::make_unique(); + options->base_options.model_asset_path = + JoinPath("./", kTestDataDirectory, kPtmModel); + options->output_type = + InteractiveSegmenterOptions::OutputType::CONFIDENCE_MASK; + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, + InteractiveSegmenter::Create(std::move(options))); + ImageProcessingOptions image_processing_options; + image_processing_options.rotation_degrees = -90; + MP_ASSERT_OK_AND_ASSIGN( + auto confidence_masks, + segmenter->Segment(image, interaction_roi, image_processing_options)); + EXPECT_EQ(confidence_masks.size(), 2); +} + +TEST_F(ImageModeTest, FailsWithRegionOfInterest) { + MP_ASSERT_OK_AND_ASSIGN( + Image image, + DecodeImageFromFile(JoinPath("./", kTestDataDirectory, kCatsAndDogsJpg))); + RegionOfInterest interaction_roi; + interaction_roi.format = RegionOfInterest::KEYPOINT; + interaction_roi.keypoint = + components::containers::NormalizedKeypoint{0.25, 0.9}; + auto options = std::make_unique(); + options->base_options.model_asset_path = + JoinPath("./", kTestDataDirectory, kPtmModel); + options->output_type = + InteractiveSegmenterOptions::OutputType::CONFIDENCE_MASK; + + MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr segmenter, + InteractiveSegmenter::Create(std::move(options))); + RectF roi{/*left=*/0.1, /*top=*/0, /*right=*/0.9, /*bottom=*/1}; + ImageProcessingOptions image_processing_options{roi, /*rotation_degrees=*/0}; + + auto results = + segmenter->Segment(image, interaction_roi, image_processing_options); + EXPECT_EQ(results.status().code(), absl::StatusCode::kInvalidArgument); + EXPECT_THAT(results.status().message(), + HasSubstr("This task doesn't support region-of-interest")); + EXPECT_THAT( + results.status().GetPayload(kMediaPipeTasksPayload), + Optional(absl::Cord(absl::StrCat( + MediaPipeTasksStatus::kImageProcessingInvalidArgumentError)))); +} + +} // namespace +} // namespace interactive_segmenter +} // namespace vision +} // namespace tasks +} // namespace mediapipe From 8f1ce5fef6b37d5ebf34ffd5dd9dd8c6365b2b75 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Wed, 15 Mar 2023 17:00:50 -0700 Subject: [PATCH 098/136] Add quality test for InteractiveSegmenter PiperOrigin-RevId: 516968294 --- .../interactive_segmenter_test.cc | 83 ++++++++++++++----- mediapipe/tasks/testdata/vision/BUILD | 4 + third_party/external_files.bzl | 20 +++-- 3 files changed, 81 insertions(+), 26 deletions(-) diff --git a/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_test.cc b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_test.cc index dbe021dce..dbc3bbe4c 100644 --- a/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_test.cc +++ b/mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter_test.cc @@ -15,8 +15,8 @@ limitations under the License. #include "mediapipe/tasks/cc/vision/interactive_segmenter/interactive_segmenter.h" -#include #include +#include #include "absl/flags/flag.h" #include "mediapipe/framework/deps/file_path.h" @@ -28,6 +28,7 @@ limitations under the License. #include "mediapipe/framework/port/opencv_core_inc.h" #include "mediapipe/framework/port/opencv_imgcodecs_inc.h" #include "mediapipe/framework/port/status_matchers.h" +#include "mediapipe/tasks/cc/components/containers/keypoint.h" #include "mediapipe/tasks/cc/components/containers/rect.h" #include "mediapipe/tasks/cc/core/proto/base_options.pb.h" #include "mediapipe/tasks/cc/core/proto/external_file.pb.h" @@ -47,6 +48,7 @@ namespace { using ::mediapipe::Image; using ::mediapipe::file::JoinPath; +using ::mediapipe::tasks::components::containers::NormalizedKeypoint; using ::mediapipe::tasks::components::containers::RectF; using ::mediapipe::tasks::vision::core::ImageProcessingOptions; using ::testing::HasSubstr; @@ -55,14 +57,16 @@ using ::testing::Optional; constexpr char kTestDataDirectory[] = "/mediapipe/tasks/testdata/vision/"; constexpr char kPtmModel[] = "ptm_512_hdt_ptm_woid.tflite"; constexpr char kCatsAndDogsJpg[] = "cats_and_dogs.jpg"; +// Golden mask for the dogs in cats_and_dogs.jpg. +constexpr char kCatsAndDogsMaskDog1[] = "cats_and_dogs_mask_dog1.png"; +constexpr char kCatsAndDogsMaskDog2[] = "cats_and_dogs_mask_dog2.png"; -constexpr float kGoldenMaskSimilarity = 0.98; +constexpr float kGoldenMaskSimilarity = 0.97; // Magnification factor used when creating the golden category masks to make -// them more human-friendly. Each pixel in the golden masks has its value -// multiplied by this factor, i.e. a value of 10 means class index 1, a value of -// 20 means class index 2, etc. -constexpr int kGoldenMaskMagnificationFactor = 10; +// them more human-friendly. Since interactive segmenter has only 2 categories, +// the golden mask uses 0 or 255 for each pixel. +constexpr int kGoldenMaskMagnificationFactor = 255; // Intentionally converting output into CV_8UC1 and then again into CV_32FC1 // as expected outputs are stored in CV_8UC1, so this conversion allows to do @@ -155,16 +159,25 @@ TEST_F(CreateFromOptionsTest, FailsWithMissingModel) { MediaPipeTasksStatus::kRunnerInitializationError)))); } -class ImageModeTest : public tflite_shims::testing::Test {}; +struct InteractiveSegmenterTestParams { + std::string test_name; + RegionOfInterest::Format format; + NormalizedKeypoint roi; + std::string golden_mask_file; + float similarity_threshold; +}; -TEST_F(ImageModeTest, SucceedsWithCategoryMask) { +using SucceedSegmentationWithRoi = + ::testing::TestWithParam; + +TEST_P(SucceedSegmentationWithRoi, SucceedsWithCategoryMask) { + const InteractiveSegmenterTestParams& params = GetParam(); MP_ASSERT_OK_AND_ASSIGN( Image image, DecodeImageFromFile(JoinPath("./", kTestDataDirectory, kCatsAndDogsJpg))); RegionOfInterest interaction_roi; - interaction_roi.format = RegionOfInterest::KEYPOINT; - interaction_roi.keypoint = - components::containers::NormalizedKeypoint{0.25, 0.9}; + interaction_roi.format = params.format; + interaction_roi.keypoint = params.roi; auto options = std::make_unique(); options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kPtmModel); @@ -175,16 +188,26 @@ TEST_F(ImageModeTest, SucceedsWithCategoryMask) { MP_ASSERT_OK_AND_ASSIGN(auto category_masks, segmenter->Segment(image, interaction_roi)); EXPECT_EQ(category_masks.size(), 1); + + cv::Mat actual_mask = mediapipe::formats::MatView( + category_masks[0].GetImageFrameSharedPtr().get()); + + cv::Mat expected_mask = + cv::imread(JoinPath("./", kTestDataDirectory, params.golden_mask_file), + cv::IMREAD_GRAYSCALE); + EXPECT_THAT(actual_mask, + SimilarToUint8Mask(expected_mask, params.similarity_threshold, + kGoldenMaskMagnificationFactor)); } -TEST_F(ImageModeTest, SucceedsWithConfidenceMask) { +TEST_P(SucceedSegmentationWithRoi, SucceedsWithConfidenceMask) { + const auto& params = GetParam(); MP_ASSERT_OK_AND_ASSIGN( Image image, DecodeImageFromFile(JoinPath("./", kTestDataDirectory, kCatsAndDogsJpg))); RegionOfInterest interaction_roi; - interaction_roi.format = RegionOfInterest::KEYPOINT; - interaction_roi.keypoint = - components::containers::NormalizedKeypoint{0.25, 0.9}; + interaction_roi.format = params.format; + interaction_roi.keypoint = params.roi; auto options = std::make_unique(); options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kPtmModel); @@ -196,8 +219,32 @@ TEST_F(ImageModeTest, SucceedsWithConfidenceMask) { MP_ASSERT_OK_AND_ASSIGN(auto confidence_masks, segmenter->Segment(image, interaction_roi)); EXPECT_EQ(confidence_masks.size(), 2); + + cv::Mat expected_mask = + cv::imread(JoinPath("./", kTestDataDirectory, params.golden_mask_file), + cv::IMREAD_GRAYSCALE); + cv::Mat expected_mask_float; + expected_mask.convertTo(expected_mask_float, CV_32FC1, 1 / 255.f); + + cv::Mat actual_mask = mediapipe::formats::MatView( + confidence_masks[1].GetImageFrameSharedPtr().get()); + EXPECT_THAT(actual_mask, SimilarToFloatMask(expected_mask_float, + params.similarity_threshold)); } +INSTANTIATE_TEST_SUITE_P( + SucceedSegmentationWithRoiTest, SucceedSegmentationWithRoi, + ::testing::ValuesIn( + {{"PointToDog1", RegionOfInterest::KEYPOINT, + NormalizedKeypoint{0.44, 0.70}, kCatsAndDogsMaskDog1, 0.84f}, + {"PointToDog2", RegionOfInterest::KEYPOINT, + NormalizedKeypoint{0.66, 0.66}, kCatsAndDogsMaskDog2, + kGoldenMaskSimilarity}}), + [](const ::testing::TestParamInfo& + info) { return info.param.test_name; }); + +class ImageModeTest : public tflite_shims::testing::Test {}; + // TODO: fix this unit test after image segmenter handled post // processing correctly with rotated image. TEST_F(ImageModeTest, DISABLED_SucceedsWithRotation) { @@ -206,8 +253,7 @@ TEST_F(ImageModeTest, DISABLED_SucceedsWithRotation) { DecodeImageFromFile(JoinPath("./", kTestDataDirectory, kCatsAndDogsJpg))); RegionOfInterest interaction_roi; interaction_roi.format = RegionOfInterest::KEYPOINT; - interaction_roi.keypoint = - components::containers::NormalizedKeypoint{0.25, 0.9}; + interaction_roi.keypoint = NormalizedKeypoint{0.66, 0.66}; auto options = std::make_unique(); options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kPtmModel); @@ -230,8 +276,7 @@ TEST_F(ImageModeTest, FailsWithRegionOfInterest) { DecodeImageFromFile(JoinPath("./", kTestDataDirectory, kCatsAndDogsJpg))); RegionOfInterest interaction_roi; interaction_roi.format = RegionOfInterest::KEYPOINT; - interaction_roi.keypoint = - components::containers::NormalizedKeypoint{0.25, 0.9}; + interaction_roi.keypoint = NormalizedKeypoint{0.66, 0.66}; auto options = std::make_unique(); options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kPtmModel); diff --git a/mediapipe/tasks/testdata/vision/BUILD b/mediapipe/tasks/testdata/vision/BUILD index ac76bfa23..097acad43 100644 --- a/mediapipe/tasks/testdata/vision/BUILD +++ b/mediapipe/tasks/testdata/vision/BUILD @@ -31,6 +31,8 @@ mediapipe_files(srcs = [ "cat_rotated.jpg", "cat_rotated_mask.jpg", "cats_and_dogs.jpg", + "cats_and_dogs_mask_dog1.png", + "cats_and_dogs_mask_dog2.png", "cats_and_dogs_no_resizing.jpg", "cats_and_dogs_rotated.jpg", "coco_efficientdet_lite0_v1_1.0_quant_2021_09_06.tflite", @@ -116,6 +118,8 @@ filegroup( "cat_rotated.jpg", "cat_rotated_mask.jpg", "cats_and_dogs.jpg", + "cats_and_dogs_mask_dog1.png", + "cats_and_dogs_mask_dog2.png", "cats_and_dogs_no_resizing.jpg", "cats_and_dogs_rotated.jpg", "fist.jpg", diff --git a/third_party/external_files.bzl b/third_party/external_files.bzl index 52636f427..3a08c61c5 100644 --- a/third_party/external_files.bzl +++ b/third_party/external_files.bzl @@ -67,13 +67,7 @@ def external_files(): http_file( name = "com_google_mediapipe_BUILD", sha256 = "d2b2a8346202691d7f831887c84e9642e974f64ed67851d9a58cf15c94b1f6b3", - urls = ["https://storage.googleapis.com/mediapipe-assets/BUILD?generation=16618756636939761678323576393653"], - ) - - http_file( - name = "com_google_mediapipe_BUILD_orig", - sha256 = "d86b98b82e00dd87cd46bd1429bf5eaa007b500c1a24d9316b73309f2e6c8df8", - urls = ["https://storage.googleapis.com/mediapipe-assets/BUILD.orig?generation=1678737479599640"], + urls = ["https://storage.googleapis.com/mediapipe-assets/BUILD?generation=166187566369397616783235763936531678737479599640"], ) http_file( @@ -136,6 +130,18 @@ def external_files(): urls = ["https://storage.googleapis.com/mediapipe-assets/cats_and_dogs.jpg?generation=1661875684064150"], ) + http_file( + name = "com_google_mediapipe_cats_and_dogs_mask_dog1_png", + sha256 = "2ab37d56ba1e46e70b3ddbfe35dac51b18b597b76904c68d7d34c7c74c677d4c", + urls = ["https://storage.googleapis.com/mediapipe-assets/cats_and_dogs_mask_dog1.png?generation=1678840350058498"], + ) + + http_file( + name = "com_google_mediapipe_cats_and_dogs_mask_dog2_png", + sha256 = "2010850e2dd7f520fe53b9086d70913b6fb53b178cae15a373e5ee7ffb46824a", + urls = ["https://storage.googleapis.com/mediapipe-assets/cats_and_dogs_mask_dog2.png?generation=1678840352961684"], + ) + http_file( name = "com_google_mediapipe_cats_and_dogs_no_resizing_jpg", sha256 = "9d55933ed66bcdc63cd6509ee2518d7eed75d12db609238387ee4cc50b173e58", From 9aea1be6f973675c495d14efeeae239760fcd98c Mon Sep 17 00:00:00 2001 From: kinaryml Date: Wed, 15 Mar 2023 23:51:12 -0700 Subject: [PATCH 099/136] Removed geometry pipeline calculator --- mediapipe/python/BUILD | 1 - 1 file changed, 1 deletion(-) diff --git a/mediapipe/python/BUILD b/mediapipe/python/BUILD index 6755f281e..a5b52a533 100644 --- a/mediapipe/python/BUILD +++ b/mediapipe/python/BUILD @@ -37,7 +37,6 @@ pybind_extension( deps = [ ":builtin_calculators", ":builtin_task_graphs", - "//mediapipe/tasks/cc/vision/face_geometry/calculators:geometry_pipeline_calculator", "//mediapipe/python/pybind:calculator_graph", "//mediapipe/python/pybind:image", "//mediapipe/python/pybind:image_frame", From 3b66ac06230f30331db32e69a2ca0f1580f60551 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 16 Mar 2023 00:40:58 -0700 Subject: [PATCH 100/136] Import the saved Keras models of BlazeFaceStylizer components into MediaPipe model maker. PiperOrigin-RevId: 517044265 --- .../model_maker/models/face_stylizer/BUILD | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 mediapipe/model_maker/models/face_stylizer/BUILD diff --git a/mediapipe/model_maker/models/face_stylizer/BUILD b/mediapipe/model_maker/models/face_stylizer/BUILD new file mode 100644 index 000000000..74ca71554 --- /dev/null +++ b/mediapipe/model_maker/models/face_stylizer/BUILD @@ -0,0 +1,24 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +licenses(["notice"]) + +package(default_visibility = ["//mediapipe/model_maker/python/vision/face_stylizer:__subpackages__"]) + +filegroup( + name = "models", + srcs = glob([ + "**", + ]), +) From a9e956baa1e87da838b2e237faf03e44913623e0 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 16 Mar 2023 10:35:07 -0700 Subject: [PATCH 101/136] Add more details to the invoke call trace. It is always useful information to know if the TPU invoke is Async or not, and if the GPU invoke on the old path or new path. This can make it obvious in the perfetto trace. PiperOrigin-RevId: 517162515 --- .../tensor/inference_calculator_gl_advanced.cc | 2 +- mediapipe/framework/calculator_profile.proto | 2 ++ mediapipe/framework/profiler/trace_buffer.h | 4 ++++ mediapipe/framework/profiler/trace_builder.cc | 10 +++++++++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/mediapipe/calculators/tensor/inference_calculator_gl_advanced.cc b/mediapipe/calculators/tensor/inference_calculator_gl_advanced.cc index 8fd55efa7..8aee46185 100644 --- a/mediapipe/calculators/tensor/inference_calculator_gl_advanced.cc +++ b/mediapipe/calculators/tensor/inference_calculator_gl_advanced.cc @@ -141,7 +141,7 @@ InferenceCalculatorGlAdvancedImpl::GpuInferenceRunner::Process( } // Run inference. { - MEDIAPIPE_PROFILING(GPU_TASK_INVOKE, cc); + MEDIAPIPE_PROFILING(GPU_TASK_INVOKE_ADVANCED, cc); return tflite_gpu_runner_->Invoke(); } })); diff --git a/mediapipe/framework/calculator_profile.proto b/mediapipe/framework/calculator_profile.proto index d86162ea5..0b5498c4e 100644 --- a/mediapipe/framework/calculator_profile.proto +++ b/mediapipe/framework/calculator_profile.proto @@ -136,6 +136,8 @@ message GraphTrace { GPU_TASK_INVOKE = 16; TPU_TASK_INVOKE = 17; CPU_TASK_INVOKE = 18; + GPU_TASK_INVOKE_ADVANCED = 19; + TPU_TASK_INVOKE_ASYNC = 20; } // The timing for one packet set being processed at one caclulator node. diff --git a/mediapipe/framework/profiler/trace_buffer.h b/mediapipe/framework/profiler/trace_buffer.h index b44d8f0bf..b5e2d9994 100644 --- a/mediapipe/framework/profiler/trace_buffer.h +++ b/mediapipe/framework/profiler/trace_buffer.h @@ -112,6 +112,10 @@ struct TraceEvent { static constexpr EventType GPU_TASK_INVOKE = GraphTrace::GPU_TASK_INVOKE; static constexpr EventType TPU_TASK_INVOKE = GraphTrace::TPU_TASK_INVOKE; static constexpr EventType CPU_TASK_INVOKE = GraphTrace::CPU_TASK_INVOKE; + static constexpr EventType GPU_TASK_INVOKE_ADVANCED = + GraphTrace::GPU_TASK_INVOKE_ADVANCED; + static constexpr EventType TPU_TASK_INVOKE_ASYNC = + GraphTrace::TPU_TASK_INVOKE_ASYNC; }; // Packet trace log buffer. diff --git a/mediapipe/framework/profiler/trace_builder.cc b/mediapipe/framework/profiler/trace_builder.cc index ce5bf1e25..9c3661ffe 100644 --- a/mediapipe/framework/profiler/trace_builder.cc +++ b/mediapipe/framework/profiler/trace_builder.cc @@ -57,7 +57,6 @@ struct hash { namespace mediapipe { namespace { - void BasicTraceEventTypes(TraceEventRegistry* result) { // The initializer arguments below are: event_type, description, // is_packet_event, is_stream_event, id_event_data. @@ -84,6 +83,15 @@ void BasicTraceEventTypes(TraceEventRegistry* result) { "A time measured by GPU clock and by CPU clock.", true, false}, {TraceEvent::PACKET_QUEUED, "An input queue size when a packet arrives.", true, true, false}, + + {TraceEvent::GPU_TASK_INVOKE, "CPU timing for initiating a GPU task."}, + {TraceEvent::TPU_TASK_INVOKE, "CPU timing for initiating a TPU task."}, + {TraceEvent::CPU_TASK_INVOKE, "CPU timing for initiating a CPU task."}, + {TraceEvent::GPU_TASK_INVOKE_ADVANCED, + "CPU timing for initiating a GPU task bypassing the TFLite " + "interpreter."}, + {TraceEvent::TPU_TASK_INVOKE_ASYNC, + "CPU timing for async initiation of a TPU task."}, }; for (const TraceEventType& t : basic_types) { (*result)[t.event_type()] = t; From 2753c79fdeb92c26a707568901e8054fdcf0e240 Mon Sep 17 00:00:00 2001 From: kinaryml Date: Thu, 16 Mar 2023 11:50:07 -0700 Subject: [PATCH 102/136] Removed MatrixData dataclass and used NumPy to represent Matrix --- .../tasks/python/components/containers/BUILD | 9 -- .../components/containers/matrix_data.py | 81 ------------ mediapipe/tasks/python/test/vision/BUILD | 1 - .../test/vision/face_landmarker_test.py | 120 +++++++++--------- mediapipe/tasks/python/vision/BUILD | 1 - .../tasks/python/vision/face_landmarker.py | 9 +- 6 files changed, 65 insertions(+), 156 deletions(-) delete mode 100644 mediapipe/tasks/python/components/containers/matrix_data.py diff --git a/mediapipe/tasks/python/components/containers/BUILD b/mediapipe/tasks/python/components/containers/BUILD index 07c31dc0c..b84ab744d 100644 --- a/mediapipe/tasks/python/components/containers/BUILD +++ b/mediapipe/tasks/python/components/containers/BUILD @@ -82,15 +82,6 @@ py_library( ], ) -py_library( - name = "matrix_data", - srcs = ["matrix_data.py"], - deps = [ - "//mediapipe/framework/formats:matrix_data_py_pb2", - "//mediapipe/tasks/python/core:optional_dependencies", - ], -) - py_library( name = "detections", srcs = ["detections.py"], diff --git a/mediapipe/tasks/python/components/containers/matrix_data.py b/mediapipe/tasks/python/components/containers/matrix_data.py deleted file mode 100644 index ded3a9b4f..000000000 --- a/mediapipe/tasks/python/components/containers/matrix_data.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright 2022 The MediaPipe Authors. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Matrix data data class.""" - -import dataclasses -import enum -from typing import Any, Optional - -import numpy as np -from mediapipe.framework.formats import matrix_data_pb2 -from mediapipe.tasks.python.core.optional_dependencies import doc_controls - -_MatrixDataProto = matrix_data_pb2.MatrixData - - -class Layout(enum.Enum): - COLUMN_MAJOR = 0 - ROW_MAJOR = 1 - - -@dataclasses.dataclass -class MatrixData: - """This stores the Matrix data. - - Here the data is stored in column-major order by default. - - Attributes: - rows: The number of rows in the matrix. - cols: The number of columns in the matrix. - data: The data stored in the matrix as a NumPy array. - layout: The order in which the data are stored. Defaults to COLUMN_MAJOR. - """ - - rows: int = None - cols: int = None - data: np.ndarray = None - layout: Optional[Layout] = Layout.COLUMN_MAJOR - - @doc_controls.do_not_generate_docs - def to_pb2(self) -> _MatrixDataProto: - """Generates a MatrixData protobuf object.""" - return _MatrixDataProto( - rows=self.rows, - cols=self.cols, - packed_data=self.data, - layout=self.layout.value) - - @classmethod - @doc_controls.do_not_generate_docs - def create_from_pb2(cls, pb2_obj: _MatrixDataProto) -> 'MatrixData': - """Creates a `MatrixData` object from the given protobuf object.""" - return MatrixData( - rows=pb2_obj.rows, - cols=pb2_obj.cols, - data=np.array(pb2_obj.packed_data), - layout=Layout(pb2_obj.layout)) - - def __eq__(self, other: Any) -> bool: - """Checks if this object is equal to the given object. - - Args: - other: The object to be compared with. - - Returns: - True if the objects are equal. - """ - if not isinstance(other, MatrixData): - return False - - return self.to_pb2().__eq__(other.to_pb2()) diff --git a/mediapipe/tasks/python/test/vision/BUILD b/mediapipe/tasks/python/test/vision/BUILD index fcff54d83..978dc1277 100644 --- a/mediapipe/tasks/python/test/vision/BUILD +++ b/mediapipe/tasks/python/test/vision/BUILD @@ -153,7 +153,6 @@ py_test( "//mediapipe/tasks/python/components/containers:category", "//mediapipe/tasks/python/components/containers:landmark", "//mediapipe/tasks/python/components/containers:rect", - "//mediapipe/tasks/python/components/containers:matrix_data", "//mediapipe/tasks/python/core:base_options", "//mediapipe/tasks/python/test:test_utils", "//mediapipe/tasks/python/vision:face_landmarker", diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py index a6b6e02f6..34d1e0b00 100644 --- a/mediapipe/tasks/python/test/vision/face_landmarker_test.py +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -26,7 +26,6 @@ from mediapipe.framework.formats import classification_pb2 from mediapipe.python._framework_bindings import image as image_module from mediapipe.tasks.python.components.containers import category as category_module from mediapipe.tasks.python.components.containers import landmark as landmark_module -from mediapipe.tasks.python.components.containers import matrix_data as matrix_data_module from mediapipe.tasks.python.components.containers import rect as rect_module from mediapipe.tasks.python.core import base_options as base_options_module from mediapipe.tasks.python.test import test_utils @@ -39,7 +38,6 @@ _BaseOptions = base_options_module.BaseOptions _Category = category_module.Category _Rect = rect_module.Rect _Landmark = landmark_module.Landmark -_MatrixData = matrix_data_module.MatrixData _NormalizedLandmark = landmark_module.NormalizedLandmark _Image = image_module.Image _FaceLandmarker = face_landmarker.FaceLandmarker @@ -90,14 +88,12 @@ def _get_expected_face_blendshapes(file_path: str): def _make_expected_facial_transformation_matrixes(): - data = np.array([[0.9995292, -0.005092691, 0.030254554, -0.37340546], + matrix = np.array([[0.9995292, -0.005092691, 0.030254554, -0.37340546], [0.0072318087, 0.99744856, -0.07102106, 22.212194], [-0.029815676, 0.07120642, 0.9970159, -64.76358], [0, 0, 0, 1]]) - rows, cols = len(data), len(data[0]) facial_transformation_matrixes_results = [] - facial_transformation_matrix = _MatrixData(rows, cols, data.flatten()) - facial_transformation_matrixes_results.append(facial_transformation_matrix) + facial_transformation_matrixes_results.append(matrix) return facial_transformation_matrixes_results @@ -111,9 +107,9 @@ class FaceLandmarkerTest(parameterized.TestCase): def setUp(self): super().setUp() self.test_image = _Image.create_from_file( - test_utils.get_test_data_path(_PORTRAIT_IMAGE)) + test_utils.get_test_data_path(_PORTRAIT_IMAGE)) self.model_path = test_utils.get_test_data_path( - _FACE_LANDMARKER_BUNDLE_ASSET_FILE) + _FACE_LANDMARKER_BUNDLE_ASSET_FILE) def _expect_landmarks_correct(self, actual_landmarks, expected_landmarks): # Expects to have the same number of faces detected. @@ -145,11 +141,13 @@ class FaceLandmarkerTest(parameterized.TestCase): self.assertLen(actual_matrix_list, len(expected_matrix_list)) for i, rename_me in enumerate(actual_matrix_list): - self.assertEqual(rename_me.rows, expected_matrix_list[i].rows) - self.assertEqual(rename_me.cols, expected_matrix_list[i].cols) + self.assertEqual(rename_me.shape[0], + expected_matrix_list[i].shape[0]) + self.assertEqual(rename_me.shape[1], + expected_matrix_list[i].shape[1]) self.assertAlmostEqual( - rename_me.data.all(), - expected_matrix_list[i].data.all(), + rename_me.all(), + expected_matrix_list[i].all(), delta=_FACIAL_TRANSFORMATION_MATRIX_DIFF_MARGIN) def test_create_from_file_succeeds_with_valid_model_path(self): @@ -169,7 +167,7 @@ class FaceLandmarkerTest(parameterized.TestCase): with self.assertRaisesRegex( RuntimeError, 'Unable to open file at /path/to/invalid/model.tflite'): base_options = _BaseOptions( - model_asset_path='/path/to/invalid/model.tflite') + model_asset_path='/path/to/invalid/model.tflite') options = _FaceLandmarkerOptions(base_options=base_options) _FaceLandmarker.create_from_options(options) @@ -182,46 +180,46 @@ class FaceLandmarkerTest(parameterized.TestCase): self.assertIsInstance(landmarker, _FaceLandmarker) @parameterized.parameters( - (ModelFileType.FILE_NAME, _FACE_LANDMARKER_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), - (ModelFileType.FILE_CONTENT, _FACE_LANDMARKER_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), - (ModelFileType.FILE_NAME, - _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), - (ModelFileType.FILE_CONTENT, - _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), - (ModelFileType.FILE_NAME, - _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - _get_expected_face_blendshapes( - _PORTRAIT_EXPECTED_BLENDSHAPES), None), - (ModelFileType.FILE_CONTENT, - _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - _get_expected_face_blendshapes( - _PORTRAIT_EXPECTED_BLENDSHAPES), None), - (ModelFileType.FILE_NAME, - _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - _get_expected_face_blendshapes( - _PORTRAIT_EXPECTED_BLENDSHAPES), - _make_expected_facial_transformation_matrixes()), - (ModelFileType.FILE_CONTENT, - _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - _get_expected_face_blendshapes( - _PORTRAIT_EXPECTED_BLENDSHAPES), - _make_expected_facial_transformation_matrixes())) + (ModelFileType.FILE_NAME, _FACE_LANDMARKER_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), + (ModelFileType.FILE_CONTENT, _FACE_LANDMARKER_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), + (ModelFileType.FILE_NAME, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), + (ModelFileType.FILE_CONTENT, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), + (ModelFileType.FILE_NAME, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), None), + (ModelFileType.FILE_CONTENT, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), None), + (ModelFileType.FILE_NAME, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), + _make_expected_facial_transformation_matrixes()), + (ModelFileType.FILE_CONTENT, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), + _make_expected_facial_transformation_matrixes())) def test_detect( self, model_file_type, model_name, expected_face_landmarks, expected_face_blendshapes, expected_facial_transformation_matrixes): @@ -238,10 +236,10 @@ class FaceLandmarkerTest(parameterized.TestCase): raise ValueError('model_file_type is invalid.') options = _FaceLandmarkerOptions( - base_options=base_options, - output_face_blendshapes=True if expected_face_blendshapes else False, - output_facial_transformation_matrixes=True - if expected_facial_transformation_matrixes else False) + base_options=base_options, + output_face_blendshapes=True if expected_face_blendshapes else False, + output_facial_transformation_matrixes=True + if expected_facial_transformation_matrixes else False) landmarker = _FaceLandmarker.create_from_options(options) # Performs face landmarks detection on the input. @@ -255,8 +253,8 @@ class FaceLandmarkerTest(parameterized.TestCase): expected_face_blendshapes) if expected_facial_transformation_matrixes is not None: self._expect_facial_transformation_matrix_correct( - detection_result.facial_transformation_matrixes, - expected_facial_transformation_matrixes) + detection_result.facial_transformation_matrixes, + expected_facial_transformation_matrixes) # Closes the face landmarker explicitly when the face landmarker is not used # in a context. @@ -342,7 +340,7 @@ class FaceLandmarkerTest(parameterized.TestCase): def test_detect_succeeds_with_num_faces(self): # Creates face landmarker. model_path = test_utils.get_test_data_path( - _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE) + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE) base_options = _BaseOptions(model_asset_path=model_path) options = _FaceLandmarkerOptions(base_options=base_options, num_faces=1, output_face_blendshapes=True) @@ -436,7 +434,7 @@ class FaceLandmarkerTest(parameterized.TestCase): @parameterized.parameters( (_FACE_LANDMARKER_BUNDLE_ASSET_FILE, _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), + _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), (_FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, _get_expected_face_landmarks( _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), diff --git a/mediapipe/tasks/python/vision/BUILD b/mediapipe/tasks/python/vision/BUILD index 83763c1ae..ae02e2775 100644 --- a/mediapipe/tasks/python/vision/BUILD +++ b/mediapipe/tasks/python/vision/BUILD @@ -189,7 +189,6 @@ py_library( "//mediapipe/tasks/cc/vision/face_geometry/proto:face_geometry_py_pb2", "//mediapipe/tasks/python/components/containers:category", "//mediapipe/tasks/python/components/containers:landmark", - "//mediapipe/tasks/python/components/containers:matrix_data", "//mediapipe/tasks/python/core:base_options", "//mediapipe/tasks/python/core:optional_dependencies", "//mediapipe/tasks/python/core:task_info", diff --git a/mediapipe/tasks/python/vision/face_landmarker.py b/mediapipe/tasks/python/vision/face_landmarker.py index 6862818ce..7d53b8208 100644 --- a/mediapipe/tasks/python/vision/face_landmarker.py +++ b/mediapipe/tasks/python/vision/face_landmarker.py @@ -17,6 +17,7 @@ import dataclasses import enum from typing import Callable, Mapping, Optional, List +import numpy as np from mediapipe.framework.formats import classification_pb2 from mediapipe.framework.formats import landmark_pb2 from mediapipe.framework.formats import matrix_data_pb2 @@ -29,7 +30,6 @@ from mediapipe.tasks.cc.vision.face_landmarker.proto import face_landmarker_grap from mediapipe.tasks.cc.vision.face_geometry.proto import face_geometry_pb2 from mediapipe.tasks.python.components.containers import category as category_module from mediapipe.tasks.python.components.containers import landmark as landmark_module -from mediapipe.tasks.python.components.containers import matrix_data as matrix_data_module from mediapipe.tasks.python.core import base_options as base_options_module from mediapipe.tasks.python.core import task_info as task_info_module from mediapipe.tasks.python.core.optional_dependencies import doc_controls @@ -39,6 +39,7 @@ from mediapipe.tasks.python.vision.core import vision_task_running_mode as runni _BaseOptions = base_options_module.BaseOptions _FaceLandmarkerGraphOptionsProto = face_landmarker_graph_options_pb2.FaceLandmarkerGraphOptions +_LayoutEnum = matrix_data_pb2.MatrixData.Layout _RunningMode = running_mode_module.VisionTaskRunningMode _ImageProcessingOptions = image_processing_options_module.ImageProcessingOptions _TaskInfo = task_info_module.TaskInfo @@ -126,7 +127,7 @@ class FaceLandmarkerResult: face_landmarks: List[List[landmark_module.NormalizedLandmark]] face_blendshapes: List[List[category_module.Category]] - facial_transformation_matrixes: List[matrix_data_module.MatrixData] + facial_transformation_matrixes: List[np.ndarray] def _build_landmarker_result( @@ -170,7 +171,9 @@ def _build_landmarker_result( if proto.pose_transform_matrix: matrix_data = matrix_data_pb2.MatrixData() matrix_data.MergeFrom(proto.pose_transform_matrix) - matrix = matrix_data_module.MatrixData.create_from_pb2(matrix_data) + order = 'C' if matrix_data.layout == _LayoutEnum.ROW_MAJOR else 'F' + data = np.array(matrix_data.packed_data, order=order) + matrix = data.reshape((matrix_data.rows, matrix_data.cols)) facial_transformation_matrixes_results.append(matrix) return FaceLandmarkerResult(face_landmarks_results, face_blendshapes_results, From b36b0bb3e84298c66d5d0578b5965c6b6607bfc3 Mon Sep 17 00:00:00 2001 From: kinaryml Date: Thu, 16 Mar 2023 12:51:19 -0700 Subject: [PATCH 103/136] Updated API and tests --- .../test/vision/face_landmarker_test.py | 28 ++++++------------- .../tasks/python/vision/face_landmarker.py | 6 ++-- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py index 34d1e0b00..0a1d87051 100644 --- a/mediapipe/tasks/python/test/vision/face_landmarker_test.py +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -89,9 +89,9 @@ def _get_expected_face_blendshapes(file_path: str): def _make_expected_facial_transformation_matrixes(): matrix = np.array([[0.9995292, -0.005092691, 0.030254554, -0.37340546], - [0.0072318087, 0.99744856, -0.07102106, 22.212194], - [-0.029815676, 0.07120642, 0.9970159, -64.76358], - [0, 0, 0, 1]]) + [0.0072318087, 0.99744856, -0.07102106, 22.212194], + [-0.029815676, 0.07120642, 0.9970159, -64.76358], + [0, 0, 0, 1]]) facial_transformation_matrixes_results = [] facial_transformation_matrixes_results.append(matrix) return facial_transformation_matrixes_results @@ -145,10 +145,10 @@ class FaceLandmarkerTest(parameterized.TestCase): expected_matrix_list[i].shape[0]) self.assertEqual(rename_me.shape[1], expected_matrix_list[i].shape[1]) - self.assertAlmostEqual( - rename_me.all(), - expected_matrix_list[i].all(), - delta=_FACIAL_TRANSFORMATION_MATRIX_DIFF_MARGIN) + self.assertSequenceAlmostEqual( + rename_me.flatten(), + expected_matrix_list[i].flatten(), + delta=_FACIAL_TRANSFORMATION_MATRIX_DIFF_MARGIN) def test_create_from_file_succeeds_with_valid_model_path(self): # Creates with default option and valid model file successfully. @@ -441,12 +441,7 @@ class FaceLandmarkerTest(parameterized.TestCase): (_FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, _get_expected_face_landmarks( _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), None), - (_FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), - _make_expected_facial_transformation_matrixes())) + _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), None)) def test_detect_for_video( self, model_name, expected_face_landmarks, expected_face_blendshapes, expected_facial_transformation_matrixes): @@ -519,12 +514,7 @@ class FaceLandmarkerTest(parameterized.TestCase): (_PORTRAIT_IMAGE, _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, _get_expected_face_landmarks( _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), None), - (_PORTRAIT_IMAGE, _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, - _get_expected_face_landmarks( - _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), - _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), - _make_expected_facial_transformation_matrixes())) + _get_expected_face_blendshapes(_PORTRAIT_EXPECTED_BLENDSHAPES), None)) def test_detect_async_calls( self, image_path, model_name, expected_face_landmarks, expected_face_blendshapes, expected_facial_transformation_matrixes): diff --git a/mediapipe/tasks/python/vision/face_landmarker.py b/mediapipe/tasks/python/vision/face_landmarker.py index 7d53b8208..b8854d294 100644 --- a/mediapipe/tasks/python/vision/face_landmarker.py +++ b/mediapipe/tasks/python/vision/face_landmarker.py @@ -171,9 +171,9 @@ def _build_landmarker_result( if proto.pose_transform_matrix: matrix_data = matrix_data_pb2.MatrixData() matrix_data.MergeFrom(proto.pose_transform_matrix) - order = 'C' if matrix_data.layout == _LayoutEnum.ROW_MAJOR else 'F' - data = np.array(matrix_data.packed_data, order=order) - matrix = data.reshape((matrix_data.rows, matrix_data.cols)) + matrix = np.array(matrix_data.packed_data) + matrix = matrix.reshape((matrix_data.rows, matrix_data.cols)) + matrix = matrix if matrix_data.layout == _LayoutEnum.ROW_MAJOR else matrix.T facial_transformation_matrixes_results.append(matrix) return FaceLandmarkerResult(face_landmarks_results, face_blendshapes_results, From 94dba82284710fa567002c1038fae95622fa92c1 Mon Sep 17 00:00:00 2001 From: kinaryml Date: Thu, 16 Mar 2023 12:54:38 -0700 Subject: [PATCH 104/136] Renamed a test method to use the plural form --- .../tasks/python/test/vision/face_landmarker_test.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py index 0a1d87051..de6dc82a7 100644 --- a/mediapipe/tasks/python/test/vision/face_landmarker_test.py +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -136,8 +136,8 @@ class FaceLandmarkerTest(parameterized.TestCase): expected_blendshapes[i].score, delta=_BLENDSHAPES_DIFF_MARGIN) - def _expect_facial_transformation_matrix_correct(self, actual_matrix_list, - expected_matrix_list): + def _expect_facial_transformation_matrixes_correct(self, actual_matrix_list, + expected_matrix_list): self.assertLen(actual_matrix_list, len(expected_matrix_list)) for i, rename_me in enumerate(actual_matrix_list): @@ -252,7 +252,7 @@ class FaceLandmarkerTest(parameterized.TestCase): self._expect_blendshapes_correct(detection_result.face_blendshapes[0], expected_face_blendshapes) if expected_facial_transformation_matrixes is not None: - self._expect_facial_transformation_matrix_correct( + self._expect_facial_transformation_matrixes_correct( detection_result.facial_transformation_matrixes, expected_facial_transformation_matrixes) @@ -333,7 +333,7 @@ class FaceLandmarkerTest(parameterized.TestCase): self._expect_blendshapes_correct(detection_result.face_blendshapes[0], expected_face_blendshapes) if expected_facial_transformation_matrixes is not None: - self._expect_facial_transformation_matrix_correct( + self._expect_facial_transformation_matrixes_correct( detection_result.facial_transformation_matrixes, expected_facial_transformation_matrixes) @@ -469,7 +469,7 @@ class FaceLandmarkerTest(parameterized.TestCase): self._expect_blendshapes_correct(detection_result.face_blendshapes[0], expected_face_blendshapes) if expected_facial_transformation_matrixes is not None: - self._expect_facial_transformation_matrix_correct( + self._expect_facial_transformation_matrixes_correct( detection_result.facial_transformation_matrixes, expected_facial_transformation_matrixes) @@ -532,7 +532,7 @@ class FaceLandmarkerTest(parameterized.TestCase): self._expect_blendshapes_correct(result.face_blendshapes[0], expected_face_blendshapes) if expected_facial_transformation_matrixes is not None: - self._expect_facial_transformation_matrix_correct( + self._expect_facial_transformation_matrixes_correct( result.facial_transformation_matrixes, expected_facial_transformation_matrixes) self.assertTrue( From 560945ad3963e4e2f52d51e2f9c5cb4e9662c3c0 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 16 Mar 2023 13:55:55 -0700 Subject: [PATCH 105/136] Internal Changes PiperOrigin-RevId: 517219631 --- .../gesture_recognizer/gesture_recognizer_test.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/mediapipe/model_maker/python/vision/gesture_recognizer/gesture_recognizer_test.py b/mediapipe/model_maker/python/vision/gesture_recognizer/gesture_recognizer_test.py index ce167df93..ad2f211f5 100644 --- a/mediapipe/model_maker/python/vision/gesture_recognizer/gesture_recognizer_test.py +++ b/mediapipe/model_maker/python/vision/gesture_recognizer/gesture_recognizer_test.py @@ -15,6 +15,7 @@ import io import os import tempfile +import unittest from unittest import mock as unittest_mock import zipfile @@ -31,6 +32,7 @@ _TEST_DATA_DIR = 'mediapipe/model_maker/python/vision/gesture_recognizer/testdat tf.keras.backend.experimental.enable_tf_random_generator() +@unittest.skip('b/273818271') class GestureRecognizerTest(tf.test.TestCase): def _load_data(self): @@ -72,8 +74,10 @@ class GestureRecognizerTest(tf.test.TestCase): self._test_accuracy(model) + @unittest.skip('b/273818271') @unittest_mock.patch.object( - tf.keras.layers, 'Dense', wraps=tf.keras.layers.Dense) + tf.keras.layers, 'Dense', wraps=tf.keras.layers.Dense + ) def test_gesture_recognizer_model_layer_widths(self, mock_dense): layer_widths = [64, 32] mo = gesture_recognizer.ModelOptions(layer_widths=layer_widths) @@ -143,12 +147,14 @@ class GestureRecognizerTest(tf.test.TestCase): hyperparameters, 'HParams', autospec=True, - return_value=gesture_recognizer.HParams(epochs=1)) + return_value=gesture_recognizer.HParams(epochs=1), + ) @unittest_mock.patch.object( model_options, 'GestureRecognizerModelOptions', autospec=True, - return_value=gesture_recognizer.ModelOptions()) + return_value=gesture_recognizer.ModelOptions(), + ) def test_create_hparams_and_model_options_if_none_in_gesture_recognizer_options( self, mock_hparams, mock_model_options): options = gesture_recognizer.GestureRecognizerOptions() From 75bc44d42a241d02b9d5f6561717b33e3d1c6762 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Thu, 16 Mar 2023 14:16:47 -0700 Subject: [PATCH 106/136] Add a comment to macos_opencv so it can be easily replaced with sed PiperOrigin-RevId: 517225588 --- WORKSPACE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WORKSPACE b/WORKSPACE index ea12f439e..17e96c0b2 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -270,7 +270,7 @@ new_local_repository( # For local MacOS builds, the path should point to an opencv@3 installation. # If you edit the path here, you will also need to update the corresponding # prefix in "opencv_macos.BUILD". - path = "/usr/local", + path = "/usr/local", # e.g. /usr/local/Cellar for HomeBrew ) new_local_repository( From 1ba285d9164036e60031ed167d69565fd8cf8446 Mon Sep 17 00:00:00 2001 From: kinaryml Date: Thu, 16 Mar 2023 15:10:39 -0700 Subject: [PATCH 107/136] Updated a method name in face_landmarker_test.py --- .../tasks/python/test/vision/face_landmarker_test.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py index de6dc82a7..d156da04e 100644 --- a/mediapipe/tasks/python/test/vision/face_landmarker_test.py +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -87,7 +87,7 @@ def _get_expected_face_blendshapes(file_path: str): return face_blendshapes_categories -def _make_expected_facial_transformation_matrixes(): +def _get_expected_facial_transformation_matrixes(): matrix = np.array([[0.9995292, -0.005092691, 0.030254554, -0.37340546], [0.0072318087, 0.99744856, -0.07102106, 22.212194], [-0.029815676, 0.07120642, 0.9970159, -64.76358], @@ -212,14 +212,14 @@ class FaceLandmarkerTest(parameterized.TestCase): _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), _get_expected_face_blendshapes( _PORTRAIT_EXPECTED_BLENDSHAPES), - _make_expected_facial_transformation_matrixes()), + _get_expected_facial_transformation_matrixes()), (ModelFileType.FILE_CONTENT, _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, _get_expected_face_landmarks( _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), _get_expected_face_blendshapes( _PORTRAIT_EXPECTED_BLENDSHAPES), - _make_expected_facial_transformation_matrixes())) + _get_expected_facial_transformation_matrixes())) def test_detect( self, model_file_type, model_name, expected_face_landmarks, expected_face_blendshapes, expected_facial_transformation_matrixes): @@ -293,14 +293,14 @@ class FaceLandmarkerTest(parameterized.TestCase): _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), _get_expected_face_blendshapes( _PORTRAIT_EXPECTED_BLENDSHAPES), - _make_expected_facial_transformation_matrixes()), + _get_expected_facial_transformation_matrixes()), (ModelFileType.FILE_CONTENT, _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, _get_expected_face_landmarks( _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), _get_expected_face_blendshapes( _PORTRAIT_EXPECTED_BLENDSHAPES), - _make_expected_facial_transformation_matrixes())) + _get_expected_facial_transformation_matrixes())) def test_detect_in_context( self, model_file_type, model_name, expected_face_landmarks, expected_face_blendshapes, expected_facial_transformation_matrixes): From 24217e2ead56061361875b0f2511a9a108348f1e Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Thu, 16 Mar 2023 22:47:34 -0700 Subject: [PATCH 108/136] Internal change PiperOrigin-RevId: 517322105 --- docs/solutions/object_detection.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/solutions/object_detection.md b/docs/solutions/object_detection.md index eea17d3cb..ef7db8671 100644 --- a/docs/solutions/object_detection.md +++ b/docs/solutions/object_detection.md @@ -118,9 +118,9 @@ on how to build MediaPipe examples. * With a TensorFlow Model This uses the - [TensorFlow model](https://github.com/google/mediapipe/tree/v0.8.10/mediapipe/models/object_detection_saved_model) + [TensorFlow model](https://storage.googleapis.com/mediapipe-assets/object_detection_saved_model/archive.zip) ( see also - [model info](https://github.com/google/mediapipe/tree/master/mediapipe/g3doc/solutions/object_detection_saved_model.md)), + [model info](https://storage.googleapis.com/mediapipe-assets/object_detection_saved_model/README.md)), and the pipeline is implemented in this [graph](https://github.com/google/mediapipe/tree/master/mediapipe/graphs/object_detection/object_detection_mobile_cpu.pbtxt). From 1a456dcbf906d80d97e7151a81e53ab03bbd1da9 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Fri, 17 Mar 2023 11:38:08 -0700 Subject: [PATCH 109/136] Internal change PiperOrigin-RevId: 517466009 --- mediapipe/framework/tool/mediapipe_proto.bzl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/framework/tool/mediapipe_proto.bzl b/mediapipe/framework/tool/mediapipe_proto.bzl index 4ce4b5cc1..01888c0d0 100644 --- a/mediapipe/framework/tool/mediapipe_proto.bzl +++ b/mediapipe/framework/tool/mediapipe_proto.bzl @@ -284,7 +284,7 @@ def mediapipe_proto_library( def_jspb_proto: define the jspb_proto_library target def_go_proto: define the go_proto_library target def_options_lib: define the mediapipe_options_library target - def_rewrite: define a sibbling mediapipe_proto_library with package "mediapipe" + def_rewrite: define a sibling mediapipe_proto_library with package "mediapipe" """ mediapipe_proto_library_impl( From 3dede1a9a532873a4ea593f9ac6b605b36dea73d Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Fri, 17 Mar 2023 14:51:42 -0700 Subject: [PATCH 110/136] Add label_map filtering into filter_detection drishti calculator. PiperOrigin-RevId: 517515046 --- .../filter_detection_calculator.cc | 19 +++++- .../filter_detection_calculator_test.cc | 63 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/mediapipe/modules/objectron/calculators/filter_detection_calculator.cc b/mediapipe/modules/objectron/calculators/filter_detection_calculator.cc index db0f27484..29f4c79d2 100644 --- a/mediapipe/modules/objectron/calculators/filter_detection_calculator.cc +++ b/mediapipe/modules/objectron/calculators/filter_detection_calculator.cc @@ -37,6 +37,7 @@ constexpr char kDetectionTag[] = "DETECTION"; constexpr char kDetectionsTag[] = "DETECTIONS"; constexpr char kLabelsTag[] = "LABELS"; constexpr char kLabelsCsvTag[] = "LABELS_CSV"; +constexpr char kLabelMapTag[] = "LABEL_MAP"; using mediapipe::RE2; using Detections = std::vector; @@ -151,6 +152,11 @@ absl::Status FilterDetectionCalculator::GetContract(CalculatorContract* cc) { if (cc->InputSidePackets().HasTag(kLabelsCsvTag)) { cc->InputSidePackets().Tag(kLabelsCsvTag).Set(); } + if (cc->InputSidePackets().HasTag(kLabelMapTag)) { + cc->InputSidePackets() + .Tag(kLabelMapTag) + .Set>>(); + } return absl::OkStatus(); } @@ -158,7 +164,8 @@ absl::Status FilterDetectionCalculator::Open(CalculatorContext* cc) { cc->SetOffset(TimestampDiff(0)); options_ = cc->Options(); limit_labels_ = cc->InputSidePackets().HasTag(kLabelsTag) || - cc->InputSidePackets().HasTag(kLabelsCsvTag); + cc->InputSidePackets().HasTag(kLabelsCsvTag) || + cc->InputSidePackets().HasTag(kLabelMapTag); if (limit_labels_) { Strings allowlist_labels; if (cc->InputSidePackets().HasTag(kLabelsCsvTag)) { @@ -168,8 +175,16 @@ absl::Status FilterDetectionCalculator::Open(CalculatorContext* cc) { for (auto& e : allowlist_labels) { absl::StripAsciiWhitespace(&e); } - } else { + } else if (cc->InputSidePackets().HasTag(kLabelsTag)) { allowlist_labels = cc->InputSidePackets().Tag(kLabelsTag).Get(); + } else if (cc->InputSidePackets().HasTag(kLabelMapTag)) { + auto label_map = cc->InputSidePackets() + .Tag(kLabelMapTag) + .Get>>() + .get(); + for (const auto& [_, v] : *label_map) { + allowlist_labels.push_back(v); + } } allowed_labels_.insert(allowlist_labels.begin(), allowlist_labels.end()); } diff --git a/mediapipe/modules/objectron/calculators/filter_detection_calculator_test.cc b/mediapipe/modules/objectron/calculators/filter_detection_calculator_test.cc index 958fe4c54..10e750a49 100644 --- a/mediapipe/modules/objectron/calculators/filter_detection_calculator_test.cc +++ b/mediapipe/modules/objectron/calculators/filter_detection_calculator_test.cc @@ -67,5 +67,68 @@ TEST(FilterDetectionCalculatorTest, DetectionFilterTest) { )); } +TEST(FilterDetectionCalculatorTest, DetectionFilterLabelMapTest) { + auto runner = std::make_unique( + ParseTextProtoOrDie(R"pb( + calculator: "FilterDetectionCalculator" + input_stream: "DETECTION:input" + input_side_packet: "LABEL_MAP:input_map" + output_stream: "DETECTION:output" + options { + [mediapipe.FilterDetectionCalculatorOptions.ext]: { min_score: 0.6 } + } + )pb")); + + runner->MutableInputs()->Tag("DETECTION").packets = { + MakePacket(ParseTextProtoOrDie(R"pb( + label: "a" + label: "b" + label: "c" + label: "d" + score: 1 + score: 0.8 + score: 0.3 + score: 0.9 + )pb")) + .At(Timestamp(20)), + MakePacket(ParseTextProtoOrDie(R"pb( + label: "a" + label: "b" + label: "c" + label: "e" + score: 0.6 + score: 0.4 + score: 0.2 + score: 0.7 + )pb")) + .At(Timestamp(40)), + }; + + auto label_map = std::make_unique>(); + (*label_map)[0] = "a"; + (*label_map)[1] = "b"; + (*label_map)[2] = "c"; + runner->MutableSidePackets()->Tag("LABEL_MAP") = + AdoptAsUniquePtr(label_map.release()); + + // Run graph. + MP_ASSERT_OK(runner->Run()); + + // Check output. + EXPECT_THAT( + runner->Outputs().Tag("DETECTION").packets, + ElementsAre(PacketContainsTimestampAndPayload( + Eq(Timestamp(20)), + EqualsProto(R"pb( + label: "a" label: "b" score: 1 score: 0.8 + )pb")), // Packet 1 at timestamp 20. + PacketContainsTimestampAndPayload( + Eq(Timestamp(40)), + EqualsProto(R"pb( + label: "a" score: 0.6 + )pb")) // Packet 2 at timestamp 40. + )); +} + } // namespace } // namespace mediapipe From 0805d61bfefe8bdff02edf36d6d9a45540bf300c Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Fri, 17 Mar 2023 14:59:01 -0700 Subject: [PATCH 111/136] Add the source code TensorsToSegmentationCalculatorOptionsProto.java into tasks core's maven package. PiperOrigin-RevId: 517516701 --- .../com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl index d8a237e8d..2fa0e5e6d 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl @@ -41,7 +41,6 @@ _VISION_TASKS_JAVA_PROTO_LITE_TARGETS = [ "//mediapipe/tasks/cc/vision/gesture_recognizer/proto:hand_gesture_recognizer_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_classifier/proto:image_classifier_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_embedder/proto:image_embedder_graph_options_java_proto_lite", - "//mediapipe/tasks/cc/vision/image_segmenter/calculators:tensors_to_segmentation_calculator_java_proto_lite", "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_java_proto_lite", "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_java_proto_lite", "//mediapipe/tasks/cc/vision/hand_detector/proto:hand_detector_graph_options_java_proto_lite", @@ -106,6 +105,11 @@ def mediapipe_tasks_core_aar(name, srcs, manifest): src_out = "com/google/mediapipe/calculator/proto/InferenceCalculatorProto.java", )) + mediapipe_tasks_java_proto_srcs.append(mediapipe_java_proto_src_extractor( + target = "//mediapipe/tasks/cc/vision/image_segmenter/calculators:tensors_to_segmentation_calculator_java_proto_lite", + src_out = "com/google/mediapipe/tasks/TensorsToSegmentationCalculatorOptionsProto.java", + )) + android_library( name = name, srcs = srcs + [ @@ -138,6 +142,7 @@ def mediapipe_tasks_core_aar(name, srcs, manifest): "//mediapipe/framework/formats:rect_java_proto_lite", "//mediapipe/java/com/google/mediapipe/framework:android_framework", "//mediapipe/java/com/google/mediapipe/framework/image", + "//mediapipe/tasks/cc/vision/image_segmenter/calculators:tensors_to_segmentation_calculator_java_proto_lite", "//mediapipe/tasks/java/com/google/mediapipe/tasks/core/jni:model_resources_cache_jni", "//third_party:androidx_annotation", "//third_party:autovalue", From 065f1f38aa3363f512cfb47f8e03de92855fe263 Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Fri, 17 Mar 2023 17:00:22 -0700 Subject: [PATCH 112/136] Fix the vision tasks aar build rule to solve the "cannot find symbol" error: ``` mediapipe/tasks/java/com/google/mediapipe/tasks/vision/imagesegmenter/ImageSegmenter.java:28: error: cannot find symbol import com.google.mediapipe.tasks.TensorsToSegmentationCalculatorOptionsProto; ``` PiperOrigin-RevId: 517542284 --- .../java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl index 2fa0e5e6d..030c7cfc9 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl @@ -228,7 +228,9 @@ EOF name = name, srcs = srcs, manifest = "AndroidManifest.xml", - java_proto_lite_targets = _CORE_TASKS_JAVA_PROTO_LITE_TARGETS + _VISION_TASKS_JAVA_PROTO_LITE_TARGETS, + java_proto_lite_targets = _CORE_TASKS_JAVA_PROTO_LITE_TARGETS + _VISION_TASKS_JAVA_PROTO_LITE_TARGETS + [ + "//mediapipe/tasks/cc/vision/image_segmenter/calculators:tensors_to_segmentation_calculator_java_proto_lite", + ], native_library = native_library, ) From 6785bcc47da80478f73dcea1cfa36ba2e6ace687 Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Fri, 17 Mar 2023 19:43:08 -0700 Subject: [PATCH 113/136] Add `java_package` and `java_outer_classname` to label_map.proto. PiperOrigin-RevId: 517563000 --- mediapipe/util/label_map.proto | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mediapipe/util/label_map.proto b/mediapipe/util/label_map.proto index 79301d2b6..5c33269f5 100644 --- a/mediapipe/util/label_map.proto +++ b/mediapipe/util/label_map.proto @@ -16,6 +16,9 @@ syntax = "proto2"; package mediapipe; +option java_package = "com.google.mediapipe.util.proto"; +option java_outer_classname = "LabelMapProto"; + // Mapping a numerical class index output to a Knowledge Graph entity // ID or any other string label representing this class. Optionally it is // possible to specify an additional display name (in a given language) which is From 6634d221610cdacf4a75513e273e5c95a3b1861f Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Fri, 17 Mar 2023 19:44:51 -0700 Subject: [PATCH 114/136] Add LabelMapProto.java source code to MediaPipe AAR. PiperOrigin-RevId: 517563190 --- mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl b/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl index 645e8b722..4eb7175b7 100644 --- a/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl +++ b/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl @@ -357,6 +357,12 @@ def mediapipe_java_proto_srcs(name = ""): target = "//mediapipe/framework/formats:rect_java_proto_lite", src_out = "com/google/mediapipe/formats/proto/RectProto.java", )) + + proto_src_list.append(mediapipe_java_proto_src_extractor( + target = "//mediapipe/util:label_map_java_proto_lite", + src_out = "com/google/mediapipe/util/proto/LabelMapProto.java", + )) + return proto_src_list def mediapipe_logging_java_proto_srcs(name = ""): From 2e5ea174fe0961f71e4286f854987ecc4cbc2750 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Sat, 18 Mar 2023 00:27:38 -0700 Subject: [PATCH 115/136] Internal change PiperOrigin-RevId: 517597924 --- docs/solutions/models.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/solutions/models.md b/docs/solutions/models.md index 1172f2cfc..c45aefa44 100644 --- a/docs/solutions/models.md +++ b/docs/solutions/models.md @@ -108,6 +108,8 @@ one over the other. * [TFLite model](https://storage.googleapis.com/mediapipe-assets/ssdlite_object_detection.tflite) * [TFLite model quantized for EdgeTPU/Coral](https://github.com/google/mediapipe/tree/master/mediapipe/examples/coral/models/object-detector-quantized_edgetpu.tflite) +* [TensorFlow model](https://storage.googleapis.com/mediapipe-assets/object_detection_saved_model/archive.zip) +* [Model information](https://storage.googleapis.com/mediapipe-assets/object_detection_saved_model/README.md) ### [Objectron](https://google.github.io/mediapipe/solutions/objectron) From f06e4224b8f4bffcd1184902ec884eb2223345f3 Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Sun, 19 Mar 2023 01:31:07 -0700 Subject: [PATCH 116/136] Allow ModelResourcesCalculator to use file_pointer_meta. PiperOrigin-RevId: 517742722 --- mediapipe/tasks/cc/core/model_resources_calculator.cc | 6 ++++-- mediapipe/tasks/cc/core/model_resources_calculator_test.cc | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/mediapipe/tasks/cc/core/model_resources_calculator.cc b/mediapipe/tasks/cc/core/model_resources_calculator.cc index 72a7b33a3..d5c8cd502 100644 --- a/mediapipe/tasks/cc/core/model_resources_calculator.cc +++ b/mediapipe/tasks/cc/core/model_resources_calculator.cc @@ -77,9 +77,11 @@ class ModelResourcesCalculator : public api2::Node { if (options.has_model_file()) { RET_CHECK(options.model_file().has_file_content() || options.model_file().has_file_descriptor_meta() || - options.model_file().has_file_name()) + options.model_file().has_file_name() || + options.model_file().has_file_pointer_meta()) << "'model_file' must specify at least one of " - "'file_content', 'file_descriptor_meta', or 'file_name'"; + "'file_content', 'file_descriptor_meta', 'file_name', or " + "'file_pointer_meta'"; } return absl::OkStatus(); } diff --git a/mediapipe/tasks/cc/core/model_resources_calculator_test.cc b/mediapipe/tasks/cc/core/model_resources_calculator_test.cc index 58659c77d..83134a8c7 100644 --- a/mediapipe/tasks/cc/core/model_resources_calculator_test.cc +++ b/mediapipe/tasks/cc/core/model_resources_calculator_test.cc @@ -179,9 +179,9 @@ TEST_F(ModelResourcesCalculatorTest, EmptyExternalFileProto) { auto status = graph.Initialize(graph_config); ASSERT_FALSE(status.ok()); EXPECT_THAT(status.message(), - testing::HasSubstr( - "'model_file' must specify at least one of " - "'file_content', 'file_descriptor_meta', or 'file_name'")); + testing::HasSubstr("'model_file' must specify at least one of " + "'file_content', 'file_descriptor_meta', " + "'file_name', or 'file_pointer_meta'")); } TEST_F(ModelResourcesCalculatorTest, GraphServiceNotAvailable) { From 524acaaaa73e73b4017a70945afbb117816b882d Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Sun, 19 Mar 2023 16:05:06 -0700 Subject: [PATCH 117/136] internal change PiperOrigin-RevId: 517823184 --- .../tensor/tensors_readback_calculator.proto | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 mediapipe/calculators/tensor/tensors_readback_calculator.proto diff --git a/mediapipe/calculators/tensor/tensors_readback_calculator.proto b/mediapipe/calculators/tensor/tensors_readback_calculator.proto new file mode 100644 index 000000000..3c6611f1f --- /dev/null +++ b/mediapipe/calculators/tensor/tensors_readback_calculator.proto @@ -0,0 +1,41 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The option proto for the TensorsReadbackCalculator. + +syntax = "proto2"; + +package mediapipe; + +import "mediapipe/framework/calculator.proto"; + +message TensorsReadbackCalculatorOptions { + extend mediapipe.CalculatorOptions { + optional TensorsReadbackCalculatorOptions ext = 514750372; + } + + // Expected shapes of the input tensors. + // The calculator uses these shape to build the GPU programs during + // initialization, and check the actual tensor shapes against the expected + // shapes during runtime. + // Batch size of the tensor is set to be 1. `TensorShape` here can be C, WC, + // or HWC. + // For example {dims: 1 dims: 2} represents a tensor with batch_size = 1, + // width = 1, and num_channels = 2. + message TensorShape { + repeated int32 dims = 1 [packed = true]; + } + // tensor_shape specifies the shape of each input tensors. + repeated TensorShape tensor_shape = 1; +} From 47fa1a9578c19b0fcb9b4bb0eb44618255307103 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Mon, 20 Mar 2023 00:23:46 -0700 Subject: [PATCH 118/136] Internal change PiperOrigin-RevId: 517886450 --- mediapipe/tasks/cc/core/model_task_graph.cc | 2 +- mediapipe/tasks/cc/core/model_task_graph.h | 4 ++-- mediapipe/tasks/cc/core/task_runner.cc | 2 +- mediapipe/tasks/cc/core/task_runner.h | 4 ++-- .../tasks/cc/metadata/metadata_version.cc | 2 +- .../metadata/tests/metadata_version_test.cc | 2 +- .../custom_ops/utils/utf/utf.h | 2 +- .../cc/vision/face_detector/face_detector.h | 2 +- .../face_geometry/libs/geometry_pipeline.cc | 4 ++-- .../face_geometry/proto/face_geometry.proto | 2 +- .../vision/face_landmarker/face_landmarker.h | 2 +- .../face_landmarker/face_landmarker_graph.cc | 6 +++--- .../face_landmarks_detector_graph.cc | 2 +- .../cc/vision/face_stylizer/face_stylizer.h | 4 ++-- .../pose_detector/pose_detector_graph.cc | 4 ++-- .../ios/common/utils/sources/MPPCommonUtils.h | 2 +- .../tasks/ios/core/sources/MPPTaskInfo.h | 2 +- .../tasks/ios/core/sources/MPPTaskRunner.h | 4 ++-- .../tasks/python/core/pybind/task_runner.cc | 4 ++-- mediapipe/tasks/python/metadata/metadata.py | 20 +++++++++---------- .../metadata_writers/image_segmenter.py | 2 +- .../audio_embedder/audio_embedder_test.ts | 2 +- .../gesture_recognizer_options.d.ts | 2 +- 23 files changed, 41 insertions(+), 41 deletions(-) diff --git a/mediapipe/tasks/cc/core/model_task_graph.cc b/mediapipe/tasks/cc/core/model_task_graph.cc index 0cb556ec2..653c6b9ff 100644 --- a/mediapipe/tasks/cc/core/model_task_graph.cc +++ b/mediapipe/tasks/cc/core/model_task_graph.cc @@ -138,7 +138,7 @@ class InferenceSubgraph : public Subgraph { delegate.mutable_tflite()->CopyFrom(acceleration.tflite()); break; case Acceleration::DELEGATE_NOT_SET: - // Deafult inference calculator setting. + // Default inference calculator setting. break; } return delegate; diff --git a/mediapipe/tasks/cc/core/model_task_graph.h b/mediapipe/tasks/cc/core/model_task_graph.h index 3068b2c46..aa864c9fc 100644 --- a/mediapipe/tasks/cc/core/model_task_graph.h +++ b/mediapipe/tasks/cc/core/model_task_graph.h @@ -124,10 +124,10 @@ class ModelTaskGraph : public Subgraph { // Inserts a mediapipe task inference subgraph into the provided // GraphBuilder. The returned node provides the following interfaces to the // the rest of the graph: - // - a tensor vector (std::vector) input stream with tag + // - a tensor vector (std::vector) input stream with tag // "TENSORS", representing the input tensors to be consumed by the // inference engine. - // - a tensor vector (std::vector) output stream with tag + // - a tensor vector (std::vector) output stream with tag // "TENSORS", representing the output tensors generated by the inference // engine. // - a MetadataExtractor output side packet with tag "METADATA_EXTRACTOR". diff --git a/mediapipe/tasks/cc/core/task_runner.cc b/mediapipe/tasks/cc/core/task_runner.cc index 9a87551e7..fc933d547 100644 --- a/mediapipe/tasks/cc/core/task_runner.cc +++ b/mediapipe/tasks/cc/core/task_runner.cc @@ -301,7 +301,7 @@ absl::Status TaskRunner::Close() { } is_running_ = false; MP_RETURN_IF_ERROR( - AddPayload(graph_.CloseAllInputStreams(), "Fail to close intput streams", + AddPayload(graph_.CloseAllInputStreams(), "Fail to close input streams", MediaPipeTasksStatus::kRunnerFailsToCloseError)); MP_RETURN_IF_ERROR(AddPayload( graph_.WaitUntilDone(), "Fail to shutdown the MediaPipe graph.", diff --git a/mediapipe/tasks/cc/core/task_runner.h b/mediapipe/tasks/cc/core/task_runner.h index 0d049c782..8123a45aa 100644 --- a/mediapipe/tasks/cc/core/task_runner.h +++ b/mediapipe/tasks/cc/core/task_runner.h @@ -65,7 +65,7 @@ class TaskRunner { // Creates the task runner with a CalculatorGraphConfig proto. // If a tflite op resolver object is provided, the task runner will take // it as the global op resolver for all models running within this task. - // The op resolver's owernship will be transferred into the pipeleine runner. + // The op resolver's ownership will be transferred into the pipeleine runner. // When a user-defined PacketsCallback is provided, clients must use the // asynchronous method, Send(), to provide the input packets. If the packets // callback is absent, clients must use the synchronous method, Process(), to @@ -84,7 +84,7 @@ class TaskRunner { // frames from a video file and an audio file. The call blocks the current // thread until a failure status or a successful result is returned. // If the input packets have no timestamp, an internal timestamp will be - // assigend per invocation. Otherwise, when the timestamp is set in the + // assigned per invocation. Otherwise, when the timestamp is set in the // input packets, the caller must ensure that the input packet timestamps are // greater than the timestamps of the previous invocation. This method is // thread-unsafe and it is the caller's responsibility to synchronize access diff --git a/mediapipe/tasks/cc/metadata/metadata_version.cc b/mediapipe/tasks/cc/metadata/metadata_version.cc index 923d3aa56..7e2414dd5 100644 --- a/mediapipe/tasks/cc/metadata/metadata_version.cc +++ b/mediapipe/tasks/cc/metadata/metadata_version.cc @@ -213,7 +213,7 @@ void UpdateMinimumVersionForTable(const tflite::Content* table, Version* min_version) { if (table == nullptr) return; - // Checks the ContenProperties field. + // Checks the ContentProperties field. if (table->content_properties_type() == ContentProperties_AudioProperties) { UpdateMinimumVersion( GetMemberVersion(SchemaMembers::kContentPropertiesAudioProperties), diff --git a/mediapipe/tasks/cc/metadata/tests/metadata_version_test.cc b/mediapipe/tasks/cc/metadata/tests/metadata_version_test.cc index 3085e6585..32ff51482 100644 --- a/mediapipe/tasks/cc/metadata/tests/metadata_version_test.cc +++ b/mediapipe/tasks/cc/metadata/tests/metadata_version_test.cc @@ -265,7 +265,7 @@ TEST(MetadataVersionTest, metadata_builder.add_subgraph_metadata(subgraphs); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), diff --git a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h index f3b14772e..24d9b9dbe 100644 --- a/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h +++ b/mediapipe/tasks/cc/text/language_detector/custom_ops/utils/utf/utf.h @@ -56,7 +56,7 @@ extern "C" { int utf_runetochar(char* s, const Rune* r); // utf_charntorune copies (decodes) at most UTFmax bytes starting at `str` to -// one rune, pointed to by `rune`, accesss at most `length` bytes of `str`, and +// one rune, pointed to by `rune`, access at most `length` bytes of `str`, and // returns the number of bytes consumed. // If the UTF sequence is incomplete within n bytes, // utf_charntorune will set *r to Runeerror and return 0. If it is complete diff --git a/mediapipe/tasks/cc/vision/face_detector/face_detector.h b/mediapipe/tasks/cc/vision/face_detector/face_detector.h index 78715528f..ae485819d 100644 --- a/mediapipe/tasks/cc/vision/face_detector/face_detector.h +++ b/mediapipe/tasks/cc/vision/face_detector/face_detector.h @@ -74,7 +74,7 @@ class FaceDetector : core::BaseVisionTaskApi { // three running modes: // 1) Image mode for detecting faces on single image inputs. Users // provide mediapipe::Image to the `Detect` method, and will receive the - // deteced face detection results as the return value. + // detected face detection results as the return value. // 2) Video mode for detecting faces on the decoded frames of a // video. Users call `DetectForVideo` method, and will receive the detected // face detection results as the return value. diff --git a/mediapipe/tasks/cc/vision/face_geometry/libs/geometry_pipeline.cc b/mediapipe/tasks/cc/vision/face_geometry/libs/geometry_pipeline.cc index c7ac7c634..061f35f51 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/libs/geometry_pipeline.cc +++ b/mediapipe/tasks/cc/vision/face_geometry/libs/geometry_pipeline.cc @@ -99,7 +99,7 @@ class ScreenToMetricSpaceConverter { // // (3) Use the canonical-to-runtime scale from (2) to unproject the screen // landmarks. The result is referenced as "intermediate landmarks" because - // they are the first estimation of the resuling metric landmarks, but are + // they are the first estimation of the resulting metric landmarks,but are // not quite there yet. // // (4) Estimate a canonical-to-runtime landmark set scale by running the @@ -347,7 +347,7 @@ class GeometryPipelineImpl : public GeometryPipeline { proto::Mesh3d* mutable_mesh = face_geometry.mutable_mesh(); // Copy the canonical face mesh as the face geometry mesh. mutable_mesh->CopyFrom(canonical_mesh_); - // Replace XYZ vertex mesh coodinates with the metric landmark positions. + // Replace XYZ vertex mesh coordinates with the metric landmark positions. for (int i = 0; i < canonical_mesh_num_vertices_; ++i) { uint32_t vertex_buffer_offset = canonical_mesh_vertex_size_ * i + canonical_mesh_vertex_position_offset_; diff --git a/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.proto b/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.proto index 45a02bbcf..149e10afd 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.proto +++ b/mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.proto @@ -28,7 +28,7 @@ message FaceGeometry { // the face landmark IDs. // // XYZ coordinates exist in the right-handed Metric 3D space configured by an - // environment. UV coodinates are taken from the canonical face mesh model. + // environment. UV coordinates are taken from the canonical face mesh model. // // XY coordinates are guaranteed to match the screen positions of // the input face landmarks after (1) being multiplied by the face pose diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h index 5a5c8404a..2c93fcba5 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker.h @@ -109,7 +109,7 @@ class FaceLandmarker : tasks::vision::core::BaseVisionTaskApi { // three running modes: // 1) Image mode for detecting face landmarks on single image inputs. Users // provide mediapipe::Image to the `Detect` method, and will receive the - // deteced face landmarks results as the return value. + // detected face landmarks results as the return value. // 2) Video mode for detecting face landmarks on the decoded frames of a // video. Users call `DetectForVideo` method, and will receive the detected // face landmarks results as the return value. diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc index 78927f27b..99d99466a 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_graph.cc @@ -160,7 +160,7 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, ->mutable_acceleration() ->mutable_xnnpack(); LOG(WARNING) << "Face blendshape model contains CPU only ops. Sets " - << "FaceBlendshapesGraph acceleartion to Xnnpack."; + << "FaceBlendshapesGraph acceleration to Xnnpack."; } return absl::OkStatus(); @@ -180,7 +180,7 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, // would be triggered to detect faces. // // FaceGeometryFromLandmarksGraph finds the transformation from canonical face -// to the detected faces. This transformation is useful for renderring face +// to the detected faces. This transformation is useful for rendering face // effects on the detected faces. This subgraph is added if users request a // FaceGeometry Tag. // @@ -324,7 +324,7 @@ class FaceLandmarkerGraph : public core::ModelTaskGraph { !sc->Service(::mediapipe::tasks::core::kModelResourcesCacheService) .IsAvailable())); if (output_geometry) { - // Set the face geometry metdata file for + // Set the face geometry metadata file for // FaceGeometryFromLandmarksGraph. ASSIGN_OR_RETURN(auto face_geometry_pipeline_metadata_file, model_asset_bundle_resources->GetFile( diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarks_detector_graph.cc b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarks_detector_graph.cc index a898f2fe9..df9cab5b5 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarks_detector_graph.cc +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarks_detector_graph.cc @@ -462,7 +462,7 @@ REGISTER_MEDIAPIPE_GRAPH( // - Accepts an input image and a vector of face rect RoIs to detect the // multiple face landmarks enclosed by the RoIs. Output vectors of // face landmarks related results, where each element in the vectors -// corrresponds to the result of the same face. +// corresponds to the result of the same face. // // Inputs: // IMAGE - Image diff --git a/mediapipe/tasks/cc/vision/face_stylizer/face_stylizer.h b/mediapipe/tasks/cc/vision/face_stylizer/face_stylizer.h index 27e64c934..58501c47b 100644 --- a/mediapipe/tasks/cc/vision/face_stylizer/face_stylizer.h +++ b/mediapipe/tasks/cc/vision/face_stylizer/face_stylizer.h @@ -81,7 +81,7 @@ class FaceStylizer : tasks::vision::core::BaseVisionTaskApi { // running mode. // // The input image can be of any size with format RGB or RGBA. - // To ensure that the output image has reasonable quailty, the stylized output + // To ensure that the output image has reasonable quality, the stylized output // image size is the smaller of the model output size and the size of the // 'region_of_interest' specified in 'image_processing_options'. absl::StatusOr Stylize( @@ -106,7 +106,7 @@ class FaceStylizer : tasks::vision::core::BaseVisionTaskApi { // The image can be of any size with format RGB or RGBA. It's required to // provide the video frame's timestamp (in milliseconds). The input timestamps // must be monotonically increasing. - // To ensure that the output image has reasonable quailty, the stylized output + // To ensure that the output image has reasonable quality, the stylized output // image size is the smaller of the model output size and the size of the // 'region_of_interest' specified in 'image_processing_options'. absl::StatusOr StylizeForVideo( diff --git a/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph.cc b/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph.cc index 7c8958b3c..2a888ca83 100644 --- a/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph.cc +++ b/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph.cc @@ -73,7 +73,7 @@ struct PoseDetectionOuts { // detector with model metadata. void ConfigureSsdAnchorsCalculator( mediapipe::SsdAnchorsCalculatorOptions* options) { - // Dervied from + // Derived from // mediapipe/modules/pose_detection/pose_detection_gpu.pbtxt options->set_num_layers(5); options->set_min_scale(0.1484375); @@ -96,7 +96,7 @@ void ConfigureSsdAnchorsCalculator( void ConfigureTensorsToDetectionsCalculator( const PoseDetectorGraphOptions& tasks_options, mediapipe::TensorsToDetectionsCalculatorOptions* options) { - // Dervied from + // Derived from // mediapipe/modules/pose_detection/pose_detection_gpu.pbtxt options->set_num_classes(1); options->set_num_boxes(2254); diff --git a/mediapipe/tasks/ios/common/utils/sources/MPPCommonUtils.h b/mediapipe/tasks/ios/common/utils/sources/MPPCommonUtils.h index 69c28b916..36d90f223 100644 --- a/mediapipe/tasks/ios/common/utils/sources/MPPCommonUtils.h +++ b/mediapipe/tasks/ios/common/utils/sources/MPPCommonUtils.h @@ -70,7 +70,7 @@ extern NSString *const MPPTasksErrorDomain; * @param error Pointer to the memory location where errors if any should be saved. If `nil`, no * error will be saved. * - * @return Pointer to the allocated block of memory on successfull allocation. `nil` in case as + * @return Pointer to the allocated block of memory on successful allocation. `nil` in case as * error is encountered because of invalid `memSize`. If failure is due to any other reason, method * terminates program execution. */ diff --git a/mediapipe/tasks/ios/core/sources/MPPTaskInfo.h b/mediapipe/tasks/ios/core/sources/MPPTaskInfo.h index b94e704d1..15f2ee0c1 100644 --- a/mediapipe/tasks/ios/core/sources/MPPTaskInfo.h +++ b/mediapipe/tasks/ios/core/sources/MPPTaskInfo.h @@ -22,7 +22,7 @@ NS_ASSUME_NONNULL_BEGIN /** - * Holds all needed informaton to initialize a MediaPipe Task. + * Holds all needed information to initialize a MediaPipe Task. */ @interface MPPTaskInfo : NSObject diff --git a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h index 704fc453f..ed57d2df2 100644 --- a/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h +++ b/mediapipe/tasks/ios/core/sources/MPPTaskRunner.h @@ -30,7 +30,7 @@ NS_ASSUME_NONNULL_BEGIN * additional functionality. For eg:, vision tasks must create an `MPPVisionTaskRunner` and provide * additional functionality. An instance of `MPPVisionTaskRunner` can in turn be used by the each * vision task for creation and execution of the task. Please see the documentation for the C++ Task - * Runner for more details on how the taks runner operates. + * Runner for more details on how the tasks runner operates. */ @interface MPPTaskRunner : NSObject @@ -66,7 +66,7 @@ NS_ASSUME_NONNULL_BEGIN * for processing either batch data such as unrelated images and texts or offline streaming data * such as the decoded frames from a video file or audio file. The call blocks the current * thread until a failure status or a successful result is returned. If the input packets have no - * timestamp, an internal timestamp will be assigend per invocation. Otherwise, when the timestamp + * timestamp, an internal timestamp will be assigned per invocation. Otherwise, when the timestamp * is set in the input packets, the caller must ensure that the input packet timestamps are greater * than the timestamps of the previous invocation. This method is thread-unsafe and it is the * caller's responsibility to synchronize access to this method across multiple threads and to diff --git a/mediapipe/tasks/python/core/pybind/task_runner.cc b/mediapipe/tasks/python/core/pybind/task_runner.cc index cb13787c3..aa48a1a9a 100644 --- a/mediapipe/tasks/python/core/pybind/task_runner.cc +++ b/mediapipe/tasks/python/core/pybind/task_runner.cc @@ -96,7 +96,7 @@ Args: Raises: RuntimeError: Any of the following: a) The graph config proto is invalid. - b) The underlying medipaipe graph fails to initilize and start. + b) The underlying medipaipe graph fails to initialize and start. )doc", py::arg("graph_config"), py::arg("packets_callback") = py::none()); @@ -120,7 +120,7 @@ This method is designed for processing either batch data such as unrelated images and texts or offline streaming data such as the decoded frames from a video file and an audio file. The call blocks the current thread until a failure status or a successful result is returned. -If the input packets have no timestamp, an internal timestamp will be assigend +If the input packets have no timestamp, an internal timestamp will be assigned per invocation. Otherwise, when the timestamp is set in the input packets, the caller must ensure that the input packet timestamps are greater than the timestamps of the previous invocation. This method is thread-unsafe and it is diff --git a/mediapipe/tasks/python/metadata/metadata.py b/mediapipe/tasks/python/metadata/metadata.py index e294bfc8c..25d83cae8 100644 --- a/mediapipe/tasks/python/metadata/metadata.py +++ b/mediapipe/tasks/python/metadata/metadata.py @@ -112,10 +112,10 @@ class MetadataPopulator(object): mediapipe/tasks/metadata/metadata_schema.fbs Example usage: - Populate matadata and label file into an image classifier model. + Populate metadata and label file into an image classifier model. First, based on metadata_schema.fbs, generate the metadata for this image - classifer model using Flatbuffers API. Attach the label file onto the ouput + classifier model using Flatbuffers API. Attach the label file onto the output tensor (the tensor of probabilities) in the metadata. Then, pack the metadata and label file into the model as follows. @@ -173,7 +173,7 @@ class MetadataPopulator(object): Raises: IOError: File not found. - ValueError: the model does not have the expected flatbuffer identifer. + ValueError: the model does not have the expected flatbuffer identifier. """ _assert_model_file_identifier(model_file) self._model_file = model_file @@ -193,7 +193,7 @@ class MetadataPopulator(object): Raises: IOError: File not found. - ValueError: the model does not have the expected flatbuffer identifer. + ValueError: the model does not have the expected flatbuffer identifier. """ return cls(model_file) @@ -210,7 +210,7 @@ class MetadataPopulator(object): A MetadataPopulator(_MetadataPopulatorWithBuffer) object. Raises: - ValueError: the model does not have the expected flatbuffer identifer. + ValueError: the model does not have the expected flatbuffer identifier. """ return _MetadataPopulatorWithBuffer(model_buf) @@ -293,7 +293,7 @@ class MetadataPopulator(object): Raises: ValueError: The metadata to be populated is empty. - ValueError: The metadata does not have the expected flatbuffer identifer. + ValueError: The metadata does not have the expected flatbuffer identifier. ValueError: Cannot get minimum metadata parser version. ValueError: The number of SubgraphMetadata is not 1. ValueError: The number of input/output tensors does not match the number @@ -646,7 +646,7 @@ class MetadataPopulator(object): class _MetadataPopulatorWithBuffer(MetadataPopulator): - """Subclass of MetadtaPopulator that populates metadata to a model buffer. + """Subclass of MetadataPopulator that populates metadata to a model buffer. This class is used to populate metadata into a in-memory model buffer. As we use Zip API to concatenate associated files after tflite model file, the @@ -664,7 +664,7 @@ class _MetadataPopulatorWithBuffer(MetadataPopulator): Raises: ValueError: model_buf is empty. - ValueError: model_buf does not have the expected flatbuffer identifer. + ValueError: model_buf does not have the expected flatbuffer identifier. """ if not model_buf: raise ValueError("model_buf cannot be empty.") @@ -826,7 +826,7 @@ def convert_to_json( metadata_buffer: valid metadata buffer in bytes. custom_metadata_schema: A dict of custom metadata schema, in which key is custom metadata name [1], value is the filepath that defines custom - metadata schema. For intance, custom_metadata_schema = + metadata schema. For instance, custom_metadata_schema = {"SEGMENTER_METADATA": "metadata/vision_tasks_metadata_schema.fbs"}. [1]: https://github.com/google/mediapipe/blob/46b5c4012d2ef76c9d92bb0d88a6b107aee83814/mediapipe/tasks/metadata/metadata_schema.fbs#L612 @@ -834,7 +834,7 @@ def convert_to_json( Metadata in JSON format. Raises: - ValueError: error occured when parsing the metadata schema file. + ValueError: error occurred when parsing the metadata schema file. """ opt = _pywrap_flatbuffers.IDLOptions() opt.strict_json = True diff --git a/mediapipe/tasks/python/metadata/metadata_writers/image_segmenter.py b/mediapipe/tasks/python/metadata/metadata_writers/image_segmenter.py index 8e215437e..3268f3b1f 100644 --- a/mediapipe/tasks/python/metadata/metadata_writers/image_segmenter.py +++ b/mediapipe/tasks/python/metadata/metadata_writers/image_segmenter.py @@ -59,7 +59,7 @@ def convert_to_json(metadata_buffer: bytearray) -> str: Metadata in JSON format. Raises: - ValueError: error occured when parsing the metadata schema file. + ValueError: error occurred when parsing the metadata schema file. """ return metadata.convert_to_json( metadata_buffer, diff --git a/mediapipe/tasks/web/audio/audio_embedder/audio_embedder_test.ts b/mediapipe/tasks/web/audio/audio_embedder/audio_embedder_test.ts index a8a2b232b..9e37e5987 100644 --- a/mediapipe/tasks/web/audio/audio_embedder/audio_embedder_test.ts +++ b/mediapipe/tasks/web/audio/audio_embedder/audio_embedder_test.ts @@ -161,7 +161,7 @@ describe('AudioEmbedder', () => { {floatEmbedding: [0.1, 0.9], headIndex: 1, headName: 'headName'}); } - it('from embeddings strem', async () => { + it('from embeddings stream', async () => { audioEmbedder.fakeWasmModule._waitUntilIdle.and.callFake(() => { verifyListenersRegistered(audioEmbedder); // Pass the test data to our listener diff --git a/mediapipe/tasks/web/vision/gesture_recognizer/gesture_recognizer_options.d.ts b/mediapipe/tasks/web/vision/gesture_recognizer/gesture_recognizer_options.d.ts index dd8fc9548..9e9728af8 100644 --- a/mediapipe/tasks/web/vision/gesture_recognizer/gesture_recognizer_options.d.ts +++ b/mediapipe/tasks/web/vision/gesture_recognizer/gesture_recognizer_options.d.ts @@ -44,7 +44,7 @@ export declare interface GestureRecognizerOptions extends VisionTaskOptions { minTrackingConfidence?: number|undefined; /** - * Sets the optional `ClassifierOptions` controling the canned gestures + * Sets the optional `ClassifierOptions` controlling the canned gestures * classifier, such as score threshold, allow list and deny list of gestures. * The categories for canned gesture * classifiers are: ["None", "Closed_Fist", "Open_Palm", "Pointing_Up", From 2651d30ebfc4b85018e3ddd1a9bacbc8d53df575 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 20 Mar 2023 09:43:53 -0700 Subject: [PATCH 119/136] Add ImageData output to GraphRunner PiperOrigin-RevId: 517994561 --- mediapipe/web/graph_runner/graph_runner_image_lib.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mediapipe/web/graph_runner/graph_runner_image_lib.ts b/mediapipe/web/graph_runner/graph_runner_image_lib.ts index 72d5ad965..a048c434a 100644 --- a/mediapipe/web/graph_runner/graph_runner_image_lib.ts +++ b/mediapipe/web/graph_runner/graph_runner_image_lib.ts @@ -10,10 +10,11 @@ type LibConstructor = new (...args: any[]) => GraphRunner; /** An image returned from a MediaPipe graph. */ export interface WasmImage { - data: Uint8Array|Float32Array; + data: Uint8Array|Uint8ClampedArray|Float32Array; width: number; height: number; } + /** * Declarations for Emscripten's WebAssembly Module behavior, so TS compiler * doesn't break our JS/C++ bridge. From 5a924669b7d8946632f2f9ba1f2daa8c19054a74 Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Mon, 20 Mar 2023 17:08:20 -0700 Subject: [PATCH 120/136] Temporarily disabling checking whether the executor is not set in a subgraph node. This is a workaround to allow the MediaPipe InferenceCalculator to have its own executor. PiperOrigin-RevId: 518117759 --- mediapipe/framework/tool/subgraph_expansion.cc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mediapipe/framework/tool/subgraph_expansion.cc b/mediapipe/framework/tool/subgraph_expansion.cc index 9f81153f1..dcd055f59 100644 --- a/mediapipe/framework/tool/subgraph_expansion.cc +++ b/mediapipe/framework/tool/subgraph_expansion.cc @@ -183,12 +183,13 @@ absl::Status FindCorrespondingStreams( // name, calculator, input_stream, output_stream, input_side_packet, // output_side_packet, options. // All other fields are only applicable to calculators. +// TODO: Check whether executor is not set in the subgraph node +// after this issues is properly solved. absl::Status ValidateSubgraphFields( const CalculatorGraphConfig::Node& subgraph_node) { if (subgraph_node.source_layer() || subgraph_node.buffer_size_hint() || subgraph_node.has_output_stream_handler() || - subgraph_node.input_stream_info_size() != 0 || - !subgraph_node.executor().empty()) { + subgraph_node.input_stream_info_size() != 0) { return mediapipe::InvalidArgumentErrorBuilder(MEDIAPIPE_LOC) << "Subgraph \"" << subgraph_node.name() << "\" has a field that is only applicable to calculators."; From 2be66e8eb0bcd4a4274ee5e17aea833f67642f73 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 21 Mar 2023 09:55:59 -0700 Subject: [PATCH 121/136] Add interactive segmenter java API PiperOrigin-RevId: 518303391 --- .../com/google/mediapipe/mediapipe_aar.bzl | 10 + .../com/google/mediapipe/tasks/vision/BUILD | 30 + .../interactivesegmenter/AndroidManifest.xml | 8 + .../InteractiveSegmenter.java | 556 ++++++++++++++++++ .../interactivesegmenter/AndroidManifest.xml | 24 + .../tasks/vision/interactivesegmenter/BUILD | 19 + .../InteractiveSegmenterTest.java | 92 +++ 7 files changed, 739 insertions(+) create mode 100644 mediapipe/tasks/java/com/google/mediapipe/tasks/vision/interactivesegmenter/AndroidManifest.xml create mode 100644 mediapipe/tasks/java/com/google/mediapipe/tasks/vision/interactivesegmenter/InteractiveSegmenter.java create mode 100644 mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/AndroidManifest.xml create mode 100644 mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/BUILD create mode 100644 mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/InteractiveSegmenterTest.java diff --git a/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl b/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl index 4eb7175b7..f7fba0fa2 100644 --- a/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl +++ b/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl @@ -358,11 +358,21 @@ def mediapipe_java_proto_srcs(name = ""): src_out = "com/google/mediapipe/formats/proto/RectProto.java", )) + proto_src_list.append(mediapipe_java_proto_src_extractor( + target = "//mediapipe/util:color_java_proto_lite", + src_out = "com/google/mediapipe/util/proto/Color.java", + )) + proto_src_list.append(mediapipe_java_proto_src_extractor( target = "//mediapipe/util:label_map_java_proto_lite", src_out = "com/google/mediapipe/util/proto/LabelMapProto.java", )) + proto_src_list.append(mediapipe_java_proto_src_extractor( + target = "//mediapipe/util:render_data_java_proto_lite", + src_out = "com/google/mediapipe/util/proto/RenderData.java", + )) + return proto_src_list def mediapipe_logging_java_proto_srcs(name = ""): diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD index a5b036924..ddff069af 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/BUILD @@ -50,6 +50,7 @@ cc_binary( "//mediapipe/tasks/cc/vision/image_classifier:image_classifier_graph", "//mediapipe/tasks/cc/vision/image_embedder:image_embedder_graph", "//mediapipe/tasks/cc/vision/image_segmenter:image_segmenter_graph", + "//mediapipe/tasks/cc/vision/interactive_segmenter:interactive_segmenter_graph", "//mediapipe/tasks/cc/vision/object_detector:object_detector_graph", "//mediapipe/tasks/java:version_script.lds", "//mediapipe/tasks/java/com/google/mediapipe/tasks/core/jni:model_resources_cache_jni", @@ -206,6 +207,35 @@ android_library( ], ) +android_library( + name = "interactivesegmenter", + srcs = [ + "imagesegmenter/ImageSegmenterResult.java", + "interactivesegmenter/InteractiveSegmenter.java", + ], + javacopts = [ + "-Xep:AndroidJdkLibsChecker:OFF", + ], + manifest = "interactivesegmenter/AndroidManifest.xml", + deps = [ + ":core", + "//mediapipe/framework:calculator_options_java_proto_lite", + "//mediapipe/java/com/google/mediapipe/framework:android_framework", + "//mediapipe/java/com/google/mediapipe/framework/image", + "//mediapipe/tasks/cc/core/proto:base_options_java_proto_lite", + "//mediapipe/tasks/cc/vision/image_segmenter/calculators:tensors_to_segmentation_calculator_java_proto_lite", + "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_java_proto_lite", + "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_java_proto_lite", + "//mediapipe/tasks/java/com/google/mediapipe/tasks/components/containers:normalizedkeypoint", + "//mediapipe/tasks/java/com/google/mediapipe/tasks/core", + "//mediapipe/util:color_java_proto_lite", + "//mediapipe/util:render_data_java_proto_lite", + "//third_party:autovalue", + "@maven//:androidx_annotation_annotation", + "@maven//:com_google_guava_guava", + ], +) + android_library( name = "imageembedder", srcs = [ diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/interactivesegmenter/AndroidManifest.xml b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/interactivesegmenter/AndroidManifest.xml new file mode 100644 index 000000000..1bde79182 --- /dev/null +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/interactivesegmenter/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/interactivesegmenter/InteractiveSegmenter.java b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/interactivesegmenter/InteractiveSegmenter.java new file mode 100644 index 000000000..8ee6951f8 --- /dev/null +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/vision/interactivesegmenter/InteractiveSegmenter.java @@ -0,0 +1,556 @@ +// Copyright 2023 The MediaPipe Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.mediapipe.tasks.vision.interactivesegmenter; + +import android.content.Context; +import com.google.auto.value.AutoValue; +import com.google.mediapipe.proto.CalculatorOptionsProto.CalculatorOptions; +import com.google.mediapipe.proto.CalculatorProto.CalculatorGraphConfig; +import com.google.mediapipe.framework.AndroidPacketGetter; +import com.google.mediapipe.framework.MediaPipeException; +import com.google.mediapipe.framework.Packet; +import com.google.mediapipe.framework.PacketGetter; +import com.google.mediapipe.framework.ProtoUtil; +import com.google.mediapipe.framework.image.BitmapImageBuilder; +import com.google.mediapipe.framework.image.ByteBufferImageBuilder; +import com.google.mediapipe.framework.image.MPImage; +import com.google.mediapipe.tasks.TensorsToSegmentationCalculatorOptionsProto; +import com.google.mediapipe.tasks.components.containers.NormalizedKeypoint; +import com.google.mediapipe.tasks.core.BaseOptions; +import com.google.mediapipe.tasks.core.ErrorListener; +import com.google.mediapipe.tasks.core.OutputHandler; +import com.google.mediapipe.tasks.core.OutputHandler.ResultListener; +import com.google.mediapipe.tasks.core.TaskInfo; +import com.google.mediapipe.tasks.core.TaskOptions; +import com.google.mediapipe.tasks.core.TaskRunner; +import com.google.mediapipe.tasks.core.proto.BaseOptionsProto; +import com.google.mediapipe.tasks.vision.core.BaseVisionTaskApi; +import com.google.mediapipe.tasks.vision.core.ImageProcessingOptions; +import com.google.mediapipe.tasks.vision.core.RunningMode; +import com.google.mediapipe.tasks.vision.imagesegmenter.ImageSegmenterResult; +import com.google.mediapipe.tasks.vision.imagesegmenter.proto.ImageSegmenterGraphOptionsProto; +import com.google.mediapipe.tasks.vision.imagesegmenter.proto.SegmenterOptionsProto; +import com.google.mediapipe.util.proto.ColorProto.Color; +import com.google.mediapipe.util.proto.RenderDataProto.RenderAnnotation; +import com.google.mediapipe.util.proto.RenderDataProto.RenderData; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Performs interactive segmentation on images. + * + *

    Note that, in addition to the standard segmentation API {@link segment} that takes an input + * image and returns the outputs, but involves deep copy of the returns, InteractiveSegmenter also + * supports the callback API, {@link segmentWithResultListener}, which allows you to access the + * outputs through zero copy. Set {@link ResultListener} in {@link InteractiveSegmenterOptions} + * properly to use the callback API. + * + *

    The API expects a TFLite model with,TFLite Model Metadata.. The model + * expects input with 4 channels, where the first 3 channels represent RGB image, and the last + * channel represents the user's region of interest. + * + *

      + *
    • Input image {@link MPImage} + *
        + *
      • The image that image segmenter runs on. + *
      + *
    • Input roi {@link RegionOfInterest} + *
        + *
      • Region of interest based on user interaction. + *
      + *
    • Output ImageSegmenterResult {@link ImageSegmenterResult} + *
        + *
      • An ImageSegmenterResult containing segmented masks. + *
      + *
    + */ +public final class InteractiveSegmenter extends BaseVisionTaskApi { + private static final String TAG = InteractiveSegmenter.class.getSimpleName(); + private static final String IMAGE_IN_STREAM_NAME = "image_in"; + private static final String ROI_IN_STREAM_NAME = "roi_in"; + private static final String NORM_RECT_IN_STREAM_NAME = "norm_rect_in"; + private static final List INPUT_STREAMS = + Collections.unmodifiableList( + Arrays.asList( + "IMAGE:" + IMAGE_IN_STREAM_NAME, + "ROI:" + ROI_IN_STREAM_NAME, + "NORM_RECT:" + NORM_RECT_IN_STREAM_NAME)); + private static final List OUTPUT_STREAMS = + Collections.unmodifiableList( + Arrays.asList( + "GROUPED_SEGMENTATION:segmented_mask_out", + "IMAGE:image_out", + "SEGMENTATION:0:segmentation")); + private static final int GROUPED_SEGMENTATION_OUT_STREAM_INDEX = 0; + private static final int IMAGE_OUT_STREAM_INDEX = 1; + private static final int SEGMENTATION_OUT_STREAM_INDEX = 2; + private static final String TASK_GRAPH_NAME = + "mediapipe.tasks.vision.interactive_segmenter.InteractiveSegmenterGraph"; + private static final String TENSORS_TO_SEGMENTATION_CALCULATOR_NAME = + "mediapipe.tasks.TensorsToSegmentationCalculator"; + private boolean hasResultListener = false; + private List labels = new ArrayList<>(); + + static { + ProtoUtil.registerTypeName(RenderData.class, "mediapipe.RenderData"); + } + + /** + * Creates an {@link InteractiveSegmenter} instance from an {@link InteractiveSegmenterOptions}. + * + * @param context an Android {@link Context}. + * @param segmenterOptions an {@link InteractiveSegmenterOptions} instance. + * @throws MediaPipeException if there is an error during {@link InteractiveSegmenter} creation. + */ + public static InteractiveSegmenter createFromOptions( + Context context, InteractiveSegmenterOptions segmenterOptions) { + // TODO: Consolidate OutputHandler and TaskRunner. + OutputHandler handler = new OutputHandler<>(); + handler.setOutputPacketConverter( + new OutputHandler.OutputPacketConverter() { + @Override + public ImageSegmenterResult convertToTaskResult(List packets) + throws MediaPipeException { + if (packets.get(GROUPED_SEGMENTATION_OUT_STREAM_INDEX).isEmpty()) { + return ImageSegmenterResult.create( + new ArrayList<>(), + packets.get(GROUPED_SEGMENTATION_OUT_STREAM_INDEX).getTimestamp()); + } + List segmentedMasks = new ArrayList<>(); + int width = PacketGetter.getImageWidth(packets.get(SEGMENTATION_OUT_STREAM_INDEX)); + int height = PacketGetter.getImageHeight(packets.get(SEGMENTATION_OUT_STREAM_INDEX)); + int imageFormat = + segmenterOptions.outputType() + == InteractiveSegmenterOptions.OutputType.CONFIDENCE_MASK + ? MPImage.IMAGE_FORMAT_VEC32F1 + : MPImage.IMAGE_FORMAT_ALPHA; + int imageListSize = + PacketGetter.getImageListSize(packets.get(GROUPED_SEGMENTATION_OUT_STREAM_INDEX)); + ByteBuffer[] buffersArray = new ByteBuffer[imageListSize]; + // If resultListener is not provided, the resulted MPImage is deep copied from mediapipe + // graph. If provided, the result MPImage is wrapping the mediapipe packet memory. + if (!segmenterOptions.resultListener().isPresent()) { + for (int i = 0; i < imageListSize; i++) { + buffersArray[i] = + ByteBuffer.allocateDirect( + width * height * (imageFormat == MPImage.IMAGE_FORMAT_VEC32F1 ? 4 : 1)); + } + } + if (!PacketGetter.getImageList( + packets.get(GROUPED_SEGMENTATION_OUT_STREAM_INDEX), + buffersArray, + !segmenterOptions.resultListener().isPresent())) { + throw new MediaPipeException( + MediaPipeException.StatusCode.INTERNAL.ordinal(), + "There is an error getting segmented masks. It usually results from incorrect" + + " options of unsupported OutputType of given model."); + } + for (ByteBuffer buffer : buffersArray) { + ByteBufferImageBuilder builder = + new ByteBufferImageBuilder(buffer, width, height, imageFormat); + segmentedMasks.add(builder.build()); + } + + return ImageSegmenterResult.create( + segmentedMasks, + BaseVisionTaskApi.generateResultTimestampMs( + RunningMode.IMAGE, packets.get(GROUPED_SEGMENTATION_OUT_STREAM_INDEX))); + } + + @Override + public MPImage convertToTaskInput(List packets) { + return new BitmapImageBuilder( + AndroidPacketGetter.getBitmapFromRgb(packets.get(IMAGE_OUT_STREAM_INDEX))) + .build(); + } + }); + segmenterOptions.resultListener().ifPresent(handler::setResultListener); + segmenterOptions.errorListener().ifPresent(handler::setErrorListener); + TaskRunner runner = + TaskRunner.create( + context, + TaskInfo.builder() + .setTaskName(InteractiveSegmenter.class.getSimpleName()) + .setTaskRunningModeName(RunningMode.IMAGE.name()) + .setTaskGraphName(TASK_GRAPH_NAME) + .setInputStreams(INPUT_STREAMS) + .setOutputStreams(OUTPUT_STREAMS) + .setTaskOptions(segmenterOptions) + .setEnableFlowLimiting(false) + .build(), + handler); + return new InteractiveSegmenter(runner, segmenterOptions.resultListener().isPresent()); + } + + /** + * Constructor to initialize an {@link InteractiveSegmenter} from a {@link TaskRunner}. + * + * @param taskRunner a {@link TaskRunner}. + */ + private InteractiveSegmenter(TaskRunner taskRunner, boolean hasResultListener) { + super(taskRunner, RunningMode.IMAGE, IMAGE_IN_STREAM_NAME, NORM_RECT_IN_STREAM_NAME); + this.hasResultListener = hasResultListener; + populateLabels(); + } + + /** + * Populate the labelmap in TensorsToSegmentationCalculator to labels field. + * + * @throws MediaPipeException if there is an error during finding TensorsToSegmentationCalculator. + */ + private void populateLabels() { + CalculatorGraphConfig graphConfig = this.runner.getCalculatorGraphConfig(); + + boolean foundTensorsToSegmentation = false; + for (CalculatorGraphConfig.Node node : graphConfig.getNodeList()) { + if (node.getName().contains(TENSORS_TO_SEGMENTATION_CALCULATOR_NAME)) { + if (foundTensorsToSegmentation) { + throw new MediaPipeException( + MediaPipeException.StatusCode.INTERNAL.ordinal(), + "The graph has more than one mediapipe.tasks.TensorsToSegmentationCalculator."); + } + foundTensorsToSegmentation = true; + TensorsToSegmentationCalculatorOptionsProto.TensorsToSegmentationCalculatorOptions options = + node.getOptions() + .getExtension( + TensorsToSegmentationCalculatorOptionsProto + .TensorsToSegmentationCalculatorOptions.ext); + for (int i = 0; i < options.getLabelItemsMap().size(); i++) { + Long labelKey = Long.valueOf(i); + if (!options.getLabelItemsMap().containsKey(labelKey)) { + throw new MediaPipeException( + MediaPipeException.StatusCode.INTERNAL.ordinal(), + "The lablemap have no expected key: " + labelKey); + } + labels.add(options.getLabelItemsMap().get(labelKey).getName()); + } + } + } + } + + /** + * Performs segmentation on the provided single image with default image processing options, given + * user's region-of-interest, i.e. without any rotation applied. TODO update java doc + * for input image format. + * + *

    Users can represent user interaction through {@link RegionOfInterest}, which gives a hint to + * perform segmentation focusing on the given region of interest. + * + *

    {@link InteractiveSegmenter} supports the following color space types: + * + *

      + *
    • {@link Bitmap.Config.ARGB_8888} + *
    + * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param roi a {@link RegionOfInterest} object to represent user interaction. + * @throws MediaPipeException if there is an internal error. Or if {@link InteractiveSegmenter} is + * created with a {@link ResultListener}. + */ + public ImageSegmenterResult segment(MPImage image, RegionOfInterest roi) { + return segment(image, roi, ImageProcessingOptions.builder().build()); + } + + /** + * Performs segmentation on the provided single image, given user's region-of-interest. + * TODO update java doc for input image format. + * + *

    Users can represent user interaction through {@link RegionOfInterest}, which gives a hint to + * perform segmentation focusing on the given region of interest. + * + *

    {@link InteractiveSegmenter} supports the following color space types: + * + *

      + *
    • {@link Bitmap.Config.ARGB_8888} + *
    + * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param roi a {@link RegionOfInterest} object to represent user interaction. + * @param imageProcessingOptions the {@link ImageProcessingOptions} specifying how to process the + * input image before running inference. Note that region-of-interest is not supported + * by this task: specifying {@link ImageProcessingOptions#regionOfInterest()} will result in + * this method throwing an IllegalArgumentException. + * @throws IllegalArgumentException if the {@link ImageProcessingOptions} specify a + * region-of-interest. + * @throws MediaPipeException if there is an internal error. Or if {@link InteractiveSegmenter} is + * created with a {@link ResultListener}. + */ + public ImageSegmenterResult segment( + MPImage image, RegionOfInterest roi, ImageProcessingOptions imageProcessingOptions) { + if (hasResultListener) { + throw new MediaPipeException( + MediaPipeException.StatusCode.FAILED_PRECONDITION.ordinal(), + "ResultListener is provided in the InteractiveSegmenterOptions, but this method will" + + " return an ImageSegmentationResult."); + } + validateImageProcessingOptions(imageProcessingOptions); + return processImageWithRoi(image, roi, imageProcessingOptions); + } + + /** + * Performs segmentation on the provided single image with default image processing options, given + * user's region-of-interest, i.e. without any rotation applied, and provides zero-copied results + * via {@link ResultListener} in {@link InteractiveSegmenterOptions}. + * + *

    TODO update java doc for input image format. + * + *

    Users can represent user interaction through {@link RegionOfInterest}, which gives a hint to + * perform segmentation focusing on the given region of interest. + * + *

    {@link InteractiveSegmenter} supports the following color space types: + * + *

      + *
    • {@link Bitmap.Config.ARGB_8888} + *
    + * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param roi a {@link RegionOfInterest} object to represent user interaction. + * @throws IllegalArgumentException if the {@link ImageProcessingOptions} specify a + * region-of-interest. + * @throws MediaPipeException if there is an internal error. Or if {@link InteractiveSegmenter} is + * not created wtih {@link ResultListener} set in {@link InteractiveSegmenterOptions}. + */ + public void segmentWithResultListener(MPImage image, RegionOfInterest roi) { + segmentWithResultListener(image, roi, ImageProcessingOptions.builder().build()); + } + + /** + * Performs segmentation on the provided single image given user's region-of-interest, and + * provides zero-copied results via {@link ResultListener} in {@link InteractiveSegmenterOptions}. + * + *

    TODO update java doc for input image format. + * + *

    Users can represent user interaction through {@link RegionOfInterest}, which gives a hint to + * perform segmentation focusing on the given region of interest. + * + *

    {@link InteractiveSegmenter} supports the following color space types: + * + *

      + *
    • {@link Bitmap.Config.ARGB_8888} + *
    + * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param roi a {@link RegionOfInterest} object to represent user interaction. + * @param imageProcessingOptions the {@link ImageProcessingOptions} specifying how to process the + * input image before running inference. Note that region-of-interest is not supported + * by this task: specifying {@link ImageProcessingOptions#regionOfInterest()} will result in + * this method throwing an IllegalArgumentException. + * @throws IllegalArgumentException if the {@link ImageProcessingOptions} specify a + * region-of-interest. + * @throws MediaPipeException if there is an internal error. Or if {@link InteractiveSegmenter} is + * not created wtih {@link ResultListener} set in {@link InteractiveSegmenterOptions}. + */ + public void segmentWithResultListener( + MPImage image, RegionOfInterest roi, ImageProcessingOptions imageProcessingOptions) { + if (!hasResultListener) { + throw new MediaPipeException( + MediaPipeException.StatusCode.FAILED_PRECONDITION.ordinal(), + "ResultListener is not set in the InteractiveSegmenterOptions, but this method expects a" + + " ResultListener to process ImageSegmentationResult."); + } + validateImageProcessingOptions(imageProcessingOptions); + ImageSegmenterResult unused = processImageWithRoi(image, roi, imageProcessingOptions); + } + + /** + * Get the category label list of the ImageSegmenter can recognize. For CATEGORY_MASK type, the + * index in the category mask corresponds to the category in the label list. For CONFIDENCE_MASK + * type, the output mask list at index corresponds to the category in the label list. + * + *

    If there is no labelmap provided in the model file, empty label list is returned. + */ + List getLabels() { + return labels; + } + + /** Options for setting up an {@link InteractiveSegmenter}. */ + @AutoValue + public abstract static class InteractiveSegmenterOptions extends TaskOptions { + + /** Builder for {@link InteractiveSegmenterOptions}. */ + @AutoValue.Builder + public abstract static class Builder { + /** Sets the base options for the image segmenter task. */ + public abstract Builder setBaseOptions(BaseOptions value); + + /** The output type from image segmenter. */ + public abstract Builder setOutputType(OutputType value); + + /** + * Sets an optional {@link ResultListener} to receive the segmentation results when the graph + * pipeline is done processing an image. + */ + public abstract Builder setResultListener( + ResultListener value); + + /** Sets an optional {@link ErrorListener}}. */ + public abstract Builder setErrorListener(ErrorListener value); + + abstract InteractiveSegmenterOptions autoBuild(); + + /** Builds the {@link InteractiveSegmenterOptions} instance. */ + public final InteractiveSegmenterOptions build() { + return autoBuild(); + } + } + + abstract BaseOptions baseOptions(); + + abstract OutputType outputType(); + + abstract Optional> resultListener(); + + abstract Optional errorListener(); + + /** The output type of segmentation results. */ + public enum OutputType { + // Gives a single output mask where each pixel represents the class which + // the pixel in the original image was predicted to belong to. + CATEGORY_MASK, + // Gives a list of output masks where, for each mask, each pixel represents + // the prediction confidence, usually in the [0, 1] range. + CONFIDENCE_MASK + } + + public static Builder builder() { + return new AutoValue_InteractiveSegmenter_InteractiveSegmenterOptions.Builder() + .setOutputType(OutputType.CATEGORY_MASK); + } + + /** + * Converts an {@link InteractiveSegmenterOptions} to a {@link CalculatorOptions} protobuf + * message. + */ + @Override + public CalculatorOptions convertToCalculatorOptionsProto() { + ImageSegmenterGraphOptionsProto.ImageSegmenterGraphOptions.Builder taskOptionsBuilder = + ImageSegmenterGraphOptionsProto.ImageSegmenterGraphOptions.newBuilder() + .setBaseOptions( + BaseOptionsProto.BaseOptions.newBuilder() + .setUseStreamMode(false) + .mergeFrom(convertBaseOptionsToProto(baseOptions())) + .build()); + + SegmenterOptionsProto.SegmenterOptions.Builder segmenterOptionsBuilder = + SegmenterOptionsProto.SegmenterOptions.newBuilder(); + if (outputType() == OutputType.CONFIDENCE_MASK) { + segmenterOptionsBuilder.setOutputType( + SegmenterOptionsProto.SegmenterOptions.OutputType.CONFIDENCE_MASK); + } else if (outputType() == OutputType.CATEGORY_MASK) { + segmenterOptionsBuilder.setOutputType( + SegmenterOptionsProto.SegmenterOptions.OutputType.CATEGORY_MASK); + } + + taskOptionsBuilder.setSegmenterOptions(segmenterOptionsBuilder); + return CalculatorOptions.newBuilder() + .setExtension( + ImageSegmenterGraphOptionsProto.ImageSegmenterGraphOptions.ext, + taskOptionsBuilder.build()) + .build(); + } + } + + /** + * Validates that the provided {@link ImageProcessingOptions} doesn't contain a + * region-of-interest. + */ + private static void validateImageProcessingOptions( + ImageProcessingOptions imageProcessingOptions) { + if (imageProcessingOptions.regionOfInterest().isPresent()) { + throw new IllegalArgumentException( + "InteractiveSegmenter doesn't support region-of-interest."); + } + } + + /** The Region-Of-Interest (ROI) to interact with. */ + public static class RegionOfInterest { + private NormalizedKeypoint keypoint; + + private RegionOfInterest() {} + + /** + * Creates a {@link RegionOfInterest} instance representing a single point pointing to the + * object that the user wants to segment. + */ + public static RegionOfInterest create(NormalizedKeypoint keypoint) { + RegionOfInterest roi = new RegionOfInterest(); + roi.keypoint = keypoint; + return roi; + } + } + + /** + * Converts a {@link RegionOfInterest} instance into a {@link RenderData} protobuf message + * + * @param roi a {@link RegionOfInterest} object to represent user interaction. + * @throws IllegalArgumentException if {@link RegionOfInterest} does not represent a valid user + * interaction. + */ + private static RenderData convertToRenderData(RegionOfInterest roi) { + RenderData.Builder builder = RenderData.newBuilder(); + if (roi.keypoint != null) { + return builder + .addRenderAnnotations( + RenderAnnotation.newBuilder() + .setColor(Color.newBuilder().setR(255)) + .setPoint( + RenderAnnotation.Point.newBuilder() + .setX(roi.keypoint.x()) + .setY(roi.keypoint.y()))) + .build(); + } + + throw new IllegalArgumentException( + "RegionOfInterest does not include a valid user interaction"); + } + + /** + * A synchronous method to process single image inputs. The call blocks the current thread until a + * failure status or a successful result is returned. + * + *

    This is almost the same as {@link BaseVisionTaskApi.processImageData} except accepting an + * additional {@link RegionOfInterest}. + * + * @param image a MediaPipe {@link MPImage} object for processing. + * @param roi a {@link RegionOfInterest} object to represent user interaction. + * @param imageProcessingOptions the {@link ImageProcessingOptions} specifying how to process the + * input image before running inference. + * @throws MediaPipeException if the task is not in the image mode. + */ + private ImageSegmenterResult processImageWithRoi( + MPImage image, RegionOfInterest roi, ImageProcessingOptions imageProcessingOptions) { + if (runningMode != RunningMode.IMAGE) { + throw new MediaPipeException( + MediaPipeException.StatusCode.FAILED_PRECONDITION.ordinal(), + "Task is not initialized with the image mode. Current running mode:" + + runningMode.name()); + } + Map inputPackets = new HashMap<>(); + inputPackets.put(IMAGE_IN_STREAM_NAME, runner.getPacketCreator().createImage(image)); + RenderData renderData = convertToRenderData(roi); + inputPackets.put(ROI_IN_STREAM_NAME, runner.getPacketCreator().createProto(renderData)); + inputPackets.put( + NORM_RECT_IN_STREAM_NAME, + runner.getPacketCreator().createProto(convertToNormalizedRect(imageProcessingOptions))); + return (ImageSegmenterResult) runner.process(inputPackets); + } +} diff --git a/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/AndroidManifest.xml b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/AndroidManifest.xml new file mode 100644 index 000000000..97280f5e4 --- /dev/null +++ b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/BUILD b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/BUILD new file mode 100644 index 000000000..c14486766 --- /dev/null +++ b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/BUILD @@ -0,0 +1,19 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package(default_visibility = ["//mediapipe/tasks:internal"]) + +licenses(["notice"]) + +# TODO: Enable this in OSS diff --git a/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/InteractiveSegmenterTest.java b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/InteractiveSegmenterTest.java new file mode 100644 index 000000000..0d9581437 --- /dev/null +++ b/mediapipe/tasks/javatests/com/google/mediapipe/tasks/vision/interactivesegmenter/InteractiveSegmenterTest.java @@ -0,0 +1,92 @@ +// Copyright 2023 The MediaPipe Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.mediapipe.tasks.vision.interactivesegmenter; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.res.AssetManager; +import android.graphics.BitmapFactory; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.mediapipe.framework.image.BitmapImageBuilder; +import com.google.mediapipe.framework.image.MPImage; +import com.google.mediapipe.tasks.components.containers.NormalizedKeypoint; +import com.google.mediapipe.tasks.core.BaseOptions; +import com.google.mediapipe.tasks.vision.imagesegmenter.ImageSegmenterResult; +import com.google.mediapipe.tasks.vision.interactivesegmenter.InteractiveSegmenter.InteractiveSegmenterOptions; +import java.io.InputStream; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Suite; +import org.junit.runners.Suite.SuiteClasses; + +/** Test for {@link InteractiveSegmenter}. */ +@RunWith(Suite.class) +@SuiteClasses({ + InteractiveSegmenterTest.General.class, +}) +public class InteractiveSegmenterTest { + private static final String DEEPLAB_MODEL_FILE = "ptm_512_hdt_ptm_woid.tflite"; + private static final String CATS_AND_DOGS_IMAGE = "cats_and_dogs.jpg"; + private static final int MAGNIFICATION_FACTOR = 10; + + @RunWith(AndroidJUnit4.class) + public static final class General extends InteractiveSegmenterTest { + @Test + public void segment_successWithCategoryMask() throws Exception { + final String inputImageName = CATS_AND_DOGS_IMAGE; + final InteractiveSegmenter.RegionOfInterest roi = + InteractiveSegmenter.RegionOfInterest.create(NormalizedKeypoint.create(0.25f, 0.9f)); + InteractiveSegmenterOptions options = + InteractiveSegmenterOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(DEEPLAB_MODEL_FILE).build()) + .setOutputType(InteractiveSegmenterOptions.OutputType.CATEGORY_MASK) + .build(); + InteractiveSegmenter imageSegmenter = + InteractiveSegmenter.createFromOptions( + ApplicationProvider.getApplicationContext(), options); + MPImage image = getImageFromAsset(inputImageName); + ImageSegmenterResult actualResult = imageSegmenter.segment(image, roi); + List segmentations = actualResult.segmentations(); + assertThat(segmentations.size()).isEqualTo(1); + } + + @Test + public void segment_successWithConfidenceMask() throws Exception { + final String inputImageName = CATS_AND_DOGS_IMAGE; + final InteractiveSegmenter.RegionOfInterest roi = + InteractiveSegmenter.RegionOfInterest.create(NormalizedKeypoint.create(0.25f, 0.9f)); + InteractiveSegmenterOptions options = + InteractiveSegmenterOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetPath(DEEPLAB_MODEL_FILE).build()) + .setOutputType(InteractiveSegmenterOptions.OutputType.CONFIDENCE_MASK) + .build(); + InteractiveSegmenter imageSegmenter = + InteractiveSegmenter.createFromOptions( + ApplicationProvider.getApplicationContext(), options); + ImageSegmenterResult actualResult = + imageSegmenter.segment(getImageFromAsset(inputImageName), roi); + List segmentations = actualResult.segmentations(); + assertThat(segmentations.size()).isEqualTo(2); + } + } + + private static MPImage getImageFromAsset(String filePath) throws Exception { + AssetManager assetManager = ApplicationProvider.getApplicationContext().getAssets(); + InputStream istr = assetManager.open(filePath); + return new BitmapImageBuilder(BitmapFactory.decodeStream(istr)).build(); + } +} From c2a3e995457d48d2c9bcdecf0d8a1c51c1914c47 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 21 Mar 2023 11:26:28 -0700 Subject: [PATCH 122/136] Internal change PiperOrigin-RevId: 518330697 --- mediapipe/calculators/image/BUILD | 20 +++ .../calculators/image/set_alpha_calculator.cc | 41 ++--- .../image/set_alpha_calculator_test.cc | 156 ++++++++++++++++++ 3 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 mediapipe/calculators/image/set_alpha_calculator_test.cc diff --git a/mediapipe/calculators/image/BUILD b/mediapipe/calculators/image/BUILD index d627bdc4a..7b94a031f 100644 --- a/mediapipe/calculators/image/BUILD +++ b/mediapipe/calculators/image/BUILD @@ -156,6 +156,7 @@ cc_library( "//mediapipe/framework/port:opencv_core", "//mediapipe/framework/port:status", "//mediapipe/framework/port:vector", + "//mediapipe/framework/port:opencv_imgproc", ] + select({ "//mediapipe/gpu:disable_gpu": [], "//conditions:default": [ @@ -168,6 +169,25 @@ cc_library( alwayslink = 1, ) +cc_test( + name = "set_alpha_calculator_test", + srcs = ["set_alpha_calculator_test.cc"], + deps = [ + ":set_alpha_calculator", + ":set_alpha_calculator_cc_proto", + "//mediapipe/framework:calculator_framework", + "//mediapipe/framework:calculator_runner", + "//mediapipe/framework/formats:image_frame_opencv", + "//mediapipe/framework/formats:rect_cc_proto", + "//mediapipe/framework/port:gtest_main", + "//mediapipe/framework/port:opencv_core", + "//mediapipe/framework/port:opencv_imgproc", + "//mediapipe/framework/port:parse_text_proto", + "//mediapipe/framework/port:status", + "@com_google_googletest//:gtest_main", + ], +) + cc_library( name = "bilateral_filter_calculator", srcs = ["bilateral_filter_calculator.cc"], diff --git a/mediapipe/calculators/image/set_alpha_calculator.cc b/mediapipe/calculators/image/set_alpha_calculator.cc index ea29278de..e20621e8d 100644 --- a/mediapipe/calculators/image/set_alpha_calculator.cc +++ b/mediapipe/calculators/image/set_alpha_calculator.cc @@ -22,6 +22,7 @@ #include "mediapipe/framework/formats/image_frame_opencv.h" #include "mediapipe/framework/port/logging.h" #include "mediapipe/framework/port/opencv_core_inc.h" +#include "mediapipe/framework/port/opencv_imgproc_inc.h" #include "mediapipe/framework/port/status.h" #include "mediapipe/framework/port/vector.h" @@ -53,24 +54,16 @@ enum { ATTRIB_VERTEX, ATTRIB_TEXTURE_POSITION, NUM_ATTRIBUTES }; // range of [0, 1). Only the first channel of Alpha is used. Input & output Mat // must be uchar. template -absl::Status MergeRGBA8Image(const cv::Mat input_mat, const cv::Mat& alpha_mat, - cv::Mat& output_mat) { - RET_CHECK_EQ(input_mat.rows, alpha_mat.rows); - RET_CHECK_EQ(input_mat.cols, alpha_mat.cols); - RET_CHECK_EQ(input_mat.rows, output_mat.rows); - RET_CHECK_EQ(input_mat.cols, output_mat.cols); +absl::Status CopyAlphaImage(const cv::Mat& alpha_mat, cv::Mat& output_mat) { + RET_CHECK_EQ(output_mat.rows, alpha_mat.rows); + RET_CHECK_EQ(output_mat.cols, alpha_mat.cols); for (int i = 0; i < output_mat.rows; ++i) { - const uchar* in_ptr = input_mat.ptr(i); const AlphaType* alpha_ptr = alpha_mat.ptr(i); uchar* out_ptr = output_mat.ptr(i); for (int j = 0; j < output_mat.cols; ++j) { const int out_idx = j * kNumChannelsRGBA; - const int in_idx = j * input_mat.channels(); const int alpha_idx = j * alpha_mat.channels(); - out_ptr[out_idx + 0] = in_ptr[in_idx + 0]; - out_ptr[out_idx + 1] = in_ptr[in_idx + 1]; - out_ptr[out_idx + 2] = in_ptr[in_idx + 2]; if constexpr (std::is_same::value) { out_ptr[out_idx + 3] = alpha_ptr[alpha_idx + 0]; // channel 0 of mask } else { @@ -273,7 +266,7 @@ absl::Status SetAlphaCalculator::RenderCpu(CalculatorContext* cc) { // Setup source image const auto& input_frame = cc->Inputs().Tag(kInputFrameTag).Get(); - const cv::Mat input_mat = mediapipe::formats::MatView(&input_frame); + const cv::Mat input_mat = formats::MatView(&input_frame); if (!(input_mat.type() == CV_8UC3 || input_mat.type() == CV_8UC4)) { LOG(ERROR) << "Only 3 or 4 channel 8-bit input image supported"; } @@ -281,38 +274,38 @@ absl::Status SetAlphaCalculator::RenderCpu(CalculatorContext* cc) { // Setup destination image auto output_frame = absl::make_unique( ImageFormat::SRGBA, input_mat.cols, input_mat.rows); - cv::Mat output_mat = mediapipe::formats::MatView(output_frame.get()); + cv::Mat output_mat = formats::MatView(output_frame.get()); const bool has_alpha_mask = cc->Inputs().HasTag(kInputAlphaTag) && !cc->Inputs().Tag(kInputAlphaTag).IsEmpty(); const bool use_alpha_mask = alpha_value_ < 0 && has_alpha_mask; - // Setup alpha image and Update image in CPU. + // Copy rgb part of the image in CPU + if (input_mat.channels() == 3) { + cv::cvtColor(input_mat, output_mat, cv::COLOR_RGB2RGBA); + } else { + input_mat.copyTo(output_mat); + } + + // Setup alpha image in CPU. if (use_alpha_mask) { const auto& alpha_mask = cc->Inputs().Tag(kInputAlphaTag).Get(); - cv::Mat alpha_mat = mediapipe::formats::MatView(&alpha_mask); + cv::Mat alpha_mat = formats::MatView(&alpha_mask); const bool alpha_is_float = CV_MAT_DEPTH(alpha_mat.type()) == CV_32F; RET_CHECK(alpha_is_float || CV_MAT_DEPTH(alpha_mat.type()) == CV_8U); if (alpha_is_float) { - MP_RETURN_IF_ERROR( - MergeRGBA8Image(input_mat, alpha_mat, output_mat)); + MP_RETURN_IF_ERROR(CopyAlphaImage(alpha_mat, output_mat)); } else { - MP_RETURN_IF_ERROR( - MergeRGBA8Image(input_mat, alpha_mat, output_mat)); + MP_RETURN_IF_ERROR(CopyAlphaImage(alpha_mat, output_mat)); } } else { const uchar alpha_value = std::min(std::max(0.0f, alpha_value_), 255.0f); for (int i = 0; i < output_mat.rows; ++i) { - const uchar* in_ptr = input_mat.ptr(i); uchar* out_ptr = output_mat.ptr(i); for (int j = 0; j < output_mat.cols; ++j) { const int out_idx = j * kNumChannelsRGBA; - const int in_idx = j * input_mat.channels(); - out_ptr[out_idx + 0] = in_ptr[in_idx + 0]; - out_ptr[out_idx + 1] = in_ptr[in_idx + 1]; - out_ptr[out_idx + 2] = in_ptr[in_idx + 2]; out_ptr[out_idx + 3] = alpha_value; // use value from options } } diff --git a/mediapipe/calculators/image/set_alpha_calculator_test.cc b/mediapipe/calculators/image/set_alpha_calculator_test.cc new file mode 100644 index 000000000..cb2352d08 --- /dev/null +++ b/mediapipe/calculators/image/set_alpha_calculator_test.cc @@ -0,0 +1,156 @@ +#include + +#include "mediapipe/calculators/image/set_alpha_calculator.pb.h" +#include "mediapipe/framework/calculator_framework.h" +#include "mediapipe/framework/calculator_runner.h" +#include "mediapipe/framework/formats/image_frame_opencv.h" +#include "mediapipe/framework/formats/rect.pb.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/framework/port/opencv_core_inc.h" +#include "mediapipe/framework/port/opencv_imgproc_inc.h" +#include "mediapipe/framework/port/parse_text_proto.h" +#include "mediapipe/framework/port/status_matchers.h" +#include "testing/base/public/benchmark.h" + +namespace mediapipe { + +namespace { + +constexpr int input_width = 100; +constexpr int input_height = 100; + +std::unique_ptr GetInputFrame(int width, int height, int channel) { + const int total_size = width * height * channel; + + ImageFormat::Format image_format; + if (channel == 4) { + image_format = ImageFormat::SRGBA; + } else if (channel == 3) { + image_format = ImageFormat::SRGB; + } else { + image_format = ImageFormat::GRAY8; + } + + auto input_frame = std::make_unique(image_format, width, height, + /*alignment_boundary =*/1); + for (int i = 0; i < total_size; ++i) { + input_frame->MutablePixelData()[i] = i % 256; + } + return input_frame; +} + +// Test SetAlphaCalculator with RGB IMAGE input. +TEST(SetAlphaCalculatorTest, CpuRgb) { + auto calculator_node = ParseTextProtoOrDie( + R"pb( + calculator: "SetAlphaCalculator" + input_stream: "IMAGE:input_frames" + input_stream: "ALPHA:masks" + output_stream: "IMAGE:output_frames" + )pb"); + CalculatorRunner runner(calculator_node); + + // Input frames. + const auto input_frame = GetInputFrame(input_width, input_height, 3); + const auto mask_frame = GetInputFrame(input_width, input_height, 1); + auto input_frame_packet = MakePacket(std::move(*input_frame)); + auto mask_frame_packet = MakePacket(std::move(*mask_frame)); + runner.MutableInputs()->Tag("IMAGE").packets.push_back( + input_frame_packet.At(Timestamp(1))); + runner.MutableInputs()->Tag("ALPHA").packets.push_back( + mask_frame_packet.At(Timestamp(1))); + + MP_ASSERT_OK(runner.Run()); + + const auto& outputs = runner.Outputs(); + EXPECT_EQ(outputs.NumEntries(), 1); + const auto& output_image = outputs.Tag("IMAGE").packets[0].Get(); + + // Generate ground truth (expected_mat). + const auto image = GetInputFrame(input_width, input_height, 3); + const auto input_mat = formats::MatView(image.get()); + const auto mask = GetInputFrame(input_width, input_height, 1); + const auto mask_mat = formats::MatView(mask.get()); + const std::array input_mats = {input_mat, mask_mat}; + cv::Mat expected_mat(input_width, input_height, CV_8UC4); + cv::mixChannels(input_mats, {expected_mat}, {0, 0, 1, 1, 2, 2, 3, 3}); + + cv::Mat output_mat = formats::MatView(&output_image); + double max_diff = cv::norm(expected_mat, output_mat, cv::NORM_INF); + EXPECT_FLOAT_EQ(max_diff, 0); +} // TEST + +// Test SetAlphaCalculator with RGBA IMAGE input. +TEST(SetAlphaCalculatorTest, CpuRgba) { + auto calculator_node = ParseTextProtoOrDie( + R"pb( + calculator: "SetAlphaCalculator" + input_stream: "IMAGE:input_frames" + input_stream: "ALPHA:masks" + output_stream: "IMAGE:output_frames" + )pb"); + CalculatorRunner runner(calculator_node); + + // Input frames. + const auto input_frame = GetInputFrame(input_width, input_height, 4); + const auto mask_frame = GetInputFrame(input_width, input_height, 1); + auto input_frame_packet = MakePacket(std::move(*input_frame)); + auto mask_frame_packet = MakePacket(std::move(*mask_frame)); + runner.MutableInputs()->Tag("IMAGE").packets.push_back( + input_frame_packet.At(Timestamp(1))); + runner.MutableInputs()->Tag("ALPHA").packets.push_back( + mask_frame_packet.At(Timestamp(1))); + + MP_ASSERT_OK(runner.Run()); + + const auto& outputs = runner.Outputs(); + EXPECT_EQ(outputs.NumEntries(), 1); + const auto& output_image = outputs.Tag("IMAGE").packets[0].Get(); + + // Generate ground truth (expected_mat). + const auto image = GetInputFrame(input_width, input_height, 4); + const auto input_mat = formats::MatView(image.get()); + const auto mask = GetInputFrame(input_width, input_height, 1); + const auto mask_mat = formats::MatView(mask.get()); + const std::array input_mats = {input_mat, mask_mat}; + cv::Mat expected_mat(input_width, input_height, CV_8UC4); + cv::mixChannels(input_mats, {expected_mat}, {0, 0, 1, 1, 2, 2, 4, 3}); + + cv::Mat output_mat = formats::MatView(&output_image); + double max_diff = cv::norm(expected_mat, output_mat, cv::NORM_INF); + EXPECT_FLOAT_EQ(max_diff, 0); +} // TEST + +static void BM_SetAlpha3ChannelImage(benchmark::State& state) { + auto calculator_node = ParseTextProtoOrDie( + R"pb( + calculator: "SetAlphaCalculator" + input_stream: "IMAGE:input_frames" + input_stream: "ALPHA:masks" + output_stream: "IMAGE:output_frames" + )pb"); + CalculatorRunner runner(calculator_node); + + // Input frames. + const auto input_frame = GetInputFrame(input_width, input_height, 3); + const auto mask_frame = GetInputFrame(input_width, input_height, 1); + auto input_frame_packet = MakePacket(std::move(*input_frame)); + auto mask_frame_packet = MakePacket(std::move(*mask_frame)); + runner.MutableInputs()->Tag("IMAGE").packets.push_back( + input_frame_packet.At(Timestamp(1))); + runner.MutableInputs()->Tag("ALPHA").packets.push_back( + mask_frame_packet.At(Timestamp(1))); + + MP_ASSERT_OK(runner.Run()); + const auto& outputs = runner.Outputs(); + ASSERT_EQ(1, outputs.NumEntries()); + + for (const auto _ : state) { + MP_ASSERT_OK(runner.Run()); + } +} + +BENCHMARK(BM_SetAlpha3ChannelImage); + +} // namespace +} // namespace mediapipe From 384f77b5c3f470e32c05c3019b1fc3f11e37f219 Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Tue, 21 Mar 2023 11:32:35 -0700 Subject: [PATCH 123/136] Fix the proto src file names. PiperOrigin-RevId: 518332541 --- mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl b/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl index f7fba0fa2..1b80744e8 100644 --- a/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl +++ b/mediapipe/java/com/google/mediapipe/mediapipe_aar.bzl @@ -360,7 +360,7 @@ def mediapipe_java_proto_srcs(name = ""): proto_src_list.append(mediapipe_java_proto_src_extractor( target = "//mediapipe/util:color_java_proto_lite", - src_out = "com/google/mediapipe/util/proto/Color.java", + src_out = "com/google/mediapipe/util/proto/ColorProto.java", )) proto_src_list.append(mediapipe_java_proto_src_extractor( @@ -370,7 +370,7 @@ def mediapipe_java_proto_srcs(name = ""): proto_src_list.append(mediapipe_java_proto_src_extractor( target = "//mediapipe/util:render_data_java_proto_lite", - src_out = "com/google/mediapipe/util/proto/RenderData.java", + src_out = "com/google/mediapipe/util/proto/RenderDataProto.java", )) return proto_src_list From 88effb19e52f78065e520ac5e331724ad746c8cb Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 21 Mar 2023 11:45:25 -0700 Subject: [PATCH 124/136] Add build variants for _gms to some MediaPipe libraries that use TFLite This change alters some cc_library to cc_library_with_tflite to add _gms variants to some select MediaPipe libraries. This CL also makes minimal changes in the code to make the _gms variants buildable. PiperOrigin-RevId: 518336242 --- mediapipe/calculators/tflite/BUILD | 9 +++++++-- .../tflite_custom_op_resolver_calculator.cc | 4 ++-- .../tflite/tflite_model_calculator.cc | 4 ++-- mediapipe/util/tflite/BUILD | 18 +++++++++++++----- mediapipe/util/tflite/cpu_op_resolver.h | 6 +++--- mediapipe/util/tflite/op_resolver.h | 6 +++--- 6 files changed, 30 insertions(+), 17 deletions(-) diff --git a/mediapipe/calculators/tflite/BUILD b/mediapipe/calculators/tflite/BUILD index 9dcb0f733..b5b01d937 100644 --- a/mediapipe/calculators/tflite/BUILD +++ b/mediapipe/calculators/tflite/BUILD @@ -14,6 +14,7 @@ # load("//mediapipe/framework/port:build_config.bzl", "mediapipe_proto_library") +load("@org_tensorflow//tensorflow/lite/core/shims:cc_library_with_tflite.bzl", "cc_library_with_tflite") load("@bazel_skylib//lib:selects.bzl", "selects") licenses(["notice"]) @@ -312,15 +313,19 @@ cc_library( alwayslink = 1, ) -cc_library( +# TODO: Re-evaluate which of these libraries we can avoid making +# cc_library_with_tflite and can be changed back to cc_library. +cc_library_with_tflite( name = "tflite_model_calculator", srcs = ["tflite_model_calculator.cc"], + tflite_deps = [ + "@org_tensorflow//tensorflow/lite:framework_stable", + ], deps = [ "//mediapipe/framework:calculator_framework", "//mediapipe/framework:packet", "//mediapipe/framework/port:ret_check", "@com_google_absl//absl/status", - "@org_tensorflow//tensorflow/lite/core/shims:framework_stable", ], alwayslink = 1, ) diff --git a/mediapipe/calculators/tflite/tflite_custom_op_resolver_calculator.cc b/mediapipe/calculators/tflite/tflite_custom_op_resolver_calculator.cc index 3a5ae8282..950d742a9 100644 --- a/mediapipe/calculators/tflite/tflite_custom_op_resolver_calculator.cc +++ b/mediapipe/calculators/tflite/tflite_custom_op_resolver_calculator.cc @@ -66,7 +66,7 @@ class TfLiteCustomOpResolverCalculator : public CalculatorBase { } else { cc->OutputSidePackets() .Index(0) - .Set(); + .Set(); } return absl::OkStatus(); } @@ -77,7 +77,7 @@ class TfLiteCustomOpResolverCalculator : public CalculatorBase { const TfLiteCustomOpResolverCalculatorOptions& options = cc->Options(); - std::unique_ptr op_resolver; + std::unique_ptr op_resolver; if (options.use_gpu()) { op_resolver = absl::make_unique(); } else { diff --git a/mediapipe/calculators/tflite/tflite_model_calculator.cc b/mediapipe/calculators/tflite/tflite_model_calculator.cc index 435ea0127..a8a85ed78 100644 --- a/mediapipe/calculators/tflite/tflite_model_calculator.cc +++ b/mediapipe/calculators/tflite/tflite_model_calculator.cc @@ -21,7 +21,7 @@ #include "mediapipe/framework/packet.h" #include "mediapipe/framework/port/ret_check.h" #include "tensorflow/lite/allocation.h" -#include "tensorflow/lite/core/shims/cc/model.h" +#include "tensorflow/lite/model.h" namespace mediapipe { @@ -82,7 +82,7 @@ class TfLiteModelCalculator : public CalculatorBase { } if (cc->InputSidePackets().HasTag("MODEL_FD")) { -#ifdef ABSL_HAVE_MMAP +#if defined(ABSL_HAVE_MMAP) && !TFLITE_WITH_STABLE_ABI model_packet = cc->InputSidePackets().Tag("MODEL_FD"); const auto& model_fd = model_packet.Get>(); diff --git a/mediapipe/util/tflite/BUILD b/mediapipe/util/tflite/BUILD index da58d59cf..0dec0392a 100644 --- a/mediapipe/util/tflite/BUILD +++ b/mediapipe/util/tflite/BUILD @@ -30,10 +30,16 @@ cc_library( ], ) -cc_library( +# TODO: Re-evaluate which of these libraries we can avoid making +# cc_library_with_tflite and can be changed back to cc_library. +cc_library_with_tflite( name = "cpu_op_resolver", srcs = ["cpu_op_resolver.cc"], hdrs = ["cpu_op_resolver.h"], + tflite_deps = [ + "@org_tensorflow//tensorflow/lite:framework_stable", + "@org_tensorflow//tensorflow/lite/kernels:builtin_ops", + ], visibility = ["//visibility:public"], deps = [ "//mediapipe/framework/port:logging", @@ -44,8 +50,6 @@ cc_library( "//mediapipe/util/tflite/operations:transform_tensor_bilinear", "//mediapipe/util/tflite/operations:transpose_conv_bias", "@org_tensorflow//tensorflow/lite:builtin_op_data", - "@org_tensorflow//tensorflow/lite/core/shims:builtin_ops", - "@org_tensorflow//tensorflow/lite/core/shims:framework_stable", ], # For using the symbol `MediaPipe_RegisterTfLiteOpResolver` in Python # with `tensorflow.lite.python.interpreter.InterpreterWithCustomOps`. @@ -63,13 +67,17 @@ cc_library( ], ) -cc_library( +# TODO: Re-evaluate which of these libraries we can avoid making +# cc_library_with_tflite and can be changed back to cc_library. +cc_library_with_tflite( name = "op_resolver", srcs = ["op_resolver.cc"], hdrs = ["op_resolver.h"], + tflite_deps = [ + "@org_tensorflow//tensorflow/lite/kernels:builtin_ops", + ], deps = [ "@org_tensorflow//tensorflow/lite:builtin_op_data", - "@org_tensorflow//tensorflow/lite/core/shims:builtin_ops", ], ) diff --git a/mediapipe/util/tflite/cpu_op_resolver.h b/mediapipe/util/tflite/cpu_op_resolver.h index 173531f11..887683013 100644 --- a/mediapipe/util/tflite/cpu_op_resolver.h +++ b/mediapipe/util/tflite/cpu_op_resolver.h @@ -15,7 +15,7 @@ #ifndef MEDIAPIPE_UTIL_TFLITE_CPU_OP_RESOLVER_H_ #define MEDIAPIPE_UTIL_TFLITE_CPU_OP_RESOLVER_H_ -#include "tensorflow/lite/core/shims/cc/kernels/register.h" +#include "tensorflow/lite/kernels/register.h" namespace mediapipe { @@ -27,8 +27,8 @@ extern "C" void MediaPipe_RegisterTfLiteOpResolver(tflite::MutableOpResolver*); // This resolver is used for the custom ops introduced by // `MediaPipe_RegisterTfLiteOpResolver` (see above). -class CpuOpResolver : public tflite_shims::ops::builtin:: - BuiltinOpResolverWithoutDefaultDelegates { +class CpuOpResolver + : public tflite::ops::builtin::BuiltinOpResolverWithoutDefaultDelegates { public: CpuOpResolver() { MediaPipe_RegisterTfLiteOpResolver(this); } }; diff --git a/mediapipe/util/tflite/op_resolver.h b/mediapipe/util/tflite/op_resolver.h index 8b04d5f1a..4ca179ef1 100644 --- a/mediapipe/util/tflite/op_resolver.h +++ b/mediapipe/util/tflite/op_resolver.h @@ -15,13 +15,13 @@ #ifndef MEDIAPIPE_UTIL_TFLITE_OP_RESOLVER_H_ #define MEDIAPIPE_UTIL_TFLITE_OP_RESOLVER_H_ -#include "tensorflow/lite/core/shims/cc/kernels/register.h" +#include "tensorflow/lite/kernels/register.h" namespace mediapipe { // This OpResolver is used for supporting "Convolution2DTransposeBias" on GPU. -class OpResolver : public tflite_shims::ops::builtin:: - BuiltinOpResolverWithoutDefaultDelegates { +class OpResolver + : public tflite::ops::builtin::BuiltinOpResolverWithoutDefaultDelegates { public: OpResolver(); }; From 7d26daf7236c60b1bc1e04c291b632e59b45db8d Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 21 Mar 2023 13:09:43 -0700 Subject: [PATCH 125/136] Make sure calling GraphTextureFrame::getTextureName()+::release() on a non-GL thread doesn't result in a crash. PiperOrigin-RevId: 518359778 --- .../mediapipe/framework/GraphTextureFrame.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/mediapipe/java/com/google/mediapipe/framework/GraphTextureFrame.java b/mediapipe/java/com/google/mediapipe/framework/GraphTextureFrame.java index 6a2c97b94..a253a8289 100644 --- a/mediapipe/java/com/google/mediapipe/framework/GraphTextureFrame.java +++ b/mediapipe/java/com/google/mediapipe/framework/GraphTextureFrame.java @@ -66,7 +66,8 @@ public class GraphTextureFrame implements TextureFrame { if (nativeBufferHandle == 0) { return 0; } - if (activeConsumerContextHandleSet.add(nativeGetCurrentExternalContextHandle())) { + long contextHandle = nativeGetCurrentExternalContextHandle(); + if (contextHandle != 0 && activeConsumerContextHandleSet.add(contextHandle)) { // Gpu wait only if deferredSync is true, such as when this GraphTextureFrame is created using // PacketGetter.getTextureFrameDeferredSync(). if (deferredSync) { @@ -116,7 +117,14 @@ public class GraphTextureFrame implements TextureFrame { GlSyncToken consumerToken = null; // Note that this remove should be moved to the other overload of release when b/68808951 is // addressed. - if (activeConsumerContextHandleSet.remove(nativeGetCurrentExternalContextHandle())) { + final long contextHandle = nativeGetCurrentExternalContextHandle(); + if (contextHandle == 0 && !activeConsumerContextHandleSet.isEmpty()) { + logger.atWarning().log( + "GraphTextureFrame is being released on non GL thread while having active consumers," + + " which may lead to external / internal GL contexts synchronization issues."); + } + + if (contextHandle != 0 && activeConsumerContextHandleSet.remove(contextHandle)) { consumerToken = new GraphGlSyncToken(nativeCreateSyncTokenForCurrentExternalContext(nativeBufferHandle)); } @@ -169,7 +177,9 @@ public class GraphTextureFrame implements TextureFrame { private native void nativeReleaseBuffer(long nativeHandle); private native int nativeGetTextureName(long nativeHandle); + private native int nativeGetWidth(long nativeHandle); + private native int nativeGetHeight(long nativeHandle); private native void nativeGpuWait(long nativeHandle); From a5fc1d4bafb11cf97a8dccecbb8ef8c170ff87c3 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 21 Mar 2023 13:19:44 -0700 Subject: [PATCH 126/136] Use Uint8ClampedArray for pixel output PiperOrigin-RevId: 518362677 --- .../tasks/web/vision/core/render_utils.ts | 19 +++++++++---------- mediapipe/tasks/web/vision/core/types.d.ts | 6 +++--- .../image_segmenter/image_segmenter_test.ts | 2 +- .../interactive_segmenter_test.ts | 2 +- .../graph_runner/graph_runner_image_lib.ts | 2 +- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/mediapipe/tasks/web/vision/core/render_utils.ts b/mediapipe/tasks/web/vision/core/render_utils.ts index c5f931b38..879e23010 100644 --- a/mediapipe/tasks/web/vision/core/render_utils.ts +++ b/mediapipe/tasks/web/vision/core/render_utils.ts @@ -36,9 +36,8 @@ const COLOR_MAP = [ /** Helper function to draw a confidence mask */ export function drawConfidenceMask( - - ctx: CanvasRenderingContext2D, image: Float32Array|Uint8Array, - width: number, height: number): void { + ctx: CanvasRenderingContext2D, image: Float32Array, width: number, + height: number): void { const uint8ClampedArray = new Uint8ClampedArray(width * height * 4); for (let i = 0; i < image.length; i++) { uint8ClampedArray[4 * i] = 128; @@ -54,9 +53,9 @@ export function drawConfidenceMask( * for now. */ export function drawCategoryMask( - ctx: CanvasRenderingContext2D, image: Float32Array|Uint8Array, + ctx: CanvasRenderingContext2D, image: Uint8ClampedArray|Float32Array, width: number, height: number): void { - const uint8ClampedArray = new Uint8ClampedArray(width * height * 4); + const rgbaArray = new Uint8ClampedArray(width * height * 4); const isFloatArray = image instanceof Float32Array; for (let i = 0; i < image.length; i++) { const colorIndex = isFloatArray ? Math.round(image[i] * 255) : image[i]; @@ -69,10 +68,10 @@ export function drawCategoryMask( return; } - uint8ClampedArray[4 * i] = color[0]; - uint8ClampedArray[4 * i + 1] = color[1]; - uint8ClampedArray[4 * i + 2] = color[2]; - uint8ClampedArray[4 * i + 3] = color[3]; + rgbaArray[4 * i] = color[0]; + rgbaArray[4 * i + 1] = color[1]; + rgbaArray[4 * i + 2] = color[2]; + rgbaArray[4 * i + 3] = color[3]; } - ctx.putImageData(new ImageData(uint8ClampedArray, width, height), 0, 0); + ctx.putImageData(new ImageData(rgbaArray, width, height), 0, 0); } diff --git a/mediapipe/tasks/web/vision/core/types.d.ts b/mediapipe/tasks/web/vision/core/types.d.ts index b88683aae..2b9ae8f77 100644 --- a/mediapipe/tasks/web/vision/core/types.d.ts +++ b/mediapipe/tasks/web/vision/core/types.d.ts @@ -17,17 +17,17 @@ import {NormalizedKeypoint} from '../../../../tasks/web/components/containers/keypoint'; /** - * The segmentation tasks return the segmentation result as a Uint8Array + * The segmentation tasks return the segmentation result as a Uint8ClampedArray * (when the default mode of `CATEGORY_MASK` is used) or as a Float32Array (for * output type `CONFIDENCE_MASK`). The `WebGLTexture` output type is reserved * for future usage. */ -export type SegmentationMask = Uint8Array|Float32Array|WebGLTexture; +export type SegmentationMask = Uint8ClampedArray|Float32Array|WebGLTexture; /** * A callback that receives the computed masks from the segmentation tasks. The * callback either receives a single element array with a category mask (as a - * `[Uint8Array]`) or multiple confidence masks (as a `Float32Array[]`). + * `[Uint8ClampedArray]`) or multiple confidence masks (as a `Float32Array[]`). * The returned data is only valid for the duration of the callback. If * asynchronous processing is needed, all data needs to be copied before the * callback returns. diff --git a/mediapipe/tasks/web/vision/image_segmenter/image_segmenter_test.ts b/mediapipe/tasks/web/vision/image_segmenter/image_segmenter_test.ts index aa81be025..4cf27b9a5 100644 --- a/mediapipe/tasks/web/vision/image_segmenter/image_segmenter_test.ts +++ b/mediapipe/tasks/web/vision/image_segmenter/image_segmenter_test.ts @@ -159,7 +159,7 @@ describe('ImageSegmenter', () => { }); it('supports category masks', (done) => { - const mask = new Uint8Array([1, 2, 3, 4]); + const mask = new Uint8ClampedArray([1, 2, 3, 4]); // Pass the test data to our listener imageSegmenter.fakeWasmModule._waitUntilIdle.and.callFake(() => { diff --git a/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_test.ts b/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_test.ts index 4be9f7d37..d6e3a97a5 100644 --- a/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_test.ts +++ b/mediapipe/tasks/web/vision/interactive_segmenter/interactive_segmenter_test.ts @@ -154,7 +154,7 @@ describe('InteractiveSegmenter', () => { }); it('supports category masks', (done) => { - const mask = new Uint8Array([1, 2, 3, 4]); + const mask = new Uint8ClampedArray([1, 2, 3, 4]); // Pass the test data to our listener interactiveSegmenter.fakeWasmModule._waitUntilIdle.and.callFake(() => { diff --git a/mediapipe/web/graph_runner/graph_runner_image_lib.ts b/mediapipe/web/graph_runner/graph_runner_image_lib.ts index a048c434a..d9bb0568b 100644 --- a/mediapipe/web/graph_runner/graph_runner_image_lib.ts +++ b/mediapipe/web/graph_runner/graph_runner_image_lib.ts @@ -10,7 +10,7 @@ type LibConstructor = new (...args: any[]) => GraphRunner; /** An image returned from a MediaPipe graph. */ export interface WasmImage { - data: Uint8Array|Uint8ClampedArray|Float32Array; + data: Uint8ClampedArray|Float32Array; width: number; height: number; } From 8bbf2621a495a461fa561f7130e0f9e06626dfc2 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 21 Mar 2023 14:21:05 -0700 Subject: [PATCH 127/136] Typo fix PiperOrigin-RevId: 518380030 --- .../tasks/cc/vision/face_landmarker/face_landmarker_result.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h index 35dd7a8ab..bc097d6c3 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_result.h @@ -34,7 +34,7 @@ namespace face_landmarker { // The face landmarks detection result from FaceLandmarker, where each vector // element represents a single face detected in the image. struct FaceLandmarkerResult { - // Detected hand landmarks in normalized image coordinates. + // Detected face landmarks in normalized image coordinates. std::vector face_landmarks; // Optional face blendshapes results. std::optional> From b71e1d14d32da86c368018f4b2e18fab304b9329 Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Tue, 21 Mar 2023 14:35:33 -0700 Subject: [PATCH 128/136] Add missing dependency library targets to mediapipe_task_aar. PiperOrigin-RevId: 518384666 --- .../java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl index 030c7cfc9..f9e8ed907 100644 --- a/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl +++ b/mediapipe/tasks/java/com/google/mediapipe/tasks/mediapipe_tasks_aar.bzl @@ -317,10 +317,14 @@ def _mediapipe_tasks_aar(name, srcs, manifest, java_proto_lite_targets, native_l "//mediapipe/tasks/java/com/google/mediapipe/tasks/components/containers:embedding", "//mediapipe/tasks/java/com/google/mediapipe/tasks/components/containers:embeddingresult", "//mediapipe/tasks/java/com/google/mediapipe/tasks/components/containers:landmark", + "//mediapipe/tasks/java/com/google/mediapipe/tasks/components/containers:normalizedkeypoint", "//mediapipe/tasks/java/com/google/mediapipe/tasks/components/containers:normalized_landmark", "//mediapipe/tasks/java/com/google/mediapipe/tasks/components/processors:classifieroptions", "//mediapipe/tasks/java/com/google/mediapipe/tasks/components/utils:cosinesimilarity", "//mediapipe/tasks/java/com/google/mediapipe/tasks/core", + "//mediapipe/util:color_java_proto_lite", + "//mediapipe/util:label_map_java_proto_lite", + "//mediapipe/util:render_data_java_proto_lite", "//third_party:androidx_annotation", "//third_party:autovalue", "@maven//:com_google_guava_guava", From bbd21e9a6de3461f953e4b4e923c9c1a6b2f5015 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 21 Mar 2023 16:13:58 -0700 Subject: [PATCH 129/136] Add the FaceStylizer Web API PiperOrigin-RevId: 518409812 --- mediapipe/tasks/web/vision/BUILD | 1 + mediapipe/tasks/web/vision/README.md | 15 + mediapipe/tasks/web/vision/core/types.d.ts | 11 + .../web/vision/core/vision_task_runner.ts | 27 +- .../tasks/web/vision/face_stylizer/BUILD | 57 ++++ .../web/vision/face_stylizer/face_stylizer.ts | 298 ++++++++++++++++++ .../face_stylizer/face_stylizer_options.d.ts | 20 ++ .../face_stylizer/face_stylizer_test.ts | 114 +++++++ mediapipe/tasks/web/vision/index.ts | 3 + mediapipe/tasks/web/vision/types.ts | 1 + 10 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 mediapipe/tasks/web/vision/face_stylizer/BUILD create mode 100644 mediapipe/tasks/web/vision/face_stylizer/face_stylizer.ts create mode 100644 mediapipe/tasks/web/vision/face_stylizer/face_stylizer_options.d.ts create mode 100644 mediapipe/tasks/web/vision/face_stylizer/face_stylizer_test.ts diff --git a/mediapipe/tasks/web/vision/BUILD b/mediapipe/tasks/web/vision/BUILD index 37709c055..67db27ddb 100644 --- a/mediapipe/tasks/web/vision/BUILD +++ b/mediapipe/tasks/web/vision/BUILD @@ -19,6 +19,7 @@ mediapipe_files(srcs = [ VISION_LIBS = [ "//mediapipe/tasks/web/core:fileset_resolver", + "//mediapipe/tasks/web/vision/face_stylizer", "//mediapipe/tasks/web/vision/gesture_recognizer", "//mediapipe/tasks/web/vision/hand_landmarker", "//mediapipe/tasks/web/vision/image_classifier", diff --git a/mediapipe/tasks/web/vision/README.md b/mediapipe/tasks/web/vision/README.md index 2ca4ff64e..a1444e10b 100644 --- a/mediapipe/tasks/web/vision/README.md +++ b/mediapipe/tasks/web/vision/README.md @@ -2,6 +2,21 @@ This package contains the vision tasks for MediaPipe. +## Face Stylizer + +The MediaPipe Face Stylizer lets you perform face stylization on images. + +``` +const vision = await FilesetResolver.forVisionTasks( + "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm" +); +const faceStylizer = await FaceStylizer.createFromModelPath(vision, + "model.tflite" +); +const image = document.getElementById("image") as HTMLImageElement; +const stylizedImage = faceStylizer.stylize(image); +``` + ## Gesture Recognition The MediaPipe Gesture Recognizer task lets you recognize hand gestures in real diff --git a/mediapipe/tasks/web/vision/core/types.d.ts b/mediapipe/tasks/web/vision/core/types.d.ts index 2b9ae8f77..f0ac08627 100644 --- a/mediapipe/tasks/web/vision/core/types.d.ts +++ b/mediapipe/tasks/web/vision/core/types.d.ts @@ -35,6 +35,17 @@ export type SegmentationMask = Uint8ClampedArray|Float32Array|WebGLTexture; export type SegmentationMaskCallback = (masks: SegmentationMask[], width: number, height: number) => void; +/** + * A callback that receives an `ImageData` object from a Vision task. The + * lifetime of the underlying data is limited to the duration of the callback. + * If asynchronous processing is needed, all data needs to be copied before the + * callback returns. + * + * The `WebGLTexture` output type is reserved for future usage. + */ +export type ImageCallback = + (image: ImageData|WebGLTexture, width: number, height: number) => void; + /** A Region-Of-Interest (ROI) to represent a region within an image. */ export declare interface RegionOfInterest { /** The ROI in keypoint format. */ diff --git a/mediapipe/tasks/web/vision/core/vision_task_runner.ts b/mediapipe/tasks/web/vision/core/vision_task_runner.ts index 4b34aca92..f19b9f2df 100644 --- a/mediapipe/tasks/web/vision/core/vision_task_runner.ts +++ b/mediapipe/tasks/web/vision/core/vision_task_runner.ts @@ -18,7 +18,7 @@ import {NormalizedRect} from '../../../../framework/formats/rect_pb'; import {TaskRunner} from '../../../../tasks/web/core/task_runner'; import {ImageProcessingOptions} from '../../../../tasks/web/vision/core/image_processing_options'; import {GraphRunner, ImageSource} from '../../../../web/graph_runner/graph_runner'; -import {SupportImage} from '../../../../web/graph_runner/graph_runner_image_lib'; +import {SupportImage, WasmImage} from '../../../../web/graph_runner/graph_runner_image_lib'; import {SupportModelResourcesGraphService} from '../../../../web/graph_runner/register_model_resources_graph_service'; import {VisionTaskOptions} from './vision_task_options'; @@ -148,6 +148,31 @@ export abstract class VisionTaskRunner extends TaskRunner { imageSource, this.imageStreamName, timestamp ?? performance.now()); this.finishProcessing(); } + + /** Converts the RGB or RGBA Uint8Array of a WasmImage to ImageData. */ + protected convertToImageData(wasmImage: WasmImage): ImageData { + const {data, width, height} = wasmImage; + if (!(data instanceof Uint8ClampedArray)) { + throw new Error( + 'Only Uint8ClampedArray-based images can be converted to ImageData'); + } + + if (data.length === width * height * 4) { + return new ImageData(data, width, height); + } else if (data.length === width * height * 3) { + const rgba = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; ++i) { + rgba[4 * i] = data[3 * i]; + rgba[4 * i + 1] = data[3 * i + 1]; + rgba[4 * i + 2] = data[3 * i + 2]; + rgba[4 * i + 3] = 255; + } + return new ImageData(rgba, width, height); + } else { + throw new Error( + `Unsupported channel count: ${data.length / width / height}`); + } + } } diff --git a/mediapipe/tasks/web/vision/face_stylizer/BUILD b/mediapipe/tasks/web/vision/face_stylizer/BUILD new file mode 100644 index 000000000..7716d617f --- /dev/null +++ b/mediapipe/tasks/web/vision/face_stylizer/BUILD @@ -0,0 +1,57 @@ +# This contains the MediaPipe Face Stylizer Task. + +load("//mediapipe/framework/port:build_config.bzl", "mediapipe_ts_declaration", "mediapipe_ts_library") +load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") + +package(default_visibility = ["//mediapipe/tasks:internal"]) + +licenses(["notice"]) + +mediapipe_ts_library( + name = "face_stylizer", + srcs = ["face_stylizer.ts"], + deps = [ + ":face_stylizer_types", + "//mediapipe/framework:calculator_jspb_proto", + "//mediapipe/framework:calculator_options_jspb_proto", + "//mediapipe/tasks/cc/core/proto:base_options_jspb_proto", + "//mediapipe/tasks/cc/vision/face_stylizer/proto:face_stylizer_graph_options_jspb_proto", + "//mediapipe/tasks/web/core", + "//mediapipe/tasks/web/vision/core:image_processing_options", + "//mediapipe/tasks/web/vision/core:types", + "//mediapipe/tasks/web/vision/core:vision_task_runner", + "//mediapipe/web/graph_runner:graph_runner_ts", + ], +) + +mediapipe_ts_declaration( + name = "face_stylizer_types", + srcs = ["face_stylizer_options.d.ts"], + deps = [ + "//mediapipe/tasks/web/core", + "//mediapipe/tasks/web/core:classifier_options", + "//mediapipe/tasks/web/vision/core:vision_task_options", + ], +) + +mediapipe_ts_library( + name = "face_stylizer_test_lib", + testonly = True, + srcs = [ + "face_stylizer_test.ts", + ], + deps = [ + ":face_stylizer", + ":face_stylizer_types", + "//mediapipe/framework:calculator_jspb_proto", + "//mediapipe/tasks/web/core", + "//mediapipe/tasks/web/core:task_runner_test_utils", + "//mediapipe/web/graph_runner:graph_runner_image_lib_ts", + ], +) + +jasmine_node_test( + name = "face_stylizer_test", + tags = ["nomsan"], + deps = [":face_stylizer_test_lib"], +) diff --git a/mediapipe/tasks/web/vision/face_stylizer/face_stylizer.ts b/mediapipe/tasks/web/vision/face_stylizer/face_stylizer.ts new file mode 100644 index 000000000..47a4ffdfd --- /dev/null +++ b/mediapipe/tasks/web/vision/face_stylizer/face_stylizer.ts @@ -0,0 +1,298 @@ +/** + * Copyright 2022 The MediaPipe Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CalculatorGraphConfig} from '../../../../framework/calculator_pb'; +import {CalculatorOptions} from '../../../../framework/calculator_options_pb'; +import {BaseOptions as BaseOptionsProto} from '../../../../tasks/cc/core/proto/base_options_pb'; +import {FaceStylizerGraphOptions as FaceStylizerGraphOptionsProto} from '../../../../tasks/cc/vision/face_stylizer/proto/face_stylizer_graph_options_pb'; +import {WasmFileset} from '../../../../tasks/web/core/wasm_fileset'; +import {ImageProcessingOptions} from '../../../../tasks/web/vision/core/image_processing_options'; +import {ImageCallback} from '../../../../tasks/web/vision/core/types'; +import {VisionGraphRunner, VisionTaskRunner} from '../../../../tasks/web/vision/core/vision_task_runner'; +import {ImageSource, WasmModule} from '../../../../web/graph_runner/graph_runner'; +// Placeholder for internal dependency on trusted resource url + +import {FaceStylizerOptions} from './face_stylizer_options'; + +export * from './face_stylizer_options'; +export {ImageSource}; // Used in the public API + +const IMAGE_STREAM = 'image_in'; +const NORM_RECT_STREAM = 'norm_rect'; +const STYLIZED_IMAGE_STREAM = 'stylized_image'; +const FACE_STYLIZER_GRAPH = + 'mediapipe.tasks.vision.face_stylizer.FaceStylizerGraph'; + +// The OSS JS API does not support the builder pattern. +// tslint:disable:jspb-use-builder-pattern + +export {ImageCallback}; + +/** Performs face stylization on images. */ +export class FaceStylizer extends VisionTaskRunner { + private userCallback: ImageCallback = () => {}; + private readonly options: FaceStylizerGraphOptionsProto; + + /** + * Initializes the Wasm runtime and creates a new Face Stylizer from the + * provided options. + * @param wasmFileset A configuration object that provides the location of + * the Wasm binary and its loader. + * @param faceStylizerOptions The options for the Face Stylizer. Note + * that either a path to the model asset or a model buffer needs to be + * provided (via `baseOptions`). + */ + static createFromOptions( + wasmFileset: WasmFileset, + faceStylizerOptions: FaceStylizerOptions): Promise { + return VisionTaskRunner.createInstance( + FaceStylizer, /* initializeCanvas= */ true, wasmFileset, + faceStylizerOptions); + } + + /** + * Initializes the Wasm runtime and creates a new Face Stylizer based on + * the provided model asset buffer. + * @param wasmFileset A configuration object that provides the location of + * the Wasm binary and its loader. + * @param modelAssetBuffer A binary representation of the model. + */ + static createFromModelBuffer( + wasmFileset: WasmFileset, + modelAssetBuffer: Uint8Array): Promise { + return VisionTaskRunner.createInstance( + FaceStylizer, /* initializeCanvas= */ true, wasmFileset, + {baseOptions: {modelAssetBuffer}}); + } + + /** + * Initializes the Wasm runtime and creates a new Face Stylizer based on + * the path to the model asset. + * @param wasmFileset A configuration object that provides the location of + * the Wasm binary and its loader. + * @param modelAssetPath The path to the model asset. + */ + static createFromModelPath( + wasmFileset: WasmFileset, + modelAssetPath: string): Promise { + return VisionTaskRunner.createInstance( + FaceStylizer, /* initializeCanvas= */ true, wasmFileset, + {baseOptions: {modelAssetPath}}); + } + + /** @hideconstructor */ + constructor( + wasmModule: WasmModule, + glCanvas?: HTMLCanvasElement|OffscreenCanvas|null) { + super( + new VisionGraphRunner(wasmModule, glCanvas), IMAGE_STREAM, + NORM_RECT_STREAM, /* roiAllowed= */ true); + this.options = new FaceStylizerGraphOptionsProto(); + this.options.setBaseOptions(new BaseOptionsProto()); + } + + + protected override get baseOptions(): BaseOptionsProto { + return this.options.getBaseOptions()!; + } + + protected override set baseOptions(proto: BaseOptionsProto) { + this.options.setBaseOptions(proto); + } + + /** + * Sets new options for the Face Stylizer. + * + * Calling `setOptions()` with a subset of options only affects those + * options. You can reset an option back to its default value by + * explicitly setting it to `undefined`. + * + * @param options The options for the Face Stylizer. + */ + override setOptions(options: FaceStylizerOptions): Promise { + return super.applyOptions(options); + } + + + /** + * Performs face stylization on the provided single image. The method returns + * synchronously once the callback returns. Only use this method when the + * FaceStylizer is created with the image running mode. + * + * The input image can be of any size. To ensure that the output image has + * reasonable quailty, the stylized output image size is determined by the + * model output size. + * + * @param image An image to process. + * @param callback The callback that is invoked with the stylized image. The + * lifetime of the returned data is only guaranteed for the duration of the + * callback. + */ + stylize(image: ImageSource, callback: ImageCallback): void; + /** + * Performs face stylization on the provided single image. The method returns + * synchronously once the callback returns. Only use this method when the + * FaceStylizer is created with the image running mode. + * + * The 'imageProcessingOptions' parameter can be used to specify one or all + * of: + * - the rotation to apply to the image before performing stylization, by + * setting its 'rotationDegrees' property. + * - the region-of-interest on which to perform stylization, by setting its + * 'regionOfInterest' property. If not specified, the full image is used. + * If both are specified, the crop around the region-of-interest is extracted + * first, then the specified rotation is applied to the crop. + * + * The input image can be of any size. To ensure that the output image has + * reasonable quailty, the stylized output image size is the smaller of the + * model output size and the size of the 'regionOfInterest' specified in + * 'imageProcessingOptions'. + * + * @param image An image to process. + * @param imageProcessingOptions the `ImageProcessingOptions` specifying how + * to process the input image before running inference. + * @param callback The callback that is invoked with the stylized image. The + * lifetime of the returned data is only guaranteed for the duration of the + * callback. + */ + stylize( + image: ImageSource, imageProcessingOptions: ImageProcessingOptions, + callback: ImageCallback): void; + stylize( + image: ImageSource, + imageProcessingOptionsOrCallback: ImageProcessingOptions|ImageCallback, + callback?: ImageCallback): void { + const imageProcessingOptions = + typeof imageProcessingOptionsOrCallback !== 'function' ? + imageProcessingOptionsOrCallback : + {}; + + this.userCallback = typeof imageProcessingOptionsOrCallback === 'function' ? + imageProcessingOptionsOrCallback : + callback!; + this.processImageData(image, imageProcessingOptions ?? {}); + this.userCallback = () => {}; + } + + /** + * Performs face stylization on the provided video frame. Only use this method + * when the FaceStylizer is created with the video running mode. + * + * The input frame can be of any size. It's required to provide the video + * frame's timestamp (in milliseconds). The input timestamps must be + * monotonically increasing. + * + * To ensure that the output image has reasonable quality, the stylized + * output image size is determined by the model output size. + * + * @param videoFrame A video frame to process. + * @param timestamp The timestamp of the current frame, in ms. + * @param callback The callback that is invoked with the stylized image. The + * lifetime of the returned data is only guaranteed for the duration of + * the callback. + */ + stylizeForVideo( + videoFrame: ImageSource, timestamp: number, + callback: ImageCallback): void; + /** + * Performs face stylization on the provided video frame. Only use this + * method when the FaceStylizer is created with the video running mode. + * + * The 'imageProcessingOptions' parameter can be used to specify one or all + * of: + * - the rotation to apply to the image before performing stylization, by + * setting its 'rotationDegrees' property. + * - the region-of-interest on which to perform stylization, by setting its + * 'regionOfInterest' property. If not specified, the full image is used. + * If both are specified, the crop around the region-of-interest is + * extracted first, then the specified rotation is applied to the crop. + * + * The input frame can be of any size. It's required to provide the video + * frame's timestamp (in milliseconds). The input timestamps must be + * monotonically increasing. + * + * To ensure that the output image has reasonable quailty, the stylized + * output image size is the smaller of the model output size and the size of + * the 'regionOfInterest' specified in 'imageProcessingOptions'. + * + * @param videoFrame A video frame to process. + * @param imageProcessingOptions the `ImageProcessingOptions` specifying how + * to process the input image before running inference. + * @param timestamp The timestamp of the current frame, in ms. + * @param callback The callback that is invoked with the stylized image. The + * lifetime of the returned data is only guaranteed for the duration of + * the callback. + */ + stylizeForVideo( + videoFrame: ImageSource, imageProcessingOptions: ImageProcessingOptions, + timestamp: number, callback: ImageCallback): void; + stylizeForVideo( + videoFrame: ImageSource, + timestampOrImageProcessingOptions: number|ImageProcessingOptions, + timestampOrCallback: number|ImageCallback, + callback?: ImageCallback): void { + const imageProcessingOptions = + typeof timestampOrImageProcessingOptions !== 'number' ? + timestampOrImageProcessingOptions : + {}; + const timestamp = typeof timestampOrImageProcessingOptions === 'number' ? + timestampOrImageProcessingOptions : + timestampOrCallback as number; + + this.userCallback = typeof timestampOrCallback === 'function' ? + timestampOrCallback : + callback!; + this.processVideoData(videoFrame, imageProcessingOptions, timestamp); + this.userCallback = () => {}; + } + + /** Updates the MediaPipe graph configuration. */ + protected override refreshGraph(): void { + const graphConfig = new CalculatorGraphConfig(); + graphConfig.addInputStream(IMAGE_STREAM); + graphConfig.addInputStream(NORM_RECT_STREAM); + graphConfig.addOutputStream(STYLIZED_IMAGE_STREAM); + + const calculatorOptions = new CalculatorOptions(); + calculatorOptions.setExtension( + FaceStylizerGraphOptionsProto.ext, this.options); + + const segmenterNode = new CalculatorGraphConfig.Node(); + segmenterNode.setCalculator(FACE_STYLIZER_GRAPH); + segmenterNode.addInputStream('IMAGE:' + IMAGE_STREAM); + segmenterNode.addInputStream('NORM_RECT:' + NORM_RECT_STREAM); + segmenterNode.addOutputStream('STYLIZED_IMAGE:' + STYLIZED_IMAGE_STREAM); + segmenterNode.setOptions(calculatorOptions); + + graphConfig.addNode(segmenterNode); + + this.graphRunner.attachImageListener( + STYLIZED_IMAGE_STREAM, (image, timestamp) => { + const imageData = this.convertToImageData(image); + this.userCallback(imageData, image.width, image.height); + this.setLatestOutputTimestamp(timestamp); + }); + this.graphRunner.attachEmptyPacketListener( + STYLIZED_IMAGE_STREAM, timestamp => { + this.setLatestOutputTimestamp(timestamp); + }); + + const binaryGraph = graphConfig.serializeBinary(); + this.setGraph(new Uint8Array(binaryGraph), /* isBinary= */ true); + } +} + + diff --git a/mediapipe/tasks/web/vision/face_stylizer/face_stylizer_options.d.ts b/mediapipe/tasks/web/vision/face_stylizer/face_stylizer_options.d.ts new file mode 100644 index 000000000..38f5028c0 --- /dev/null +++ b/mediapipe/tasks/web/vision/face_stylizer/face_stylizer_options.d.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2022 The MediaPipe Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {VisionTaskOptions} from '../../../../tasks/web/vision/core/vision_task_options'; + +/** Options to configure the MediaPipe Face Stylizer Task */ +export interface FaceStylizerOptions extends VisionTaskOptions {} diff --git a/mediapipe/tasks/web/vision/face_stylizer/face_stylizer_test.ts b/mediapipe/tasks/web/vision/face_stylizer/face_stylizer_test.ts new file mode 100644 index 000000000..72d540797 --- /dev/null +++ b/mediapipe/tasks/web/vision/face_stylizer/face_stylizer_test.ts @@ -0,0 +1,114 @@ +/** + * Copyright 2022 The MediaPipe Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'jasmine'; + +// Placeholder for internal dependency on encodeByteArray +import {CalculatorGraphConfig} from '../../../../framework/calculator_pb'; +import {addJasmineCustomFloatEqualityTester, createSpyWasmModule, MediapipeTasksFake, SpyWasmModule, verifyGraph, verifyListenersRegistered} from '../../../../tasks/web/core/task_runner_test_utils'; +import {WasmImage} from '../../../../web/graph_runner/graph_runner_image_lib'; + +import {FaceStylizer} from './face_stylizer'; + +class FaceStylizerFake extends FaceStylizer implements MediapipeTasksFake { + calculatorName = 'mediapipe.tasks.vision.face_stylizer.FaceStylizerGraph'; + attachListenerSpies: jasmine.Spy[] = []; + graph: CalculatorGraphConfig|undefined; + + fakeWasmModule: SpyWasmModule; + imageListener: ((images: WasmImage, timestamp: number) => void)|undefined; + + constructor() { + super(createSpyWasmModule(), /* glCanvas= */ null); + this.fakeWasmModule = + this.graphRunner.wasmModule as unknown as SpyWasmModule; + + this.attachListenerSpies[0] = + spyOn(this.graphRunner, 'attachImageListener') + .and.callFake((stream, listener) => { + expect(stream).toEqual('stylized_image'); + this.imageListener = listener; + }); + spyOn(this.graphRunner, 'setGraph').and.callFake(binaryGraph => { + this.graph = CalculatorGraphConfig.deserializeBinary(binaryGraph); + }); + spyOn(this.graphRunner, 'addGpuBufferAsImageToStream'); + } +} + +describe('FaceStylizer', () => { + let faceStylizer: FaceStylizerFake; + + beforeEach(async () => { + addJasmineCustomFloatEqualityTester(); + faceStylizer = new FaceStylizerFake(); + await faceStylizer.setOptions( + {baseOptions: {modelAssetBuffer: new Uint8Array([])}}); + }); + + it('initializes graph', async () => { + verifyGraph(faceStylizer); + verifyListenersRegistered(faceStylizer); + }); + + it('can use custom models', async () => { + const newModel = new Uint8Array([0, 1, 2, 3, 4]); + const newModelBase64 = Buffer.from(newModel).toString('base64'); + await faceStylizer.setOptions({ + baseOptions: { + modelAssetBuffer: newModel, + } + }); + + verifyGraph( + faceStylizer, + /* expectedCalculatorOptions= */ undefined, + /* expectedBaseOptions= */ + [ + 'modelAsset', { + fileContent: newModelBase64, + fileName: undefined, + fileDescriptorMeta: undefined, + filePointerMeta: undefined + } + ]); + }); + + it('invokes callback', (done) => { + if (typeof ImageData === 'undefined') { + console.log('ImageData tests are not supported on Node'); + done(); + return; + } + + // Pass the test data to our listener + faceStylizer.fakeWasmModule._waitUntilIdle.and.callFake(() => { + verifyListenersRegistered(faceStylizer); + faceStylizer.imageListener! + ({data: new Uint8ClampedArray([1, 1, 1, 1]), width: 1, height: 1}, + /* timestamp= */ 1337); + }); + + // Invoke the face stylizeer + faceStylizer.stylize({} as HTMLImageElement, (image, width, height) => { + expect(faceStylizer.fakeWasmModule._waitUntilIdle).toHaveBeenCalled(); + expect(image).toBeInstanceOf(ImageData); + expect(width).toEqual(1); + expect(height).toEqual(1); + done(); + }); + }); +}); diff --git a/mediapipe/tasks/web/vision/index.ts b/mediapipe/tasks/web/vision/index.ts index fdbb1a65a..7fca725ec 100644 --- a/mediapipe/tasks/web/vision/index.ts +++ b/mediapipe/tasks/web/vision/index.ts @@ -15,6 +15,7 @@ */ import {FilesetResolver as FilesetResolverImpl} from '../../../tasks/web/core/fileset_resolver'; +import {FaceStylizer as FaceStylizerImpl} from '../../../tasks/web/vision/face_stylizer/face_stylizer'; import {GestureRecognizer as GestureRecognizerImpl} from '../../../tasks/web/vision/gesture_recognizer/gesture_recognizer'; import {HandLandmarker as HandLandmarkerImpl} from '../../../tasks/web/vision/hand_landmarker/hand_landmarker'; import {ImageClassifier as ImageClassifierImpl} from '../../../tasks/web/vision/image_classifier/image_classifier'; @@ -26,6 +27,7 @@ import {ObjectDetector as ObjectDetectorImpl} from '../../../tasks/web/vision/ob // Declare the variables locally so that Rollup in OSS includes them explicitly // as exports. const FilesetResolver = FilesetResolverImpl; +const FaceStylizer = FaceStylizerImpl; const GestureRecognizer = GestureRecognizerImpl; const HandLandmarker = HandLandmarkerImpl; const ImageClassifier = ImageClassifierImpl; @@ -36,6 +38,7 @@ const ObjectDetector = ObjectDetectorImpl; export { FilesetResolver, + FaceStylizer, GestureRecognizer, HandLandmarker, ImageClassifier, diff --git a/mediapipe/tasks/web/vision/types.ts b/mediapipe/tasks/web/vision/types.ts index fa6939460..7836192a0 100644 --- a/mediapipe/tasks/web/vision/types.ts +++ b/mediapipe/tasks/web/vision/types.ts @@ -15,6 +15,7 @@ */ export * from '../../../tasks/web/core/fileset_resolver'; +export * from '../../../tasks/web/vision/face_stylizer/face_stylizer'; export * from '../../../tasks/web/vision/gesture_recognizer/gesture_recognizer'; export * from '../../../tasks/web/vision/hand_landmarker/hand_landmarker'; export * from '../../../tasks/web/vision/image_classifier/image_classifier'; From 788e8d87772684aa57e0c26b3620fa83ac82bdb0 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 21 Mar 2023 16:15:11 -0700 Subject: [PATCH 130/136] Fix crash when FaceLandmarker does not return a result PiperOrigin-RevId: 518410060 --- .../geometry_pipeline_calculator.cc | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc b/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc index 4fbbf08e0..9dead7289 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc +++ b/mediapipe/tasks/cc/vision/face_geometry/calculators/geometry_pipeline_calculator.cc @@ -169,12 +169,11 @@ class GeometryPipelineCalculator : public CalculatorBase { } absl::Status Process(CalculatorContext* cc) override { - // Both the `IMAGE_SIZE` and the `MULTI_FACE_LANDMARKS` streams are required - // to have a non-empty packet. In case this requirement is not met, there's - // nothing to be processed at the current timestamp. - if (cc->Inputs().Tag(kImageSizeTag).IsEmpty() || - (cc->Inputs().Tag(kMultiFaceLandmarksTag).IsEmpty() && - cc->Inputs().Tag(kFaceLandmarksTag).IsEmpty())) { + // Both the `IMAGE_SIZE` and either the `FACE_LANDMARKS` or + // `MULTI_FACE_LANDMARKS` streams are required to have a non-empty packet. + // In case this requirement is not met, there's nothing to be processed at + // the current timestamp and we return early (checked here and below). + if (cc->Inputs().Tag(kImageSizeTag).IsEmpty()) { return absl::OkStatus(); } @@ -182,6 +181,10 @@ class GeometryPipelineCalculator : public CalculatorBase { cc->Inputs().Tag(kImageSizeTag).Get>(); if (cc->Inputs().HasTag(kMultiFaceLandmarksTag)) { + if (cc->Inputs().Tag(kMultiFaceLandmarksTag).IsEmpty()) { + return absl::OkStatus(); + } + const auto& multi_face_landmarks = cc->Inputs() .Tag(kMultiFaceLandmarksTag) @@ -202,10 +205,14 @@ class GeometryPipelineCalculator : public CalculatorBase { .AddPacket(mediapipe::Adopt>( multi_face_geometry.release()) .At(cc->InputTimestamp())); - } else { + } else if (cc->Inputs().HasTag(kFaceLandmarksTag)) { + if (cc->Inputs().Tag(kFaceLandmarksTag).IsEmpty()) { + return absl::OkStatus(); + } + const auto& face_landmarks = cc->Inputs() - .Tag(kMultiFaceLandmarksTag) + .Tag(kFaceLandmarksTag) .Get(); ASSIGN_OR_RETURN( From 18b4caa7f3866e1ffaacb86a0a1c1c9bcdd834bc Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Wed, 22 Mar 2023 07:12:05 -0700 Subject: [PATCH 131/136] Internal change PiperOrigin-RevId: 518559368 --- mediapipe/framework/profiler/BUILD | 6 +-- mediapipe/gpu/BUILD | 48 +++++++++---------- .../com/google/mediapipe/framework/jni/BUILD | 30 ++++++------ mediapipe/util/BUILD | 10 ++-- 4 files changed, 47 insertions(+), 47 deletions(-) diff --git a/mediapipe/framework/profiler/BUILD b/mediapipe/framework/profiler/BUILD index 6184ed45b..38d72b265 100644 --- a/mediapipe/framework/profiler/BUILD +++ b/mediapipe/framework/profiler/BUILD @@ -315,11 +315,11 @@ cc_library( visibility = ["//visibility:private"], deps = [ "@com_google_absl//absl/flags:flag", + "//mediapipe/framework/deps:file_path", "//mediapipe/framework/port:logging", "//mediapipe/framework/port:ret_check", - "//mediapipe/framework/port:statusor", "//mediapipe/framework/port:status", - "//mediapipe/framework/deps:file_path", + "//mediapipe/framework/port:statusor", ] + select({ "//conditions:default": [ "//mediapipe/framework/port:file_helpers", @@ -328,8 +328,8 @@ cc_library( "//mediapipe/framework/port:file_helpers", ], "//mediapipe:android": [ - "//mediapipe/java/com/google/mediapipe/framework/jni:jni_util", "//mediapipe/framework/port:file_helpers", + "//mediapipe/java/com/google/mediapipe/framework/jni:jni_util", ], "//mediapipe:apple": [ "//mediapipe/framework/port:file_helpers", diff --git a/mediapipe/gpu/BUILD b/mediapipe/gpu/BUILD index 018b1b247..9b538a7f2 100644 --- a/mediapipe/gpu/BUILD +++ b/mediapipe/gpu/BUILD @@ -288,16 +288,16 @@ cc_library( deps = [ ":gpu_buffer_format", ":gpu_buffer_storage", + ":gpu_buffer_storage_image_frame", "@com_google_absl//absl/functional:bind_front", "@com_google_absl//absl/strings", "@com_google_absl//absl/synchronization", "//mediapipe/framework/formats:image_frame", "//mediapipe/framework/port:logging", - ":gpu_buffer_storage_image_frame", ] + select({ "//conditions:default": [ - ":gl_texture_view", ":gl_texture_buffer", + ":gl_texture_view", ], ":platform_ios_with_gpu": [ ":gl_texture_view", @@ -305,9 +305,9 @@ cc_library( "//mediapipe/objc:CFHolder", ], ":platform_macos_with_gpu": [ - "//mediapipe/objc:CFHolder", - ":gl_texture_view", ":gl_texture_buffer", + ":gl_texture_view", + "//mediapipe/objc:CFHolder", ], ":disable_gpu": [], }) + select({ @@ -324,9 +324,9 @@ cc_library( hdrs = ["gpu_buffer_format.h"], visibility = ["//visibility:public"], deps = [ - "//mediapipe/framework/formats:image_format_cc_proto", "@com_google_absl//absl/container:flat_hash_map", "//mediapipe/framework/deps:no_destructor", + "//mediapipe/framework/formats:image_format_cc_proto", "//mediapipe/framework/port:logging", ] + select({ "//conditions:default": [ @@ -613,22 +613,22 @@ cc_library( }), visibility = ["//visibility:private"], deps = [ - ":gl_context_options_cc_proto", - ":graph_support", - "//mediapipe/framework:calculator_context", - "//mediapipe/framework:executor", - "//mediapipe/framework:calculator_node", - "//mediapipe/framework/port:ret_check", - "//mediapipe/framework/deps:no_destructor", ":gl_base", ":gl_context", + ":gl_context_options_cc_proto", ":gpu_buffer_multi_pool", ":gpu_shared_data_header", + ":graph_support", + "//mediapipe/framework:calculator_context", + "//mediapipe/framework:calculator_node", + "//mediapipe/framework:executor", + "//mediapipe/framework/deps:no_destructor", + "//mediapipe/framework/port:ret_check", ] + select({ "//conditions:default": [], "//mediapipe:apple": [ - ":metal_shared_resources", ":cv_texture_cache_manager", + ":metal_shared_resources", ], }), ) @@ -697,13 +697,13 @@ cc_library( ":gpu_buffer", ":gpu_shared_data_header", ":multi_pool", + "@com_google_absl//absl/hash", + "@com_google_absl//absl/memory", + "@com_google_absl//absl/synchronization", "//mediapipe/framework:calculator_context", "//mediapipe/framework:calculator_node", "//mediapipe/framework/port:logging", "//mediapipe/util:resource_cache", - "@com_google_absl//absl/hash", - "@com_google_absl//absl/memory", - "@com_google_absl//absl/synchronization", ] + select({ "//conditions:default": [ ":gl_texture_buffer", @@ -719,9 +719,9 @@ cc_library( "//mediapipe:macos": [ ":cv_pixel_buffer_pool_wrapper", ":cv_texture_cache_manager", - ":pixel_buffer_pool_util", ":gl_texture_buffer", ":gl_texture_buffer_pool", + ":pixel_buffer_pool_util", ], }), ) @@ -789,31 +789,31 @@ cc_library( ":gpu_buffer", ":gpu_buffer_format", ":gpu_buffer_multi_pool", - ":gpu_shared_data_internal", ":gpu_service", + ":gpu_shared_data_internal", ":graph_support", ":image_frame_view", ":shader_util", - "//mediapipe/framework:calculator_framework", "@com_google_absl//absl/base:core_headers", + "@com_google_absl//absl/memory", + "@com_google_absl//absl/synchronization", "//mediapipe/framework:calculator_context", - "//mediapipe/framework:calculator_node", "//mediapipe/framework:calculator_contract", + "//mediapipe/framework:calculator_framework", + "//mediapipe/framework:calculator_node", "//mediapipe/framework:demangle", "//mediapipe/framework:legacy_calculator_support", "//mediapipe/framework:packet", "//mediapipe/framework:packet_set", "//mediapipe/framework:packet_type", "//mediapipe/framework:timestamp", + "//mediapipe/framework/deps:registration", "//mediapipe/framework/formats:image", "//mediapipe/framework/formats:image_frame", "//mediapipe/framework/port:logging", + "//mediapipe/framework/port:map_util", "//mediapipe/framework/port:ret_check", "//mediapipe/framework/port:status", - "@com_google_absl//absl/memory", - "@com_google_absl//absl/synchronization", - "//mediapipe/framework/deps:registration", - "//mediapipe/framework/port:map_util", ] + select({ "//conditions:default": [ ], diff --git a/mediapipe/java/com/google/mediapipe/framework/jni/BUILD b/mediapipe/java/com/google/mediapipe/framework/jni/BUILD index 4540f63a6..fa9ccffe9 100644 --- a/mediapipe/java/com/google/mediapipe/framework/jni/BUILD +++ b/mediapipe/java/com/google/mediapipe/framework/jni/BUILD @@ -30,11 +30,11 @@ cc_library( "compat_jni.cc", "graph.cc", "graph_jni.cc", + "graph_profiler_jni.cc", "graph_service_jni.cc", "packet_context_jni.cc", "packet_creator_jni.cc", "packet_getter_jni.cc", - "graph_profiler_jni.cc", ] + select({ "//conditions:default": [], "//mediapipe:android": [ @@ -54,11 +54,11 @@ cc_library( "compat_jni.h", "graph.h", "graph_jni.h", + "graph_profiler_jni.h", "graph_service_jni.h", "packet_context_jni.h", "packet_creator_jni.h", "packet_getter_jni.h", - "graph_profiler_jni.h", ] + select({ "//conditions:default": [], "//mediapipe:android": [ @@ -84,40 +84,40 @@ cc_library( deps = [ ":class_registry", ":jni_util", - "//mediapipe/framework/formats:image_format_cc_proto", - "//mediapipe/framework/formats:time_series_header_cc_proto", - "//mediapipe/framework:calculator_cc_proto", - "//mediapipe/framework:calculator_profile_cc_proto", - "//mediapipe/framework:calculator_framework", "@com_google_absl//absl/strings", "@com_google_absl//absl/strings:str_format", "@com_google_absl//absl/synchronization", "@eigen_archive//:eigen3", + "//mediapipe/framework:calculator_cc_proto", + "//mediapipe/framework:calculator_framework", + "//mediapipe/framework:calculator_profile_cc_proto", "//mediapipe/framework:camera_intrinsics", "//mediapipe/framework/formats:image", + "//mediapipe/framework/formats:image_format_cc_proto", "//mediapipe/framework/formats:image_frame", "//mediapipe/framework/formats:matrix", + "//mediapipe/framework/formats:time_series_header_cc_proto", "//mediapipe/framework/formats:video_stream_header", - "//mediapipe/framework/stream_handler:fixed_size_input_stream_handler", - "//mediapipe/framework/tool:name_util", - "//mediapipe/framework/tool:executor_util", "//mediapipe/framework/port:core_proto", "//mediapipe/framework/port:logging", - "//mediapipe/framework/port:threadpool", "//mediapipe/framework/port:singleton", "//mediapipe/framework/port:status", + "//mediapipe/framework/port:threadpool", + "//mediapipe/framework/stream_handler:fixed_size_input_stream_handler", + "//mediapipe/framework/tool:executor_util", + "//mediapipe/framework/tool:name_util", ] + select({ "//conditions:default": [ "//mediapipe/framework/port:file_helpers", ], "//mediapipe:android": [ - "//mediapipe/util/android/file/base", "//mediapipe/util/android:asset_manager_util", + "//mediapipe/util/android/file/base", ], }) + select({ "//conditions:default": [ - "//mediapipe/gpu:gl_quad_renderer", "//mediapipe/gpu:gl_calculator_helper", + "//mediapipe/gpu:gl_quad_renderer", "//mediapipe/gpu:gl_surface_sink_calculator", "//mediapipe/gpu:gpu_buffer", "//mediapipe/gpu:gpu_shared_data_internal", @@ -153,9 +153,9 @@ cc_library( srcs = ["class_registry.cc"], hdrs = ["class_registry.h"], deps = [ + "@com_google_absl//absl/container:node_hash_map", "@com_google_absl//absl/strings", "@com_google_absl//absl/strings:str_format", - "@com_google_absl//absl/container:node_hash_map", ] + select({ "//conditions:default": [ ], @@ -172,9 +172,9 @@ cc_library( ":class_registry", ":loose_headers", ":mediapipe_framework_jni", + "@com_google_absl//absl/container:node_hash_map", "@com_google_absl//absl/strings", "@com_google_absl//absl/strings:str_format", - "@com_google_absl//absl/container:node_hash_map", "//mediapipe/framework/port:logging", ] + select({ "//conditions:default": [ diff --git a/mediapipe/util/BUILD b/mediapipe/util/BUILD index 59d03361e..9c9c19b2b 100644 --- a/mediapipe/util/BUILD +++ b/mediapipe/util/BUILD @@ -198,15 +198,15 @@ cc_library( visibility = ["//visibility:public"], deps = [ ":resource_util_custom", + "@com_google_absl//absl/strings", "@com_google_absl//absl/strings:str_format", "//mediapipe/framework/deps:file_path", + "//mediapipe/framework/port:file_helpers", "//mediapipe/framework/port:logging", "//mediapipe/framework/port:ret_check", "//mediapipe/framework/port:singleton", "//mediapipe/framework/port:status", "//mediapipe/framework/port:statusor", - "//mediapipe/framework/port:file_helpers", - "@com_google_absl//absl/strings", ] + select({ "//conditions:default": [ "@com_google_absl//absl/flags:flag", @@ -253,13 +253,13 @@ cc_library( ], visibility = ["//visibility:public"], deps = [ + "@com_google_absl//absl/strings:str_format", + "@com_google_absl//absl/types:variant", "//mediapipe/framework/formats:detection_cc_proto", "//mediapipe/framework/formats:location", "//mediapipe/framework/formats:location_opencv", - "@com_google_absl//absl/strings:str_format", - "@com_google_absl//absl/types:variant", - "//mediapipe/framework/port:status", "//mediapipe/framework/port:map_util", + "//mediapipe/framework/port:status", ] + select({ "//conditions:default": [ "@org_tensorflow//tensorflow/core:framework", From 21e0ff3d4e017515c228af1409e21453bf707efa Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Wed, 22 Mar 2023 08:19:49 -0700 Subject: [PATCH 132/136] Skip unnecessary cpu<->gpu conversion if the input and output are already on the same storage. PiperOrigin-RevId: 518573284 --- mediapipe/calculators/image/image_clone_calculator.cc | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/mediapipe/calculators/image/image_clone_calculator.cc b/mediapipe/calculators/image/image_clone_calculator.cc index 1e76848b1..6660d55b4 100644 --- a/mediapipe/calculators/image/image_clone_calculator.cc +++ b/mediapipe/calculators/image/image_clone_calculator.cc @@ -81,7 +81,8 @@ class ImageCloneCalculator : public Node { absl::Status Process(CalculatorContext* cc) override { std::unique_ptr output; const auto& input = *kIn(cc); - if (input.UsesGpu()) { + bool input_on_gpu = input.UsesGpu(); + if (input_on_gpu) { #if !MEDIAPIPE_DISABLE_GPU // Create an output Image that co-owns the underlying texture buffer as // the input Image. @@ -97,15 +98,15 @@ class ImageCloneCalculator : public Node { // Image. This ensures a correct life span of the shared pixel data. output = std::make_unique(std::make_unique( input.image_format(), input.width(), input.height(), input.step(), - const_cast(input.GetImageFrameSharedPtr()->PixelData()), - [packet_copy_ptr](uint8*) { delete packet_copy_ptr; })); + const_cast(input.GetImageFrameSharedPtr()->PixelData()), + [packet_copy_ptr](uint8_t*) { delete packet_copy_ptr; })); } - if (output_on_gpu_) { + if (output_on_gpu_ && !input_on_gpu) { #if !MEDIAPIPE_DISABLE_GPU gpu_helper_.RunInGlContext([&output]() { output->ConvertToGpu(); }); #endif // !MEDIAPIPE_DISABLE_GPU - } else { + } else if (!output_on_gpu_ && input_on_gpu) { output->ConvertToCpu(); } kOut(cc).Send(std::move(output)); From cb2ee877057a691723b046cccd947036fdc88587 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Wed, 22 Mar 2023 09:31:57 -0700 Subject: [PATCH 133/136] Internal change PiperOrigin-RevId: 518591192 --- mediapipe/model_maker/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/model_maker/requirements.txt b/mediapipe/model_maker/requirements.txt index d7e4a950f..29e5426e0 100644 --- a/mediapipe/model_maker/requirements.txt +++ b/mediapipe/model_maker/requirements.txt @@ -5,4 +5,4 @@ opencv-python tensorflow>=2.10 tensorflow-datasets tensorflow-hub -tf-models-official>=2.10.1 +tf-models-official>=2.11.4 From eda8cb6b425cb31cf29e4f8233fe6092ec642b80 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 22 Mar 2023 10:17:30 -0700 Subject: [PATCH 134/136] Typo fix PiperOrigin-RevId: 518603982 --- .../face_landmarker/face_landmarker_test.cc | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_test.cc b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_test.cc index 0b6d9af73..033f92cf1 100644 --- a/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_test.cc +++ b/mediapipe/tasks/cc/vision/face_landmarker/face_landmarker_test.cc @@ -65,7 +65,7 @@ constexpr char kFaceLandmarkerWithBlendshapesModelBundleName[] = constexpr char kPortraitImageName[] = "portrait.jpg"; constexpr char kPortraitExpectedFaceLandamrksName[] = "portrait_expected_face_landmarks.pbtxt"; -constexpr char kPortraitExpectedFaceLandamrksWithAttentionName[] = +constexpr char kPortraitExpectedFaceLandmarksWithAttentionName[] = "portrait_expected_face_landmarks_with_attention.pbtxt"; constexpr char kPortraitExpectedBlendshapesName[] = "portrait_expected_blendshapes_with_attention.pbtxt"; @@ -250,7 +250,7 @@ INSTANTIATE_TEST_SUITE_P( /* expected_result= */ ConvertToFaceLandmarkerResult( {GetExpectedProto( - kPortraitExpectedFaceLandamrksWithAttentionName)})}, + kPortraitExpectedFaceLandmarksWithAttentionName)})}, FaceLandmarkerTestParams{ /* test_name= */ "PortraitWithBlendshapes", /* input_model_name= */ @@ -260,7 +260,7 @@ INSTANTIATE_TEST_SUITE_P( /* expected_result= */ ConvertToFaceLandmarkerResult( {GetExpectedProto( - kPortraitExpectedFaceLandamrksWithAttentionName)}, + kPortraitExpectedFaceLandmarksWithAttentionName)}, {{GetExpectedProto( kPortraitExpectedBlendshapesName)}})}, FaceLandmarkerTestParams{ @@ -273,7 +273,7 @@ INSTANTIATE_TEST_SUITE_P( /* expected_result= */ ConvertToFaceLandmarkerResult( {GetExpectedProto( - kPortraitExpectedFaceLandamrksWithAttentionName)}, + kPortraitExpectedFaceLandmarksWithAttentionName)}, {{GetExpectedProto( kPortraitExpectedBlendshapesName)}}, {{MakePortraitExpectedFacialTransformationMatrix()}})}), @@ -336,7 +336,7 @@ INSTANTIATE_TEST_SUITE_P( /* expected_result= */ ConvertToFaceLandmarkerResult( {GetExpectedProto( - kPortraitExpectedFaceLandamrksWithAttentionName)})}, + kPortraitExpectedFaceLandmarksWithAttentionName)})}, FaceLandmarkerTestParams{ /* test_name= */ "PortraitWithBlendshapes", /* input_model_name= */ @@ -346,7 +346,7 @@ INSTANTIATE_TEST_SUITE_P( /* expected_result= */ ConvertToFaceLandmarkerResult( {GetExpectedProto( - kPortraitExpectedFaceLandamrksWithAttentionName)}, + kPortraitExpectedFaceLandmarksWithAttentionName)}, {{GetExpectedProto( kPortraitExpectedBlendshapesName)}})}), [](const TestParamInfo& info) { @@ -431,7 +431,7 @@ INSTANTIATE_TEST_SUITE_P( /* expected_result= */ ConvertToFaceLandmarkerResult( {GetExpectedProto( - kPortraitExpectedFaceLandamrksWithAttentionName)})}, + kPortraitExpectedFaceLandmarksWithAttentionName)})}, FaceLandmarkerTestParams{ /* test_name= */ "PortraitWithBlendshapes", /* input_model_name= */ @@ -441,7 +441,7 @@ INSTANTIATE_TEST_SUITE_P( /* expected_result= */ ConvertToFaceLandmarkerResult( {GetExpectedProto( - kPortraitExpectedFaceLandamrksWithAttentionName)}, + kPortraitExpectedFaceLandmarksWithAttentionName)}, {{GetExpectedProto( kPortraitExpectedBlendshapesName)}})}), [](const TestParamInfo& info) { From 37111e8fa58ac942e68f958da8b7be7a9390e58a Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Wed, 22 Mar 2023 13:26:49 -0700 Subject: [PATCH 135/136] Internal change PiperOrigin-RevId: 518657355 --- .../gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 61574 bytes .../gradle/wrapper/gradle-wrapper.properties | 3 +- mediapipe/examples/android/solutions/gradlew | 285 +++++++++++------- .../examples/android/solutions/gradlew.bat | 181 +++++------ 4 files changed, 266 insertions(+), 203 deletions(-) diff --git a/mediapipe/examples/android/solutions/gradle/wrapper/gradle-wrapper.jar b/mediapipe/examples/android/solutions/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..943f0cbfa754578e88a3dae77fce6e3dea56edbf 100644 GIT binary patch delta 39316 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 delta 36846 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 CcabXq diff --git a/mediapipe/examples/android/solutions/gradle/wrapper/gradle-wrapper.properties b/mediapipe/examples/android/solutions/gradle/wrapper/gradle-wrapper.properties index 070cb702f..508322917 100644 --- a/mediapipe/examples/android/solutions/gradle/wrapper/gradle-wrapper.properties +++ b/mediapipe/examples/android/solutions/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/mediapipe/examples/android/solutions/gradlew b/mediapipe/examples/android/solutions/gradlew index 4f906e0c8..65dcd68d6 100755 --- a/mediapipe/examples/android/solutions/gradlew +++ b/mediapipe/examples/android/solutions/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/mediapipe/examples/android/solutions/gradlew.bat b/mediapipe/examples/android/solutions/gradlew.bat index ac1b06f93..93e3f59f1 100755 --- a/mediapipe/examples/android/solutions/gradlew.bat +++ b/mediapipe/examples/android/solutions/gradlew.bat @@ -1,89 +1,92 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega From 1a7be8a4c15534a86af46d7ff2f12633b9bff1d0 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 22 Mar 2023 20:41:08 -0700 Subject: [PATCH 136/136] Internal change PiperOrigin-RevId: 518747623 --- .../vision/image_segmenter/calculators/BUILD | 1 - .../tensors_to_segmentation_calculator.proto | 2 +- mediapipe/tasks/web/core/BUILD | 1 + mediapipe/tasks/web/core/task_runner.ts | 21 ++++++++ .../tasks/web/core/task_runner_test_utils.ts | 9 +++- .../tasks/web/vision/image_segmenter/BUILD | 2 + .../vision/image_segmenter/image_segmenter.ts | 53 +++++++++++++++++++ mediapipe/web/graph_runner/graph_runner.ts | 38 +++++++++++++ 8 files changed, 123 insertions(+), 4 deletions(-) diff --git a/mediapipe/tasks/cc/vision/image_segmenter/calculators/BUILD b/mediapipe/tasks/cc/vision/image_segmenter/calculators/BUILD index b54e7352b..c621016dc 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/calculators/BUILD +++ b/mediapipe/tasks/cc/vision/image_segmenter/calculators/BUILD @@ -23,7 +23,6 @@ mediapipe_proto_library( srcs = ["tensors_to_segmentation_calculator.proto"], deps = [ "//mediapipe/framework:calculator_options_proto", - "//mediapipe/framework:calculator_proto", "//mediapipe/framework/formats:image_format_proto", "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_proto", "//mediapipe/util:label_map_proto", diff --git a/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.proto b/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.proto index f267bf09b..dbaf34db0 100644 --- a/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.proto +++ b/mediapipe/tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator.proto @@ -18,7 +18,7 @@ syntax = "proto2"; // TODO: consolidate TensorToSegmentationCalculator. package mediapipe.tasks; -import "mediapipe/framework/calculator.proto"; +import "mediapipe/framework/calculator_options.proto"; import "mediapipe/tasks/cc/vision/image_segmenter/proto/segmenter_options.proto"; import "mediapipe/util/label_map.proto"; diff --git a/mediapipe/tasks/web/core/BUILD b/mediapipe/tasks/web/core/BUILD index ec65548d4..a417d4d72 100644 --- a/mediapipe/tasks/web/core/BUILD +++ b/mediapipe/tasks/web/core/BUILD @@ -19,6 +19,7 @@ mediapipe_ts_library( deps = [ ":core", "//mediapipe/calculators/tensor:inference_calculator_jspb_proto", + "//mediapipe/framework:calculator_jspb_proto", "//mediapipe/tasks/cc/core/proto:acceleration_jspb_proto", "//mediapipe/tasks/cc/core/proto:base_options_jspb_proto", "//mediapipe/tasks/cc/core/proto:external_file_jspb_proto", diff --git a/mediapipe/tasks/web/core/task_runner.ts b/mediapipe/tasks/web/core/task_runner.ts index a01bb1c92..68208c970 100644 --- a/mediapipe/tasks/web/core/task_runner.ts +++ b/mediapipe/tasks/web/core/task_runner.ts @@ -15,6 +15,7 @@ */ import {InferenceCalculatorOptions} from '../../../calculators/tensor/inference_calculator_pb'; +import {CalculatorGraphConfig} from '../../../framework/calculator_pb'; import {Acceleration} from '../../../tasks/cc/core/proto/acceleration_pb'; import {BaseOptions as BaseOptionsProto} from '../../../tasks/cc/core/proto/base_options_pb'; import {ExternalFile} from '../../../tasks/cc/core/proto/external_file_pb'; @@ -120,11 +121,13 @@ export abstract class TaskRunner { .then(buffer => { this.setExternalFile(new Uint8Array(buffer)); this.refreshGraph(); + this.onGraphRefreshed(); }); } else { // Apply the setting synchronously. this.setExternalFile(baseOptions.modelAssetBuffer); this.refreshGraph(); + this.onGraphRefreshed(); return Promise.resolve(); } } @@ -132,6 +135,24 @@ export abstract class TaskRunner { /** Appliest the current options to the MediaPipe graph. */ protected abstract refreshGraph(): void; + /** + * Callback that gets invoked once a new graph configuration has been + * applied. + */ + protected onGraphRefreshed(): void {} + + /** Returns the current CalculatorGraphConfig. */ + protected getCalculatorGraphConfig(): CalculatorGraphConfig { + let config: CalculatorGraphConfig|undefined; + this.graphRunner.getCalculatorGraphConfig(binaryData => { + config = CalculatorGraphConfig.deserializeBinary(binaryData); + }); + if (!config) { + throw new Error('Failed to retrieve CalculatorGraphConfig'); + } + return config; + } + /** * Takes the raw data from a MediaPipe graph, and passes it to C++ to be run * over the video stream. Will replace the previously running MediaPipe graph, diff --git a/mediapipe/tasks/web/core/task_runner_test_utils.ts b/mediapipe/tasks/web/core/task_runner_test_utils.ts index 62dd0463a..b0aa34095 100644 --- a/mediapipe/tasks/web/core/task_runner_test_utils.ts +++ b/mediapipe/tasks/web/core/task_runner_test_utils.ts @@ -16,7 +16,7 @@ import 'jasmine'; import {CalculatorGraphConfig} from '../../../framework/calculator_pb'; -import {WasmModule} from '../../../web/graph_runner/graph_runner'; +import {CALCULATOR_GRAPH_CONFIG_LISTENER_NAME, SimpleListener, WasmModule} from '../../../web/graph_runner/graph_runner'; import {WasmModuleRegisterModelResources} from '../../../web/graph_runner/register_model_resources_graph_service'; type SpyWasmModuleInternal = WasmModule&WasmModuleRegisterModelResources; @@ -36,8 +36,13 @@ export function createSpyWasmModule(): SpyWasmModule { '_setAutoRenderToScreen', 'stringToNewUTF8', '_attachProtoListener', '_attachProtoVectorListener', '_free', '_waitUntilIdle', '_addStringToInputStream', '_registerModelResourcesGraphService', - '_configureAudio', '_malloc', '_addProtoToInputStream' + '_configureAudio', '_malloc', '_addProtoToInputStream', '_getGraphConfig' ]); + spyWasmModule._getGraphConfig.and.callFake(() => { + (spyWasmModule.simpleListeners![CALCULATOR_GRAPH_CONFIG_LISTENER_NAME] as + SimpleListener)( + new CalculatorGraphConfig().serializeBinary(), 0); + }); spyWasmModule.HEAPU8 = jasmine.createSpyObj(['set']); return spyWasmModule; } diff --git a/mediapipe/tasks/web/vision/image_segmenter/BUILD b/mediapipe/tasks/web/vision/image_segmenter/BUILD index 3ca2a64eb..a4b9008dd 100644 --- a/mediapipe/tasks/web/vision/image_segmenter/BUILD +++ b/mediapipe/tasks/web/vision/image_segmenter/BUILD @@ -15,12 +15,14 @@ mediapipe_ts_library( "//mediapipe/framework:calculator_jspb_proto", "//mediapipe/framework:calculator_options_jspb_proto", "//mediapipe/tasks/cc/core/proto:base_options_jspb_proto", + "//mediapipe/tasks/cc/vision/image_segmenter/calculators:tensors_to_segmentation_calculator_jspb_proto", "//mediapipe/tasks/cc/vision/image_segmenter/proto:image_segmenter_graph_options_jspb_proto", "//mediapipe/tasks/cc/vision/image_segmenter/proto:segmenter_options_jspb_proto", "//mediapipe/tasks/web/core", "//mediapipe/tasks/web/vision/core:image_processing_options", "//mediapipe/tasks/web/vision/core:types", "//mediapipe/tasks/web/vision/core:vision_task_runner", + "//mediapipe/util:label_map_jspb_proto", "//mediapipe/web/graph_runner:graph_runner_ts", ], ) diff --git a/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts b/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts index f8ff0dcca..cb192b0ce 100644 --- a/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts +++ b/mediapipe/tasks/web/vision/image_segmenter/image_segmenter.ts @@ -17,12 +17,14 @@ import {CalculatorGraphConfig} from '../../../../framework/calculator_pb'; import {CalculatorOptions} from '../../../../framework/calculator_options_pb'; import {BaseOptions as BaseOptionsProto} from '../../../../tasks/cc/core/proto/base_options_pb'; +import {TensorsToSegmentationCalculatorOptions} from '../../../../tasks/cc/vision/image_segmenter/calculators/tensors_to_segmentation_calculator_pb'; import {ImageSegmenterGraphOptions as ImageSegmenterGraphOptionsProto} from '../../../../tasks/cc/vision/image_segmenter/proto/image_segmenter_graph_options_pb'; import {SegmenterOptions as SegmenterOptionsProto} from '../../../../tasks/cc/vision/image_segmenter/proto/segmenter_options_pb'; import {WasmFileset} from '../../../../tasks/web/core/wasm_fileset'; import {ImageProcessingOptions} from '../../../../tasks/web/vision/core/image_processing_options'; import {SegmentationMask, SegmentationMaskCallback} from '../../../../tasks/web/vision/core/types'; import {VisionGraphRunner, VisionTaskRunner} from '../../../../tasks/web/vision/core/vision_task_runner'; +import {LabelMapItem} from '../../../../util/label_map_pb'; import {ImageSource, WasmModule} from '../../../../web/graph_runner/graph_runner'; // Placeholder for internal dependency on trusted resource url @@ -37,6 +39,8 @@ const NORM_RECT_STREAM = 'norm_rect'; const GROUPED_SEGMENTATIONS_STREAM = 'segmented_masks'; const IMAGE_SEGMENTER_GRAPH = 'mediapipe.tasks.vision.image_segmenter.ImageSegmenterGraph'; +const TENSORS_TO_SEGMENTATION_CALCULATOR_NAME = + 'mediapipe.tasks.TensorsToSegmentationCalculator'; // The OSS JS API does not support the builder pattern. // tslint:disable:jspb-use-builder-pattern @@ -44,6 +48,7 @@ const IMAGE_SEGMENTER_GRAPH = /** Performs image segmentation on images. */ export class ImageSegmenter extends VisionTaskRunner { private userCallback: SegmentationMaskCallback = () => {}; + private labels: string[] = []; private readonly options: ImageSegmenterGraphOptionsProto; private readonly segmenterOptions: SegmenterOptionsProto; @@ -146,6 +151,39 @@ export class ImageSegmenter extends VisionTaskRunner { return super.applyOptions(options); } + protected override onGraphRefreshed(): void { + this.populateLabels(); + } + + /** + * Populate the labelMap in TensorsToSegmentationCalculator to labels field. + * @throws Exception if there is an error during finding + * TensorsToSegmentationCalculator. + */ + private populateLabels(): void { + const graphConfig = this.getCalculatorGraphConfig(); + const tensorsToSegmentationCalculators = graphConfig.getNodeList().filter( + (n: CalculatorGraphConfig.Node) => + n.getName().includes(TENSORS_TO_SEGMENTATION_CALCULATOR_NAME)); + + this.labels = []; + if (tensorsToSegmentationCalculators.length > 1) { + throw new Error(`The graph has more than one ${ + TENSORS_TO_SEGMENTATION_CALCULATOR_NAME}.`); + } else if (tensorsToSegmentationCalculators.length === 1) { + const labelItems = + tensorsToSegmentationCalculators[0] + .getOptions() + ?.getExtension(TensorsToSegmentationCalculatorOptions.ext) + ?.getLabelItemsMap() ?? + new Map(); + labelItems.forEach((value, index) => { + // tslint:disable-next-line:no-unnecessary-type-assertion + this.labels[Number(index)] = value.getName()!; + }); + } + } + /** * Performs image segmentation on the provided single image and invokes the * callback with the response. The method returns synchronously once the @@ -191,6 +229,21 @@ export class ImageSegmenter extends VisionTaskRunner { this.userCallback = () => {}; } + /** + * Get the category label list of the ImageSegmenter can recognize. For + * `CATEGORY_MASK` type, the index in the category mask corresponds to the + * category in the label list. For `CONFIDENCE_MASK` type, the output mask + * list at index corresponds to the category in the label list. + * + * If there is no labelmap provided in the model file, empty label array is + * returned. + * + * @return The labels used by the current model. + */ + getLabels(): string[] { + return this.labels; + } + /** * Performs image segmentation on the provided video frame and invokes the * callback with the response. The method returns synchronously once the diff --git a/mediapipe/web/graph_runner/graph_runner.ts b/mediapipe/web/graph_runner/graph_runner.ts index 4417f6a03..e2b1684a0 100644 --- a/mediapipe/web/graph_runner/graph_runner.ts +++ b/mediapipe/web/graph_runner/graph_runner.ts @@ -36,6 +36,17 @@ export type EmptyPacketListener = (timestamp: number) => void; export type VectorListener = (data: T, done: boolean, timestamp: number) => void; +/** + * A listener that receives the CalculatorGraphConfig in binary encoding. + */ +export type CalculatorGraphConfigListener = (graphConfig: Uint8Array) => void; + +/** + * The name of the internal listener that we use to obtain the calculator graph + * config. Intended for internal usage. Exported for testing only. + */ +export const CALCULATOR_GRAPH_CONFIG_LISTENER_NAME = '__graph_config__'; + /** * Declarations for Emscripten's WebAssembly Module behavior, so TS compiler * doesn't break our JS/C++ bridge. @@ -124,6 +135,10 @@ export declare interface WasmModule { _configureAudio: (channels: number, samples: number, sampleRate: number, streamNamePtr: number, headerNamePtr: number) => void; + // Get the graph configuration and invoke the listener configured under + // streamNamePtr + _getGraphConfig: (streamNamePtr: number, makeDeepCopy?: boolean) => void; + // TODO: Refactor to just use a few numbers (perhaps refactor away // from gl_graph_runner_internal.cc entirely to use something a little more // streamlined; new version is _processFrame above). @@ -437,6 +452,29 @@ export class GraphRunner { this.wasmModule._free(heapSpace); } + /** + * Invokes the callback with the current calculator configuration (in binary + * format). + * + * Consumers must deserialize the binary representation themselves as this + * avoids addding a direct dependency on the Protobuf JSPB target in the graph + * library. + */ + getCalculatorGraphConfig( + callback: CalculatorGraphConfigListener, makeDeepCopy?: boolean): void { + const listener = CALCULATOR_GRAPH_CONFIG_LISTENER_NAME; + + // Create a short-lived listener to receive the binary encoded proto + this.setListener(listener, (data: Uint8Array) => { + callback(data); + }); + this.wrapStringPtr(listener, (outputStreamNamePtr: number) => { + this.wasmModule._getGraphConfig(outputStreamNamePtr, makeDeepCopy); + }); + + delete this.wasmModule.simpleListeners![listener]; + } + /** * Ensures existence of the simple listeners table and registers the callback. * Intended for internal usage.

  • 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 IWjXhM035k+HUIzs delta 313 zcmW;I%WgqY6vpv$Y(?qT)~)WU7njzhwAeF=nPcK1ya66y55&NvLgGopz+3PFr-^jn zDg2$4{8qlK!OF_|KmVD(2iugxfpABRYlRmhEoai4N!L~itp$VmQVY0o0}s6L;TC=b za0iJX?(u*}gb+pqQN-|sI1)%Ag)}nAB8O+>@q$;p;T;7OQQ9qac^`5vwCwWSvOn;N z3aY4~jxRLO#5aD>LVIV~ozv>NVbhqXt`o)+)ptU2s#31rdefPzd{7v|IP#xjNzpF= Cvv}D6 diff --git a/third_party/external_files.bzl b/third_party/external_files.bzl index c140003bf..4e26dff29 100644 --- a/third_party/external_files.bzl +++ b/third_party/external_files.bzl @@ -67,7 +67,7 @@ def external_files(): http_file( name = "com_google_mediapipe_BUILD", sha256 = "d2b2a8346202691d7f831887c84e9642e974f64ed67851d9a58cf15c94b1f6b3", - urls = ["https://storage.googleapis.com/mediapipe-assets/BUILD?generation=1661875663693976"], + urls = ["https://storage.googleapis.com/mediapipe-assets/BUILD?generation=16618756636939761678323576393653"], ) http_file( @@ -318,8 +318,8 @@ def external_files(): http_file( name = "com_google_mediapipe_face_landmarker_with_blendshapes_task", - sha256 = "a75c1ba70e4b8568000af2ad0b355ed559ab5d5793db50fa9ad241f8dc4fad5f", - urls = ["https://storage.googleapis.com/mediapipe-assets/face_landmarker_with_blendshapes.task?generation=1678323586260800"], + sha256 = "b44e4cae6f5822456d60f33e7c852640d78c7e342aee7eacc22589451a0b9dc2", + urls = ["https://storage.googleapis.com/mediapipe-assets/face_landmarker_with_blendshapes.task?generation=1678504998301299"], ) http_file( @@ -822,8 +822,8 @@ def external_files(): http_file( name = "com_google_mediapipe_portrait_expected_face_geometry_with_attention_pbtxt", - sha256 = "5cc57b8da3ad0527dce581fe1309f6b36043e5837e3f4f5af5e24005a99dc52a", - urls = ["https://storage.googleapis.com/mediapipe-assets/portrait_expected_face_geometry_with_attention.pbtxt?generation=1678323601064393"], + sha256 = "7ed1eed98e61e0a10811bb611c895d87c8023f398a36db01b6d9ba2e1ab09e16", + urls = ["https://storage.googleapis.com/mediapipe-assets/portrait_expected_face_geometry_with_attention.pbtxt?generation=1678505004840652"], ) http_file( From 23681cde0dbb844e71a5cb5fb4b2c61d9cbb9fcf Mon Sep 17 00:00:00 2001 From: kinaryml Date: Tue, 14 Mar 2023 00:37:32 -0700 Subject: [PATCH 066/136] Revised face landmarker implementation and tests --- .../components/containers/matrix_data.py | 15 +-- mediapipe/tasks/python/test/vision/BUILD | 2 +- .../test/vision/face_landmarker_test.py | 109 ++++++++++++++++-- .../tasks/python/vision/face_landmarker.py | 1 + mediapipe/tasks/testdata/vision/BUILD | 2 + 5 files changed, 109 insertions(+), 20 deletions(-) diff --git a/mediapipe/tasks/python/components/containers/matrix_data.py b/mediapipe/tasks/python/components/containers/matrix_data.py index 9f0d5dfd5..2cef4a5c6 100644 --- a/mediapipe/tasks/python/components/containers/matrix_data.py +++ b/mediapipe/tasks/python/components/containers/matrix_data.py @@ -17,6 +17,7 @@ import dataclasses import enum from typing import Any, Optional +import numpy as np from mediapipe.framework.formats import matrix_data_pb2 from mediapipe.tasks.python.core.optional_dependencies import doc_controls @@ -32,7 +33,7 @@ class MatrixData: Attributes: rows: The number of rows in the matrix. cols: The number of columns in the matrix. - data: The data stored in the matrix. + data: The data stored in the matrix as a NumPy array. layout: The order in which the data are stored. Defaults to COLUMN_MAJOR. """ @@ -40,10 +41,10 @@ class MatrixData: COLUMN_MAJOR = 0 ROW_MAJOR = 1 - rows: Optional[int] = None - cols: Optional[int] = None - data: Optional[float] = None - layout: Optional[Layout] = None + rows: int = None + cols: int = None + data: np.ndarray = None + layout: Optional[Layout] = Layout.COLUMN_MAJOR @doc_controls.do_not_generate_docs def to_pb2(self) -> _MatrixDataProto: @@ -51,7 +52,7 @@ class MatrixData: return _MatrixDataProto( rows=self.rows, cols=self.cols, - data=self.data, + data=self.data.tolist(), layout=self.layout) @classmethod @@ -61,7 +62,7 @@ class MatrixData: return MatrixData( rows=pb2_obj.rows, cols=pb2_obj.cols, - data=pb2_obj.data, + data=np.array(pb2_obj.data), layout=pb2_obj.layout) def __eq__(self, other: Any) -> bool: diff --git a/mediapipe/tasks/python/test/vision/BUILD b/mediapipe/tasks/python/test/vision/BUILD index 0a1a18fff..55f619ae4 100644 --- a/mediapipe/tasks/python/test/vision/BUILD +++ b/mediapipe/tasks/python/test/vision/BUILD @@ -126,10 +126,10 @@ py_test( deps = [ "//mediapipe/python:_framework_bindings", "//mediapipe/framework/formats:landmark_py_pb2", + "//mediapipe/framework/formats:classification_py_pb2", "//mediapipe/tasks/python/components/containers:category", "//mediapipe/tasks/python/components/containers:landmark", "//mediapipe/tasks/python/components/containers:rect", - "//mediapipe/tasks/python/components/containers:classification_result", "//mediapipe/tasks/python/components/containers:matrix_data", "//mediapipe/tasks/python/core:base_options", "//mediapipe/tasks/python/test:test_utils", diff --git a/mediapipe/tasks/python/test/vision/face_landmarker_test.py b/mediapipe/tasks/python/test/vision/face_landmarker_test.py index a9dd57151..49cdacbfe 100644 --- a/mediapipe/tasks/python/test/vision/face_landmarker_test.py +++ b/mediapipe/tasks/python/test/vision/face_landmarker_test.py @@ -22,11 +22,12 @@ import numpy as np from google.protobuf import text_format from mediapipe.framework.formats import landmark_pb2 +from mediapipe.framework.formats import classification_pb2 from mediapipe.python._framework_bindings import image as image_module from mediapipe.tasks.python.components.containers import category as category_module from mediapipe.tasks.python.components.containers import landmark as landmark_module +from mediapipe.tasks.python.components.containers import matrix_data as matrix_data_module from mediapipe.tasks.python.components.containers import rect as rect_module -from mediapipe.tasks.python.components.containers import classification_result as classification_result_module from mediapipe.tasks.python.core import base_options as base_options_module from mediapipe.tasks.python.test import test_utils from mediapipe.tasks.python.vision import face_landmarker @@ -38,6 +39,7 @@ _BaseOptions = base_options_module.BaseOptions _Category = category_module.Category _Rect = rect_module.Rect _Landmark = landmark_module.Landmark +_MatrixData = matrix_data_module.MatrixData _NormalizedLandmark = landmark_module.NormalizedLandmark _Image = image_module.Image _FaceLandmarker = face_landmarker.FaceLandmarker @@ -51,6 +53,7 @@ _PORTRAIT_IMAGE = 'portrait.jpg' _PORTRAIT_EXPECTED_FACE_LANDMARKS = 'portrait_expected_face_landmarks.pbtxt' _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION = 'portrait_expected_face_landmarks_with_attention.pbtxt' _PORTRAIT_EXPECTED_BLENDSHAPES = 'portrait_expected_blendshapes_with_attention.pbtxt' +_PORTRAIT_EXPECTED_FACE_GEOMETRY = 'portrait_expected_face_geometry_with_attention.pbtxt' _LANDMARKS_DIFF_MARGIN = 0.03 _BLENDSHAPES_DIFF_MARGIN = 0.1 _FACIAL_TRANSFORMATION_MATRIX_DIFF_MARGIN = 0.02 @@ -61,10 +64,40 @@ def _get_expected_face_landmarks(file_path: str): with open(proto_file_path, 'rb') as f: proto = landmark_pb2.NormalizedLandmarkList() text_format.Parse(f.read(), proto) - landmarks = [] + face_landmarks = [] for landmark in proto.landmark: - landmarks.append(_NormalizedLandmark.create_from_pb2(landmark)) - return landmarks + face_landmarks.append(_NormalizedLandmark.create_from_pb2(landmark)) + return face_landmarks + + +def _get_expected_face_blendshapes(file_path: str): + proto_file_path = test_utils.get_test_data_path(file_path) + with open(proto_file_path, 'rb') as f: + proto = classification_pb2.ClassificationList() + text_format.Parse(f.read(), proto) + face_blendshapes_categories = [] + face_blendshapes_classifications = classification_pb2.ClassificationList() + face_blendshapes_classifications.MergeFrom(proto) + for face_blendshapes in face_blendshapes_classifications.classification: + face_blendshapes_categories.append( + category_module.Category( + index=face_blendshapes.index, + score=face_blendshapes.score, + display_name=face_blendshapes.display_name, + category_name=face_blendshapes.label)) + return face_blendshapes_categories + + +def _make_expected_facial_transformation_matrixes(): + data = np.array([[0.9995292, -0.005092691, 0.030254554, -0.37340546], + [0.0072318087, 0.99744856, -0.07102106, 22.212194], + [-0.029815676, 0.07120642, 0.9970159, -64.76358], + [0, 0, 0, 1]]) + rows, cols = len(data), len(data[0]) + facial_transformation_matrixes_results = [] + facial_transformation_matrix = _MatrixData(rows, cols, data) + facial_transformation_matrixes_results.append(facial_transformation_matrix) + return facial_transformation_matrixes_results class ModelFileType(enum.Enum): @@ -148,30 +181,82 @@ class HandLandmarkerTest(parameterized.TestCase): self.assertIsInstance(landmarker, _FaceLandmarker) @parameterized.parameters( + (ModelFileType.FILE_NAME, _FACE_LANDMARKER_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), + (ModelFileType.FILE_CONTENT, _FACE_LANDMARKER_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS), None, None), (ModelFileType.FILE_NAME, - _get_expected_face_landmarks(_PORTRAIT_EXPECTED_FACE_LANDMARKS)), + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), (ModelFileType.FILE_CONTENT, - _get_expected_face_landmarks(_PORTRAIT_EXPECTED_FACE_LANDMARKS))) - def test_detect(self, model_file_type, expected_face_landmarks): + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), None, None), + (ModelFileType.FILE_NAME, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), None), + (ModelFileType.FILE_CONTENT, + _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + _get_expected_face_landmarks( + _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + _get_expected_face_blendshapes( + _PORTRAIT_EXPECTED_BLENDSHAPES), None), + # (ModelFileType.FILE_NAME, + # _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + # _get_expected_face_landmarks( + # _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + # _get_expected_face_blendshapes( + # _PORTRAIT_EXPECTED_BLENDSHAPES), + # _make_expected_facial_transformation_matrixes()), + # (ModelFileType.FILE_CONTENT, + # _FACE_LANDMARKER_WITH_BLENDSHAPES_BUNDLE_ASSET_FILE, + # _get_expected_face_landmarks( + # _PORTRAIT_EXPECTED_FACE_LANDMARKS_WITH_ATTENTION), + # _get_expected_face_blendshapes( + # _PORTRAIT_EXPECTED_BLENDSHAPES), + # _make_expected_facial_transformation_matrixes()) + ) + def test_detect(self, model_file_type, model_name, expected_face_landmarks, + expected_face_blendshapes, expected_facial_transformation_matrix): # Creates face landmarker. + model_path = test_utils.get_test_data_path(model_name) if model_file_type is ModelFileType.FILE_NAME: - base_options = _BaseOptions(model_asset_path=self.model_path) + base_options = _BaseOptions(model_asset_path=model_path) elif model_file_type is ModelFileType.FILE_CONTENT: - with open(self.model_path, 'rb') as f: + with open(model_path, 'rb') as f: model_content = f.read() base_options = _BaseOptions(model_asset_buffer=model_content) else: # Should never happen raise ValueError('model_file_type is invalid.') - options = _FaceLandmarkerOptions(base_options=base_options) + options = _FaceLandmarkerOptions( + base_options=base_options, + output_face_blendshapes=True if expected_face_blendshapes else False, + output_facial_transformation_matrixes=True + if expected_facial_transformation_matrix else False) landmarker = _FaceLandmarker.create_from_options(options) # Performs face landmarks detection on the input. detection_result = landmarker.detect(self.test_image) # Comparing results. - self._expect_landmarks_correct(detection_result.face_landmarks[0], - expected_face_landmarks) + if expected_face_landmarks is not None: + self._expect_landmarks_correct(detection_result.face_landmarks[0], + expected_face_landmarks) + if expected_face_blendshapes is not None: + self._expect_blendshapes_correct(detection_result.face_blendshapes[0], + expected_face_blendshapes) + if expected_facial_transformation_matrix is not None: + self._expect_facial_transformation_matrix_correct( + detection_result.facial_transformation_matrixes[0], + expected_facial_transformation_matrix) + # Closes the face landmarker explicitly when the face landmarker is not used # in a context. landmarker.close() diff --git a/mediapipe/tasks/python/vision/face_landmarker.py b/mediapipe/tasks/python/vision/face_landmarker.py index c109c646a..519a78dfb 100644 --- a/mediapipe/tasks/python/vision/face_landmarker.py +++ b/mediapipe/tasks/python/vision/face_landmarker.py @@ -162,6 +162,7 @@ def _build_landmarker_result( facial_transformation_matrixes_results = [] if _FACE_GEOMETRY_STREAM_NAME in output_packets: + print(output_packets[_FACE_GEOMETRY_STREAM_NAME]) facial_transformation_matrixes_proto_list = packet_getter.get_proto_list( output_packets[_FACE_GEOMETRY_STREAM_NAME]) for proto in facial_transformation_matrixes_proto_list: diff --git a/mediapipe/tasks/testdata/vision/BUILD b/mediapipe/tasks/testdata/vision/BUILD index 63e3613e6..f15b6bab3 100644 --- a/mediapipe/tasks/testdata/vision/BUILD +++ b/mediapipe/tasks/testdata/vision/BUILD @@ -156,6 +156,7 @@ filegroup( "face_landmark.tflite", "face_landmark_with_attention.tflite", "face_landmarker.task", + "face_landmarker_with_blendshapes.task", "hair_segmentation.tflite", "hand_landmark_full.tflite", "hand_landmark_lite.tflite", @@ -191,6 +192,7 @@ filegroup( "pointing_up_landmarks.pbtxt", "pointing_up_rotated_landmarks.pbtxt", "portrait_expected_detection.pbtxt", + "portrait_expected_blendshapes_with_attention.pbtxt", "portrait_expected_face_geometry_with_attention.pbtxt", "portrait_expected_face_landmarks.pbtxt", "portrait_expected_face_landmarks_with_attention.pbtxt", From bc641a22a88a064efe03afe73a313acd9c99b458 Mon Sep 17 00:00:00 2001 From: Alan Kelly Date: Tue, 14 Mar 2023 07:38:36 -0700 Subject: [PATCH 067/136] Internal change PiperOrigin-RevId: 516520860 --- .../object_detector/object_detector_test.cc | 46 ++++----- .../test/vision/object_detector_test.py | 98 +++++++++++-------- 2 files changed, 79 insertions(+), 65 deletions(-) diff --git a/mediapipe/tasks/cc/vision/object_detector/object_detector_test.cc b/mediapipe/tasks/cc/vision/object_detector/object_detector_test.cc index 1b0dcf2fb..cb37bbead 100644 --- a/mediapipe/tasks/cc/vision/object_detector/object_detector_test.cc +++ b/mediapipe/tasks/cc/vision/object_detector/object_detector_test.cc @@ -108,31 +108,31 @@ std::vector GenerateMobileSsdNoImageResizingFullExpectedResults() { return {ParseTextProtoOrDie(R"pb( label: "cat" - score: 0.6328125 + score: 0.6210937 location_data { format: BOUNDING_BOX - bounding_box { xmin: 14 ymin: 197 width: 98 height: 99 } + bounding_box { xmin: 15 ymin: 197 width: 98 height: 99 } })pb"), ParseTextProtoOrDie(R"pb( label: "cat" - score: 0.59765625 + score: 0.609375 location_data { format: BOUNDING_BOX - bounding_box { xmin: 151 ymin: 78 width: 104 height: 223 } + bounding_box { xmin: 150 ymin: 78 width: 104 height: 223 } })pb"), ParseTextProtoOrDie(R"pb( label: "cat" score: 0.5 location_data { format: BOUNDING_BOX - bounding_box { xmin: 65 ymin: 199 width: 41 height: 101 } + bounding_box { xmin: 64 ymin: 199 width: 42 height: 101 } })pb"), ParseTextProtoOrDie(R"pb( label: "dog" - score: 0.48828125 + score: 0.5 location_data { format: BOUNDING_BOX - bounding_box { xmin: 12 ymin: 110 width: 153 height: 193 } + bounding_box { xmin: 14 ymin: 110 width: 153 height: 193 } })pb")}; } @@ -268,7 +268,7 @@ TEST_F(CreateFromOptionsTest, FailsWithIllegalCallbackInImageOrVideoMode) { options->running_mode = running_mode; options->result_callback = [](absl::StatusOr detections, const Image& image, - int64 timestamp_ms) {}; + int64_t timestamp_ms) {}; absl::StatusOr> object_detector = ObjectDetector::Create(std::move(options)); EXPECT_EQ(object_detector.status().code(), @@ -381,28 +381,28 @@ TEST_F(ImageModeTest, Succeeds) { score: 0.69921875 location_data { format: BOUNDING_BOX - bounding_box { xmin: 608 ymin: 161 width: 381 height: 439 } + bounding_box { xmin: 608 ymin: 164 width: 381 height: 432 } })pb"), ParseTextProtoOrDie(R"pb( label: "cat" - score: 0.64453125 + score: 0.65625 location_data { format: BOUNDING_BOX - bounding_box { xmin: 60 ymin: 398 width: 386 height: 196 } + bounding_box { xmin: 57 ymin: 398 width: 386 height: 196 } })pb"), ParseTextProtoOrDie(R"pb( label: "cat" score: 0.51171875 location_data { format: BOUNDING_BOX - bounding_box { xmin: 256 ymin: 395 width: 173 height: 202 } + bounding_box { xmin: 256 ymin: 394 width: 173 height: 202 } })pb"), ParseTextProtoOrDie(R"pb( label: "cat" score: 0.48828125 location_data { format: BOUNDING_BOX - bounding_box { xmin: 362 ymin: 191 width: 325 height: 419 } + bounding_box { xmin: 360 ymin: 195 width: 330 height: 412 } })pb")})); } @@ -484,10 +484,10 @@ TEST_F(ImageModeTest, SucceedsWithScoreCalibration) { results, ConvertToDetectionResult({ParseTextProtoOrDie(R"pb( label: "cat" - score: 0.6531269142 + score: 0.650467276 location_data { format: BOUNDING_BOX - bounding_box { xmin: 14 ymin: 197 width: 98 height: 99 } + bounding_box { xmin: 15 ymin: 197 width: 98 height: 99 } })pb")})); } @@ -507,9 +507,9 @@ TEST_F(ImageModeTest, SucceedsWithScoreThresholdOption) { GenerateMobileSsdNoImageResizingFullExpectedResults(); ExpectApproximatelyEqual( - results, ConvertToDetectionResult({full_expected_results[0], - full_expected_results[1], - full_expected_results[2]})); + results, ConvertToDetectionResult( + {full_expected_results[0], full_expected_results[1], + full_expected_results[2], full_expected_results[3]})); } TEST_F(ImageModeTest, SucceedsWithMaxResultsOption) { @@ -685,7 +685,7 @@ TEST_F(LiveStreamModeTest, FailsWithCallingWrongMethod) { JoinPath("./", kTestDataDirectory, kMobileSsdWithMetadata); options->running_mode = core::RunningMode::LIVE_STREAM; options->result_callback = [](absl::StatusOr detections, - const Image& image, int64 timestamp_ms) {}; + const Image& image, int64_t timestamp_ms) {}; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr object_detector, ObjectDetector::Create(std::move(options))); @@ -716,7 +716,7 @@ TEST_F(LiveStreamModeTest, FailsWithOutOfOrderInputTimestamps) { options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kMobileSsdWithMetadata); options->result_callback = [](absl::StatusOr detections, - const Image& image, int64 timestamp_ms) {}; + const Image& image, int64_t timestamp_ms) {}; MP_ASSERT_OK_AND_ASSIGN(std::unique_ptr object_detector, ObjectDetector::Create(std::move(options))); MP_ASSERT_OK(object_detector->DetectAsync(image, 1)); @@ -742,13 +742,13 @@ TEST_F(LiveStreamModeTest, Succeeds) { options->running_mode = core::RunningMode::LIVE_STREAM; std::vector detection_results; std::vector> image_sizes; - std::vector timestamps; + std::vector timestamps; options->base_options.model_asset_path = JoinPath("./", kTestDataDirectory, kMobileSsdWithMetadata); options->result_callback = [&detection_results, &image_sizes, ×tamps]( absl::StatusOr detections, const Image& image, - int64 timestamp_ms) { + int64_t timestamp_ms) { MP_ASSERT_OK(detections.status()); detection_results.push_back(std::move(detections).value()); image_sizes.push_back({image.width(), image.height()}); @@ -775,7 +775,7 @@ TEST_F(LiveStreamModeTest, Succeeds) { EXPECT_EQ(image_size.first, image.width()); EXPECT_EQ(image_size.second, image.height()); } - int64 timestamp_ms = -1; + int64_t timestamp_ms = -1; for (const auto& timestamp : timestamps) { EXPECT_GT(timestamp, timestamp_ms); timestamp_ms = timestamp; diff --git a/mediapipe/tasks/python/test/vision/object_detector_test.py b/mediapipe/tasks/python/test/vision/object_detector_test.py index 5afa31459..2bb9b0214 100644 --- a/mediapipe/tasks/python/test/vision/object_detector_test.py +++ b/mediapipe/tasks/python/test/vision/object_detector_test.py @@ -42,48 +42,62 @@ _RUNNING_MODE = running_mode_module.VisionTaskRunningMode _MODEL_FILE = 'coco_ssd_mobilenet_v1_1.0_quant_2018_06_29.tflite' _IMAGE_FILE = 'cats_and_dogs.jpg' -_EXPECTED_DETECTION_RESULT = _DetectionResult(detections=[ - _Detection( - bounding_box=_BoundingBox( - origin_x=608, origin_y=161, width=381, height=439), - categories=[ - _Category( - index=None, - score=0.69921875, - display_name=None, - category_name='cat') - ]), - _Detection( - bounding_box=_BoundingBox( - origin_x=60, origin_y=398, width=386, height=196), - categories=[ - _Category( - index=None, - score=0.64453125, - display_name=None, - category_name='cat') - ]), - _Detection( - bounding_box=_BoundingBox( - origin_x=256, origin_y=395, width=173, height=202), - categories=[ - _Category( - index=None, - score=0.51171875, - display_name=None, - category_name='cat') - ]), - _Detection( - bounding_box=_BoundingBox( - origin_x=362, origin_y=191, width=325, height=419), - categories=[ - _Category( - index=None, - score=0.48828125, - display_name=None, - category_name='cat') - ]) -]) +_EXPECTED_DETECTION_RESULT = _DetectionResult( + detections=[ + _Detection( + bounding_box=_BoundingBox( + origin_x=608, origin_y=161, width=381, height=439 + ), + categories=[ + _Category( + index=None, + score=0.69921875, + display_name=None, + category_name='cat', + ) + ], + ), + _Detection( + bounding_box=_BoundingBox( + origin_x=60, origin_y=398, width=386, height=196 + ), + categories=[ + _Category( + index=None, + score=0.64453125, + display_name=None, + category_name='cat', + ) + ], + ), + _Detection( + bounding_box=_BoundingBox( + origin_x=256, origin_y=395, width=173, height=202 + ), + categories=[ + _Category( + index=None, + score=0.51171875, + display_name=None, + category_name='cat', + ) + ], + ), + _Detection( + bounding_box=_BoundingBox( + origin_x=362, origin_y=191, width=325, height=419 + ), + categories=[ + _Category( + index=None, + score=0.48828125, + display_name=None, + category_name='cat', + ) + ], + ), + ] +) _ALLOW_LIST = ['cat', 'dog'] _DENY_LIST = ['cat'] _SCORE_THRESHOLD = 0.3 From 2659ea0392ee229506d23739a33b4c54b48cde2d Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 08:42:31 -0700 Subject: [PATCH 068/136] Internal change PiperOrigin-RevId: 516535124 --- mediapipe/tasks/cc/common.h | 2 +- .../tasks/cc/metadata/metadata_populator.h | 2 +- .../cc/metadata/python/metadata_version.cc | 4 +-- .../metadata/tests/metadata_version_test.cc | 32 +++++++++---------- .../sentencepiece_tokenizer_test.cc | 2 +- .../gesture_recognizer_graph.cc | 2 +- .../vision/hand_landmarker/hand_landmarker.h | 2 +- .../hand_landmarks_detector_graph.cc | 2 +- .../image_embedder/image_embedder_test.cc | 2 +- mediapipe/tasks/metadata/metadata_schema.fbs | 14 ++++---- 10 files changed, 32 insertions(+), 32 deletions(-) diff --git a/mediapipe/tasks/cc/common.h b/mediapipe/tasks/cc/common.h index 1295177df..70892c5cd 100644 --- a/mediapipe/tasks/cc/common.h +++ b/mediapipe/tasks/cc/common.h @@ -30,7 +30,7 @@ constexpr absl::string_view kMediaPipeTasksPayload = "MediaPipeTasksStatus"; // // At runtime, such codes are meant to be attached (where applicable) to a // `absl::Status` in a key-value manner with `kMediaPipeTasksPayload` as key and -// stringifed error code as value (aka payload). This logic is encapsulated in +// stringified error code as value (aka payload). This logic is encapsulated in // the `CreateStatusWithPayload` helper below for convenience. // // The returned status includes: diff --git a/mediapipe/tasks/cc/metadata/metadata_populator.h b/mediapipe/tasks/cc/metadata/metadata_populator.h index 024ad785f..c0554f704 100644 --- a/mediapipe/tasks/cc/metadata/metadata_populator.h +++ b/mediapipe/tasks/cc/metadata/metadata_populator.h @@ -64,7 +64,7 @@ class ModelMetadataPopulator { // Loads associated files into the TFLite FlatBuffer model. The input is a map // of {filename, file contents}. // - // Warning: this method removes any previoulsy present associated files. + // Warning: this method removes any previously present associated files. // Calling this method multiple time removes any associated files from // previous calls, so this method should usually be called only once. void LoadAssociatedFiles( diff --git a/mediapipe/tasks/cc/metadata/python/metadata_version.cc b/mediapipe/tasks/cc/metadata/python/metadata_version.cc index 860a00e4f..e3072bc9e 100644 --- a/mediapipe/tasks/cc/metadata/python/metadata_version.cc +++ b/mediapipe/tasks/cc/metadata/python/metadata_version.cc @@ -31,8 +31,8 @@ PYBIND11_MODULE(_pywrap_metadata_version, m) { // Using pybind11 type conversions to convert between Python and native // C++ types. There are other options to provide access to native Python types - // in C++ and vice versa. See the pybind 11 instrcution [1] for more details. - // Type converstions is recommended by pybind11, though the main downside + // in C++ and vice versa. See the pybind 11 instruction [1] for more details. + // Type conversions is recommended by pybind11, though the main downside // is that a copy of the data must be made on every Python to C++ transition: // this is needed since the C++ and Python versions of the same type generally // won’t have the same memory layout. diff --git a/mediapipe/tasks/cc/metadata/tests/metadata_version_test.cc b/mediapipe/tasks/cc/metadata/tests/metadata_version_test.cc index 273c91685..3085e6585 100644 --- a/mediapipe/tasks/cc/metadata/tests/metadata_version_test.cc +++ b/mediapipe/tasks/cc/metadata/tests/metadata_version_test.cc @@ -79,7 +79,7 @@ TEST(MetadataVersionTest, auto metadata = metadata_builder.Finish(); FinishModelMetadataBuffer(builder, metadata); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -100,7 +100,7 @@ TEST(MetadataVersionTest, auto metadata = metadata_builder.Finish(); builder.Finish(metadata); - // Gets the mimimum metadata parser version and triggers error. + // Gets the minimum metadata parser version and triggers error. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -121,7 +121,7 @@ TEST(MetadataVersionTest, metadata_builder.add_associated_files(associated_files); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -147,7 +147,7 @@ TEST(MetadataVersionTest, metadata_builder.add_subgraph_metadata(subgraphs); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -172,7 +172,7 @@ TEST(MetadataVersionTest, std::vector>{tensor_builder.Finish()}); CreateModelWithMetadata(tensors, builder); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -203,7 +203,7 @@ TEST(MetadataVersionTest, metadata_builder.add_subgraph_metadata(subgraphs); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -234,7 +234,7 @@ TEST(MetadataVersionTest, metadata_builder.add_subgraph_metadata(subgraphs); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -294,7 +294,7 @@ TEST(MetadataVersionTest, std::vector>{tensor_builder.Finish()}); CreateModelWithMetadata(tensors, builder); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -323,7 +323,7 @@ TEST(MetadataVersionTest, std::vector>{tensor_builder.Finish()}); CreateModelWithMetadata(tensors, builder); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -348,7 +348,7 @@ TEST(MetadataVersionTest, metadata_builder.add_subgraph_metadata(subgraphs); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -373,7 +373,7 @@ TEST(MetadataVersionTest, metadata_builder.add_subgraph_metadata(subgraphs); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -404,7 +404,7 @@ TEST(MetadataVersionTest, metadata_builder.add_subgraph_metadata(subgraphs); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -431,7 +431,7 @@ TEST(MetadataVersionTest, std::vector>{tensor_builder.Finish()}); CreateModelWithMetadata(tensors, builder); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -453,7 +453,7 @@ TEST(MetadataVersionTest, metadata_builder.add_associated_files(associated_files); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -476,7 +476,7 @@ TEST(MetadataVersionTest, metadata_builder.add_associated_files(associated_files); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), @@ -504,7 +504,7 @@ TEST(MetadataVersionTest, GetMinimumMetadataParserVersionForOptions) { metadata_builder.add_subgraph_metadata(subgraphs); FinishModelMetadataBuffer(builder, metadata_builder.Finish()); - // Gets the mimimum metadata parser version. + // Gets the minimum metadata parser version. std::string min_version; EXPECT_EQ(GetMinimumMetadataParserVersion(builder.GetBufferPointer(), builder.GetSize(), &min_version), diff --git a/mediapipe/tasks/cc/text/tokenizers/sentencepiece_tokenizer_test.cc b/mediapipe/tasks/cc/text/tokenizers/sentencepiece_tokenizer_test.cc index a42719446..88afabe1e 100644 --- a/mediapipe/tasks/cc/text/tokenizers/sentencepiece_tokenizer_test.cc +++ b/mediapipe/tasks/cc/text/tokenizers/sentencepiece_tokenizer_test.cc @@ -34,7 +34,7 @@ constexpr char kTestSPModelPath[] = std::unique_ptr CreateSentencePieceTokenizer( absl::string_view model_path) { - // We are using `LoadBinaryContent()` instead of loading the model direclty + // We are using `LoadBinaryContent()` instead of loading the model directly // via `SentencePieceTokenizer` so that the file can be located on Windows std::string buffer = LoadBinaryContent(kTestSPModelPath); return absl::make_unique(buffer.data(), diff --git a/mediapipe/tasks/cc/vision/gesture_recognizer/gesture_recognizer_graph.cc b/mediapipe/tasks/cc/vision/gesture_recognizer/gesture_recognizer_graph.cc index b6f6c88da..11b2d12c4 100644 --- a/mediapipe/tasks/cc/vision/gesture_recognizer/gesture_recognizer_graph.cc +++ b/mediapipe/tasks/cc/vision/gesture_recognizer/gesture_recognizer_graph.cc @@ -127,7 +127,7 @@ absl::Status SetSubTaskBaseOptions(const ModelAssetBundleResources& resources, ->mutable_acceleration() ->mutable_xnnpack(); LOG(WARNING) << "Hand Gesture Recognizer contains CPU only ops. Sets " - << "HandGestureRecognizerGraph acceleartion to Xnnpack."; + << "HandGestureRecognizerGraph acceleration to Xnnpack."; } hand_gesture_recognizer_graph_options->mutable_base_options() ->set_use_stream_mode(options->base_options().use_stream_mode()); diff --git a/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker.h b/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker.h index 6f96fc68e..7a43d20d7 100644 --- a/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker.h +++ b/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarker.h @@ -101,7 +101,7 @@ class HandLandmarker : tasks::vision::core::BaseVisionTaskApi { // three running modes: // 1) Image mode for detecting hand landmarks on single image inputs. Users // provide mediapipe::Image to the `Detect` method, and will receive the - // deteced hand landmarks results as the return value. + // detected hand landmarks results as the return value. // 2) Video mode for detecting hand landmarks on the decoded frames of a // video. Users call `DetectForVideo` method, and will receive the detected // hand landmarks results as the return value. diff --git a/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarks_detector_graph.cc b/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarks_detector_graph.cc index 6d232d3f1..f7fa83a11 100644 --- a/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarks_detector_graph.cc +++ b/mediapipe/tasks/cc/vision/hand_landmarker/hand_landmarks_detector_graph.cc @@ -409,7 +409,7 @@ REGISTER_MEDIAPIPE_GRAPH( // - Accepts CPU input image and a vector of hand rect RoIs to detect the // multiple hands landmarks enclosed by the RoIs. Output vectors of // hand landmarks related results, where each element in the vectors -// corrresponds to the result of the same hand. +// corresponds to the result of the same hand. // // Inputs: // IMAGE - Image diff --git a/mediapipe/tasks/cc/vision/image_embedder/image_embedder_test.cc b/mediapipe/tasks/cc/vision/image_embedder/image_embedder_test.cc index dd602bef5..8fa036b7d 100644 --- a/mediapipe/tasks/cc/vision/image_embedder/image_embedder_test.cc +++ b/mediapipe/tasks/cc/vision/image_embedder/image_embedder_test.cc @@ -52,7 +52,7 @@ constexpr char kMobileNetV3Embedder[] = constexpr double kSimilarityTolerancy = 1e-6; // Utility function to check the sizes, head_index and head_names of a result -// procuded by kMobileNetV3Embedder. +// procduced by kMobileNetV3Embedder. void CheckMobileNetV3Result(const ImageEmbedderResult& result, bool quantized) { EXPECT_EQ(result.embeddings.size(), 1); EXPECT_EQ(result.embeddings[0].head_index, 0); diff --git a/mediapipe/tasks/metadata/metadata_schema.fbs b/mediapipe/tasks/metadata/metadata_schema.fbs index 3253e1ea8..8fe7a08fa 100644 --- a/mediapipe/tasks/metadata/metadata_schema.fbs +++ b/mediapipe/tasks/metadata/metadata_schema.fbs @@ -233,7 +233,7 @@ table ImageProperties { // // : // Input image tensors: NA. -// Output image tensors: parses the values into a data stucture that represents +// Output image tensors: parses the values into a data structure that represents // bounding boxes. For example, in the generated wrapper for Android, it returns // the output as android.graphics.Rect objects. enum BoundingBoxType : byte { @@ -389,7 +389,7 @@ table NormalizationOptions{ // mean and std are normalization parameters. Tensor values are normalized // on a per-channel basis, by the formula // (x - mean) / std. - // If there is only one value in mean or std, we'll propogate the value to + // If there is only one value in mean or std, we'll propagate the value to // all channels. // // Quantized models share the same normalization parameters as their @@ -526,7 +526,7 @@ table Stats { // Max and min are not currently used in tflite.support codegen. They mainly // serve as references for users to better understand the model. They can also // be used to validate model pre/post processing results. - // If there is only one value in max or min, we'll propogate the value to + // If there is only one value in max or min, we'll propagate the value to // all channels. // Per-channel maximum value of the tensor. @@ -542,7 +542,7 @@ table Stats { // has four outputs: classes, scores, bounding boxes, and number of detections. // If the four outputs are bundled together using TensorGroup (for example, // named as "detection result"), the codegen tool will generate the class, -// `DetectionResult`, which contains the class, score, and bouding box. And the +// `DetectionResult`, which contains the class, score, and bounding box. And the // outputs of the model will be converted to a list of `DetectionResults` and // the number of detection. Note that the number of detection is a single // number, therefore is inappropriate for the list of `DetectionResult`. @@ -624,7 +624,7 @@ table SubGraphMetadata { // A description explains details about what the subgraph does. description:string; - // Metadata of all input tensors used in this subgraph. It matches extactly + // Metadata of all input tensors used in this subgraph. It matches exactly // the input tensors specified by `SubGraph.inputs` in the TFLite // schema.fbs file[2]. The number of `TensorMetadata` in the array should // equal to the number of indices in `SubGraph.inputs`. @@ -634,7 +634,7 @@ table SubGraphMetadata { // Determines how to process the inputs. input_tensor_metadata:[TensorMetadata]; - // Metadata of all output tensors used in this subgraph. It matches extactly + // Metadata of all output tensors used in this subgraph. It matches exactly // the output tensors specified by `SubGraph.outputs` in the TFLite // schema.fbs file[2]. The number of `TensorMetadata` in the array should // equal to the number of indices in `SubGraph.outputs`. @@ -724,7 +724,7 @@ table ModelMetadata { // number among the versions of all the fields populated and the smallest // compatible version indicated by the file identifier. // - // This field is automaticaly populated by the MetadataPopulator when + // This field is automatically populated by the MetadataPopulator when // the metadata is populated into a TFLite model. min_parser_version:string; } From c895867427954131263aa9063dddeb444d7d03dc Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 09:28:00 -0700 Subject: [PATCH 069/136] Expose `FrameBuffer` view on GpuBufferStorageYuvImage. PiperOrigin-RevId: 516546716 --- mediapipe/gpu/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/mediapipe/gpu/BUILD b/mediapipe/gpu/BUILD index 8e2b1c476..018b1b247 100644 --- a/mediapipe/gpu/BUILD +++ b/mediapipe/gpu/BUILD @@ -467,6 +467,7 @@ cc_library( "//mediapipe/framework/formats:frame_buffer", "//mediapipe/framework/formats:image_frame", "//mediapipe/framework/formats:yuv_image", + "//mediapipe/util/frame_buffer:frame_buffer_util", "//third_party/libyuv", "@com_google_absl//absl/log", "@com_google_absl//absl/log:check", From 5bba1f245fae492934b4853b90696864ed3a0a1e Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 14 Mar 2023 09:43:57 -0700 Subject: [PATCH 070/136] Internal change PiperOrigin-RevId: 516550977 --- third_party/BUILD | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/third_party/BUILD b/third_party/BUILD index 7522bab1b..034243b3e 100644 --- a/third_party/BUILD +++ b/third_party/BUILD @@ -169,7 +169,10 @@ cmake_external( "-lm", "-lpthread", "-lrt", - ], + ] + select({ + "//mediapipe:ios": ["-framework Cocoa"], + "//conditions:default": [], + }), shared_libraries = select({ "@bazel_tools//src/conditions:darwin": ["libopencv_%s.%s.dylib" % (module, OPENCV_SO_VERSION) for module in OPENCV_MODULES], # Only the shared objects listed here will be linked in the directory From 8a41a5e44da95929c673b348789fcd9d9adcbbae Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 11:03:29 -0700 Subject: [PATCH 071/136] Update models. PiperOrigin-RevId: 516575530 --- mediapipe/tasks/testdata/vision/BUILD | 4 ++++ third_party/external_files.bzl | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/mediapipe/tasks/testdata/vision/BUILD b/mediapipe/tasks/testdata/vision/BUILD index 63e3613e6..888da6ea1 100644 --- a/mediapipe/tasks/testdata/vision/BUILD +++ b/mediapipe/tasks/testdata/vision/BUILD @@ -78,6 +78,8 @@ mediapipe_files(srcs = [ "selfie_segm_128_128_3_expected_mask.jpg", "selfie_segm_144_256_3.tflite", "selfie_segm_144_256_3_expected_mask.jpg", + "selfie_segmentation.tflite", + "selfie_segmentation_landscape.tflite", "thumb_up.jpg", "victory.jpg", ]) @@ -171,6 +173,8 @@ filegroup( "palm_detection_full.tflite", "selfie_segm_128_128_3.tflite", "selfie_segm_144_256_3.tflite", + "selfie_segmentation.tflite", + "selfie_segmentation_landscape.tflite", ], ) diff --git a/third_party/external_files.bzl b/third_party/external_files.bzl index 4e26dff29..91bd7c756 100644 --- a/third_party/external_files.bzl +++ b/third_party/external_files.bzl @@ -210,8 +210,8 @@ def external_files(): http_file( name = "com_google_mediapipe_deeplabv3_tflite", - sha256 = "9711334db2b01d5894feb8ed0f5cb3e97d125b8d229f8d8692f625801818f5ef", - urls = ["https://storage.googleapis.com/mediapipe-assets/deeplabv3.tflite?generation=1661875711618421"], + sha256 = "5faed2c653905d3e22a8f6f29ee198da84e9b0e7936a207bf431f17f6b4d87ff", + urls = ["https://storage.googleapis.com/mediapipe-assets/deeplabv3.tflite?generation=1678775085237701"], ) http_file( @@ -390,8 +390,8 @@ def external_files(): http_file( name = "com_google_mediapipe_hair_segmentation_tflite", - sha256 = "0bec40bc9ba97c4143f3d4225a935014abffea37c1f3766ae32aba3f2748e711", - urls = ["https://storage.googleapis.com/mediapipe-assets/hair_segmentation.tflite?generation=1678218355806671"], + sha256 = "7cbddcfe6f6e10c3e0a509eb2e14225fda5c0de6c35e2e8c6ca8e3971988fc17", + urls = ["https://storage.googleapis.com/mediapipe-assets/hair_segmentation.tflite?generation=1678775089064550"], ) http_file( @@ -972,8 +972,8 @@ def external_files(): http_file( name = "com_google_mediapipe_selfie_segm_128_128_3_tflite", - sha256 = "bb154f248543c0738e32f1c74375245651351a84746dc21f10bdfaabd8fae4ca", - urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segm_128_128_3.tflite?generation=1661875919964123"], + sha256 = "8322982866488b063af6531b1d16ac27c7bf404135b7905f20aaf5e6af7aa45b", + urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segm_128_128_3.tflite?generation=1678775097370282"], ) http_file( @@ -984,20 +984,20 @@ def external_files(): http_file( name = "com_google_mediapipe_selfie_segm_144_256_3_tflite", - sha256 = "5c770b8834ad50586599eae7710921be09d356898413fc0bf37a9458da0610eb", - urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segm_144_256_3.tflite?generation=1661875925519713"], + sha256 = "f16a9551a408edeadd53f70d1d2911fc20f9f9de7a394129a268ca9faa2d6a08", + urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segm_144_256_3.tflite?generation=1678775099616375"], ) http_file( name = "com_google_mediapipe_selfie_segmentation_landscape_tflite", - sha256 = "4aafe6223bb8dac6fac8ca8ed56852870a33051ef3f6238822d282a109962894", - urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segmentation_landscape.tflite?generation=1661875928328455"], + sha256 = "28fb4c287d6295a2dba6c1f43b43315a37f927ddcd6693d635d625d176eef162", + urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segmentation_landscape.tflite?generation=1678775102234495"], ) http_file( name = "com_google_mediapipe_selfie_segmentation_tflite", - sha256 = "8d13b7fae74af625c641226813616a2117bd6bca19eb3b75574621fc08557f27", - urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segmentation.tflite?generation=1661875931201364"], + sha256 = "b0e2ec6f95107795b952b27f3d92806b45f0bc069dac76dcd264cd1b90d61c6c", + urls = ["https://storage.googleapis.com/mediapipe-assets/selfie_segmentation.tflite?generation=1678775104900954"], ) http_file( From 854ab25ee9590fcf8e4df07964e7cc725b970ca0 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 12:04:51 -0700 Subject: [PATCH 072/136] Internal change. PiperOrigin-RevId: 516594221 --- mediapipe/tasks/cc/vision/pose_detector/BUILD | 53 +++ .../pose_detector/pose_detector_graph.cc | 354 ++++++++++++++++++ .../pose_detector/pose_detector_graph_test.cc | 165 ++++++++ .../tasks/cc/vision/pose_detector/proto/BUILD | 31 ++ .../proto/pose_detector_graph_options.proto | 45 +++ mediapipe/tasks/testdata/vision/BUILD | 5 + .../vision/pose_expected_detection.pbtxt | 27 ++ third_party/external_files.bzl | 26 +- 8 files changed, 699 insertions(+), 7 deletions(-) create mode 100644 mediapipe/tasks/cc/vision/pose_detector/BUILD create mode 100644 mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph.cc create mode 100644 mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph_test.cc create mode 100644 mediapipe/tasks/cc/vision/pose_detector/proto/BUILD create mode 100644 mediapipe/tasks/cc/vision/pose_detector/proto/pose_detector_graph_options.proto create mode 100644 mediapipe/tasks/testdata/vision/pose_expected_detection.pbtxt diff --git a/mediapipe/tasks/cc/vision/pose_detector/BUILD b/mediapipe/tasks/cc/vision/pose_detector/BUILD new file mode 100644 index 000000000..1e361afbe --- /dev/null +++ b/mediapipe/tasks/cc/vision/pose_detector/BUILD @@ -0,0 +1,53 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +package(default_visibility = [ + "//mediapipe/tasks:internal", +]) + +licenses(["notice"]) + +cc_library( + name = "pose_detector_graph", + srcs = ["pose_detector_graph.cc"], + deps = [ + "//mediapipe/calculators/core:clip_vector_size_calculator_cc_proto", + "//mediapipe/calculators/tensor:image_to_tensor_calculator_cc_proto", + "//mediapipe/calculators/tensor:inference_calculator", + "//mediapipe/calculators/tensor:tensors_to_detections_calculator", + "//mediapipe/calculators/tensor:tensors_to_detections_calculator_cc_proto", + "//mediapipe/calculators/tflite:ssd_anchors_calculator", + "//mediapipe/calculators/tflite:ssd_anchors_calculator_cc_proto", + "//mediapipe/calculators/util:detection_projection_calculator", + "//mediapipe/calculators/util:detection_transformation_calculator", + "//mediapipe/calculators/util:detections_to_rects_calculator", + "//mediapipe/calculators/util:detections_to_rects_calculator_cc_proto", + "//mediapipe/calculators/util:non_max_suppression_calculator", + "//mediapipe/calculators/util:non_max_suppression_calculator_cc_proto", + "//mediapipe/calculators/util:rect_transformation_calculator", + "//mediapipe/calculators/util:rect_transformation_calculator_cc_proto", + "//mediapipe/framework:calculator_cc_proto", + "//mediapipe/framework:subgraph", + "//mediapipe/framework/api2:builder", + "//mediapipe/framework/formats:detection_cc_proto", + "//mediapipe/framework/formats:image", + "//mediapipe/framework/formats:rect_cc_proto", + "//mediapipe/framework/formats:tensor", + "//mediapipe/tasks/cc/components/processors:image_preprocessing_graph", + "//mediapipe/tasks/cc/core:model_task_graph", + "//mediapipe/tasks/cc/vision/pose_detector/proto:pose_detector_graph_options_cc_proto", + "@com_google_absl//absl/status:statusor", + ], + alwayslink = 1, +) diff --git a/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph.cc b/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph.cc new file mode 100644 index 000000000..7c8958b3c --- /dev/null +++ b/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph.cc @@ -0,0 +1,354 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include + +#include "absl/status/statusor.h" +#include "mediapipe/calculators/core/clip_vector_size_calculator.pb.h" +#include "mediapipe/calculators/tensor/image_to_tensor_calculator.pb.h" +#include "mediapipe/calculators/tensor/tensors_to_detections_calculator.pb.h" +#include "mediapipe/calculators/tflite/ssd_anchors_calculator.pb.h" +#include "mediapipe/calculators/util/detections_to_rects_calculator.pb.h" +#include "mediapipe/calculators/util/non_max_suppression_calculator.pb.h" +#include "mediapipe/calculators/util/rect_transformation_calculator.pb.h" +#include "mediapipe/framework/api2/builder.h" +#include "mediapipe/framework/calculator.pb.h" +#include "mediapipe/framework/formats/detection.pb.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/formats/rect.pb.h" +#include "mediapipe/framework/formats/tensor.h" +#include "mediapipe/framework/subgraph.h" +#include "mediapipe/tasks/cc/components/processors/image_preprocessing_graph.h" +#include "mediapipe/tasks/cc/core/model_task_graph.h" +#include "mediapipe/tasks/cc/vision/pose_detector/proto/pose_detector_graph_options.pb.h" + +namespace mediapipe { +namespace tasks { +namespace vision { +namespace pose_detector { + +using ::mediapipe::NormalizedRect; +using ::mediapipe::Tensor; +using ::mediapipe::api2::Input; +using ::mediapipe::api2::Output; +using ::mediapipe::api2::builder::Graph; +using ::mediapipe::api2::builder::Source; +using ::mediapipe::tasks::vision::pose_detector::proto:: + PoseDetectorGraphOptions; + +namespace { +constexpr char kImageTag[] = "IMAGE"; +constexpr char kNormRectTag[] = "NORM_RECT"; +constexpr char kTensorsTag[] = "TENSORS"; +constexpr char kImageSizeTag[] = "IMAGE_SIZE"; +constexpr char kAnchorsTag[] = "ANCHORS"; +constexpr char kDetectionsTag[] = "DETECTIONS"; +constexpr char kNormRectsTag[] = "NORM_RECTS"; +constexpr char kPixelDetectionsTag[] = "PIXEL_DETECTIONS"; +constexpr char kPoseRectsTag[] = "POSE_RECTS"; +constexpr char kExpandedPoseRectsTag[] = "EXPANDED_POSE_RECTS"; +constexpr char kMatrixTag[] = "MATRIX"; +constexpr char kProjectionMatrixTag[] = "PROJECTION_MATRIX"; + +struct PoseDetectionOuts { + Source> pose_detections; + Source> pose_rects; + Source> expanded_pose_rects; + Source image; +}; + +// TODO: Configuration detection related calculators in pose +// detector with model metadata. +void ConfigureSsdAnchorsCalculator( + mediapipe::SsdAnchorsCalculatorOptions* options) { + // Dervied from + // mediapipe/modules/pose_detection/pose_detection_gpu.pbtxt + options->set_num_layers(5); + options->set_min_scale(0.1484375); + options->set_max_scale(0.75); + options->set_input_size_height(224); + options->set_input_size_width(224); + options->set_anchor_offset_x(0.5); + options->set_anchor_offset_y(0.5); + options->add_strides(8); + options->add_strides(16); + options->add_strides(32); + options->add_strides(32); + options->add_strides(32); + options->add_aspect_ratios(1.0); + options->set_fixed_anchor_size(true); +} + +// TODO: Configuration detection related calculators in pose +// detector with model metadata. +void ConfigureTensorsToDetectionsCalculator( + const PoseDetectorGraphOptions& tasks_options, + mediapipe::TensorsToDetectionsCalculatorOptions* options) { + // Dervied from + // mediapipe/modules/pose_detection/pose_detection_gpu.pbtxt + options->set_num_classes(1); + options->set_num_boxes(2254); + options->set_num_coords(12); + options->set_box_coord_offset(0); + options->set_keypoint_coord_offset(4); + options->set_num_keypoints(4); + options->set_num_values_per_keypoint(2); + options->set_sigmoid_score(true); + options->set_score_clipping_thresh(100.0); + options->set_reverse_output_order(true); + options->set_min_score_thresh(tasks_options.min_detection_confidence()); + options->set_x_scale(224.0); + options->set_y_scale(224.0); + options->set_w_scale(224.0); + options->set_h_scale(224.0); +} + +void ConfigureNonMaxSuppressionCalculator( + const PoseDetectorGraphOptions& tasks_options, + mediapipe::NonMaxSuppressionCalculatorOptions* options) { + options->set_min_suppression_threshold( + tasks_options.min_suppression_threshold()); + options->set_overlap_type( + mediapipe::NonMaxSuppressionCalculatorOptions::INTERSECTION_OVER_UNION); + options->set_algorithm( + mediapipe::NonMaxSuppressionCalculatorOptions::WEIGHTED); +} + +// TODO: Configuration detection related calculators in pose +// detector with model metadata. +void ConfigureDetectionsToRectsCalculator( + mediapipe::DetectionsToRectsCalculatorOptions* options) { + options->set_rotation_vector_start_keypoint_index(0); + options->set_rotation_vector_end_keypoint_index(2); + options->set_rotation_vector_target_angle(90); + options->set_output_zero_rect_for_empty_detections(true); +} + +// TODO: Configuration detection related calculators in pose +// detector with model metadata. +void ConfigureRectTransformationCalculator( + mediapipe::RectTransformationCalculatorOptions* options) { + options->set_scale_x(2.6); + options->set_scale_y(2.6); + options->set_shift_y(-0.5); + options->set_square_long(true); +} + +} // namespace + +// A "mediapipe.tasks.vision.pose_detector.PoseDetectorGraph" performs pose +// detection. +// +// Inputs: +// IMAGE - Image +// Image to perform detection on. +// NORM_RECT - NormalizedRect @Optional +// Describes image rotation and region of image to perform detection on. If +// not provided, whole image is used for pose detection. +// +// Outputs: +// DETECTIONS - std::vector +// Detected pose with maximum `num_poses` specified in options. +// POSE_RECTS - std::vector +// Detected pose bounding boxes in normalized coordinates. +// EXPANDED_POSE_RECTS - std::vector +// Expanded pose bounding boxes in normalized coordinates so that bounding +// boxes likely contain the whole pose. This is usually used as RoI for pose +// landmarks detection to run on. +// IMAGE - Image +// The input image that the pose detector runs on and has the pixel data +// stored on the target storage (CPU vs GPU). +// All returned coordinates are in the unrotated and uncropped input image +// coordinates system. +// +// Example: +// node { +// calculator: "mediapipe.tasks.vision.pose_detector.PoseDetectorGraph" +// input_stream: "IMAGE:image" +// input_stream: "NORM_RECT:norm_rect" +// output_stream: "DETECTIONS:palm_detections" +// output_stream: "POSE_RECTS:pose_rects" +// output_stream: "EXPANDED_POSE_RECTS:expanded_pose_rects" +// output_stream: "IMAGE:image_out" +// options { +// [mediapipe.tasks.vision.pose_detector.proto.PoseDetectorGraphOptions.ext] +// { +// base_options { +// model_asset { +// file_name: "pose_detection.tflite" +// } +// } +// min_detection_confidence: 0.5 +// num_poses: 2 +// } +// } +// } +class PoseDetectorGraph : public core::ModelTaskGraph { + public: + absl::StatusOr GetConfig( + SubgraphContext* sc) override { + ASSIGN_OR_RETURN(const auto* model_resources, + CreateModelResources(sc)); + Graph graph; + ASSIGN_OR_RETURN(auto outs, + BuildPoseDetectionSubgraph( + sc->Options(), + *model_resources, graph[Input(kImageTag)], + graph[Input(kNormRectTag)], graph)); + + outs.pose_detections >> + graph.Out(kDetectionsTag).Cast>(); + outs.pose_rects >> + graph.Out(kPoseRectsTag).Cast>(); + outs.expanded_pose_rects >> + graph.Out(kExpandedPoseRectsTag).Cast>(); + outs.image >> graph.Out(kImageTag).Cast(); + + return graph.GetConfig(); + } + + private: + absl::StatusOr BuildPoseDetectionSubgraph( + const PoseDetectorGraphOptions& subgraph_options, + const core::ModelResources& model_resources, Source image_in, + Source norm_rect_in, Graph& graph) { + // Image preprocessing subgraph to convert image to tensor for the tflite + // model. + auto& preprocessing = graph.AddNode( + "mediapipe.tasks.components.processors.ImagePreprocessingGraph"); + bool use_gpu = + components::processors::DetermineImagePreprocessingGpuBackend( + subgraph_options.base_options().acceleration()); + MP_RETURN_IF_ERROR(components::processors::ConfigureImagePreprocessingGraph( + model_resources, use_gpu, + &preprocessing.GetOptions< + components::processors::proto::ImagePreprocessingGraphOptions>())); + auto& image_to_tensor_options = + *preprocessing + .GetOptions() + .mutable_image_to_tensor_options(); + image_to_tensor_options.set_keep_aspect_ratio(true); + image_to_tensor_options.set_border_mode( + mediapipe::ImageToTensorCalculatorOptions::BORDER_ZERO); + image_in >> preprocessing.In(kImageTag); + norm_rect_in >> preprocessing.In(kNormRectTag); + auto preprocessed_tensors = preprocessing.Out(kTensorsTag); + auto matrix = preprocessing.Out(kMatrixTag); + auto image_size = preprocessing.Out(kImageSizeTag); + + // Pose detection model inferece. + auto& inference = AddInference( + model_resources, subgraph_options.base_options().acceleration(), graph); + preprocessed_tensors >> inference.In(kTensorsTag); + auto model_output_tensors = + inference.Out(kTensorsTag).Cast>(); + + // Generates a single side packet containing a vector of SSD anchors. + auto& ssd_anchor = graph.AddNode("SsdAnchorsCalculator"); + ConfigureSsdAnchorsCalculator( + &ssd_anchor.GetOptions()); + auto anchors = ssd_anchor.SideOut(""); + + // Converts output tensors to Detections. + auto& tensors_to_detections = + graph.AddNode("TensorsToDetectionsCalculator"); + ConfigureTensorsToDetectionsCalculator( + subgraph_options, + &tensors_to_detections + .GetOptions()); + model_output_tensors >> tensors_to_detections.In(kTensorsTag); + anchors >> tensors_to_detections.SideIn(kAnchorsTag); + auto detections = tensors_to_detections.Out(kDetectionsTag); + + // Non maximum suppression removes redundant face detections. + auto& non_maximum_suppression = + graph.AddNode("NonMaxSuppressionCalculator"); + ConfigureNonMaxSuppressionCalculator( + subgraph_options, + &non_maximum_suppression + .GetOptions()); + detections >> non_maximum_suppression.In(""); + auto nms_detections = non_maximum_suppression.Out(""); + + // Projects detections back into the input image coordinates system. + auto& detection_projection = graph.AddNode("DetectionProjectionCalculator"); + nms_detections >> detection_projection.In(kDetectionsTag); + matrix >> detection_projection.In(kProjectionMatrixTag); + Source> pose_detections = + detection_projection.Out(kDetectionsTag).Cast>(); + + if (subgraph_options.has_num_poses()) { + // Clip face detections to maximum number of poses. + auto& clip_detection_vector_size = + graph.AddNode("ClipDetectionVectorSizeCalculator"); + clip_detection_vector_size + .GetOptions() + .set_max_vec_size(subgraph_options.num_poses()); + pose_detections >> clip_detection_vector_size.In(""); + pose_detections = + clip_detection_vector_size.Out("").Cast>(); + } + + // Converts results of pose detection into a rectangle (normalized by image + // size) that encloses the face and is rotated such that the line connecting + // left eye and right eye is aligned with the X-axis of the rectangle. + auto& detections_to_rects = graph.AddNode("DetectionsToRectsCalculator"); + ConfigureDetectionsToRectsCalculator( + &detections_to_rects + .GetOptions()); + image_size >> detections_to_rects.In(kImageSizeTag); + pose_detections >> detections_to_rects.In(kDetectionsTag); + auto pose_rects = detections_to_rects.Out(kNormRectsTag) + .Cast>(); + + // Expands and shifts the rectangle that contains the pose so that it's + // likely to cover the entire pose. + auto& rect_transformation = graph.AddNode("RectTransformationCalculator"); + ConfigureRectTransformationCalculator( + &rect_transformation + .GetOptions()); + pose_rects >> rect_transformation.In(kNormRectsTag); + image_size >> rect_transformation.In(kImageSizeTag); + auto expanded_pose_rects = + rect_transformation.Out("").Cast>(); + + // Calculator to convert relative detection bounding boxes to pixel + // detection bounding boxes. + auto& detection_transformation = + graph.AddNode("DetectionTransformationCalculator"); + detection_projection.Out(kDetectionsTag) >> + detection_transformation.In(kDetectionsTag); + preprocessing.Out(kImageSizeTag) >> + detection_transformation.In(kImageSizeTag); + auto pose_pixel_detections = + detection_transformation.Out(kPixelDetectionsTag) + .Cast>(); + + return PoseDetectionOuts{ + /* pose_detections= */ pose_pixel_detections, + /* pose_rects= */ pose_rects, + /* expanded_pose_rects= */ expanded_pose_rects, + /* image= */ preprocessing.Out(kImageTag).Cast()}; + } +}; + +REGISTER_MEDIAPIPE_GRAPH( + ::mediapipe::tasks::vision::pose_detector::PoseDetectorGraph); + +} // namespace pose_detector +} // namespace vision +} // namespace tasks +} // namespace mediapipe diff --git a/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph_test.cc b/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph_test.cc new file mode 100644 index 000000000..5706bd9d7 --- /dev/null +++ b/mediapipe/tasks/cc/vision/pose_detector/pose_detector_graph_test.cc @@ -0,0 +1,165 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +#include "absl/flags/flag.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_format.h" +#include "absl/strings/string_view.h" +#include "mediapipe/calculators/tensor/inference_calculator.pb.h" +#include "mediapipe/framework/api2/builder.h" +#include "mediapipe/framework/api2/port.h" +#include "mediapipe/framework/calculator_framework.h" +#include "mediapipe/framework/deps/file_path.h" +#include "mediapipe/framework/formats/detection.pb.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/formats/rect.pb.h" +#include "mediapipe/framework/packet.h" +#include "mediapipe/framework/port/file_helpers.h" +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/tasks/cc/core/mediapipe_builtin_op_resolver.h" +#include "mediapipe/tasks/cc/core/proto/base_options.pb.h" +#include "mediapipe/tasks/cc/core/proto/external_file.pb.h" +#include "mediapipe/tasks/cc/core/task_runner.h" +#include "mediapipe/tasks/cc/vision/pose_detector/proto/pose_detector_graph_options.pb.h" +#include "mediapipe/tasks/cc/vision/utils/image_utils.h" + +namespace mediapipe { +namespace tasks { +namespace vision { +namespace pose_detector { +namespace { + +using ::file::Defaults; +using ::file::GetTextProto; +using ::mediapipe::NormalizedRect; +using ::mediapipe::api2::Input; +using ::mediapipe::api2::Output; +using ::mediapipe::api2::builder::Graph; +using ::mediapipe::api2::builder::Source; +using ::mediapipe::file::JoinPath; +using ::mediapipe::tasks::core::TaskRunner; +using ::mediapipe::tasks::vision::pose_detector::proto:: + PoseDetectorGraphOptions; +using ::testing::EqualsProto; +using ::testing::Pointwise; +using ::testing::TestParamInfo; +using ::testing::TestWithParam; +using ::testing::Values; +using ::testing::proto::Approximately; +using ::testing::proto::Partially; + +constexpr char kTestDataDirectory[] = "/mediapipe/tasks/testdata/vision/"; +constexpr char kPoseDetectionModel[] = "pose_detection.tflite"; +constexpr char kPortraitImage[] = "pose.jpg"; +constexpr char kPoseExpectedDetection[] = "pose_expected_detection.pbtxt"; + +constexpr char kImageTag[] = "IMAGE"; +constexpr char kImageName[] = "image"; +constexpr char kNormRectTag[] = "NORM_RECT"; +constexpr char kNormRectName[] = "norm_rect"; +constexpr char kDetectionsTag[] = "DETECTIONS"; +constexpr char kDetectionsName[] = "detections"; + +constexpr float kPoseDetectionMaxDiff = 0.01; + +// Helper function to create a TaskRunner. +absl::StatusOr> CreateTaskRunner( + absl::string_view model_name) { + Graph graph; + + auto& pose_detector_graph = + graph.AddNode("mediapipe.tasks.vision.pose_detector.PoseDetectorGraph"); + + auto options = std::make_unique(); + options->mutable_base_options()->mutable_model_asset()->set_file_name( + JoinPath("./", kTestDataDirectory, model_name)); + options->set_min_detection_confidence(0.6); + options->set_min_suppression_threshold(0.3); + pose_detector_graph.GetOptions().Swap( + options.get()); + + graph[Input(kImageTag)].SetName(kImageName) >> + pose_detector_graph.In(kImageTag); + graph[Input(kNormRectTag)].SetName(kNormRectName) >> + pose_detector_graph.In(kNormRectTag); + + pose_detector_graph.Out(kDetectionsTag).SetName(kDetectionsName) >> + graph[Output>(kDetectionsTag)]; + + return TaskRunner::Create( + graph.GetConfig(), std::make_unique()); +} + +Detection GetExpectedPoseDetectionResult(absl::string_view file_name) { + Detection detection; + CHECK_OK(GetTextProto(file::JoinPath("./", kTestDataDirectory, file_name), + &detection, Defaults())) + << "Expected pose detection result does not exist."; + return detection; +} + +struct TestParams { + // The name of this test, for convenience when displaying test results. + std::string test_name; + // The filename of pose landmark detection model. + std::string pose_detection_model_name; + // The filename of test image. + std::string test_image_name; + // Expected pose detection results. + std::vector expected_result; +}; + +class PoseDetectorGraphTest : public testing::TestWithParam {}; + +TEST_P(PoseDetectorGraphTest, Succeed) { + MP_ASSERT_OK_AND_ASSIGN( + Image image, DecodeImageFromFile(JoinPath("./", kTestDataDirectory, + GetParam().test_image_name))); + NormalizedRect input_norm_rect; + input_norm_rect.set_x_center(0.5); + input_norm_rect.set_y_center(0.5); + input_norm_rect.set_width(1.0); + input_norm_rect.set_height(1.0); + MP_ASSERT_OK_AND_ASSIGN( + auto task_runner, CreateTaskRunner(GetParam().pose_detection_model_name)); + auto output_packets = task_runner->Process( + {{kImageName, MakePacket(std::move(image))}, + {kNormRectName, + MakePacket(std::move(input_norm_rect))}}); + MP_ASSERT_OK(output_packets); + const std::vector& pose_detections = + (*output_packets)[kDetectionsName].Get>(); + EXPECT_THAT(pose_detections, Pointwise(Approximately(Partially(EqualsProto()), + kPoseDetectionMaxDiff), + GetParam().expected_result)); +} + +INSTANTIATE_TEST_SUITE_P( + PoseDetectorGraphTest, PoseDetectorGraphTest, + Values(TestParams{.test_name = "DetectPose", + .pose_detection_model_name = kPoseDetectionModel, + .test_image_name = kPortraitImage, + .expected_result = {GetExpectedPoseDetectionResult( + kPoseExpectedDetection)}}), + [](const TestParamInfo& info) { + return info.param.test_name; + }); + +} // namespace +} // namespace pose_detector +} // namespace vision +} // namespace tasks +} // namespace mediapipe diff --git a/mediapipe/tasks/cc/vision/pose_detector/proto/BUILD b/mediapipe/tasks/cc/vision/pose_detector/proto/BUILD new file mode 100644 index 000000000..287ed0183 --- /dev/null +++ b/mediapipe/tasks/cc/vision/pose_detector/proto/BUILD @@ -0,0 +1,31 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("//mediapipe/framework/port:build_config.bzl", "mediapipe_proto_library") + +package(default_visibility = [ + "//mediapipe/tasks:internal", +]) + +licenses(["notice"]) + +mediapipe_proto_library( + name = "pose_detector_graph_options_proto", + srcs = ["pose_detector_graph_options.proto"], + deps = [ + "//mediapipe/framework:calculator_options_proto", + "//mediapipe/framework:calculator_proto", + "//mediapipe/tasks/cc/core/proto:base_options_proto", + ], +) diff --git a/mediapipe/tasks/cc/vision/pose_detector/proto/pose_detector_graph_options.proto b/mediapipe/tasks/cc/vision/pose_detector/proto/pose_detector_graph_options.proto new file mode 100644 index 000000000..693f95262 --- /dev/null +++ b/mediapipe/tasks/cc/vision/pose_detector/proto/pose_detector_graph_options.proto @@ -0,0 +1,45 @@ +/* Copyright 2023 The MediaPipe Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +==============================================================================*/ + +syntax = "proto2"; + +package mediapipe.tasks.vision.pose_detector.proto; + +import "mediapipe/framework/calculator.proto"; +import "mediapipe/framework/calculator_options.proto"; +import "mediapipe/tasks/cc/core/proto/base_options.proto"; + +option java_package = "com.google.mediapipe.tasks.vision.posedetector.proto"; +option java_outer_classname = "PoseDetectorGraphOptionsProto"; + +message PoseDetectorGraphOptions { + extend mediapipe.CalculatorOptions { + optional PoseDetectorGraphOptions ext = 514774813; + } + // Base options for configuring Task library, such as specifying the TfLite + // model file with metadata, accelerator options, etc. + optional core.proto.BaseOptions base_options = 1; + + // Minimum confidence value ([0.0, 1.0]) for confidence score to be considered + // successfully detecting a pose in the image. + optional float min_detection_confidence = 2 [default = 0.5]; + + // IoU threshold ([0,0, 1.0]) for non-maximu-suppression to be considered + // duplicate detections. + optional float min_suppression_threshold = 3 [default = 0.5]; + + // Maximum number of poses to detect in the image. + optional int32 num_poses = 4; +} diff --git a/mediapipe/tasks/testdata/vision/BUILD b/mediapipe/tasks/testdata/vision/BUILD index 888da6ea1..087d0ea75 100644 --- a/mediapipe/tasks/testdata/vision/BUILD +++ b/mediapipe/tasks/testdata/vision/BUILD @@ -70,6 +70,8 @@ mediapipe_files(srcs = [ "portrait.jpg", "portrait_hair_expected_mask.jpg", "portrait_rotated.jpg", + "pose.jpg", + "pose_detection.tflite", "right_hands.jpg", "right_hands_rotated.jpg", "segmentation_golden_rotation0.png", @@ -127,6 +129,7 @@ filegroup( "portrait.jpg", "portrait_hair_expected_mask.jpg", "portrait_rotated.jpg", + "pose.jpg", "right_hands.jpg", "right_hands_rotated.jpg", "segmentation_golden_rotation0.png", @@ -171,6 +174,7 @@ filegroup( "mobilenet_v2_1.0_224.tflite", "mobilenet_v3_small_100_224_embedder.tflite", "palm_detection_full.tflite", + "pose_detection.tflite", "selfie_segm_128_128_3.tflite", "selfie_segm_144_256_3.tflite", "selfie_segmentation.tflite", @@ -199,6 +203,7 @@ filegroup( "portrait_expected_face_landmarks.pbtxt", "portrait_expected_face_landmarks_with_attention.pbtxt", "portrait_rotated_expected_detection.pbtxt", + "pose_expected_detection.pbtxt", "thumb_up_landmarks.pbtxt", "thumb_up_rotated_landmarks.pbtxt", "victory_landmarks.pbtxt", diff --git a/mediapipe/tasks/testdata/vision/pose_expected_detection.pbtxt b/mediapipe/tasks/testdata/vision/pose_expected_detection.pbtxt new file mode 100644 index 000000000..411a374d4 --- /dev/null +++ b/mediapipe/tasks/testdata/vision/pose_expected_detection.pbtxt @@ -0,0 +1,27 @@ +# proto-file: mediapipe/framework/formats/detection.proto +# proto-message: Detection +location_data { + format: BOUNDING_BOX + bounding_box { + xmin: 397 + ymin: 198 + width: 199 + height: 199 + } + relative_keypoints { + x: 0.4879558 + y: 0.7013345 + } + relative_keypoints { + x: 0.48453212 + y: 0.32265592 + } + relative_keypoints { + x: 0.4992165 + y: 0.4854874 + } + relative_keypoints { + x: 0.50227845 + y: 0.159788 + } +} diff --git a/third_party/external_files.bzl b/third_party/external_files.bzl index 91bd7c756..f6c2e8ce1 100644 --- a/third_party/external_files.bzl +++ b/third_party/external_files.bzl @@ -72,8 +72,8 @@ def external_files(): http_file( name = "com_google_mediapipe_BUILD_orig", - sha256 = "64d5343a6a5f9be06db0a5074a2260f9ae63a989fe01702832cd215680dc19c1", - urls = ["https://storage.googleapis.com/mediapipe-assets/BUILD.orig?generation=1678323576393653"], + sha256 = "d86b98b82e00dd87cd46bd1429bf5eaa007b500c1a24d9316b73309f2e6c8df8", + urls = ["https://storage.googleapis.com/mediapipe-assets/BUILD.orig?generation=1678737479599640"], ) http_file( @@ -823,7 +823,7 @@ def external_files(): http_file( name = "com_google_mediapipe_portrait_expected_face_geometry_with_attention_pbtxt", sha256 = "7ed1eed98e61e0a10811bb611c895d87c8023f398a36db01b6d9ba2e1ab09e16", - urls = ["https://storage.googleapis.com/mediapipe-assets/portrait_expected_face_geometry_with_attention.pbtxt?generation=1678505004840652"], + urls = ["https://storage.googleapis.com/mediapipe-assets/portrait_expected_face_geometry_with_attention.pbtxt?generation=1678737486927530"], ) http_file( @@ -864,8 +864,20 @@ def external_files(): http_file( name = "com_google_mediapipe_pose_detection_tflite", - sha256 = "a63c614bef30d35947f13be361820b1e4e3bec9cfeebf4d11216a18373108e85", - urls = ["https://storage.googleapis.com/mediapipe-assets/pose_detection.tflite?generation=1661875889147923"], + sha256 = "9ba9dd3d42efaaba86b4ff0122b06f29c4122e756b329d89dca1e297fd8f866c", + urls = ["https://storage.googleapis.com/mediapipe-assets/pose_detection.tflite?generation=1678737489600422"], + ) + + http_file( + name = "com_google_mediapipe_pose_expected_detection_pbtxt", + sha256 = "e0d40e98dd5320a780a642c336d0c8720243ac5bcc0e39c4061ad970a503ae24", + urls = ["https://storage.googleapis.com/mediapipe-assets/pose_expected_detection.pbtxt?generation=1678737492211540"], + ) + + http_file( + name = "com_google_mediapipe_pose_jpg", + sha256 = "c8a830ed683c0276d713dd5aeda28f415f10cd6291972084a40d0d8b934ed62b", + urls = ["https://storage.googleapis.com/mediapipe-assets/pose.jpg?generation=1678737494661975"], ) http_file( @@ -1224,8 +1236,8 @@ def external_files(): http_file( name = "com_google_mediapipe_object_detection_saved_model_README_md", - sha256 = "fe163cf12fbd017738a2fd360c03d223e964ba6404ac75c635f5918784e9c34d", - urls = ["https://storage.googleapis.com/mediapipe-assets/object_detection_saved_model/README.md?generation=1661875995856372"], + sha256 = "acc23dee09f69210717ac060035c844ba902e8271486f1086f29fb156c236690", + urls = ["https://storage.googleapis.com/mediapipe-assets/object_detection_saved_model/README.md?generation=1678737498915254"], ) http_file( From fef8b9cb583045233548839e4221a4ce653dab5d Mon Sep 17 00:00:00 2001 From: Jiuqiang Tang Date: Tue, 14 Mar 2023 12:18:26 -0700 Subject: [PATCH 073/136] Registering FaceGeometry proto. PiperOrigin-RevId: 516597971 --- mediapipe/python/BUILD | 1 + mediapipe/python/packet_test.py | 6 ++++++ mediapipe/tasks/cc/vision/face_geometry/proto/BUILD | 11 +++++++++++ 3 files changed, 18 insertions(+) diff --git a/mediapipe/python/BUILD b/mediapipe/python/BUILD index f56e5b3d4..e6c5723f3 100644 --- a/mediapipe/python/BUILD +++ b/mediapipe/python/BUILD @@ -57,6 +57,7 @@ pybind_extension( "//mediapipe/framework/formats:landmark_registration", "//mediapipe/framework/formats:rect_registration", "//mediapipe/modules/objectron/calculators:annotation_registration", + "//mediapipe/tasks/cc/vision/face_geometry/proto:face_geometry_registration", ], ) diff --git a/mediapipe/python/packet_test.py b/mediapipe/python/packet_test.py index 16fc37c87..93c8601bb 100644 --- a/mediapipe/python/packet_test.py +++ b/mediapipe/python/packet_test.py @@ -28,6 +28,7 @@ from mediapipe.python._framework_bindings import calculator_graph from mediapipe.python._framework_bindings import image from mediapipe.python._framework_bindings import image_frame from mediapipe.python._framework_bindings import packet +from mediapipe.tasks.cc.vision.face_geometry.proto import face_geometry_pb2 CalculatorGraph = calculator_graph.CalculatorGraph Image = image.Image @@ -177,6 +178,11 @@ class PacketTest(absltest.TestCase): text_format.Parse('score: 0.5', detection) p = packet_creator.create_proto(detection).at(100) + def test_face_geometry_proto_packet(self): + face_geometry_in = face_geometry_pb2.FaceGeometry() + p = packet_creator.create_proto(face_geometry_in).at(100) + face_geometry_out = packet_getter.get_proto(p) + def test_string_packet(self): p = packet_creator.create_string('abc').at(100) self.assertEqual(packet_getter.get_str(p), 'abc') diff --git a/mediapipe/tasks/cc/vision/face_geometry/proto/BUILD b/mediapipe/tasks/cc/vision/face_geometry/proto/BUILD index c9dd15845..e337a3452 100644 --- a/mediapipe/tasks/cc/vision/face_geometry/proto/BUILD +++ b/mediapipe/tasks/cc/vision/face_geometry/proto/BUILD @@ -13,6 +13,7 @@ # limitations under the License. load("//mediapipe/framework/port:build_config.bzl", "mediapipe_proto_library") +load("//mediapipe/framework:mediapipe_register_type.bzl", "mediapipe_register_type") licenses(["notice"]) @@ -23,6 +24,16 @@ mediapipe_proto_library( srcs = ["environment.proto"], ) +mediapipe_register_type( + base_name = "face_geometry", + include_headers = ["mediapipe/tasks/cc/vision/face_geometry/proto/face_geometry.pb.h"], + types = [ + "::mediapipe::tasks::vision::face_geometry::proto::FaceGeometry", + "::std::vector<::mediapipe::tasks::vision::face_geometry::proto::FaceGeometry>", + ], + deps = [":face_geometry_cc_proto"], +) + mediapipe_proto_library( name = "face_geometry_proto", srcs = ["face_geometry.proto"], From ed3e728bb831514ec4f9f2b7b4d51deecf74698e Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 12:54:59 -0700 Subject: [PATCH 074/136] Internal change PiperOrigin-RevId: 516607678 --- mediapipe/calculators/util/BUILD | 44 ++++ .../util/flat_color_image_calculator.cc | 138 ++++++++++++ .../util/flat_color_image_calculator.proto | 32 +++ .../util/flat_color_image_calculator_test.cc | 210 ++++++++++++++++++ 4 files changed, 424 insertions(+) create mode 100644 mediapipe/calculators/util/flat_color_image_calculator.cc create mode 100644 mediapipe/calculators/util/flat_color_image_calculator.proto create mode 100644 mediapipe/calculators/util/flat_color_image_calculator_test.cc diff --git a/mediapipe/calculators/util/BUILD b/mediapipe/calculators/util/BUILD index 6ac60d2c1..710a60d8a 100644 --- a/mediapipe/calculators/util/BUILD +++ b/mediapipe/calculators/util/BUILD @@ -1270,6 +1270,50 @@ cc_library( alwayslink = 1, ) +mediapipe_proto_library( + name = "flat_color_image_calculator_proto", + srcs = ["flat_color_image_calculator.proto"], + deps = [ + "//mediapipe/framework:calculator_options_proto", + "//mediapipe/framework:calculator_proto", + "//mediapipe/util:color_proto", + ], +) + +cc_library( + name = "flat_color_image_calculator", + srcs = ["flat_color_image_calculator.cc"], + deps = [ + ":flat_color_image_calculator_cc_proto", + "//mediapipe/framework:calculator_framework", + "//mediapipe/framework/api2:node", + "//mediapipe/framework/formats:image", + "//mediapipe/framework/formats:image_frame", + "//mediapipe/framework/formats:image_frame_opencv", + "//mediapipe/framework/port:opencv_core", + "//mediapipe/util:color_cc_proto", + "@com_google_absl//absl/status", + "@com_google_absl//absl/strings", + ], + alwayslink = 1, +) + +cc_test( + name = "flat_color_image_calculator_test", + srcs = ["flat_color_image_calculator_test.cc"], + deps = [ + ":flat_color_image_calculator", + "//mediapipe/framework:calculator_framework", + "//mediapipe/framework:calculator_runner", + "//mediapipe/framework:packet", + "//mediapipe/framework/formats:image", + "//mediapipe/framework/formats:image_frame", + "//mediapipe/framework/port:gtest", + "//mediapipe/framework/port:gtest_main", + "//mediapipe/util:color_cc_proto", + ], +) + cc_library( name = "from_image_calculator", srcs = ["from_image_calculator.cc"], diff --git a/mediapipe/calculators/util/flat_color_image_calculator.cc b/mediapipe/calculators/util/flat_color_image_calculator.cc new file mode 100644 index 000000000..71d3582c5 --- /dev/null +++ b/mediapipe/calculators/util/flat_color_image_calculator.cc @@ -0,0 +1,138 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +#include "absl/status/status.h" +#include "absl/strings/str_cat.h" +#include "mediapipe/calculators/util/flat_color_image_calculator.pb.h" +#include "mediapipe/framework/api2/node.h" +#include "mediapipe/framework/calculator_framework.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/formats/image_frame.h" +#include "mediapipe/framework/formats/image_frame_opencv.h" +#include "mediapipe/framework/port/opencv_core_inc.h" +#include "mediapipe/util/color.pb.h" + +namespace mediapipe { + +namespace { + +using ::mediapipe::api2::Input; +using ::mediapipe::api2::Node; +using ::mediapipe::api2::Output; +} // namespace + +// A calculator for generating an image filled with a single color. +// +// Inputs: +// IMAGE (Image, optional) +// If provided, the output will have the same size +// COLOR (Color proto, optional) +// Color to paint the output with. Takes precedence over the equivalent +// calculator options. +// +// Outputs: +// IMAGE (Image) +// Image filled with the requested color. +// +// Example useage: +// node { +// calculator: "FlatColorImageCalculator" +// input_stream: "IMAGE:image" +// input_stream: "COLOR:color" +// output_stream: "IMAGE:blank_image" +// options { +// [mediapipe.FlatColorImageCalculatorOptions.ext] { +// color: { +// r: 255 +// g: 255 +// b: 255 +// } +// } +// } +// } + +class FlatColorImageCalculator : public Node { + public: + static constexpr Input::Optional kInImage{"IMAGE"}; + static constexpr Input::Optional kInColor{"COLOR"}; + static constexpr Output kOutImage{"IMAGE"}; + + MEDIAPIPE_NODE_CONTRACT(kInImage, kInColor, kOutImage); + + static absl::Status UpdateContract(CalculatorContract* cc) { + const auto& options = cc->Options(); + + RET_CHECK(kInImage(cc).IsConnected() ^ + (options.has_output_height() || options.has_output_width())) + << "Either set IMAGE input stream, or set through options"; + RET_CHECK(kInColor(cc).IsConnected() ^ options.has_color()) + << "Either set COLOR input stream, or set through options"; + + return absl::OkStatus(); + } + + absl::Status Open(CalculatorContext* cc) override; + absl::Status Process(CalculatorContext* cc) override; + + private: + bool use_dimension_from_option_ = false; + bool use_color_from_option_ = false; +}; +MEDIAPIPE_REGISTER_NODE(FlatColorImageCalculator); + +absl::Status FlatColorImageCalculator::Open(CalculatorContext* cc) { + use_dimension_from_option_ = !kInImage(cc).IsConnected(); + use_color_from_option_ = !kInColor(cc).IsConnected(); + return absl::OkStatus(); +} + +absl::Status FlatColorImageCalculator::Process(CalculatorContext* cc) { + const auto& options = cc->Options(); + + int output_height = -1; + int output_width = -1; + if (use_dimension_from_option_) { + output_height = options.output_height(); + output_width = options.output_width(); + } else if (!kInImage(cc).IsEmpty()) { + const Image& input_image = kInImage(cc).Get(); + output_height = input_image.height(); + output_width = input_image.width(); + } else { + return absl::OkStatus(); + } + + Color color; + if (use_color_from_option_) { + color = options.color(); + } else if (!kInColor(cc).IsEmpty()) { + color = kInColor(cc).Get(); + } else { + return absl::OkStatus(); + } + + auto output_frame = std::make_shared(ImageFormat::SRGB, + output_width, output_height); + cv::Mat output_mat = mediapipe::formats::MatView(output_frame.get()); + + output_mat.setTo(cv::Scalar(color.r(), color.g(), color.b())); + + kOutImage(cc).Send(Image(output_frame)); + + return absl::OkStatus(); +} + +} // namespace mediapipe diff --git a/mediapipe/calculators/util/flat_color_image_calculator.proto b/mediapipe/calculators/util/flat_color_image_calculator.proto new file mode 100644 index 000000000..183bc796e --- /dev/null +++ b/mediapipe/calculators/util/flat_color_image_calculator.proto @@ -0,0 +1,32 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto2"; + +package mediapipe; + +import "mediapipe/framework/calculator.proto"; +import "mediapipe/util/color.proto"; + +message FlatColorImageCalculatorOptions { + extend CalculatorOptions { + optional FlatColorImageCalculatorOptions ext = 515548435; + } + + // Output dimensions. + optional int32 output_width = 1; + optional int32 output_height = 2; + // The color to fill with in the output image. + optional Color color = 3; +} diff --git a/mediapipe/calculators/util/flat_color_image_calculator_test.cc b/mediapipe/calculators/util/flat_color_image_calculator_test.cc new file mode 100644 index 000000000..53c6de1b1 --- /dev/null +++ b/mediapipe/calculators/util/flat_color_image_calculator_test.cc @@ -0,0 +1,210 @@ +// Copyright 2023 The MediaPipe Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +#include "mediapipe/framework/calculator_framework.h" +#include "mediapipe/framework/calculator_runner.h" +#include "mediapipe/framework/formats/image.h" +#include "mediapipe/framework/formats/image_frame.h" +#include "mediapipe/framework/packet.h" +#include "mediapipe/framework/port/gmock.h" +#include "mediapipe/framework/port/gtest.h" +#include "mediapipe/framework/port/status_matchers.h" +#include "mediapipe/util/color.pb.h" + +namespace mediapipe { +namespace { + +using ::testing::HasSubstr; + +constexpr char kImageTag[] = "IMAGE"; +constexpr char kColorTag[] = "COLOR"; +constexpr int kImageWidth = 256; +constexpr int kImageHeight = 256; + +TEST(FlatColorImageCalculatorTest, SpecifyColorThroughOptions) { + CalculatorRunner runner(R"pb( + calculator: "FlatColorImageCalculator" + input_stream: "IMAGE:image" + output_stream: "IMAGE:out_image" + options { + [mediapipe.FlatColorImageCalculatorOptions.ext] { + color: { + r: 100, + g: 200, + b: 255, + } + } + } + )pb"); + + auto image_frame = std::make_shared(ImageFormat::SRGB, + kImageWidth, kImageHeight); + + for (int ts = 0; ts < 3; ++ts) { + runner.MutableInputs()->Tag(kImageTag).packets.push_back( + MakePacket(image_frame).At(Timestamp(ts))); + } + MP_ASSERT_OK(runner.Run()); + + const auto& outputs = runner.Outputs().Tag(kImageTag).packets; + ASSERT_EQ(outputs.size(), 3); + + for (const auto& packet : outputs) { + const auto& image = packet.Get(); + EXPECT_EQ(image.width(), kImageWidth); + EXPECT_EQ(image.height(), kImageHeight); + auto image_frame = image.GetImageFrameSharedPtr(); + auto* pixel_data = image_frame->PixelData(); + EXPECT_EQ(pixel_data[0], 100); + EXPECT_EQ(pixel_data[1], 200); + EXPECT_EQ(pixel_data[2], 255); + } +} + +TEST(FlatColorImageCalculatorTest, SpecifyDimensionThroughOptions) { + CalculatorRunner runner(R"pb( + calculator: "FlatColorImageCalculator" + input_stream: "COLOR:color" + output_stream: "IMAGE:out_image" + options { + [mediapipe.FlatColorImageCalculatorOptions.ext] { + output_width: 7, + output_height: 13, + } + } + )pb"); + + Color color; + color.set_r(0); + color.set_g(5); + color.set_b(0); + + for (int ts = 0; ts < 3; ++ts) { + runner.MutableInputs()->Tag(kColorTag).packets.push_back( + MakePacket(color).At(Timestamp(ts))); + } + MP_ASSERT_OK(runner.Run()); + + const auto& outputs = runner.Outputs().Tag(kImageTag).packets; + ASSERT_EQ(outputs.size(), 3); + + for (const auto& packet : outputs) { + const auto& image = packet.Get(); + EXPECT_EQ(image.width(), 7); + EXPECT_EQ(image.height(), 13); + auto image_frame = image.GetImageFrameSharedPtr(); + const uint8_t* pixel_data = image_frame->PixelData(); + EXPECT_EQ(pixel_data[0], 0); + EXPECT_EQ(pixel_data[1], 5); + EXPECT_EQ(pixel_data[2], 0); + } +} + +TEST(FlatColorImageCalculatorTest, FailureMissingDimension) { + CalculatorRunner runner(R"pb( + calculator: "FlatColorImageCalculator" + input_stream: "COLOR:color" + output_stream: "IMAGE:out_image" + )pb"); + + Color color; + color.set_r(0); + color.set_g(5); + color.set_b(0); + + for (int ts = 0; ts < 3; ++ts) { + runner.MutableInputs()->Tag(kColorTag).packets.push_back( + MakePacket(color).At(Timestamp(ts))); + } + ASSERT_THAT(runner.Run().message(), + HasSubstr("Either set IMAGE input stream")); +} + +TEST(FlatColorImageCalculatorTest, FailureMissingColor) { + CalculatorRunner runner(R"pb( + calculator: "FlatColorImageCalculator" + input_stream: "IMAGE:image" + output_stream: "IMAGE:out_image" + )pb"); + + auto image_frame = std::make_shared(ImageFormat::SRGB, + kImageWidth, kImageHeight); + + for (int ts = 0; ts < 3; ++ts) { + runner.MutableInputs()->Tag(kImageTag).packets.push_back( + MakePacket(image_frame).At(Timestamp(ts))); + } + ASSERT_THAT(runner.Run().message(), + HasSubstr("Either set COLOR input stream")); +} + +TEST(FlatColorImageCalculatorTest, FailureDuplicateDimension) { + CalculatorRunner runner(R"pb( + calculator: "FlatColorImageCalculator" + input_stream: "IMAGE:image" + input_stream: "COLOR:color" + output_stream: "IMAGE:out_image" + options { + [mediapipe.FlatColorImageCalculatorOptions.ext] { + output_width: 7, + output_height: 13, + } + } + )pb"); + + auto image_frame = std::make_shared(ImageFormat::SRGB, + kImageWidth, kImageHeight); + + for (int ts = 0; ts < 3; ++ts) { + runner.MutableInputs()->Tag(kImageTag).packets.push_back( + MakePacket(image_frame).At(Timestamp(ts))); + } + ASSERT_THAT(runner.Run().message(), + HasSubstr("Either set IMAGE input stream")); +} + +TEST(FlatColorImageCalculatorTest, FailureDuplicateColor) { + CalculatorRunner runner(R"pb( + calculator: "FlatColorImageCalculator" + input_stream: "IMAGE:image" + input_stream: "COLOR:color" + output_stream: "IMAGE:out_image" + options { + [mediapipe.FlatColorImageCalculatorOptions.ext] { + color: { + r: 100, + g: 200, + b: 255, + } + } + } + )pb"); + + Color color; + color.set_r(0); + color.set_g(5); + color.set_b(0); + + for (int ts = 0; ts < 3; ++ts) { + runner.MutableInputs()->Tag(kColorTag).packets.push_back( + MakePacket(color).At(Timestamp(ts))); + } + ASSERT_THAT(runner.Run().message(), + HasSubstr("Either set COLOR input stream")); +} + +} // namespace +} // namespace mediapipe From ade31b567b55e49ba4b5c91924fdcdbc4664b0f1 Mon Sep 17 00:00:00 2001 From: Hadon Nash Date: Tue, 14 Mar 2023 14:02:10 -0700 Subject: [PATCH 075/136] Internal change PiperOrigin-RevId: 516626371 --- mediapipe/framework/tool/mediapipe_proto.bzl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediapipe/framework/tool/mediapipe_proto.bzl b/mediapipe/framework/tool/mediapipe_proto.bzl index 598bc08f2..4ce4b5cc1 100644 --- a/mediapipe/framework/tool/mediapipe_proto.bzl +++ b/mediapipe/framework/tool/mediapipe_proto.bzl @@ -204,7 +204,7 @@ def rewrite_mediapipe_proto(name, rewrite_proto, source_proto, **kwargs): 'import public "' + join_path + '";', ) rewrite_ref = SubsituteCommand( - r"mediapipe\\.(" + rewrite_message_regex + ")", + r"mediapipe\.(" + rewrite_message_regex + ")", r"mediapipe.\\1", ) rewrite_objc = SubsituteCommand( From 6774794d0241942a2bca0cc05ef4626c2148fc71 Mon Sep 17 00:00:00 2001 From: MediaPipe Team Date: Tue, 14 Mar 2023 14:08:49 -0700 Subject: [PATCH 076/136] Add the dataset module for face stylizer in model maker. PiperOrigin-RevId: 516628350 --- .../python/vision/face_stylizer/BUILD | 48 +++++++++ .../python/vision/face_stylizer/__init__.py | 14 +++ .../python/vision/face_stylizer/dataset.py | 98 ++++++++++++++++++ .../vision/face_stylizer/dataset_test.py | 48 +++++++++ .../face_stylizer/testdata/cartoon/disney.png | Bin 0 -> 354978 bytes .../face_stylizer/testdata/sketch/sketch.png | Bin 0 -> 343649 bytes 6 files changed, 208 insertions(+) create mode 100644 mediapipe/model_maker/python/vision/face_stylizer/BUILD create mode 100644 mediapipe/model_maker/python/vision/face_stylizer/__init__.py create mode 100644 mediapipe/model_maker/python/vision/face_stylizer/dataset.py create mode 100644 mediapipe/model_maker/python/vision/face_stylizer/dataset_test.py create mode 100644 mediapipe/model_maker/python/vision/face_stylizer/testdata/cartoon/disney.png create mode 100644 mediapipe/model_maker/python/vision/face_stylizer/testdata/sketch/sketch.png diff --git a/mediapipe/model_maker/python/vision/face_stylizer/BUILD b/mediapipe/model_maker/python/vision/face_stylizer/BUILD new file mode 100644 index 000000000..804511540 --- /dev/null +++ b/mediapipe/model_maker/python/vision/face_stylizer/BUILD @@ -0,0 +1,48 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Placeholder for internal Python strict test compatibility macro. +# Placeholder for internal Python strict library and test compatibility macro. + +licenses(["notice"]) + +package(default_visibility = ["//mediapipe:__subpackages__"]) + +filegroup( + name = "testdata", + srcs = glob([ + "testdata/**", + ]), +) + +py_library( + name = "dataset", + srcs = ["dataset.py"], + deps = [ + "//mediapipe/model_maker/python/core/data:classification_dataset", + "//mediapipe/model_maker/python/vision/core:image_utils", + ], +) + +py_test( + name = "dataset_test", + srcs = ["dataset_test.py"], + data = [ + ":testdata", + ], + deps = [ + ":dataset", + "//mediapipe/tasks/python/test:test_utils", + ], +) diff --git a/mediapipe/model_maker/python/vision/face_stylizer/__init__.py b/mediapipe/model_maker/python/vision/face_stylizer/__init__.py new file mode 100644 index 000000000..e935c0c76 --- /dev/null +++ b/mediapipe/model_maker/python/vision/face_stylizer/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""MediaPipe Model Maker Python Public API For Face Stylization.""" diff --git a/mediapipe/model_maker/python/vision/face_stylizer/dataset.py b/mediapipe/model_maker/python/vision/face_stylizer/dataset.py new file mode 100644 index 000000000..b6c85d6f3 --- /dev/null +++ b/mediapipe/model_maker/python/vision/face_stylizer/dataset.py @@ -0,0 +1,98 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Face stylizer dataset library.""" + +import logging +import os + +import tensorflow as tf + +from mediapipe.model_maker.python.core.data import classification_dataset +from mediapipe.model_maker.python.vision.core import image_utils + + +# TODO: Change to a unlabeled dataset if it makes sense. +class Dataset(classification_dataset.ClassificationDataset): + """Dataset library for face stylizer fine tuning.""" + + @classmethod + def from_folder( + cls, dirname: str + ) -> classification_dataset.ClassificationDataset: + """Loads images from the given directory. + + The style image dataset directory is expected to contain one subdirectory + whose name represents the label of the style. There can be one or multiple + images of the same style in that subdirectory. Supported input image formats + include 'jpg', 'jpeg', 'png'. + + Args: + dirname: Name of the directory containing the image files. + + Returns: + Dataset containing images and labels and other related info. + Raises: + ValueError: if the input data directory is empty. + """ + data_root = os.path.abspath(dirname) + + # Assumes the image data of the same label are in the same subdirectory, + # gets image path and label names. + all_image_paths = list(tf.io.gfile.glob(data_root + r'/*/*')) + all_image_size = len(all_image_paths) + if all_image_size == 0: + raise ValueError('Invalid input data directory') + if not any( + fname.endswith(('.jpg', '.jpeg', '.png')) for fname in all_image_paths + ): + raise ValueError('No images found under given directory') + + label_names = sorted( + name + for name in os.listdir(data_root) + if os.path.isdir(os.path.join(data_root, name)) + ) + all_label_size = len(label_names) + index_by_label = dict( + (name, index) for index, name in enumerate(label_names) + ) + # Get the style label from the subdirectory name. + all_image_labels = [ + index_by_label[os.path.basename(os.path.dirname(path))] + for path in all_image_paths + ] + + path_ds = tf.data.Dataset.from_tensor_slices(all_image_paths) + + image_ds = path_ds.map( + image_utils.load_image, num_parallel_calls=tf.data.AUTOTUNE + ) + + # Load label + label_ds = tf.data.Dataset.from_tensor_slices( + tf.cast(all_image_labels, tf.int64) + ) + + # Create a dataset of (image, label) pairs + image_label_ds = tf.data.Dataset.zip((image_ds, label_ds)) + + logging.info( + 'Load images dataset with size: %d, num_label: %d, labels: %s.', + all_image_size, + all_label_size, + ', '.join(label_names), + ) + return Dataset( + dataset=image_label_ds, size=all_image_size, label_names=label_names + ) diff --git a/mediapipe/model_maker/python/vision/face_stylizer/dataset_test.py b/mediapipe/model_maker/python/vision/face_stylizer/dataset_test.py new file mode 100644 index 000000000..a8af222d4 --- /dev/null +++ b/mediapipe/model_maker/python/vision/face_stylizer/dataset_test.py @@ -0,0 +1,48 @@ +# Copyright 2023 The MediaPipe Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import tensorflow as tf + +from mediapipe.model_maker.python.vision.face_stylizer import dataset +from mediapipe.tasks.python.test import test_utils + + +class DatasetTest(tf.test.TestCase): + + def setUp(self): + super().setUp() + # TODO: Replace the stylize image dataset with licensed images. + self._test_data_dirname = 'testdata' + + def test_from_folder(self): + input_data_dir = test_utils.get_test_data_path(self._test_data_dirname) + data = dataset.Dataset.from_folder(dirname=input_data_dir) + self.assertEqual(data.num_classes, 2) + self.assertEqual(data.label_names, ['cartoon', 'sketch']) + self.assertLen(data, 2) + + def test_from_folder_raise_value_error_for_invalid_path(self): + with self.assertRaisesRegex(ValueError, 'Invalid input data directory'): + dataset.Dataset.from_folder(dirname='invalid') + + def test_from_folder_raise_value_error_for_valid_no_data_path(self): + input_data_dir = test_utils.get_test_data_path('face_stylizer') + with self.assertRaisesRegex( + ValueError, 'No images found under given directory' + ): + dataset.Dataset.from_folder(dirname=input_data_dir) + + +if __name__ == '__main__': + tf.test.main() diff --git a/mediapipe/model_maker/python/vision/face_stylizer/testdata/cartoon/disney.png b/mediapipe/model_maker/python/vision/face_stylizer/testdata/cartoon/disney.png new file mode 100644 index 0000000000000000000000000000000000000000..87e9d3d8d3dc949367c55a3d8247c78c425dbf67 GIT binary patch literal 354978 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 Kz3`Kp-jD;l4UW3|1b3pTm*g6~C;4gU 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