Repository: bentoml/BentoML

Issue:
Multiple Image Inputs support
**Is your feature request related to a problem? Please describe.**

The new ImageInput with micro-batching support does not allow multiple image inputs, comparing to the old ImageHandler



**Describe the solution you'd like**
Add `bentoml.adapters.MultiImageInput` input adapter type:

```
class MultiImageInput(BaseInputAdapter):
    """
    Warning: this is a not yet implemented, follow issue #806 for updates
    Transform incoming images data from http request, cli or lambda event into numpy
    array.
    Args:
        input_names (string[]]): A tuple of acceptable input name for HTTP request.
            Default value is (image,)
        accept_image_formats (string[]):  A list of acceptable image formats.
            Default value is loaded from bentoml config
            'apiserver/default_image_input_accept_file_extensions', which is
            set to ['.jpg', '.png', '.jpeg', '.tiff', '.webp', '.bmp'] by default.
            List of all supported format can be found here:
            https://imageio.readthedocs.io/en/stable/formats.html
        pilmode (string): The pilmode to be used for reading image file into numpy
            array. Default value is 'RGB'.  Find more information at:
            https://imageio.readthedocs.io/en/stable/format_png-pil.html
    Raises:
        ImportError: imageio package is required to use ImageInput
    Example usage:
        ```
        class MyService(BentoService):
            @bentoml.api(input=MultiImageInput(input_names=('imageX', 'imageY')))
            def predict(self, image_groups):
                for image_group in image_groups:
                    image_array_x = image_group['imageX']
                    image_array_y = image_group['imageY']
        ```
    """

```
see placeholder file here: https://github.com/bentoml/BentoML/pull/804

**Describe alternatives you've considered**
`bentoml.adapters.MultiFileInput`

**Additional context**
N/A



Discussion:
~~~
input_names (string[]]): A tuple of acceptable input name for HTTP request.
            Default value is (image,)
~~~
Why do we want to use this instead of passing along all of the files and letting the consumer decide which files they will use?
@akainth015 great question, I think it makes sense to support inputs of a variable-length list of files/images instead of only fixed-length, named images.   The original design allows us to give the user a `columnar` look of a batch of input, e.g. `{'image1': [<image_file>, ...], 'image2': [<image_file>] }`, but I think it makes more sense to group input images in rows considering this case, e.g. `[ [<image_file>, ..], [<image_file>, ..], ... ]` or `[ {'image1': <image_file>, 'image2': <image_file>}, ... ]`, cc @bojiang 
The current design is exactly what you said:
```
image_array_x = image_group['imageX']
```
->
```
 {'image1': <image_file>, 'image2': <image_file>}, ... ]
```

In most cases, the API developer doesn't need file inputs with dynamic names. The `input_names` would be useful for API users, for example, shown in the swagger.

Resolving patch:
diff --git a/bentoml/adapters/multi_image_input.py b/bentoml/adapters/multi_image_input.py
--- a/bentoml/adapters/multi_image_input.py
+++ b/bentoml/adapters/multi_image_input.py
@@ -11,21 +11,33 @@
 # 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 argparse
+import pathlib
+from io import BytesIO
+from typing import Iterable
+
+from werkzeug import Request
+from werkzeug.utils import secure_filename
 
 from bentoml.adapters.base_input import BaseInputAdapter
-from bentoml.adapters.image_input import get_default_accept_image_formats
+from bentoml.adapters.image_input import (
+    get_default_accept_image_formats,
+    verify_image_format_or_raise,
+    _import_imageio_imread,
+)
+from bentoml.exceptions import BadInput
+from bentoml.marshal.utils import SimpleRequest, SimpleResponse
+
+
+imread = _import_imageio_imread()
 
 
 class MultiImageInput(BaseInputAdapter):
     """
-    Warning: this is a not yet implemented, follow issue #806 for updates
-    Transform incoming images data from http request, cli or lambda event into numpy
-    array.
-
     Args:
-        input_names (string[]]): A tuple of acceptable input name for HTTP request.
+        input_names (string[]): A tuple of acceptable input name for HTTP request.
             Default value is (image,)
-        accept_image_formats (string[]):  A list of acceptable image formats.
+        accepted_image_formats (string[]):  A list of acceptable image formats.
             Default value is loaded from bentoml config
             'apiserver/default_image_input_accept_file_extensions', which is
             set to ['.jpg', '.png', '.jpeg', '.tiff', '.webp', '.bmp'] by default.
@@ -36,64 +48,136 @@ class MultiImageInput(BaseInputAdapter):
             https://imageio.readthedocs.io/en/stable/format_png-pil.html
 
     Raises:
-        ImportError: imageio package is required to use ImageInput
+        ImportError: imageio package is required to use MultiImageInput
 
     Example usage:
-        ```
-        class MyService(BentoService):
-            @bentoml.api(input=MultiImageInput(input_names=('imageX', 'imageY')))
-            def predict(self, image_groups):
-                for image_group in image_groups:
-                    image_array_x = image_group['imageX']
-                    image_array_y = image_group['imageY']
-        ```
+
+    >>> from bentoml import BentoService
+    >>> import bentoml
+    >>>
+    >>> class MyService(BentoService):
+    >>>     @bentoml.api(input=MultiImageInput(input_names=('imageX', 'imageY')))
+    >>>     def predict(self, image_groups):
+    >>>         for image_group in image_groups:
+    >>>             image_array_x = image_group['imageX']
+    >>>             image_array_y = image_group['imageY']
+
+    The endpoint could then be used with an HTML form that sends multipart data, like
+    the example below
+
+
+    >>> <form action="http://localhost:8000" method="POST"
+    >>>       enctype="multipart/form-data">
+    >>>     <input name="imageX" type="file">
+    >>>     <input name="imageY" type="file">
+    >>>     <input type="submit">
+    >>> </form>
+
+    Or the following cURL command
+
+    >>> curl -F imageX=@image_file_x.png
+    >>>      -F imageY=@image_file_y.jpg
+    >>>      http://localhost:8000
     """
 
     def __init__(
         self,
         input_names=("image",),
-        accept_image_formats=None,
+        accepted_image_formats=None,
         pilmode="RGB",
         is_batch_input=False,
         **base_kwargs,
     ):
