Repository: deepjavalibrary/djl
Pull request #1331 (2021-10-30 02:24:10)

[opencv] Add opencv extension
Change-Id: I738397d316ae128dd66f6ff5de8d52bfc56927e2

## Description ##

Brief description of what this PR is about

- If this change is a backward incompatible change, why must this change be made?
- Interesting edge cases to note here


codecov-commenter: # [Codecov](https://codecov.io/gh/deepjavalibrary/djl/pull/1331?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary) Report
> Merging [#1331](https://codecov.io/gh/deepjavalibrary/djl/pull/1331?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary) (77249e2) into [master](https://codecov.io/gh/deepjavalibrary/djl/commit/bb5073f7ffc33f1883621fc1783dbb01e70b418f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary) (bb5073f) will **decrease** coverage by `0.48%`.
> The diff coverage is `62.53%`.

[![Impacted file tree graph](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/graphs/tree.svg?width=650&height=150&src=pr&token=mDKVNwFXRV&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary)](https://codecov.io/gh/deepjavalibrary/djl/pull/1331?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary)

```diff
@@             Coverage Diff              @@
##             master    #1331      +/-   ##
============================================
- Coverage     72.08%   71.60%   -0.49%     
- Complexity     5126     5165      +39     
============================================
  Files           473      480       +7     
  Lines         21970    22398     +428     
  Branches       2351     2402      +51     
============================================
+ Hits          15838    16039     +201     
- Misses         4925     5140     +215     
- Partials       1207     1219      +12     
```


| [Impacted Files](https://codecov.io/gh/deepjavalibrary/djl/pull/1331?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary) | Coverage Δ | |
|---|---|---|
| [api/src/main/java/ai/djl/modality/cv/Image.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL21vZGFsaXR5L2N2L0ltYWdlLmphdmE=) | `69.23% <ø> (-4.11%)` | :arrow_down: |
| [...i/djl/modality/cv/translator/BigGANTranslator.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL21vZGFsaXR5L2N2L3RyYW5zbGF0b3IvQmlnR0FOVHJhbnNsYXRvci5qYXZh) | `21.42% <ø> (-5.24%)` | :arrow_down: |
| [...odality/cv/translator/BigGANTranslatorFactory.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL21vZGFsaXR5L2N2L3RyYW5zbGF0b3IvQmlnR0FOVHJhbnNsYXRvckZhY3RvcnkuamF2YQ==) | `33.33% <0.00%> (+8.33%)` | :arrow_up: |
| [...nslator/InstanceSegmentationTranslatorFactory.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL21vZGFsaXR5L2N2L3RyYW5zbGF0b3IvSW5zdGFuY2VTZWdtZW50YXRpb25UcmFuc2xhdG9yRmFjdG9yeS5qYXZh) | `14.28% <0.00%> (-3.90%)` | :arrow_down: |
| [.../modality/cv/translator/YoloTranslatorFactory.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL21vZGFsaXR5L2N2L3RyYW5zbGF0b3IvWW9sb1RyYW5zbGF0b3JGYWN0b3J5LmphdmE=) | `8.33% <0.00%> (-1.67%)` | :arrow_down: |
| [...i/djl/modality/cv/translator/YoloV5Translator.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL21vZGFsaXR5L2N2L3RyYW5zbGF0b3IvWW9sb1Y1VHJhbnNsYXRvci5qYXZh) | `5.69% <0.00%> (ø)` | |
| [...odality/cv/translator/YoloV5TranslatorFactory.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL21vZGFsaXR5L2N2L3RyYW5zbGF0b3IvWW9sb1Y1VHJhbnNsYXRvckZhY3RvcnkuamF2YQ==) | `8.33% <0.00%> (-1.67%)` | :arrow_down: |
| [...n/java/ai/djl/modality/nlp/bert/BertTokenizer.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL21vZGFsaXR5L25scC9iZXJ0L0JlcnRUb2tlbml6ZXIuamF2YQ==) | `92.68% <0.00%> (-2.32%)` | :arrow_down: |
| [...modality/nlp/embedding/TrainableWordEmbedding.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL21vZGFsaXR5L25scC9lbWJlZGRpbmcvVHJhaW5hYmxlV29yZEVtYmVkZGluZy5qYXZh) | `45.00% <0.00%> (-3.34%)` | :arrow_down: |
| [...pi/src/main/java/ai/djl/ndarray/BytesSupplier.java](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary#diff-YXBpL3NyYy9tYWluL2phdmEvYWkvZGpsL25kYXJyYXkvQnl0ZXNTdXBwbGllci5qYXZh) | `54.54% <0.00%> (-12.13%)` | :arrow_down: |
| ... and [94 more](https://codecov.io/gh/deepjavalibrary/djl/pull/1331/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/deepjavalibrary/djl/pull/1331?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/deepjavalibrary/djl/pull/1331?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary). Last update [866161a...77249e2](https://codecov.io/gh/deepjavalibrary/djl/pull/1331?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=deepjavalibrary).


Patch:
diff --git a/api/src/main/java/ai/djl/modality/cv/BufferedImageFactory.java b/api/src/main/java/ai/djl/modality/cv/BufferedImageFactory.java
index 6eef92cc7..254dbcf98 100644
--- a/api/src/main/java/ai/djl/modality/cv/BufferedImageFactory.java
+++ b/api/src/main/java/ai/djl/modality/cv/BufferedImageFactory.java
@@ -34,7 +34,6 @@ import java.awt.image.BufferedImage;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.net.URL;
 import java.nio.ByteBuffer;
 import java.nio.file.Path;
 import java.util.List;
@@ -61,16 +60,6 @@ public class BufferedImageFactory extends ImageFactory {
         return new BufferedImageWrapper(image);
     }
 
-    /** {@inheritDoc} */
-    @Override
-    public Image fromUrl(URL url) throws IOException {
-        BufferedImage image = ImageIO.read(url);
-        if (image == null) {
-            throw new IOException("Failed to read image from: " + url);
-        }
-        return new BufferedImageWrapper(image);
-    }
-
     /** {@inheritDoc} */
     @Override
     public Image fromInputStream(InputStream is) throws IOException {
@@ -146,7 +135,7 @@ public class BufferedImageFactory extends ImageFactory {
 
     private class BufferedImageWrapper implements Image {
 
-        private final BufferedImage image;
+        private BufferedImage image;
 
         BufferedImageWrapper(BufferedImage image) {
             this.image = image;
@@ -172,19 +161,22 @@ public class BufferedImageFactory extends ImageFactory {
 
         /** {@inheritDoc} */
         @Override
-        public Image getSubimage(int x, int y, int w, int h) {
+        public Image getSubImage(int x, int y, int w, int h) {
             return new BufferedImageWrapper(image.getSubimage(x, y, w, h));
         }
 
-        /** {@inheritDoc} */
-        @Override
-        public Image duplicate(Type type) {
+        private void convertIdNeeded() {
+            if (image.getType() == BufferedImage.TYPE_INT_ARGB) {
+                return;
+            }
+
             BufferedImage newImage =
-                    new BufferedImage(image.getWidth(), image.getHeight(), getType(type));
+                    new BufferedImage(
+                            image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
             Graphics2D g = newImage.createGraphics();
             g.drawImage(image, 0, 0, null);
             g.dispose();
-            return new BufferedImageWrapper(newImage);
+            image = newImage;
         }
 
         /** {@inheritDoc} */
@@ -242,6 +234,9 @@ public class BufferedImageFactory extends ImageFactory {
         /** {@inheritDoc} */
         @Override
         public void drawBoundingBoxes(DetectedObjects detections) {
+            // Make image copy with alpha channel because original image was jpg
+            convertIdNeeded();
+
             Graphics2D g = (Graphics2D) image.getGraphics();
             int stroke = 2;
             g.setStroke(new BasicStroke(stroke));
@@ -267,10 +262,9 @@ public class BufferedImageFactory extends ImageFactory {
                 drawText(g, className, x, y, stroke, 4);
                 // If we have a mask instead of a plain rectangle, draw tha mask
                 if (box instanceof Mask) {
-                    Mask mask = (Mask) box;
-                    drawMask(image, mask);
+                    drawMask((Mask) box);
                 } else if (box instanceof Landmark) {
-                    drawLandmarks(image, box);
+                    drawLandmarks(box);
                 }
             }
             g.dispose();
@@ -279,6 +273,9 @@ public class BufferedImageFactory extends ImageFactory {
         /** {@inheritDoc} */
         @Override
         public void drawJoints(Joints joints) {
+            // Make image copy with alpha channel because original image was jpg
+            convertIdNeeded();
+
             Graphics2D g = (Graphics2D) image.getGraphics();
             int stroke = 2;
             g.setStroke(new BasicStroke(stroke));
@@ -295,13 +292,6 @@ public class BufferedImageFactory extends ImageFactory {
             g.dispose();
         }
 
-        private int getType(Type type) {
-            if (type == Type.TYPE_INT_ARGB) {
-                return BufferedImage.TYPE_INT_ARGB;
-            }
-            throw new IllegalArgumentException("the type is not supported!");
-        }
-
         private Color randomColor() {
             return new Color(RandomUtils.nextInt(255));
         }
@@ -319,7 +309,7 @@ public class BufferedImageFactory extends ImageFactory {
             g.drawString(text, x + padding, y + ascent);
         }
 
-        private void drawMask(BufferedImage image, Mask mask) {
+        private void drawMask(Mask mask) {
             float r = RandomUtils.nextFloat();
             float g = RandomUtils.nextFloat();
             float b = RandomUtils.nextFloat();
@@ -341,13 +331,7 @@ public class BufferedImageFactory extends ImageFactory {
                             probDist.length, probDist[0].length, BufferedImage.TYPE_INT_ARGB);
             for (int xCor = 0; xCor < probDist.length; xCor++) {
                 for (int yCor = 0; yCor < probDist[xCor].length; yCor++) {
-                    float opacity = probDist[xCor][yCor];
-                    if (opacity < 0.1) {
-                        opacity = 0f;
-                    }
-                    if (opacity > 0.8) {
-                        opacity = 0.8f;
-                    }
+                    float opacity = probDist[xCor][yCor] * 0.8f;
                     maskImage.setRGB(xCor, yCor, new Color(r, g, b, opacity).darker().getRGB());
                 }
             }
@@ -356,7 +340,7 @@ public class BufferedImageFactory extends ImageFactory {
             gR.dispose();
         }
 
-        private void drawLandmarks(BufferedImage image, BoundingBox box) {
+        private void drawLandmarks(BoundingBox box) {
             Graphics2D g = (Graphics2D) image.getGraphics();
             g.setColor(new Color(246, 96, 0));
             BasicStroke bStroke = new BasicStroke(4, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER);
diff --git a/api/src/main/java/ai/djl/modality/cv/Image.java b/api/src/main/java/ai/djl/modality/cv/Image.java
index f15c36e8a..eaa26af1b 100644
--- a/api/src/main/java/ai/djl/modality/cv/Image.java
+++ b/api/src/main/java/ai/djl/modality/cv/Image.java
@@ -55,15 +55,7 @@ public interface Image {
      * @param h the height of the specified rectangular region
      * @return subimage of this image
      */
-    Image getSubimage(int x, int y, int w, int h);
-
-    /**
-     * Gets a deep copy of the original image given the type.
-     *
-     * @param type the type of the copied image
-     * @return the copy of the original image.
-     */
-    Image duplicate(Type type);
+    Image getSubImage(int x, int y, int w, int h);
 
     /**
      * Converts image to a RGB {@link NDArray}.
@@ -129,11 +121,6 @@ public interface Image {
         }
     }
 
-    /** Type indicates the type options for images. */
-    enum Type {
-        TYPE_INT_ARGB
-    }
-
     /** Interpolation indicates the Interpolation options for resizinig an image. */
     enum Interpolation {
         NEAREST,
diff --git a/api/src/main/java/ai/djl/modality/cv/ImageFactory.java b/api/src/main/java/ai/djl/modality/cv/ImageFactory.java
index 8bc778113..dbc1e3985 100644
--- a/api/src/main/java/ai/djl/modality/cv/ImageFactory.java
+++ b/api/src/main/java/ai/djl/modality/cv/ImageFactory.java
@@ -19,6 +19,8 @@ import java.net.URI;
 import java.net.URL;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * {@code ImageFactory} contains image creation mechanism on top of different platforms like PC and
@@ -26,20 +28,31 @@ import java.nio.file.Paths;
  */
 public abstract class ImageFactory {
 
+    private static final Logger logger = LoggerFactory.getLogger(ImageFactory.class);
+
+    private static final String[] FACTORIES = {
+        "ai.djl.opencv.OpenCVImageFactory",
+        "ai.djl.modality.cv.BufferedImageFactory",
+        "ai.djl.android.core.BitmapImageFactory"
+    };
+
     private static ImageFactory factory = newInstance();
 
     private static ImageFactory newInstance() {
-        String className = "ai.djl.modality.cv.BufferedImageFactory";
+        int index = 0;
         if ("http://www.android.com/".equals(System.getProperty("java.vendor.url"))) {
-            className = "ai.djl.android.core.BitmapImageFactory";
+            index = 2;
         }
-        try {
-            Class<? extends ImageFactory> clazz =
-                    Class.forName(className).asSubclass(ImageFactory.class);
-            return clazz.getConstructor().newInstance();
-        } catch (ReflectiveOperationException e) {
-            throw new IllegalStateException("Create new ImageFactory failed!", e);
+        for (int i = index; i < FACTORIES.length; ++i) {
+            try {
+                Class<? extends ImageFactory> clazz =
+                        Class.forName(FACTORIES[i]).asSubclass(ImageFactory.class);
+                return clazz.getConstructor().newInstance();
+            } catch (ReflectiveOperationException e) {
+                logger.trace("", e);
+            }
         }
+        throw new IllegalStateException("Create new ImageFactory failed!");
     }
 
     /**
@@ -76,7 +89,11 @@ public abstract class ImageFactory {
      * @return {@link Image}
      * @throws IOException URL is not valid.
      */
-    public abstract Image fromUrl(URL url) throws IOException;
+    public Image fromUrl(URL url) throws IOException {
+        try (InputStream is = url.openStream()) {
+            return fromInputStream(is);
+        }
+    }
 
     /**
      * Gets {@link Image} from URL.
diff --git a/bom/build.gradle b/bom/build.gradle
index 9dfe1eee2..c8459712d 100644
--- a/bom/build.gradle
+++ b/bom/build.gradle
@@ -27,6 +27,7 @@ dependencies {
         api "ai.djl.mxnet:mxnet-engine:${version}"
         api "ai.djl.mxnet:mxnet-model-zoo:${version}"
         api "ai.djl.mxnet:mxnet-native-auto:${mxnet_version}"
+        api "ai.djl.opencv:opencv:${version}"
         api "ai.djl.onnxruntime:onnxruntime-engine:${version}"
         api "ai.djl.paddlepaddle:paddlepaddle-engine:${version}"
         api "ai.djl.paddlepaddle:paddlepaddle-model-zoo:${version}"
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index ed174b4f4..886f0a17d 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -131,8 +131,9 @@ nav:
   - Extensions:
       - Android: 'android/README.md'
       - AWS S3 support: 'extensions/aws-ai/README.md'
-      - Hadoop support: 'extensions/hadoop/README.md'
       - fastText: 'extensions/fasttext/README.md'
+      - Hadoop support: 'extensions/hadoop/README.md'
+      - OpenCV: 'extensions/opencv/README.md'
       - SentencePiece: 'extensions/sentencepiece/README.md'
   - Demos:
       - Demos: 'docs/demos/README.md'
diff --git a/examples/src/main/java/ai/djl/examples/inference/InstanceSegmentation.java b/examples/src/main/java/ai/djl/examples/inference/InstanceSegmentation.java
index 2ef464878..cd85b059f 100644
--- a/examples/src/main/java/ai/djl/examples/inference/InstanceSegmentation.java
+++ b/examples/src/main/java/ai/djl/examples/inference/InstanceSegmentation.java
@@ -75,12 +75,10 @@ public final class InstanceSegmentation {
         Path outputDir = Paths.get("build/output");
         Files.createDirectories(outputDir);
 
-        // Make image copy with alpha channel because original image was jpg
-        Image newImage = img.duplicate(Image.Type.TYPE_INT_ARGB);
-        newImage.drawBoundingBoxes(detection);
+        img.drawBoundingBoxes(detection);
 
         Path imagePath = outputDir.resolve("instances.png");
-        newImage.save(Files.newOutputStream(imagePath), "png");
+        img.save(Files.newOutputStream(imagePath), "png");
         logger.info("Segmentation result image has been saved in: {}", imagePath);
     }
 }
diff --git a/examples/src/main/java/ai/djl/examples/inference/ObjectDetection.java b/examples/src/main/java/ai/djl/examples/inference/ObjectDetection.java
index 550bb2296..5973035dc 100644
--- a/examples/src/main/java/ai/djl/examples/inference/ObjectDetection.java
+++ b/examples/src/main/java/ai/djl/examples/inference/ObjectDetection.java
@@ -81,13 +81,11 @@ public final class ObjectDetection {
         Path outputDir = Paths.get("build/output");
         Files.createDirectories(outputDir);
 
-        // Make image copy with alpha channel because original image was jpg
-        Image newImage = img.duplicate(Image.Type.TYPE_INT_ARGB);
-        newImage.drawBoundingBoxes(detection);
+        img.drawBoundingBoxes(detection);
 
         Path imagePath = outputDir.resolve("detected-dog_bike_car.png");
         // OpenJDK can't save jpg with alpha channel
-        newImage.save(Files.newOutputStream(imagePath), "png");
+        img.save(Files.newOutputStream(imagePath), "png");
         logger.info("Detected objects image has been saved in: {}", imagePath);
     }
 }
diff --git a/examples/src/main/java/ai/djl/examples/inference/ObjectDetectionWithTensorflowSavedModel.java b/examples/src/main/java/ai/djl/examples/inference/ObjectDetectionWithTensorflowSavedModel.java
index e0473e752..cc6197d74 100644
--- a/examples/src/main/java/ai/djl/examples/inference/ObjectDetectionWithTensorflowSavedModel.java
+++ b/examples/src/main/java/ai/djl/examples/inference/ObjectDetectionWithTensorflowSavedModel.java
@@ -110,13 +110,11 @@ public final class ObjectDetectionWithTensorflowSavedModel {
         Path outputDir = Paths.get("build/output");
         Files.createDirectories(outputDir);
 
-        // Make image copy with alpha channel because original image was jpg
-        Image newImage = img.duplicate(Image.Type.TYPE_INT_ARGB);
-        newImage.drawBoundingBoxes(detection);
+        img.drawBoundingBoxes(detection);
 
         Path imagePath = outputDir.resolve("detected-tensorflow-model-dog_bike_car.png");
         // OpenJDK can't save jpg with alpha channel
-        newImage.save(Files.newOutputStream(imagePath), "png");
+        img.save(Files.newOutputStream(imagePath), "png");
         logger.info("Detected objects image has been saved in: {}", imagePath);
     }
 
diff --git a/examples/src/main/java/ai/djl/examples/inference/PoseEstimation.java b/examples/src/main/java/ai/djl/examples/inference/PoseEstimation.java
index a7688b901..8462059f0 100644
--- a/examples/src/main/java/ai/djl/examples/inference/PoseEstimation.java
+++ b/examples/src/main/java/ai/djl/examples/inference/PoseEstimation.java
@@ -94,7 +94,7 @@ public final class PoseEstimation {
                 Rectangle rect = item.getBoundingBox().getBounds();
                 int width = img.getWidth();
                 int height = img.getHeight();
-                return img.getSubimage(
+                return img.getSubImage(
                         (int) (rect.getX() * width),
                         (int) (rect.getY() * height),
                         (int) (rect.getWidth() * width),
diff --git a/examples/src/main/java/ai/djl/examples/inference/face/LightFaceDetection.java b/examples/src/main/java/ai/djl/examples/inference/face/LightFaceDetection.java
index d1503d545..153eeee3e 100644
--- a/examples/src/main/java/ai/djl/examples/inference/face/LightFaceDetection.java
+++ b/examples/src/main/java/ai/djl/examples/inference/face/LightFaceDetection.java
@@ -89,12 +89,10 @@ public final class LightFaceDetection {
         Path outputDir = Paths.get("build/output");
         Files.createDirectories(outputDir);
 
-        // Make image copy with alpha channel because original image was jpg
-        Image newImage = img.duplicate(Image.Type.TYPE_INT_ARGB);
-        newImage.drawBoundingBoxes(detection);
+        img.drawBoundingBoxes(detection);
 
         Path imagePath = outputDir.resolve("ultranet_detected.png");
-        newImage.save(Files.newOutputStream(imagePath), "png");
+        img.save(Files.newOutputStream(imagePath), "png");
         logger.info("Face detection result image has been saved in: {}", imagePath);
     }
 }
diff --git a/examples/src/main/java/ai/djl/examples/inference/face/RetinaFaceDetection.java b/examples/src/main/java/ai/djl/examples/inference/face/RetinaFaceDetection.java
index a6ab31f02..a64914e16 100644
--- a/examples/src/main/java/ai/djl/examples/inference/face/RetinaFaceDetection.java
+++ b/examples/src/main/java/ai/djl/examples/inference/face/RetinaFaceDetection.java
@@ -89,12 +89,10 @@ public final class RetinaFaceDetection {
         Path outputDir = Paths.get("build/output");
         Files.createDirectories(outputDir);
 
-        // Make image copy with alpha channel because original image was jpg
-        Image newImage = img.duplicate(Image.Type.TYPE_INT_ARGB);
-        newImage.drawBoundingBoxes(detection);
+        img.drawBoundingBoxes(detection);
 
         Path imagePath = outputDir.resolve("retinaface_detected.png");
-        newImage.save(Files.newOutputStream(imagePath), "png");
+        img.save(Files.newOutputStream(imagePath), "png");
         logger.info("Face detection result image has been saved in: {}", imagePath);
     }
 }
diff --git a/examples/src/main/java/ai/djl/examples/inference/sr/SuperResolution.java b/examples/src/main/java/ai/djl/examples/inference/sr/SuperResolution.java
index 713a30531..3ae45ae8f 100644
--- a/examples/src/main/java/ai/djl/examples/inference/sr/SuperResolution.java
+++ b/examples/src/main/java/ai/djl/examples/inference/sr/SuperResolution.java
@@ -32,7 +32,7 @@ import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
 import org.slf4j.Logger;
@@ -53,7 +53,7 @@ public final class SuperResolution {
         ImageFactory imageFactory = ImageFactory.getInstance();
 
         List<Image> inputImages =
-                Arrays.asList(imageFactory.fromFile(Paths.get(imagePath + "fox.png")));
+                Collections.singletonList(imageFactory.fromFile(Paths.get(imagePath + "fox.png")));
 
         List<Image> enhancedImages = enhance(inputImages);
 
diff --git a/extensions/opencv/README.md b/extensions/opencv/README.md
new file mode 100644
index 000000000..e9b8315d8
--- /dev/null
+++ b/extensions/opencv/README.md
@@ -0,0 +1,26 @@
+# Image support with OpenCV
+
+This module contains the image support extension with [OpenCV](https://opencv.org/). It is based on the [Java package from OpenPnP](https://github.com/openpnp/opencv).
+
+Right now, the package provides an `OpenCVImage` that acts as a faster implementation than the native `BufferedImage`. Once this package is added to your classpath, it will automatically be used through the standard DJL `ImageFactory`.
+
+## Documentation
+
+You can build the latest javadocs locally using the following command:
+
+```sh
+./gradlew javadoc
+```
+The javadocs output is built in the `build/doc/javadoc` folder.
+
+## Installation
+
+You can pull the module from the central Maven repository by including the following dependency in your `pom.xml` file:
+
+```xml
+<dependency>
+    <groupId>ai.djl.opencv</groupId>
+    <artifactId>opencv</artifactId>
+    <version>0.14.0</version>
+</dependency>
+```
diff --git a/extensions/opencv/build.gradle b/extensions/opencv/build.gradle
new file mode 100644
index 000000000..0228d48c2
--- /dev/null
+++ b/extensions/opencv/build.gradle
@@ -0,0 +1,25 @@
+group "ai.djl.opencv"
+
+dependencies {
+    api project(":api")
+    implementation "org.openpnp:opencv:4.5.1-2"
+
+    testImplementation("org.testng:testng:${testng_version}") {
+        exclude group: "junit", module: "junit"
+    }
+    testRuntimeOnly "org.apache.logging.log4j:log4j-slf4j-impl:${log4j_slf4j_version}"
+
+    testRuntimeOnly project(":engines:pytorch:pytorch-model-zoo")
+}
+
+publishing {
+    publications {
+        maven(MavenPublication) {
+            pom {
+                name = "OpenCV toolkit for DJL"
+                description = "OpenCV toolkit for DJL"
+                url = "http://www.djl.ai/extensions/${project.name}"
+            }
+        }
+    }
+}
diff --git a/extensions/opencv/gradlew b/extensions/opencv/gradlew
new file mode 120000
index 000000000..343e0d2ca
--- /dev/null
+++ b/extensions/opencv/gradlew
@@ -0,0 +1,1 @@
+../../gradlew
\ No newline at end of file
diff --git a/extensions/opencv/src/main/java/ai/djl/opencv/OpenCVImage.java b/extensions/opencv/src/main/java/ai/djl/opencv/OpenCVImage.java
new file mode 100644
index 000000000..316008932
--- /dev/null
+++ b/extensions/opencv/src/main/java/ai/djl/opencv/OpenCVImage.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright 2021 Amazon.com, Inc. or its affiliates. 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. A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0/
+ *
+ * or in the "license" file accompanying this file. This file 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 ai.djl.opencv;
+
+import ai.djl.modality.cv.Image;
+import ai.djl.modality.cv.output.BoundingBox;
+import ai.djl.modality.cv.output.DetectedObjects;
+import ai.djl.modality.cv.output.Joints;
+import ai.djl.modality.cv.output.Landmark;
+import ai.djl.modality.cv.output.Mask;
+import ai.djl.modality.cv.output.Rectangle;
+import ai.djl.ndarray.NDArray;
+import ai.djl.ndarray.NDManager;
+import ai.djl.ndarray.types.DataType;
+import ai.djl.ndarray.types.Shape;
+import ai.djl.util.RandomUtils;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferByte;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.List;
+import org.opencv.core.CvType;
+import org.opencv.core.Mat;
+import org.opencv.core.MatOfByte;
+import org.opencv.core.Point;
+import org.opencv.core.Rect;
+import org.opencv.core.Scalar;
+import org.opencv.core.Size;
+import org.opencv.imgcodecs.Imgcodecs;
+import org.opencv.imgproc.Imgproc;
+
+/** {@code OpenCVImage} is a high performance implementation of {@link Image}. */
+class OpenCVImage implements Image {
+
+    private Mat image;
+
+    /**
+     * Constructs a new {@code OpenCVImage} instance.
+     *
+     * @param image the wrapped image
+     */
+    public OpenCVImage(Mat image) {
+        this.image = image;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int getWidth() {
+        return image.width();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int getHeight() {
+        return image.height();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Object getWrappedImage() {
+        return image;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Image getSubImage(int x, int y, int w, int h) {
+        Mat mat = image.submat(new Rect(x, y, w, h));
+        return new OpenCVImage(mat);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public NDArray toNDArray(NDManager manager, Flag flag) {
+        Mat mat = new Mat();
+        if (flag == Flag.GRAYSCALE) {
+            Imgproc.cvtColor(image, mat, Imgproc.COLOR_BGR2GRAY);
+        } else {
+            Imgproc.cvtColor(image, mat, Imgproc.COLOR_BGR2RGB);
+        }
+        byte[] buf = new byte[mat.height() * mat.width() * mat.channels()];
+        mat.get(0, 0, buf);
+
+        Shape shape = new Shape(mat.height(), mat.width(), mat.channels());
+        return manager.create(ByteBuffer.wrap(buf), shape, DataType.UINT8);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void save(OutputStream os, String type) throws IOException {
+        MatOfByte buf = new MatOfByte();
+        if (!Imgcodecs.imencode('.' + type, image, buf)) {
+            throw new IOException("Failed save image.");
+        }
+        os.write(buf.toArray());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void drawBoundingBoxes(DetectedObjects detections) {
+        int imageWidth = image.width();
+        int imageHeight = image.height();
+
+        List<DetectedObjects.DetectedObject> list = detections.items();
+        for (DetectedObjects.DetectedObject result : list) {
+            String className = result.getClassName();
+            BoundingBox box = result.getBoundingBox();
+
+            Rectangle rectangle = box.getBounds();
+            int x = (int) (rectangle.getX() * imageWidth);
+            int y = (int) (rectangle.getY() * imageHeight);
+            Rect rect =
+                    new Rect(
+                            x,
+                            y,
+                            (int) (rectangle.getWidth() * imageWidth),
+                            (int) (rectangle.getHeight() * imageHeight));
+            Scalar color =
+                    new Scalar(
+                            RandomUtils.nextInt(178),
+                            RandomUtils.nextInt(178),
+                            RandomUtils.nextInt(178));
+            Imgproc.rectangle(image, rect.tl(), rect.br(), color, 2);
+
+            Size size = Imgproc.getTextSize(className, Imgproc.FONT_HERSHEY_PLAIN, 1.3, 1, null);
+            Point br = new Point(x + size.width + 4, y + size.height + 4);
+            Imgproc.rectangle(image, rect.tl(), br, color, -1);
+
+            Point point = new Point(x, y + size.height + 2);
+            color = new Scalar(255, 255, 255);
+            Imgproc.putText(image, className, point, Imgproc.FONT_HERSHEY_PLAIN, 1.3, color, 1);
+            // If we have a mask instead of a plain rectangle, draw tha mask
+            if (box instanceof Mask) {
+                Mask mask = (Mask) box;
+                BufferedImage img = mat2Image(image);
+                drawMask(img, mask);
+                image = image2Mat(img);
+            } else if (box instanceof Landmark) {
+                drawLandmarks(box);
+            }
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void drawJoints(Joints joints) {
+        int imageWidth = image.width();
+        int imageHeight = image.height();
+
+        Scalar color =
+                new Scalar(
+                        RandomUtils.nextInt(178),
+                        RandomUtils.nextInt(178),
+                        RandomUtils.nextInt(178));
+        for (Joints.Joint joint : joints.getJoints()) {
+            int x = (int) (joint.getX() * imageWidth);
+            int y = (int) (joint.getY() * imageHeight);
+            Point point = new Point(x, y);
+            Imgproc.circle(image, point, 6, color, -1, Imgproc.LINE_AA);
+        }
+    }
+
+    private void drawLandmarks(BoundingBox box) {
+        Scalar color = new Scalar(0, 96, 246);
+        for (ai.djl.modality.cv.output.Point point : box.getPath()) {
+            Point lt = new Point(point.getX() - 4, point.getY() - 4);
+            Point rb = new Point(point.getX() + 4, point.getY() + 4);
+            Imgproc.rectangle(image, lt, rb, color, -1);
+        }
+    }
+
+    private void drawMask(BufferedImage img, Mask mask) {
+        // TODO: use OpenCV native way to draw mask
+        float r = RandomUtils.nextFloat();
+        float g = RandomUtils.nextFloat();
+        float b = RandomUtils.nextFloat();
+        int imageWidth = img.getWidth();
+        int imageHeight = img.getHeight();
+        int x = (int) (mask.getX() * imageWidth);
+        int y = (int) (mask.getY() * imageHeight);
+        float[][] probDist = mask.getProbDist();
+        // Correct some coordinates of box when going out of image
+        if (x < 0) {
+            x = 0;
+        }
+        if (y < 0) {
+            y = 0;
+        }
+
+        BufferedImage maskImage =
+                new BufferedImage(probDist.length, probDist[0].length, BufferedImage.TYPE_INT_ARGB);
+        for (int xCor = 0; xCor < probDist.length; xCor++) {
+            for (int yCor = 0; yCor < probDist[xCor].length; yCor++) {
+                float opacity = probDist[xCor][yCor] * 0.8f;
+                maskImage.setRGB(xCor, yCor, new Color(r, g, b, opacity).getRGB());
+            }
+        }
+        Graphics2D gR = (Graphics2D) img.getGraphics();
+        gR.drawImage(maskImage, x, y, null);
+        gR.dispose();
+    }
+
+    private static BufferedImage mat2Image(Mat mat) {
+        int width = mat.width();
+        int height = mat.height();
+        byte[] data = new byte[width * height * (int) mat.elemSize()];
+        Imgproc.cvtColor(mat, mat, Imgproc.COLOR_BGR2RGB);
+
+        mat.get(0, 0, data);
+
+        BufferedImage ret = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
+        ret.getRaster().setDataElements(0, 0, width, height, data);
+        return ret;
+    }
+
+    private static Mat image2Mat(BufferedImage img) {
+        int width = img.getWidth();
+        int height = img.getHeight();
+        byte[] data = ((DataBufferByte) img.getRaster().getDataBuffer()).getData();
+        Mat mat = new Mat(height, width, CvType.CV_8UC3);
+        mat.put(0, 0, data);
+        return mat;
+    }
+}
diff --git a/extensions/opencv/src/main/java/ai/djl/opencv/OpenCVImageFactory.java b/extensions/opencv/src/main/java/ai/djl/opencv/OpenCVImageFactory.java
new file mode 100644
index 000000000..f71f36b0a
--- /dev/null
+++ b/extensions/opencv/src/main/java/ai/djl/opencv/OpenCVImageFactory.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2021 Amazon.com, Inc. or its affiliates. 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. A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0/
+ *
+ * or in the "license" file accompanying this file. This file 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 ai.djl.opencv;
+
+import ai.djl.modality.cv.Image;
+import ai.djl.modality.cv.ImageFactory;
+import ai.djl.modality.cv.util.NDImageUtils;
+import ai.djl.ndarray.NDArray;
+import ai.djl.ndarray.types.DataType;
+import ai.djl.ndarray.types.Shape;
+import ai.djl.util.Utils;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Path;
+import nu.pattern.OpenCV;
+import org.opencv.core.CvType;
+import org.opencv.core.Mat;
+import org.opencv.core.MatOfByte;
+import org.opencv.imgcodecs.Imgcodecs;
+import org.opencv.imgproc.Imgproc;
+
+/** {@code OpenCVImageFactory} is a high performance implementation of {@link ImageFactory}. */
+public class OpenCVImageFactory extends ImageFactory {
+
+    static {
+        OpenCV.loadLocally();
+        if (System.getProperty("apple.awt.UIElement") == null) {
+            // disables coffee cup image showing up on macOS
+            System.setProperty("apple.awt.UIElement", "true");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Image fromFile(Path path) throws IOException {
+        Mat img = Imgcodecs.imread(path.toAbsolutePath().toString());
+        if (img.empty()) {
+            throw new IOException("Read image failed: " + path);
+        }
+        return new OpenCVImage(img);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Image fromInputStream(InputStream is) throws IOException {
+        byte[] buf = Utils.toByteArray(is);
+        Mat mat = new MatOfByte(buf);
+        Mat img = Imgcodecs.imdecode(mat, Imgcodecs.IMREAD_COLOR);
+        if (img.empty()) {
+            throw new IOException("Read image failed.");
+        }
+        return new OpenCVImage(img);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Image fromImage(Object image) {
+        return new OpenCVImage((Mat) image);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Image fromNDArray(NDArray array) {
+        Shape shape = array.getShape();
+        if (shape.dimension() == 4) {
+            throw new UnsupportedOperationException("Batch is not supported");
+        } else if (shape.get(0) == 1 || shape.get(2) == 1) {
+            throw new UnsupportedOperationException("Grayscale image is not supported");
+        } else if (array.getDataType() != DataType.UINT8 && array.getDataType() != DataType.INT8) {
+            throw new IllegalArgumentException("Datatype should be INT8 or UINT8");
+        }
+        if (NDImageUtils.isCHW(shape)) {
+            array = array.transpose(1, 2, 0);
+            shape = array.getShape();
+        }
+        int width = Math.toIntExact(shape.get(1));
+        int height = Math.toIntExact(shape.get(0));
+        Mat img = new Mat(height, width, CvType.CV_8UC3);
+        img.put(0, 0, array.toByteArray());
+        Imgproc.cvtColor(img, img, Imgproc.COLOR_RGB2BGR);
+        return new OpenCVImage(img);
+    }
+}
diff --git a/extensions/opencv/src/main/java/ai/djl/opencv/package-info.java b/extensions/opencv/src/main/java/ai/djl/opencv/package-info.java
new file mode 100644
index 000000000..2245804c9
--- /dev/null
+++ b/extensions/opencv/src/main/java/ai/djl/opencv/package-info.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2021 Amazon.com, Inc. or its affiliates. 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. A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0/
+ *
+ * or in the "license" file accompanying this file. This file 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.
+ */
+/** Contains classes that provides high performance image processing functionalities. */
+package ai.djl.opencv;
diff --git a/extensions/opencv/src/main/javadoc/overview.html b/extensions/opencv/src/main/javadoc/overview.html
new file mode 100644
index 000000000..9dae2a08c
--- /dev/null
+++ b/extensions/opencv/src/main/javadoc/overview.html
@@ -0,0 +1,10 @@
+<html>
+<head>
+    <meta charset="UTF-8">
+</head>
+<body>
+<p>This document is the API specification for the OpenCV for DJL.</p>
+
+<p>The opencv module contains classes that provides high performance image processing for DJL.</p>
+</body>
+</html>
diff --git a/settings.gradle b/settings.gradle
index af87ddb48..f9aa78cf7 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -28,6 +28,7 @@ include ':extensions:aws-ai'
 include ':extensions:benchmark'
 include ':extensions:fasttext'
 include ':extensions:hadoop'
+include ':extensions:opencv'
 include ':extensions:sentencepiece'
 include ':integration'
 include ':model-zoo'
diff --git a/tools/gradle/publish.gradle b/tools/gradle/publish.gradle
index bb76845a2..28021d259 100644
--- a/tools/gradle/publish.gradle
+++ b/tools/gradle/publish.gradle
@@ -18,6 +18,7 @@ configure([
         project(':extensions:aws-ai'),
         project(':extensions:fasttext'),
         project(':extensions:hadoop'),
+        project(':extensions:opencv'),
         project(':extensions:sentencepiece'),
         project(':model-zoo')]) {
     apply plugin: "maven-publish"


Test patch:
diff --git a/engines/paddlepaddle/paddlepaddle-model-zoo/src/test/java/ai/djl/paddlepaddle/zoo/MaskDetectionTest.java b/engines/paddlepaddle/paddlepaddle-model-zoo/src/test/java/ai/djl/paddlepaddle/zoo/MaskDetectionTest.java
index e2f36c656..6924c0cb1 100644
--- a/engines/paddlepaddle/paddlepaddle-model-zoo/src/test/java/ai/djl/paddlepaddle/zoo/MaskDetectionTest.java
+++ b/engines/paddlepaddle/paddlepaddle-model-zoo/src/test/java/ai/djl/paddlepaddle/zoo/MaskDetectionTest.java
@@ -72,7 +72,7 @@ public class MaskDetectionTest {
                         rect.getWidth() * width,
                         rect.getHeight() * height,
                         0.18);
-        return img.getSubimage(squareBox[0], squareBox[1], squareBox[2], squareBox[2]);
+        return img.getSubImage(squareBox[0], squareBox[1], squareBox[2], squareBox[2]);
     }
 
     private static int[] extendSquare(
@@ -90,13 +90,11 @@ public class MaskDetectionTest {
         Path outputDir = Paths.get("build/output");
         Files.createDirectories(outputDir);
 
-        // Make image copy with alpha channel because original image was jpg
-        Image newImage = img.duplicate(Image.Type.TYPE_INT_ARGB);
-        newImage.drawBoundingBoxes(detection);
+        img.drawBoundingBoxes(detection);
 
         Path imagePath = outputDir.resolve("test.png");
         // OpenJDK can't save jpg with alpha channel
-        newImage.save(Files.newOutputStream(imagePath), "png");
+        img.save(Files.newOutputStream(imagePath), "png");
     }
 
     private static Predictor<Image, Classifications> getClassifier()
diff --git a/engines/paddlepaddle/paddlepaddle-model-zoo/src/test/java/ai/djl/paddlepaddle/zoo/OCRTest.java b/engines/paddlepaddle/paddlepaddle-model-zoo/src/test/java/ai/djl/paddlepaddle/zoo/OCRTest.java
index a96456510..5bbffc49a 100644
--- a/engines/paddlepaddle/paddlepaddle-model-zoo/src/test/java/ai/djl/paddlepaddle/zoo/OCRTest.java
+++ b/engines/paddlepaddle/paddlepaddle-model-zoo/src/test/java/ai/djl/paddlepaddle/zoo/OCRTest.java
@@ -115,7 +115,7 @@ public class OCRTest {
             (int) (extended[2] * width),
             (int) (extended[3] * height)
         };
-        return img.getSubimage(recovered[0], recovered[1], recovered[2], recovered[3]);
+        return img.getSubImage(recovered[0], recovered[1], recovered[2], recovered[3]);
     }
 
     private static double[] extendRect(double xmin, double ymin, double width, double height) {
diff --git a/engines/tensorflow/tensorflow-model-zoo/src/test/java/ai/djl/tensorflow/integration/modality/cv/TfSsdTest.java b/engines/tensorflow/tensorflow-model-zoo/src/test/java/ai/djl/tensorflow/integration/modality/cv/TfSsdTest.java
index 0d214d047..d5cf7cb9e 100644
--- a/engines/tensorflow/tensorflow-model-zoo/src/test/java/ai/djl/tensorflow/integration/modality/cv/TfSsdTest.java
+++ b/engines/tensorflow/tensorflow-model-zoo/src/test/java/ai/djl/tensorflow/integration/modality/cv/TfSsdTest.java
@@ -83,12 +83,10 @@ public class TfSsdTest {
         Path outputDir = Paths.get("build/output");
         Files.createDirectories(outputDir);
 
-        // Make image copy with alpha channel because original image was jpg
-        Image newImage = img.duplicate(Image.Type.TYPE_INT_ARGB);
-        newImage.drawBoundingBoxes(detection);
+        img.drawBoundingBoxes(detection);
 
         Path imagePath = outputDir.resolve("detected-dog_bike_car.png");
         // OpenJDK can't save jpg with alpha channel
-        newImage.save(Files.newOutputStream(imagePath), "png");
+        img.save(Files.newOutputStream(imagePath), "png");
     }
 }
diff --git a/extensions/opencv/src/test/java/ai/djl/opencv/OpenCVImageFactoryTest.java b/extensions/opencv/src/test/java/ai/djl/opencv/OpenCVImageFactoryTest.java
new file mode 100644
index 000000000..b10805aa8
--- /dev/null
+++ b/extensions/opencv/src/test/java/ai/djl/opencv/OpenCVImageFactoryTest.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2021 Amazon.com, Inc. or its affiliates. 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. A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0/
+ *
+ * or in the "license" file accompanying this file. This file 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 ai.djl.opencv;
+
+import ai.djl.modality.cv.BufferedImageFactory;
+import ai.djl.modality.cv.Image;
+import ai.djl.modality.cv.ImageFactory;
+import ai.djl.modality.cv.output.BoundingBox;
+import ai.djl.modality.cv.output.DetectedObjects;
+import ai.djl.modality.cv.output.Joints;
+import ai.djl.modality.cv.output.Landmark;
+import ai.djl.modality.cv.output.Mask;
+import ai.djl.ndarray.NDArray;
+import ai.djl.ndarray.NDManager;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+public class OpenCVImageFactoryTest {
+
+    @Test
+    public void testImage() throws IOException {
+        ImageFactory factory = ImageFactory.getInstance();
+        ImageFactory defFactory = new BufferedImageFactory();
+        Path path = Paths.get("../../examples/src/test/resources/kitten.jpg");
+        String url = path.toUri().toURL().toString();
+        Image gold = defFactory.fromFile(path);
+
+        try (NDManager manager = NDManager.newBaseManager()) {
+            NDArray expected = gold.toNDArray(manager);
+
+            Image img = factory.fromFile(path);
+            Assert.assertEquals(img.getWidth(), gold.getWidth());
+            Assert.assertEquals(img.getHeight(), gold.getHeight());
+            Assert.assertEquals(img.getWrappedImage().getClass().getSimpleName(), "Mat");
+
+            NDArray array = img.toNDArray(manager);
+            Assert.assertEquals(array, expected);
+
+            Image newImage = factory.fromNDArray(array);
+            Assert.assertEquals(newImage.getWidth(), gold.getWidth());
+            Assert.assertEquals(newImage.getHeight(), gold.getHeight());
+            array = array.transpose(2, 0, 1);
+            newImage = factory.fromNDArray(array);
+            Assert.assertEquals(newImage.getWidth(), gold.getWidth());
+            Assert.assertEquals(newImage.getHeight(), gold.getHeight());
+
+            img = factory.fromUrl(url);
+            array = img.toNDArray(manager);
+            Assert.assertEquals(array, expected);
+
+            newImage = factory.fromImage(img.getWrappedImage());
+            newImage = newImage.getSubImage(0, 0, 4, 2);
+
+            array = newImage.toNDArray(manager, Image.Flag.GRAYSCALE);
+            expected = gold.getSubImage(0, 0, 4, 2).toNDArray(manager, Image.Flag.GRAYSCALE);
+            Assert.assertEquals(array, expected);
+
+            List<String> list = Collections.singletonList("cat");
+            List<Double> prob = Collections.singletonList(0.9);
+            List<ai.djl.modality.cv.output.Point> points = new ArrayList<>();
+            points.add(new ai.djl.modality.cv.output.Point(120, 160));
+            List<BoundingBox> boundingBoxes =
+                    Collections.singletonList(new Landmark(0.35, 0.15, 0.5, 0.6, points));
+
+            DetectedObjects detectedObjects = new DetectedObjects(list, prob, boundingBoxes);
+            img.drawBoundingBoxes(detectedObjects);
+
+            float[][] maskProb = {
+                {1f, 1f, 1f, 1f, 1f, 1f, 1f},
+                {1f, 1f, 1f, 1f, 1f, 1f, 1f},
+                {1f, 1f, 1f, 1f, 1f, 1f, 1f},
+                {1f, 1f, 1f, 1f, 1f, 1f, 1f},
+                {1f, 1f, 1f, 1f, 1f, 1f, 1f},
+                {1f, 1f, 1f, 1f, 1f, 1f, 1f}
+            };
+            List<BoundingBox> masks =
+                    Collections.singletonList(new Mask(0.1, 0.5, 0.1, 0.1, maskProb));
+
+            DetectedObjects mask = new DetectedObjects(list, prob, masks);
+            img.drawBoundingBoxes(mask);
+
+            List<Joints.Joint> jointList =
+                    Collections.singletonList(new Joints.Joint(0.2, 0.2, 0.8));
+            Joints joints = new Joints(jointList);
+            img.drawJoints(joints);
+
+            try (OutputStream os = Files.newOutputStream(Paths.get("build/newImage.png"))) {
+                img.save(os, "png");
+            }
+
+            Assert.assertThrows(
+                    IOException.class,
+                    () -> {
+                        factory.fromFile(Paths.get("nonexist.jpg"));
+                    });
+            Assert.assertThrows(
+                    IOException.class,
+                    () -> {
+                        factory.fromUrl("file:build.gradle");
+                    });
+        }
+    }
+}
diff --git a/extensions/opencv/src/test/java/ai/djl/opencv/package-info.java b/extensions/opencv/src/test/java/ai/djl/opencv/package-info.java
new file mode 100644
index 000000000..e952ba6db
--- /dev/null
+++ b/extensions/opencv/src/test/java/ai/djl/opencv/package-info.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2021 Amazon.com, Inc. or its affiliates. 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. A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0/
+ *
+ * or in the "license" file accompanying this file. This file 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.
+ */
+/** Contains tests for the opencv module. */
+package ai.djl.opencv;