-        if is_batch_input:
-            raise ValueError('ImageInput can not accpept batch inputs')
         super(MultiImageInput, self).__init__(
             is_batch_input=is_batch_input, **base_kwargs
         )
         self.input_names = input_names
         self.pilmode = pilmode
-        self.accept_image_formats = (
-            accept_image_formats or get_default_accept_image_formats()
+        self.accepted_image_formats = (
+            accepted_image_formats or get_default_accept_image_formats()
         )
 
-    def handle_batch_request(self, requests, func):
-        """Handles an HTTP request, convert it into corresponding data
-        format that user API function is expecting, and return API
-        function result as the HTTP response to client
-
-        :param requests: List of flask request object
-        :param func: user API function
-        """
-        raise NotImplementedError
+    def handle_request(self, request: Request, func):
+        files = {
+            name: self.read_file(file.filename, file.stream)
+            for (name, file) in request.files.items()
+        }
+        result = func((files,))[0]
+        return self.output_adapter.to_response(result, request)
+
+    def read_file(self, name: str, file: BytesIO):
+        safe_name = secure_filename(name)
+        verify_image_format_or_raise(safe_name, self.accepted_image_formats)
+        return imread(file, pilmode=self.pilmode)
+
+    def handle_batch_request(
+        self, requests: Iterable[SimpleRequest], func
+    ) -> Iterable[SimpleResponse]:
+        inputs = []
+        slices = []
+        for i, req in enumerate(requests):
+            content_type = next(
+                header[1] for header in req.headers if header[0] == b"Content-Type"
+            )
+
+            if b"multipart/form-data" not in content_type:
+                slices.append(None)
+            else:
+                files = {}
+                request = Request.from_values(
+                    data=req.data, content_type=content_type, headers=req.headers,
+                )
+                for name in request.files:
+                    file = request.files[name]
+                    files[name] = self.read_file(file.filename, file.stream)
+                inputs.append(files)
+                slices.append(i)
+        results = func(inputs) if inputs else []
+        return self.output_adapter.to_batch_response(results, slices, requests)
 
     def handle_cli(self, args, func):
         """Handles an CLI command call, convert CLI arguments into
         corresponding data format that user API function is expecting, and
         prints the API function result to console output
-
         :param args: CLI arguments
         :param func: user API function
         """
-        raise NotImplementedError
+        parser = argparse.ArgumentParser()
+        for input_name in self.input_names:
+            parser.add_argument('--' + input_name, required=True)
+        args, unknown_args = parser.parse_known_args(args)
+        args = vars(args)
+        files = {
+            input_name: self.read_file(
+                pathlib.Path(args[input_name]).name, args[input_name]
+            )
+            for input_name in self.input_names
+        }
+        result = func((files,))[0]
+        return self.output_adapter.to_cli(result, unknown_args)
 
     def handle_aws_lambda_event(self, event, func):
         """Handles a Lambda event, convert event dict into corresponding
         data format that user API function is expecting, and use API
         function result as response
-
         :param event: AWS lambda event data of the python `dict` type
         :param func: user API function
         """
-        raise NotImplementedError
+        content_type = event['headers']['Content-Type']
+        if "multipart/form-data" in content_type:
+            files = {}
+
+            request = Request.from_values(
+                data=event['body'], content_type=content_type, headers=event['headers']
+            )
+            for name in request.files:
+                file = request.files[name]
+                files[name] = self.read_file(file.filename, file.stream)
+            result = func((files,))[0]
+            return self.output_adapter.to_aws_lambda_event(result, event)
+        else:
+            raise BadInput(
+                "Multi-image requests don't support the {} content type".format(
+                    content_type
+                )
+            )


Test patch:
diff --git a/tests/handlers/test_multi_image_input_adapter.py b/tests/handlers/test_multi_image_input_adapter.py
new file mode 100644
--- /dev/null
+++ b/tests/handlers/test_multi_image_input_adapter.py
@@ -0,0 +1,122 @@
+from werkzeug import Request
+
+from bentoml.exceptions import BadInput
+from bentoml.adapters.multi_image_input import MultiImageInput
+from bentoml.marshal.utils import SimpleRequest
+from urllib3.filepost import encode_multipart_formdata
+
+import pytest
+
+
+def generate_multipart_body(image_file):
+    image = ("image.jpg", open(image_file, "rb").read())
+    files = {"imageX": image, "imageY": image}
+    body, content_type = encode_multipart_formdata(files)
+
+    headers = {
+        'Content-Type': content_type,
+        'Content-Length': len(body),
+    }
+    return body, headers
+
+
+def predict(requests):
+    return [(request['imageX'].shape, request['imageY'].shape) for request in requests]
+
+
+def test_multi_image_input_cli(capsys, img_file):
+    adapter = MultiImageInput(input_names=("imageX", "imageY"), is_batch_input=True)
+
+    test_args = ['--imageX', img_file, '--imageY', img_file]
+    adapter.handle_cli(test_args, predict)
+    out, _ = capsys.readouterr()
+    assert out.strip() == '((10, 10, 3), (10, 10, 3))'
+
+
+def test_multi_image_input_request(img_file):
+    adapter = MultiImageInput(("imageX", "imageY"))
+
+    multipart_data, headers = generate_multipart_body(img_file)
+    request = Request.from_values(
+        data=multipart_data,
+        content_type=headers['Content-Type'],
+        content_length=headers['Content-Length'],
+    )
+
+    response = adapter.handle_request(request, predict)
+    assert response.status_code == 200
+    assert response.data == b'[[10, 10, 3], [10, 10, 3]]'
+
+
+def test_multi_image_batch_input(img_file):
+    adapter = MultiImageInput(("imageX", "imageY"), is_batch_input=True)
+
+    multipart_data, headers = generate_multipart_body(img_file)
+    request = SimpleRequest.from_flask_request(
+        Request.from_values(
+            data=multipart_data,
+            content_type=headers['Content-Type'],
+            content_length=headers['Content-Length'],
+        )
+    )
+
+    responses = adapter.handle_batch_request([request] * 5, predict)
+    for response in responses:
+        assert response.status == 200
+        assert response.data == '[[10, 10, 3], [10, 10, 3]]'
+
+
+def test_bad_multi_image_batch_input(img_file):
+    adapter = MultiImageInput(("imageX", "imageY"), is_batch_input=True)
+
+    multipart_data, headers = generate_multipart_body(img_file)
+    request = SimpleRequest.from_flask_request(
+        Request.from_values(
+            data=multipart_data,
+            content_type=headers['Content-Type'],
+            content_length=headers['Content-Length'],
+        )
+    )
+
+    responses = adapter.handle_batch_request(
+        [request] * 5
+        + [
+            SimpleRequest.from_flask_request(
+                Request.from_values(
+                    data=multipart_data,
+                    content_type='application/octet-stream',
+                    content_length=headers['Content-Length'],
+                )
+            )
+        ],
+        predict,
+    )
+    print(responses[-1])
+    assert isinstance(responses[-1], SimpleRequest)
+
+
+def test_multi_image_aws_event(img_file):
+    adapter = MultiImageInput(("imageX", "imageY"), is_batch_input=True)
+
+    multipart_data, headers = generate_multipart_body(img_file)
+    aws_lambda_event = {"body": multipart_data, "headers": headers}
+
+    aws_result = adapter.handle_aws_lambda_event(aws_lambda_event, predict)
+    assert aws_result["statusCode"] == 200
+    assert aws_result["body"] == '[[10, 10, 3], [10, 10, 3]]'
+
+
+def test_bad_multi_image_aws_event(img_file):
+    adapter = MultiImageInput(("imageX", "imageY"), is_batch_input=True)
+
+    multipart_data, headers = generate_multipart_body(img_file)
+    aws_lambda_event = {
+        "body": multipart_data,
+        "headers": {
+            "Content-Type": "application/octet-stream",
+            "Content-Length": headers["Content-Length"],
+        },
+    }
+
+    with pytest.raises(BadInput):
+        adapter.handle_aws_lambda_event(aws_lambda_event, predict)