Repository: deepghs/imgutils (language: python)

Issue:
dev(narugo): add lineart onnx export



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml
index f2ec5d77c46..83b468a456b 100644
--- a/.github/workflows/doc.yml
+++ b/.github/workflows/doc.yml
@@ -24,6 +24,10 @@ jobs:
           - 18080:8080
 
     steps:
+      - name: Set Swap Space
+        uses: pierotofy/set-swap-space@master
+        with:
+          swap-size-gb: 8
       - name: Checkout code
         uses: actions/checkout@v2
         with:
diff --git a/.github/workflows/export.yml b/.github/workflows/export.yml
index 4dc6221c1cf..5c964832b27 100644
--- a/.github/workflows/export.yml
+++ b/.github/workflows/export.yml
@@ -17,7 +17,8 @@ jobs:
           - '3.8'
         model-name:
           #          - 'lpips'
-          - 'monochrome'
+          #          - 'monochrome'
+          - 'lineart'
 
     steps:
       - name: Checkout code
diff --git a/docs/source/_libs/plot.py b/docs/source/_libs/plot.py
index 64519328f92..ca8c9485450 100644
--- a/docs/source/_libs/plot.py
+++ b/docs/source/_libs/plot.py
@@ -1,11 +1,13 @@
-from typing import Tuple
+from typing import Tuple, List
 
 import matplotlib.pyplot as plt
 from PIL import Image
 
-from imgutils.data import load_image, grid_transparent
+from imgutils.data import load_image, grid_transparent, ImageTyping
 from imgutils.validate.truncate import _mock_load_truncated_images
 
+INCHES_TO_PIXELS = 96
+
 
 def _image_input_process(img) -> Tuple[Image.Image, str]:
     if isinstance(img, tuple):
@@ -45,3 +47,35 @@ def image_plot(*images, save_as: str, columns=2, keep_axis: bool = False, figsiz
         ax.axis('off')
 
     plt.savefig(save_as, bbox_inches='tight', pad_inches=0.1, dpi=300, transparent=True)
+
+
+@_mock_load_truncated_images(True)
+def image_table(images: List[List[ImageTyping]], columns: List[str], rows: List[str], save_as: str,
+                keep_axis: bool = False, figsize=(720, 600), dpi: int = 300, fontsize: int = 18, padsize: int = 5):
+    plt.cla()
+    plt.tight_layout()
+
+    assert images, 'No less than 1 images required.'
+    fig, axs = plt.subplots(len(rows), len(columns),
+                            figsize=(figsize[0] / INCHES_TO_PIXELS, figsize[1] / INCHES_TO_PIXELS))
+    plt.subplots_adjust(wspace=padsize / INCHES_TO_PIXELS, hspace=padsize / INCHES_TO_PIXELS)
+    for xi in range(len(rows)):
+        for yi in range(len(columns)):
+            img = images[xi][yi]
+            ax = axs[yi] if len(rows) == 1 else axs[xi, yi]
+            if img:
+                image = load_image(img, force_background=None)
+                ax.imshow(grid_transparent(image))
+                if not keep_axis:
+                    ax.set_xticks([])
+                    ax.set_yticks([])
+            else:
+                ax.set_xticks([])
+                ax.set_yticks([])
+
+            if xi == 0:
+                ax.set_title(columns[yi], fontsize=fontsize)
+            if yi == 0:
+                ax.set_ylabel(rows[xi], fontsize=fontsize)
+
+    plt.savefig(save_as, bbox_inches='tight', pad_inches=0.02, dpi=dpi, transparent=True)
diff --git a/docs/source/api_doc/data/grid_istack.demo.py b/docs/source/api_doc/data/grid_istack.demo.py
new file mode 100644
index 00000000000..e224b1e02a3
--- /dev/null
+++ b/docs/source/api_doc/data/grid_istack.demo.py
@@ -0,0 +1,21 @@
+import numpy as np
+from PIL import Image
+
+from imgutils.data import istack
+from plot import image_plot
+
+if __name__ == '__main__':
+    width, height = Image.open('nian.png').size
+    hs1 = (1 - np.abs(np.linspace(-1 / 3, 1, height))) ** 0.5
+    ws1 = (1 - np.abs(np.linspace(-1, 1, width))) ** 0.5
+    nian_mask = hs1[..., None] * ws1  # HxW
+
+    image_plot(
+        'nian.png',
+        (istack('lime', 'nian.png'), 'nian_lime.png'),
+        (istack(('yellow', 0.5), ('nian.png', 0.9)), 'nian_trans.png'),
+        (istack(('nian.png', nian_mask)), 'nian_mask.png'),
+        save_as='grid_istack.dat.svg',
+        columns=2,
+        figsize=(12, 12),
+    )
diff --git a/docs/source/api_doc/data/index.rst b/docs/source/api_doc/data/index.rst
index 35bfa3e252c..6f636cf1d95 100644
--- a/docs/source/api_doc/data/index.rst
+++ b/docs/source/api_doc/data/index.rst
@@ -13,3 +13,4 @@ imgutils.data
     encode
     decode
     image
+    layer
diff --git a/docs/source/api_doc/data/layer.rst b/docs/source/api_doc/data/layer.rst
new file mode 100644
index 00000000000..7b7b80fae8d
--- /dev/null
+++ b/docs/source/api_doc/data/layer.rst
@@ -0,0 +1,15 @@
+imgutils.data.layer
+==========================
+
+.. currentmodule:: imgutils.data.layer
+
+.. automodule:: imgutils.data.layer
+
+
+istack
+---------------------------
+
+.. autofunction:: istack
+
+
+
diff --git a/docs/source/api_doc/edge/canny.demo.py b/docs/source/api_doc/edge/canny.demo.py
new file mode 100644
index 00000000000..2f97bc07156
--- /dev/null
+++ b/docs/source/api_doc/edge/canny.demo.py
@@ -0,0 +1,28 @@
+from functools import partial
+
+from hbutils.reflection import nested_for
+from tqdm.auto import tqdm
+
+from imgutils.edge import edge_image_with_canny as edge_image
+from plot import image_table
+
+if __name__ == '__main__':
+    demo_images = ['hutao.png', 'skadi.jpg']
+    funcs = [
+        ('canny', partial(edge_image)),
+        ('canny\nbackcolor=transparent', partial(edge_image, backcolor='transparent')),
+        ('canny\nbackcolor=white\nforecolor=black',
+         partial(edge_image, backcolor='white', forecolor='black')),
+    ]
+    table = [[item] for item in demo_images]
+    for (xi, origin_image), (_, func) in \
+            tqdm(list(nested_for(enumerate(demo_images), funcs))):
+        table[xi].append(func(origin_image))
+
+    image_table(
+        table,
+        columns=['origin', *(name for name, _ in funcs)],
+        rows=['' for _ in demo_images],
+        save_as='canny.dat.svg',
+        figsize=(1600, 980),
+    )
diff --git a/docs/source/api_doc/edge/canny.rst b/docs/source/api_doc/edge/canny.rst
new file mode 100644
index 00000000000..6aae87478d3
--- /dev/null
+++ b/docs/source/api_doc/edge/canny.rst
@@ -0,0 +1,23 @@
+imgutils.edge.canny
+==============================
+
+.. currentmodule:: imgutils.edge.canny
+
+.. automodule:: imgutils.edge.canny
+
+
+
+get_edge_by_canny
+--------------------------------------
+
+.. autofunction:: get_edge_by_canny
+
+
+
+edge_image_with_canny
+--------------------------------------
+
+.. autofunction:: edge_image_with_canny
+
+
+
diff --git a/docs/source/api_doc/edge/edge.demo.py b/docs/source/api_doc/edge/edge.demo.py
new file mode 100644
index 00000000000..e6d27793b74
--- /dev/null
+++ b/docs/source/api_doc/edge/edge.demo.py
@@ -0,0 +1,25 @@
+from hbutils.reflection import nested_for
+from tqdm.auto import tqdm
+
+from imgutils.edge import edge_image_with_canny, edge_image_with_lineart, edge_image_with_lineart_anime
+from plot import image_table
+
+if __name__ == '__main__':
+    demo_images = ['hutao.png', 'skadi.jpg', 'xx.jpg', 'surtr_underwear.png']
+    funcs = [
+        ('canny', edge_image_with_canny),
+        ('lineart', edge_image_with_lineart),
+        ('lineart\nanime', edge_image_with_lineart_anime)
+    ]
+    table = [[item] for item in demo_images]
+    for (xi, origin_image), (_, func) in \
+            tqdm(list(nested_for(enumerate(demo_images), funcs))):
+        table[xi].append(func(origin_image, backcolor='white', forecolor='black'))
+
+    image_table(
+        table,
+        columns=['origin', *(name for name, _ in funcs)],
+        rows=['' for _ in demo_images],
+        save_as='edge.dat.svg',
+        figsize=(1800, 1350),
+    )
diff --git a/docs/source/api_doc/edge/hutao.png b/docs/source/api_doc/edge/hutao.png
new file mode 100644
index 00000000000..a8f42dce778
Binary files /dev/null and b/docs/source/api_doc/edge/hutao.png differ
diff --git a/docs/source/api_doc/edge/index.rst b/docs/source/api_doc/edge/index.rst
new file mode 100644
index 00000000000..f022823df69
--- /dev/null
+++ b/docs/source/api_doc/edge/index.rst
@@ -0,0 +1,15 @@
+imgutils.edge
+========================
+
+.. currentmodule:: imgutils.edge
+
+.. automodule:: imgutils.edge
+
+
+.. toctree::
+    :maxdepth: 3
+
+    canny
+    lineart
+    lineart_anime
+
diff --git a/docs/source/api_doc/edge/lineart.demo.py b/docs/source/api_doc/edge/lineart.demo.py
new file mode 100644
index 00000000000..c03b372707d
--- /dev/null
+++ b/docs/source/api_doc/edge/lineart.demo.py
@@ -0,0 +1,28 @@
+from functools import partial
+
+from hbutils.reflection import nested_for
+from tqdm.auto import tqdm
+
+from imgutils.edge import edge_image_with_lineart as edge_image
+from plot import image_table
+
+if __name__ == '__main__':
+    demo_images = ['hutao.png', 'skadi.jpg']
+    funcs = [
+        ('lineart', partial(edge_image)),
+        ('lineart\nbackcolor=transparent', partial(edge_image, backcolor='transparent')),
+        ('lineart\nbackcolor=white\nforecolor=black',
+         partial(edge_image, backcolor='white', forecolor='black')),
+    ]
+    table = [[item] for item in demo_images]
+    for (xi, origin_image), (_, func) in \
+            tqdm(list(nested_for(enumerate(demo_images), funcs))):
+        table[xi].append(func(origin_image))
+
+    image_table(
+        table,
+        columns=['origin', *(name for name, _ in funcs)],
+        rows=['' for _ in demo_images],
+        save_as='lineart.dat.svg',
+        figsize=(1600, 980),
+    )
diff --git a/docs/source/api_doc/edge/lineart.rst b/docs/source/api_doc/edge/lineart.rst
new file mode 100644
index 00000000000..dca366ad731
--- /dev/null
+++ b/docs/source/api_doc/edge/lineart.rst
@@ -0,0 +1,24 @@
+imgutils.edge.lineart
+==============================
+
+.. currentmodule:: imgutils.edge.lineart
+
+.. automodule:: imgutils.edge.lineart
+
+
+
+get_edge_by_lineart
+--------------------------------------
+
+.. autofunction:: get_edge_by_lineart
+
+
+
+edge_image_with_lineart
+--------------------------------------
+
+.. autofunction:: edge_image_with_lineart
+
+
+
+
diff --git a/docs/source/api_doc/edge/lineart_anime.demo.py b/docs/source/api_doc/edge/lineart_anime.demo.py
new file mode 100644
index 00000000000..e7e134f60ac
--- /dev/null
+++ b/docs/source/api_doc/edge/lineart_anime.demo.py
@@ -0,0 +1,28 @@
+from functools import partial
+
+from hbutils.reflection import nested_for
+from tqdm.auto import tqdm
+
+from imgutils.edge import edge_image_with_lineart_anime as edge_image
+from plot import image_table
+
+if __name__ == '__main__':
+    demo_images = ['hutao.png', 'skadi.jpg']
+    funcs = [
+        ('lineart_anime', partial(edge_image)),
+        ('lineart_anime\nbackcolor=transparent', partial(edge_image, backcolor='transparent')),
+        ('lineart_anime\nbackcolor=white\nforecolor=black',
+         partial(edge_image, backcolor='white', forecolor='black')),
+    ]
+    table = [[item] for item in demo_images]
+    for (xi, origin_image), (_, func) in \
+            tqdm(list(nested_for(enumerate(demo_images), funcs))):
+        table[xi].append(func(origin_image))
+
+    image_table(
+        table,
+        columns=['origin', *(name for name, _ in funcs)],
+        rows=['' for _ in demo_images],
+        save_as='lineart_anime.dat.svg',
+        figsize=(1600, 980),
+    )
diff --git a/docs/source/api_doc/edge/lineart_anime.rst b/docs/source/api_doc/edge/lineart_anime.rst
new file mode 100644
index 00000000000..be3d3946c4b
--- /dev/null
+++ b/docs/source/api_doc/edge/lineart_anime.rst
@@ -0,0 +1,23 @@
+imgutils.edge.lineart_anime
+==============================
+
+.. currentmodule:: imgutils.edge.lineart_anime
+
+.. automodule:: imgutils.edge.lineart_anime
+
+
+
+get_edge_by_lineart_anime
+--------------------------------------
+
+.. autofunction:: get_edge_by_lineart_anime
+
+
+
+edge_image_with_lineart_anime
+--------------------------------------
+
+.. autofunction:: edge_image_with_lineart_anime
+
+
+
diff --git a/docs/source/api_doc/edge/lineart_coarse.demo.py b/docs/source/api_doc/edge/lineart_coarse.demo.py
new file mode 100644
index 00000000000..9dc6e7a7ccc
--- /dev/null
+++ b/docs/source/api_doc/edge/lineart_coarse.demo.py
@@ -0,0 +1,28 @@
+from functools import partial
+
+from hbutils.reflection import nested_for
+from tqdm.auto import tqdm
+
+from imgutils.edge import edge_image_with_lineart as edge_image
+from plot import image_table
+
+if __name__ == '__main__':
+    demo_images = ['hutao.png', 'skadi.jpg']
+    funcs = [
+        ('lineart\ncoarse', partial(edge_image, coarse=True)),
+        ('lineart\ncoarse\nbackcolor=transparent', partial(edge_image, backcolor='transparent', coarse=True)),
+        ('lineart\ncoarse\nbackcolor=white\nforecolor=black',
+         partial(edge_image, backcolor='white', forecolor='black', coarse=True)),
+    ]
+    table = [[item] for item in demo_images]
+    for (xi, origin_image), (_, func) in \
+            tqdm(list(nested_for(enumerate(demo_images), funcs))):
+        table[xi].append(func(origin_image))
+
+    image_table(
+        table,
+        columns=['origin', *(name for name, _ in funcs)],
+        rows=['' for _ in demo_images],
+        save_as='lineart_coarse.dat.svg',
+        figsize=(1600, 980),
+    )
diff --git a/docs/source/api_doc/edge/skadi.jpg b/docs/source/api_doc/edge/skadi.jpg
new file mode 100644
index 00000000000..a585ecb7c85
Binary files /dev/null and b/docs/source/api_doc/edge/skadi.jpg differ
diff --git a/docs/source/api_doc/edge/surtr_underwear.png b/docs/source/api_doc/edge/surtr_underwear.png
new file mode 100644
index 00000000000..fe65e32ce24
Binary files /dev/null and b/docs/source/api_doc/edge/surtr_underwear.png differ
diff --git a/docs/source/api_doc/edge/xx.jpg b/docs/source/api_doc/edge/xx.jpg
new file mode 100644
index 00000000000..dfa950eeab0
Binary files /dev/null and b/docs/source/api_doc/edge/xx.jpg differ
diff --git a/docs/source/index.rst b/docs/source/index.rst
index d6599ab13b4..809ff8cde1e 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -25,6 +25,7 @@ configuration file's structure and their versions.
 
     api_doc/config/index
     api_doc/data/index
+    api_doc/edge/index
     api_doc/metrics/index
     api_doc/segment/index
     api_doc/tagging/index
diff --git a/imgutils/data/__init__.py b/imgutils/data/__init__.py
index 9735870f876..08f16971fb7 100644
--- a/imgutils/data/__init__.py
+++ b/imgutils/data/__init__.py
@@ -2,3 +2,4 @@
 from .decode import *
 from .encode import *
 from .image import *
+from .layer import *
diff --git a/imgutils/data/background.py b/imgutils/data/background.py
index c47e4db0545..512b3b4717d 100644
--- a/imgutils/data/background.py
+++ b/imgutils/data/background.py
@@ -3,7 +3,8 @@
 import numpy as np
 from PIL import ImageColor, Image
 
-from .image import ImageTyping, load_image
+from .image import ImageTyping
+from .layer import istack
 
 __all__ = [
     'grid_background',
@@ -62,6 +63,4 @@ def grid_transparent(image: ImageTyping, step: Optional[int] = None,
 
     """
     retval = grid_background(image.height, image.width, step, forecolor, backcolor)
-    image = load_image(image, force_background=None, mode='RGBA')
-    retval.paste(image, mask=image)
-    return retval.convert('RGB')
+    return istack(retval, image).convert('RGB')
diff --git a/imgutils/data/image.py b/imgutils/data/image.py
index 4580f19e104..199ccf91b19 100644
--- a/imgutils/data/image.py
+++ b/imgutils/data/image.py
@@ -43,10 +43,5 @@ def load_images(images: MultiImagesTyping, mode=None) -> List[Image.Image]:
 
 
 def add_background_for_rgba(image: ImageTyping, background: str = 'white'):
-    if not isinstance(image, Image.Image):
-        image = load_image(image)
-    image = image.convert("RGBA")
-    new_image = Image.new("RGBA", image.size, background)
-    new_image.paste(image, mask=image)
-    image = new_image.convert("RGB")
-    return image
+    from .layer import istack
+    return istack(background, image).convert('RGB')
diff --git a/imgutils/data/layer.py b/imgutils/data/layer.py
new file mode 100644
index 00000000000..752744e42d5
--- /dev/null
+++ b/imgutils/data/layer.py
@@ -0,0 +1,109 @@
+from typing import Union, Tuple, Optional
+
+import numpy as np
+from PIL import ImageColor, Image
+
+from .image import load_image, ImageTyping
+
+__all__ = [
+    'istack',
+]
+
+
+def _load_image_or_color(image) -> Union[str, Image.Image]:
+    if isinstance(image, str):
+        try:
+            _ = ImageColor.getrgb(image)
+        except ValueError:
+            pass
+        else:
+            return image
+
+    return load_image(image, mode='RGBA', force_background=None)
+
+
+def _process(item):
+    if isinstance(item, tuple):
+        image, alpha = item
+    else:
+        image, alpha = item, 1
+
+    return _load_image_or_color(image), alpha
+
+
+_AlphaTyping = Union[float, np.ndarray]
+
+
+def _add_alpha(image: Image.Image, alpha: _AlphaTyping) -> Image.Image:
+    data = np.array(image.convert('RGBA')).astype(np.float32)
+    data[:, :, 3] = (data[:, :, 3] * alpha).clip(0, 255)
+    return Image.fromarray(data.astype(np.uint8), mode='RGBA')
+
+
+def istack(*items: Union[ImageTyping, str, Tuple[ImageTyping, _AlphaTyping], Tuple[str, _AlphaTyping]],
+           size: Optional[Tuple[int, int]] = None) -> Image.Image:
+    """
+    Overview:
+        Layer multiple images (which may contain transparent areas) and
+        color blocks together into a new image, similar to a layering technique in PS.
+
+    :param items: The layers that need to be stacked. If a PIL object or the file path of an image is given,
+        the image will be used as a layer; if a color is given, the color will be used as a layer.
+        Additionally, if a tuple is given, the second element represents the transparency,
+        with a value range of :math:`\\left[0, 1\\right]`. It can be a float type or a two-dimensional numpy array
+        in the format of ``float32[H, W]`` which represents the transparency of each position.
+    :param size: The size of the target image. By default, the size of the first image object in the `items` list
+        will be used. However, when all layers are solid colors, this parameter is required.
+    :return: Stacked image.
+
+    Examples::
+        >>> from imgutils.data import istack
+        >>>
+        >>> # pure color
+        >>> istack('lime', 'nian.png').save('nian_lime.png')
+        >>>
+        >>> # transparency
+        >>> istack(('yellow', 0.5), ('nian.png', 0.9)).save('nian_trans.png')
+        >>>
+        >>> # custom mask
+        >>> import numpy as np
+        >>> from PIL import Image
+        >>> width, height = Image.open('nian.png').size
+        >>> hs1 = (1 - np.abs(np.linspace(-1 / 3, 1, height))) ** 0.5
+        >>> ws1 = (1 - np.abs(np.linspace(-1, 1, width))) ** 0.5
+        >>> nian_mask = hs1[..., None] * ws1  # HxW
+        >>> istack(('nian.png', nian_mask)).save('nian_mask.png')
+
+        The result should be
+
+        .. image:: grid_istack.dat.svg
+           :align: center
+    """
+    if size is None:
+        height, width = None, None
+        items = list(map(_process, items))
+        for item, alpha in items:
+            if isinstance(item, Image.Image):
+                height, width = item.height, item.width
+                break
+    else:
+        width, height = size
+
+    if height is None:
+        raise ValueError('Unable to determine image size, please make sure '
+                         'you have provided at least one image object (image path or PIL object).')
+
+    retval = Image.fromarray(np.zeros((height, width, 4), dtype=np.uint8), mode='RGBA')
+    for item, alpha in items:
+        if isinstance(item, str):
+            current = Image.new("RGBA", (width, height), item)
+        elif isinstance(item, Image.Image):
+            current = item
+        else:
+            assert False, f'Invalid type - {item!r}. If you encounter this situation, ' \
+                          f'it means there is a bug in the code. Please contact the developer.'  # pragma: no cover
+
+        current = _add_alpha(current, alpha)
+        retval.paste(current, mask=current)
+
+    return retval
diff --git a/imgutils/edge/__init__.py b/imgutils/edge/__init__.py
new file mode 100644
index 00000000000..bf31337d996
--- /dev/null
+++ b/imgutils/edge/__init__.py
@@ -0,0 +1,13 @@
+"""
+Overview:
+    Obtaining the outline (or you can call that line drawing) of an anime image.
+
+    Here is the example and comparison:
+
+    .. image:: edge.dat.svg
+        :align: center
+
+"""
+from .canny import get_edge_by_canny, edge_image_with_canny
+from .lineart import get_edge_by_lineart, edge_image_with_lineart
+from .lineart_anime import get_edge_by_lineart_anime, edge_image_with_lineart_anime
diff --git a/imgutils/edge/_base.py b/imgutils/edge/_base.py
new file mode 100644
index 00000000000..cd103aa366c
--- /dev/null
+++ b/imgutils/edge/_base.py
@@ -0,0 +1,40 @@
+import math
+from typing import Optional
+
+import cv2
+from PIL import Image
+
+from ..data import ImageTyping, load_image, istack
+
+
+def cv2_resize(input_image, width, height):
+    _origin_height, _origin_width, _ = input_image.shape
+    k = max(width / _origin_width, height / _origin_height)
+    return cv2.resize(
+        input_image, (width, height),
+        interpolation=cv2.INTER_LANCZOS4 if k > 1 else cv2.INTER_AREA
+    )  # when channel is 1, the return value will be HxW instead of HxWxC
+
+
+def resize_image(input_image, resolution, align: int = 64):
+    height, width, _ = input_image.shape
+    k = float(resolution) / min(height, width)
+    new_height, new_width = height * k, width * k
+    if align:
+        new_height = int(math.ceil(height / align)) * align
+        new_width = int(math.ceil(width / align)) * align
+
+    return cv2_resize(input_image, new_width, new_height)
+
+
+def _get_image_edge(image: ImageTyping, edge_func, backcolor: str = 'white', forecolor: Optional[str] = None):
+    image = load_image(image, mode='RGB')
+    edge = edge_func(image)
+
+    is_transparent = backcolor.lower() == 'transparent'
+    back_image = Image.new('RGBA', (image.width, image.height), '#ffffff' if is_transparent else backcolor)
+    back_alpha = 0.0 if is_transparent else 1.0
+    fore_image = image if forecolor is None else Image.new('RGBA', (image.width, image.height), forecolor)
+
+    retval = istack((back_image, back_alpha), (fore_image, edge))
+    return retval if is_transparent else retval.convert('RGB')
diff --git a/imgutils/edge/canny.py b/imgutils/edge/canny.py
new file mode 100644
index 00000000000..444ec0b01d9
--- /dev/null
+++ b/imgutils/edge/canny.py
@@ -0,0 +1,56 @@
+"""
+Overview:
+    Get edge with ``cv2.Canny``.
+
+    Having **the fastest running speed and the lowest system resource overhead**.
+"""
+from functools import partial
+from typing import Optional
+
+import cv2
+import numpy as np
+from PIL import Image
+
+from ._base import _get_image_edge
+from ..data import ImageTyping, load_image
+
+
+def get_edge_by_canny(image: ImageTyping, low_threshold=100, high_threshold=200):
+    """
+    Overview:
+        Get edge mask with ``cv2.Canny``.
+
+    :param image: Original image (assuming its size is ``HxW``).
+    :param low_threshold: Low threshold of canny, default is ``100``.
+    :param high_threshold: High threshold of canny, default is ``200``.
+    :return: A mask with format ``float32[H, W]``.
+    """
+    image = load_image(image, mode='RGB')
+    img = cv2.Canny(np.array(image), low_threshold, high_threshold)
+    return img.astype(np.float32) / 255.0
+
+
+def edge_image_with_canny(image: ImageTyping, low_threshold=100, high_threshold=200,
+                          backcolor: str = 'white', forecolor: Optional[str] = None) -> Image.Image:
+    """
+    Overview:
+        Get an image with the extracted edge from ``image``.
+
+    :param image: Original image (assuming its size is ``HxW``).
+    :param low_threshold: Low threshold of canny, default is ``100``.
+    :param high_threshold: High threshold of canny, default is ``200``.
+    :param backcolor: Background color the target image. Default is ``white``. When ``transparent`` is given, \
+        the background will be transparent.
+    :param forecolor: Fore color of the target image. Default is ``None`` which means use the color \
+        from the given ``image``.
+    :return: An image with the extracted edge from ``image``.
+
+    Examples::
+        .. image:: canny.dat.svg
+            :align: center
+    """
+    return _get_image_edge(
+        image,
+        partial(get_edge_by_canny, low_threshold=low_threshold, high_threshold=high_threshold),
+        backcolor, forecolor
+    )
diff --git a/imgutils/edge/lineart.py b/imgutils/edge/lineart.py
new file mode 100644
index 00000000000..bb6bfacb33a
--- /dev/null
+++ b/imgutils/edge/lineart.py
@@ -0,0 +1,80 @@
+"""
+Overview:
+    Get edge with lineart model.
+
+    Having the **best effect**, closest to the drawing lines,
+    but consuming a large amount of memory and computing power at runtime.
+"""
+from functools import lru_cache, partial
+from typing import Optional
+
+import numpy as np
+from PIL import Image
+from huggingface_hub import hf_hub_download
+
+from ._base import resize_image, cv2_resize, _get_image_edge
+from ..data import ImageTyping, load_image
+from ..utils import open_onnx_model
+
+
+def _preprocess(input_image: Image.Image, detect_resolution: int = 512):
+    input_image = np.array(input_image, dtype=np.uint8)
+    input_image = resize_image(input_image, detect_resolution)
+    return (input_image / 255.0).transpose(2, 0, 1)[None, ...].astype(np.float32)
+
+
+@lru_cache()
+def _open_la_model(coarse: bool):
+    return open_onnx_model(hf_hub_download(
+        'deepghs/imgutils-models',
+        f'lineart/{"lineart.onnx" if not coarse else "lineart_coarse.onnx"}',
+    ))
+
+
+def get_edge_by_lineart(image: ImageTyping, coarse: bool = False, detect_resolution: int = 512):
+    """
+    Overview:
+        Get edge mask with lineart model.
+
+    :param image: Original image (assuming its size is ``HxW``).
+    :param coarse: Use coarse model. In the coarse model, the lines will be deeper and richer,
+        but the probability of extra lines or content appearing will increase.
+    :param detect_resolution: Resolution when passing the image into neural network. Default is ``512``.
+    :return: A mask with format ``float32[H, W]``.
+    """
+    image = load_image(image, mode='RGB')
+    output_, = _open_la_model(coarse).run(['output'], {'input': _preprocess(image, detect_resolution)})
+    output_ = cv2_resize(output_[0].transpose(1, 2, 0), image.width, image.height)
+    return 1.0 - output_.clip(0.0, 1.0)
+
+
+def edge_image_with_lineart(image: ImageTyping, coarse: bool = False, detect_resolution: int = 512,
+                            backcolor: str = 'white', forecolor: Optional[str] = None):
+    """
+    Overview:
+        Get an image with the extracted edge from ``image``.
+
+    :param image: Original image (assuming its size is ``HxW``).
+    :param coarse: Use coarse model. In the coarse model, the lines will be deeper and richer,
+        but the probability of extra lines or content appearing will increase.
+    :param detect_resolution: Resolution when passing the image into neural network. Default is ``512``.
+    :param backcolor: Background color the target image. Default is ``white``. When ``transparent`` is given, \
+        the background will be transparent.
+    :param forecolor: Fore color of the target image. Default is ``None`` which means use the color \
+        from the given ``image``.
+    :return: An image with the extracted edge from ``image``.
+
+    Examples::
+        .. image:: lineart.dat.svg
+            :align: center
+
+        When ``coarse`` is used:
+
+        .. image:: lineart_coarse.dat.svg
+            :align: center
+    """
+    return _get_image_edge(
+        image,
+        partial(get_edge_by_lineart, coarse=coarse, detect_resolution=detect_resolution),
+        backcolor, forecolor
+    )
diff --git a/imgutils/edge/lineart_anime.py b/imgutils/edge/lineart_anime.py
new file mode 100644
index 00000000000..4cc27c76eb2
--- /dev/null
+++ b/imgutils/edge/lineart_anime.py
@@ -0,0 +1,69 @@
+"""
+Overview:
+    Get edge with lineart anime model.
+"""
+from functools import lru_cache, partial
+from typing import Optional
+
+import numpy as np
+from huggingface_hub import hf_hub_download
+
+from ._base import resize_image, cv2_resize, _get_image_edge
+from ..data import ImageTyping, load_image
+from ..utils import open_onnx_model
+
+
+def _preprocess(input_image, detect_resolution: int = 512):
+    input_image = np.array(input_image, dtype=np.uint8)
+    img = resize_image(input_image, detect_resolution, align=256).astype(np.float32)
+    img = (img / 127.5 - 1.0).transpose(2, 0, 1)[None, ...]
+    return img
+
+
+@lru_cache()
+def _open_la_anime_model():
+    return open_onnx_model(hf_hub_download(
+        'deepghs/imgutils-models',
+        f'lineart/lineart_anime.onnx',
+    ))
+
+
+def get_edge_by_lineart_anime(image: ImageTyping, detect_resolution: int = 512):
+    """
+    Overview:
+        Get edge mask with lineart anime model.
+
+    :param image: Original image (assuming its size is ``HxW``).
+    :param detect_resolution: Resolution when passing the image into neural network. Default is ``512``.
+    :return: A mask with format ``float32[H, W]``.
+    """
+    image = load_image(image, mode='RGB')
+    output_, = _open_la_anime_model().run(['output'], {'input': _preprocess(image, detect_resolution)})
+    output_ = (output_ + 1.0) / 2.0
+    output_ = cv2_resize(output_[0].transpose(1, 2, 0), image.width, image.height)
+    return 1.0 - output_.clip(0.0, 1.0)
+
+
+def edge_image_with_lineart_anime(image: ImageTyping, detect_resolution: int = 512,
+                                  backcolor: str = 'white', forecolor: Optional[str] = None):
+    """
+    Overview:
+        Get an image with the extracted edge from ``image``.
+
+    :param image: Original image (assuming its size is ``HxW``).
+    :param detect_resolution: Resolution when passing the image into neural network. Default is ``512``.
+    :param backcolor: Background color the target image. Default is ``white``. When ``transparent`` is given, \
+        the background will be transparent.
+    :param forecolor: Fore color of the target image. Default is ``None`` which means use the color \
+        from the given ``image``.
+    :return: An image with the extracted edge from ``image``.
+
+    Examples::
+        .. image:: lineart_anime.dat.svg
+            :align: center
+    """
+    return _get_image_edge(
+        image,
+        partial(get_edge_by_lineart_anime, detect_resolution=detect_resolution),
+        backcolor, forecolor
+    )
diff --git a/imgutils/segment/isnetis.py b/imgutils/segment/isnetis.py
index 892b087f446..adabed5606b 100644
--- a/imgutils/segment/isnetis.py
+++ b/imgutils/segment/isnetis.py
@@ -7,9 +7,8 @@
 import cv2
 import huggingface_hub
 import numpy as np
-from PIL import ImageColor, Image
 
-from ..data import ImageTyping, load_image
+from ..data import ImageTyping, load_image, istack
 from ..utils.onnxruntime import open_onnx_model
 
 
@@ -26,7 +25,7 @@ def get_isnetis_mask(image: ImageTyping, scale: int = 1024):
     :param image: Original image (assume its size is ``(H, W)``).
     :param scale: Scale when passing it into neural network. Default is ``1024``,
         inspired by https://huggingface.co/spaces/skytnt/anime-remove-background/blob/main/app.py#L8 .
-    :return: Get a mask with all the pixels, which shape is ``(H, W, 1)``.
+    :return: Get a mask with all the pixels, which shape is ``(H, W)``.
     """
     image = np.asarray(load_image(image, mode='RGB'))
     image = (image / 255).astype(np.float32)
@@ -41,7 +40,7 @@ def get_isnetis_mask(image: ImageTyping, scale: int = 1024):
     mask = np.transpose(mask, (1, 2, 0))
     mask = mask[ph // 2:ph // 2 + h, pw // 2:pw // 2 + w]
     mask = cv2.resize(mask, (w0, h0))[:, :, np.newaxis]
-    return mask
+    return mask.reshape(*mask.shape[:-1])
 
 
 def segment_with_isnetis(image: ImageTyping, background: str = 'lime', scale: int = 1024):
@@ -72,18 +71,7 @@ def segment_with_isnetis(image: ImageTyping, background: str = 'lime', scale: in
     """
     image = load_image(image, mode='RGB')
     mask = get_isnetis_mask(image, scale)
-    h, w, _ = mask.shape
-
-    _mk = np.zeros((h, w, 3))
-    r, g, b = ImageColor.getrgb(background)
-    _mk[:, :, 0] = r
-    _mk[:, :, 1] = g
-    _mk[:, :, 2] = b
-
-    _image_arr = np.asarray(image)
-    _masked_data = (_mk * (1 - mask) + _image_arr * mask).astype(np.uint8)
-
-    return mask, Image.fromarray(_masked_data, mode='RGB')
+    return mask, istack((background, 1.0), (image, mask)).convert('RGB')
 
 
 def segment_rgba_with_isnetis(image: ImageTyping, scale: int = 1024):
@@ -113,11 +101,4 @@ def segment_rgba_with_isnetis(image: ImageTyping, scale: int = 1024):
     """
     image = load_image(image, mode='RGB')
     mask = get_isnetis_mask(image, scale)
-    h, w, _ = mask.shape
-
-    _mk = np.zeros((h, w, 4))
-    _mk[:, :, :3] = np.asarray(image)
-    _mk[:, :, 3] = 255
-    _mk = (_mk * mask).astype(np.uint8)
-
-    return mask, Image.fromarray(_mk, mode='RGBA')
+    return mask, istack((image, mask))
diff --git a/requirements-zoo.txt b/requirements-zoo.txt
index 9e844cf1f2e..1b37cad6b26 100644
--- a/requirements-zoo.txt
+++ b/requirements-zoo.txt
@@ -15,4 +15,5 @@ accelerate
 timm
 ftfy
 regex
-git+https://github.com/openai/CLIP.git
\ No newline at end of file
+git+https://github.com/openai/CLIP.git
+controlnet_aux
\ No newline at end of file
diff --git a/zoo/lineart/__init__.py b/zoo/lineart/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/zoo/lineart/__main__.py b/zoo/lineart/__main__.py
new file mode 100644
index 00000000000..6d48cc92271
--- /dev/null
+++ b/zoo/lineart/__main__.py
@@ -0,0 +1,87 @@
+import os.path
+import re
+import tempfile
+from functools import partial
+from typing import Optional
+
+import click
+from hbutils.testing import disable_output
+from tqdm.auto import tqdm
+
+from .hed import _MyHEDDetector
+from .la import _MyLineartDetector
+from .la_anime import _MyLineartAnimeDetector
+from .pidi import _MyPidiNetDetector
+from ..utils import GLOBAL_CONTEXT_SETTINGS
+from ..utils import print_version as _origin_print_version
+
+_ONNX_ITEMS = {
+    'lineart': (_MyLineartDetector, {'coarse': False}),
+    'lineart_coarse': (_MyLineartDetector, {'coarse': True}),
+    'lineart_anime': (_MyLineartAnimeDetector, {}),
+    'pidi': (_MyPidiNetDetector, {}),
+    'hed': (_MyHEDDetector, {}),
+}
+
+
+def _get_detector(name):
+    _cls, _kwargs = _ONNX_ITEMS[name]
+    return _cls.from_pretrained("lllyasviel/Annotators"), _kwargs
+
+
+print_version = partial(_origin_print_version, 'zoo.lineart')
+
+
+@click.group(context_settings={**GLOBAL_CONTEXT_SETTINGS})
+@click.option('-v', '--version', is_flag=True,
+              callback=print_version, expose_value=False, is_eager=True,
+              help="Utils with pixiv resources.")
+def cli():
+    pass  # pragma: no cover
+
+
+@cli.command('onnx_check', help='Check onnx export is okay or not')
+@click.option('--model', '-m', 'model', type=str, required=None,
+              help='Model to be checked. Check all when not given', show_default=True)
+@click.option('--verbose', '-V', 'verbose', is_flag=True, type=bool, default=False,
+              help='Show verbose information.', show_default=True)
+@click.option('--output_dir', '-O', 'output_dir', type=click.Path(file_okay=False), default=None,
+              help='Output directory of all models.', show_default=True)
+def onnx_check(model: Optional[str] = None, verbose: bool = False,
+               output_dir: Optional[str] = None):
+    if model is None:
+        models = list(_ONNX_ITEMS.keys())
+    else:
+        models = [model]
+
+    with tempfile.TemporaryDirectory() as td:
+        for item in models:
+            click.echo(click.style(f'Try exporting {item} to onnx ...'), nl=False)
+            onnx_filename = os.path.join(output_dir or td, re.sub(r'\W+', '-', f'{item}') + '.onnx')
+            try:
+                _model, _kwargs = _get_detector(item)
+                if verbose:
+                    _model.export_onnx(onnx_filename, **_kwargs, verbose=verbose)
+                else:
+                    with disable_output():
+                        _model.export_onnx(onnx_filename, **_kwargs, verbose=verbose)
+            except:
+                click.echo(click.style('FAILED', fg='red'), nl=True)
+                raise
+            else:
+                click.echo(click.style('OK', fg='green'), nl=True)
+
+
+@cli.command('export', help='Export all models as onnx.',
+             context_settings={**GLOBAL_CONTEXT_SETTINGS})
+@click.option('--output_dir', '-O', 'output_dir', type=click.Path(file_okay=False), required=True,
+              help='Output directory of all models.', show_default=True)
+def export(output_dir: str):
+    for key in tqdm(_ONNX_ITEMS.keys()):
+        _model, _kwargs = _get_detector(key)
+        onnx_filename = os.path.join(output_dir, re.sub(r'\W+', '-', f'{key}') + '.onnx')
+        _model.export_onnx(onnx_filename, **_kwargs)
+
+
+if __name__ == '__main__':
+    cli()
diff --git a/zoo/lineart/hed.py b/zoo/lineart/hed.py
new file mode 100644
index 00000000000..b85b1553137
--- /dev/null
+++ b/zoo/lineart/hed.py
@@ -0,0 +1,30 @@
+import numpy as np
+import torch
+from controlnet_aux import HEDdetector
+from controlnet_aux.open_pose import resize_image, HWC3
+from einops import rearrange
+
+from .onnx import _FixedOnnxMixin
+
+
+class _MyHEDDetector(HEDdetector, _FixedOnnxMixin):
+    def __init__(self, model):
+        HEDdetector.__init__(self, model)
+        _FixedOnnxMixin.__init__(self, model)
+
+    def preprocess(self, input_image, detect_resolution: int = 512):
+        device = next(iter(self.netNetwork.parameters())).device
+        if not isinstance(input_image, np.ndarray):
+            input_image = np.array(input_image, dtype=np.uint8)
+
+        input_image = HWC3(input_image)
+        input_image = resize_image(input_image, detect_resolution)
+
+        assert input_image.ndim == 3
+        input_image = input_image[:, :, ::-1].copy()
+        with torch.no_grad():
+            image_hed = torch.from_numpy(input_image).float()
+            image_hed = image_hed.to(device)
+            image_hed = image_hed / 255.0
+            image_hed = rearrange(image_hed, 'h w c -> 1 c h w')
+            return image_hed
diff --git a/zoo/lineart/la.py b/zoo/lineart/la.py
new file mode 100644
index 00000000000..1e4845a2a11
--- /dev/null
+++ b/zoo/lineart/la.py
@@ -0,0 +1,29 @@
+import numpy as np
+import torch
+from controlnet_aux import LineartDetector
+from controlnet_aux.open_pose import HWC3, resize_image
+from einops import rearrange
+
+from .onnx import _BaseOnnxMixin
+
+
+class _MyLineartDetector(LineartDetector, _BaseOnnxMixin):
+    def _get_model(self, coarse=False, **kwargs):
+        return self.model_coarse if coarse else self.model
+
+    def preprocess(self, input_image, coarse=False, detect_resolution: int = 512, **kwargs):
+        device = next(iter(self.model.parameters())).device
+        if not isinstance(input_image, np.ndarray):
+            input_image = np.array(input_image, dtype=np.uint8)
+
+        input_image = HWC3(input_image)
+        input_image = resize_image(input_image, detect_resolution)
+
+        assert input_image.ndim == 3
+        image = input_image
+        with torch.no_grad():
+            image = torch.from_numpy(image).float().to(device)
+            image = image / 255.0
+            image = rearrange(image, 'h w c -> 1 c h w')
+
+            return image
diff --git a/zoo/lineart/la_anime.py b/zoo/lineart/la_anime.py
new file mode 100644
index 00000000000..4f8f35b7cb8
--- /dev/null
+++ b/zoo/lineart/la_anime.py
@@ -0,0 +1,49 @@
+import cv2
+import numpy as np
+import torch
+from controlnet_aux import LineartAnimeDetector
+from controlnet_aux.open_pose import HWC3, resize_image
+from einops import rearrange
+
+from imgutils.data import load_image
+from .onnx import _FixedOnnxMixin
+
+
+class _MyLineartAnimeDetector(LineartAnimeDetector, _FixedOnnxMixin):
+    def __init__(self, model):
+        LineartAnimeDetector.__init__(self, model)
+        _FixedOnnxMixin.__init__(self, model)
+
+    def preprocess(self, input_image, detect_resolution=512):
+        input_image = load_image(input_image)
+        device = next(iter(self.model.parameters())).device
+        if not isinstance(input_image, np.ndarray):
+            input_image = np.array(input_image, dtype=np.uint8)
+
+        input_image = HWC3(input_image)
+        input_image = resize_image(input_image, detect_resolution)
+
+        height, width, channels = input_image.shape
+        height_n = 256 * int(np.ceil(float(height) / 256.0))
+        weight_n = 256 * int(np.ceil(float(width) / 256.0))
+        img = cv2.resize(input_image, (weight_n, height_n), interpolation=cv2.INTER_CUBIC)
+        with torch.no_grad():
+            image_feed = torch.from_numpy(img).float().to(device)
+            image_feed = image_feed / 127.5 - 1.0
+            image_feed = rearrange(image_feed, 'h w c -> 1 c h w')
+
+            return image_feed
+
+    def postprocess(self, input_image, output):
+        line = output[0, 0] * 127.5 + 127.5
+        line = line.cpu().numpy()
+
+        height, width, channels = input_image.shape
+        line = cv2.resize(line, (width, height), interpolation=cv2.INTER_CUBIC)
+        line = line.clip(0, 255).astype(np.uint8)
+
+        detected_map = HWC3(line)
+        detected_map = cv2.resize(detected_map, (width, height), interpolation=cv2.INTER_LINEAR)
+        detected_map = 255 - detected_map
+
+        return detected_map
diff --git a/zoo/lineart/onnx.py b/zoo/lineart/onnx.py
new file mode 100644
index 00000000000..3d2debb0d82
--- /dev/null
+++ b/zoo/lineart/onnx.py
@@ -0,0 +1,33 @@
+from PIL import Image
+
+from ..utils import get_testfile, onnx_quick_export
+
+
+class _BaseOnnxMixin:
+    def _get_model(self, **kwargs):
+        raise NotImplementedError
+
+    def preprocess(self, input_image, **kwargs):
+        raise NotImplementedError
+
+    def dynamic_axes(self):
+        return {
+            "input": {0: "batch", 2: 'height', 3: 'width'},
+            "output": {0: "batch", 2: 'height', 3: 'width'},
+        }
+
+    def export_onnx(self, onnx_filename, opset_version: int = 14, verbose: bool = True,
+                    no_optimize: bool = False, input_image=None, **kwargs):
+        input_image = Image.open(input_image or get_testfile('6125785.png'))
+        onnx_quick_export(
+            self._get_model(**kwargs), self.preprocess(input_image, **kwargs),
+            onnx_filename, opset_version, verbose, no_optimize, self.dynamic_axes(), no_gpu=True
+        )
+
+
+class _FixedOnnxMixin(_BaseOnnxMixin):
+    def __init__(self, model):
+        self._model = model
+
+    def _get_model(self, **kwargs):
+        return self._model
diff --git a/zoo/lineart/pidi.py b/zoo/lineart/pidi.py
new file mode 100644
index 00000000000..cd89ba32d7c
--- /dev/null
+++ b/zoo/lineart/pidi.py
@@ -0,0 +1,28 @@
+import numpy as np
+import torch
+from controlnet_aux import PidiNetDetector
+from controlnet_aux.open_pose import HWC3, resize_image
+from einops import rearrange
+
+from .onnx import _FixedOnnxMixin
+
+
+class _MyPidiNetDetector(PidiNetDetector, _FixedOnnxMixin):
+    def __init__(self, model):
+        PidiNetDetector.__init__(self, model)
+        _FixedOnnxMixin.__init__(self, model)
+
+    def preprocess(self, input_image, detect_resolution: int = 512):
+        if not isinstance(input_image, np.ndarray):
+            input_image = np.array(input_image, dtype=np.uint8)
+
+        input_image = HWC3(input_image)
+        input_image = resize_image(input_image, detect_resolution)
+        assert input_image.ndim == 3
+        input_image = input_image[:, :, ::-1].copy()
+        with torch.no_grad():
+            image_pidi = torch.from_numpy(input_image).float()
+            image_pidi = image_pidi / 255.0
+            image_pidi = rearrange(image_pidi, 'h w c -> 1 c h w')
+
+            return image_pidi
diff --git a/zoo/utils/__init__.py b/zoo/utils/__init__.py
index 92748946d1e..d699c746f2d 100644
--- a/zoo/utils/__init__.py
+++ b/zoo/utils/__init__.py
@@ -1,4 +1,5 @@
 from .cli import GLOBAL_CONTEXT_SETTINGS, print_version
 from .lr import get_init_lr, get_dynamic_lr_scheduler, LRTyping
+from .onnx import onnx_quick_export
 from .optimize import onnx_optimize
 from .testfile import get_testfile
diff --git a/zoo/utils/onnx.py b/zoo/utils/onnx.py
new file mode 100644
index 00000000000..7cf7450060c
--- /dev/null
+++ b/zoo/utils/onnx.py
@@ -0,0 +1,41 @@
+import os
+from tempfile import TemporaryDirectory
+
+import onnx
+import torch
+
+from .optimize import onnx_optimize
+
+
+def onnx_quick_export(model, example_input, onnx_filename, opset_version: int = 14, verbose: bool = True,
+                      no_optimize: bool = False, dynamic_axes=None, no_gpu=False):
+    model = model.float()
+    if torch.cuda.is_available() and not no_gpu:
+        example_input = example_input.cuda()
+        model = model.cuda()
+    else:
+        example_input = example_input.cpu()
+        model = model.cpu()
+
+    with torch.no_grad(), TemporaryDirectory() as td:
+        onnx_model_file = os.path.join(td, 'model.onnx')
+        torch.onnx.export(
+            model,
+            example_input,
+            onnx_model_file,
+            verbose=verbose,
+            input_names=["input"],
+            output_names=["output"],
+
+            opset_version=opset_version,
+            dynamic_axes=dynamic_axes or {},
+        )
+
+        model = onnx.load(onnx_model_file)
+        if not no_optimize:
+            model = onnx_optimize(model)
+
+        output_model_dir, _ = os.path.split(onnx_filename)
+        if output_model_dir:
+            os.makedirs(output_model_dir, exist_ok=True)
+        onnx.save(model, onnx_filename)


Test patch:
diff --git a/test/data/test_layer.py b/test/data/test_layer.py
new file mode 100644
index 00000000000..f76b80d1657
--- /dev/null
+++ b/test/data/test_layer.py
@@ -0,0 +1,51 @@
+import numpy as np
+import pytest
+from PIL import Image
+from hbutils.testing import tmatrix
+
+from imgutils.data import istack, load_image
+from ..testings import get_testfile
+
+
+@pytest.mark.unittest
+class TestDataLayer:
+    def test_istack(self, image_diff):
+        width, height = Image.open(get_testfile('nian.png')).size
+        hs1 = (1 - np.abs(np.linspace(-1, 1, height))) ** 0.5
+        ws1 = (1 - np.abs(np.linspace(-1, 1, width))) ** 0.5
+        nian_mask = hs1[..., None] * ws1
+
+        hs2 = np.abs(np.linspace(-1, 1, height)) ** 0.5
+        ws2 = np.abs(np.linspace(-1, 1, width)) ** 0.5
+        color_mask = hs2[..., None] * ws2
+
+        image = istack((get_testfile('nian.png'), nian_mask), ('green', color_mask))
+        for color in ['red', 'green', 'white', 'black', 'pink']:
+            assert image_diff(
+                load_image(get_testfile('nian_green.png'), force_background=color, mode='RGB'),
+                load_image(image, force_background=color, mode='RGB'),
+                throw_exception=False
+            ) < 1e-2
+
+    @pytest.mark.parametrize(*tmatrix({
+        'color1': ['red', 'green', 'blue'],
+        'color2': ['red', 'green', 'blue'],
+    }))
+    def test_istack_pure_color(self, color1, color2, image_diff):
+        image = istack((color1, 0.7), (color2, 0.4), size=(768, 512))
+        target_filename = get_testfile(f'istack_{color1}_{color2}.png')
+
+        assert image.height == 512
+        assert image.width == 768
+        assert image.mode == 'RGBA'
+
+        for color in ['red', 'green', 'white', 'black', 'pink']:
+            assert image_diff(
+                load_image(target_filename, force_background=color, mode='RGB'),
+                load_image(image, force_background=color, mode='RGB'),
+                throw_exception=False
+            ) < 1e-2
+
+    def test_istack_error(self):
+        with pytest.raises(ValueError):
+            _ = istack(('red', 0.5), ('green', 0.5))
diff --git a/test/edge/__init__.py b/test/edge/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/test/edge/test_canny.py b/test/edge/test_canny.py
new file mode 100644
index 00000000000..f588d4abfdb
--- /dev/null
+++ b/test/edge/test_canny.py
@@ -0,0 +1,30 @@
+import os.path
+
+import pytest
+from hbutils.testing import tmatrix
+
+from imgutils.data import load_image
+from imgutils.edge import edge_image_with_canny
+from test.testings import get_testfile
+
+
+@pytest.mark.unittest
+class TestEdgeCannyAnime:
+    @pytest.mark.parametrize(*tmatrix({
+        'original_image': ['6125785.jpg', '6125901.jpg'],
+        'backcolor': ['transparent', 'white'],
+        'forecolor': ['', 'black'],
+    }))
+    def test_edge_image_with_canny(self, original_image, backcolor, forecolor, image_diff):
+        image = edge_image_with_canny(
+            get_testfile(original_image),
+            backcolor=backcolor, forecolor=None if not forecolor else forecolor,
+        )
+        body, _ = os.path.splitext(original_image)
+
+        assert image_diff(
+            load_image(get_testfile(f'canny_{body}_{backcolor}_{forecolor}.png'),
+                       mode='RGB', force_background='pink'),
+            load_image(image, mode='RGB', force_background='pink'),
+            throw_exception=False
+        ) < 1e-2
diff --git a/test/edge/test_lineart.py b/test/edge/test_lineart.py
new file mode 100644
index 00000000000..9c86e20b3cb
--- /dev/null
+++ b/test/edge/test_lineart.py
@@ -0,0 +1,41 @@
+import os.path
+
+import pytest
+from hbutils.testing import tmatrix
+
+from imgutils.data import load_image
+from imgutils.edge import edge_image_with_lineart
+from imgutils.edge.lineart import _open_la_model
+from test.testings import get_testfile
+
+
+@pytest.fixture()
+def _release_model_after_run():
+    try:
+        yield
+    finally:
+        _open_la_model.cache_clear()
+
+
+@pytest.mark.unittest
+class TestEdgeLineart:
+    @pytest.mark.parametrize(*tmatrix({
+        'original_image': ['6125785.jpg', '6125901.jpg'],
+        'backcolor': ['transparent', 'white'],
+        'forecolor': ['', 'black'],
+        'coarse': [True, False],
+    }))
+    def test_edge_image_with_lineart(self, original_image, backcolor, forecolor, coarse,
+                                     image_diff, _release_model_after_run):
+        image = edge_image_with_lineart(
+            get_testfile(original_image), coarse=coarse,
+            backcolor=backcolor, forecolor=None if not forecolor else forecolor,
+        )
+        body, _ = os.path.splitext(original_image)
+
+        assert image_diff(
+            load_image(get_testfile(f'lineart_{body}_{backcolor}_{forecolor}_{coarse}.png'),
+                       mode='RGB', force_background='pink'),
+            load_image(image, mode='RGB', force_background='pink'),
+            throw_exception=False
+        ) < 1e-2
diff --git a/test/edge/test_lineart_anime.py b/test/edge/test_lineart_anime.py
new file mode 100644
index 00000000000..1bf28b2e2ac
--- /dev/null
+++ b/test/edge/test_lineart_anime.py
@@ -0,0 +1,40 @@
+import os.path
+
+import pytest
+from hbutils.testing import tmatrix
+
+from imgutils.data import load_image
+from imgutils.edge import edge_image_with_lineart_anime
+from imgutils.edge.lineart_anime import _open_la_anime_model
+from test.testings import get_testfile
+
+
+@pytest.fixture()
+def _release_model_after_run():
+    try:
+        yield
+    finally:
+        _open_la_anime_model.cache_clear()
+
+
+@pytest.mark.unittest
+class TestEdgeLineartAnime:
+    @pytest.mark.parametrize(*tmatrix({
+        'original_image': ['6125785.jpg', '6125901.jpg'],
+        'backcolor': ['transparent', 'white'],
+        'forecolor': ['', 'black']
+    }))
+    def test_edge_image_with_lineart_anime(self, original_image, backcolor, forecolor,
+                                           image_diff, _release_model_after_run):
+        image = edge_image_with_lineart_anime(
+            get_testfile(original_image),
+            backcolor=backcolor, forecolor=None if not forecolor else forecolor,
+        )
+        body, _ = os.path.splitext(original_image)
+
+        assert image_diff(
+            load_image(get_testfile(f'lineart_anime_{body}_{backcolor}_{forecolor}.png'),
+                       mode='RGB', force_background='pink'),
+            load_image(image, mode='RGB', force_background='pink'),
+            throw_exception=False
+        ) < 1e-2
diff --git a/test/segment/test_isnetis.py b/test/segment/test_isnetis.py
index 3aeaa047dbd..7b52d51aeb3 100644
--- a/test/segment/test_isnetis.py
+++ b/test/segment/test_isnetis.py
@@ -20,7 +20,7 @@ def test_segment_with_isnetis(self, original_image, background, image_diff):
         target_image_file = get_testfile(f'isnetis_{obody}_{background}{oext}')
 
         mask, ret_image = segment_with_isnetis(image, **({'background': background} if background else {}))
-        assert mask.shape == (image.height, image.width, 1)
+        assert mask.shape == (image.height, image.width)
         assert ret_image.size == image.size
         assert ret_image.mode == 'RGB'
 
@@ -38,7 +38,7 @@ def test_segment_rgba_with_isnetis(self, original_image, image_diff):
         target_image_file = get_testfile(f'isnetis_rgba_{obody}.png')
 
         mask, ret_image = segment_rgba_with_isnetis(image)
-        assert mask.shape == (image.height, image.width, 1)
+        assert mask.shape == (image.height, image.width)
         assert ret_image.size == image.size
         assert ret_image.mode == 'RGBA'
 
diff --git a/test/tagging/test_deepdanbooru.py b/test/tagging/test_deepdanbooru.py
index 69d25f0cfbc..168448f88f8 100644
--- a/test/tagging/test_deepdanbooru.py
+++ b/test/tagging/test_deepdanbooru.py
@@ -1,12 +1,21 @@
 import pytest
 
 from imgutils.tagging import get_deepdanbooru_tags
+from imgutils.tagging.deepdanbooru import _get_deepdanbooru_model
 from test.testings import get_testfile
 
 
+@pytest.fixture()
+def _release_model_after_run():
+    try:
+        yield
+    finally:
+        _get_deepdanbooru_model.cache_clear()
+
+
 @pytest.mark.unittest
 class TestTaggingDeepdanbooru:
-    def test_get_deepdanbooru_tags(self):
+    def test_get_deepdanbooru_tags(self, _release_model_after_run):
         rating, tags, chars = get_deepdanbooru_tags(get_testfile('6124220.jpg'))
         assert rating['rating:safe'] > 0.9
         assert tags['greyscale'] >= 0.8
diff --git a/test/tagging/test_wd14.py b/test/tagging/test_wd14.py
index 53bd2f9c46b..3318795a64b 100644
--- a/test/tagging/test_wd14.py
+++ b/test/tagging/test_wd14.py
@@ -1,9 +1,18 @@
 import pytest
 
 from imgutils.tagging import get_wd14_tags
+from imgutils.tagging.wd14 import _get_wd14_model
 from test.testings import get_testfile
 
 
+@pytest.fixture()
+def _release_model_after_run():
+    try:
+        yield
+    finally:
+        _get_wd14_model.cache_clear()
+
+
 @pytest.mark.unittest
 class TestTaggingWd14:
     def test_get_wd14_tags(self):
diff --git a/test/testfile/canny_6125785_transparent_.png b/test/testfile/canny_6125785_transparent_.png
new file mode 100644
index 00000000000..ec9f31d3f63
Binary files /dev/null and b/test/testfile/canny_6125785_transparent_.png differ
diff --git a/test/testfile/canny_6125785_white_black.png b/test/testfile/canny_6125785_white_black.png
new file mode 100644
index 00000000000..456e4d67bf2
Binary files /dev/null and b/test/testfile/canny_6125785_white_black.png differ
diff --git a/test/testfile/canny_6125901_transparent_black.png b/test/testfile/canny_6125901_transparent_black.png
new file mode 100644
index 00000000000..f5bfda1d6aa
Binary files /dev/null and b/test/testfile/canny_6125901_transparent_black.png differ
diff --git a/test/testfile/canny_6125901_white_.png b/test/testfile/canny_6125901_white_.png
new file mode 100644
index 00000000000..0f28c310ccf
Binary files /dev/null and b/test/testfile/canny_6125901_white_.png differ
diff --git a/test/testfile/isnetis_6125785_.jpg b/test/testfile/isnetis_6125785_.jpg
index 450141558d6..a95db16edac 100644
Binary files a/test/testfile/isnetis_6125785_.jpg and b/test/testfile/isnetis_6125785_.jpg differ
diff --git a/test/testfile/isnetis_6125785_black.jpg b/test/testfile/isnetis_6125785_black.jpg
index c2de297fb31..198ea87e0ca 100644
Binary files a/test/testfile/isnetis_6125785_black.jpg and b/test/testfile/isnetis_6125785_black.jpg differ
diff --git a/test/testfile/isnetis_6125785_white.jpg b/test/testfile/isnetis_6125785_white.jpg
index 87d55053bd6..a5c4a29b3c5 100644
Binary files a/test/testfile/isnetis_6125785_white.jpg and b/test/testfile/isnetis_6125785_white.jpg differ
diff --git a/test/testfile/isnetis_6125901_.jpg b/test/testfile/isnetis_6125901_.jpg
index d249f78e954..b226bdc2a3d 100644
Binary files a/test/testfile/isnetis_6125901_.jpg and b/test/testfile/isnetis_6125901_.jpg differ
diff --git a/test/testfile/isnetis_6125901_black.jpg b/test/testfile/isnetis_6125901_black.jpg
index 90fb75895cc..acebfd97389 100644
Binary files a/test/testfile/isnetis_6125901_black.jpg and b/test/testfile/isnetis_6125901_black.jpg differ
diff --git a/test/testfile/isnetis_6125901_white.jpg b/test/testfile/isnetis_6125901_white.jpg
index ecd930b7662..f9af1deb59f 100644
Binary files a/test/testfile/isnetis_6125901_white.jpg and b/test/testfile/isnetis_6125901_white.jpg differ
diff --git a/test/testfile/isnetis_rgba_6125785.png b/test/testfile/isnetis_rgba_6125785.png
index a7ddd25e195..4ab647cfcfe 100644
Binary files a/test/testfile/isnetis_rgba_6125785.png and b/test/testfile/isnetis_rgba_6125785.png differ
diff --git a/test/testfile/isnetis_rgba_6125901.png b/test/testfile/isnetis_rgba_6125901.png
index bd2780e775b..3167c1e9738 100644
Binary files a/test/testfile/isnetis_rgba_6125901.png and b/test/testfile/isnetis_rgba_6125901.png differ
diff --git a/test/testfile/istack_blue_blue.png b/test/testfile/istack_blue_blue.png
new file mode 100644
index 00000000000..f31a059d3e6
Binary files /dev/null and b/test/testfile/istack_blue_blue.png differ
diff --git a/test/testfile/istack_blue_green.png b/test/testfile/istack_blue_green.png
new file mode 100644
index 00000000000..0a896e6c963
Binary files /dev/null and b/test/testfile/istack_blue_green.png differ
diff --git a/test/testfile/istack_blue_red.png b/test/testfile/istack_blue_red.png
new file mode 100644
index 00000000000..c4c000b1d54
Binary files /dev/null and b/test/testfile/istack_blue_red.png differ
diff --git a/test/testfile/istack_green_blue.png b/test/testfile/istack_green_blue.png
new file mode 100644
index 00000000000..6822ee1bda6
Binary files /dev/null and b/test/testfile/istack_green_blue.png differ
diff --git a/test/testfile/istack_green_green.png b/test/testfile/istack_green_green.png
new file mode 100644
index 00000000000..ff67b62d81c
Binary files /dev/null and b/test/testfile/istack_green_green.png differ
diff --git a/test/testfile/istack_green_red.png b/test/testfile/istack_green_red.png
new file mode 100644
index 00000000000..5ea4a3b8e41
Binary files /dev/null and b/test/testfile/istack_green_red.png differ
diff --git a/test/testfile/istack_red_blue.png b/test/testfile/istack_red_blue.png
new file mode 100644
index 00000000000..19f30fe7a98
Binary files /dev/null and b/test/testfile/istack_red_blue.png differ
diff --git a/test/testfile/istack_red_green.png b/test/testfile/istack_red_green.png
new file mode 100644
index 00000000000..a47e2adfd9d
Binary files /dev/null and b/test/testfile/istack_red_green.png differ
diff --git a/test/testfile/istack_red_red.png b/test/testfile/istack_red_red.png
new file mode 100644
index 00000000000..fd9ac197523
Binary files /dev/null and b/test/testfile/istack_red_red.png differ
diff --git a/test/testfile/lineart_6125785_transparent__True.png b/test/testfile/lineart_6125785_transparent__True.png
new file mode 100644
index 00000000000..a713350ef4d
Binary files /dev/null and b/test/testfile/lineart_6125785_transparent__True.png differ
diff --git a/test/testfile/lineart_6125785_white__False.png b/test/testfile/lineart_6125785_white__False.png
new file mode 100644
index 00000000000..8bbe7fce41f
Binary files /dev/null and b/test/testfile/lineart_6125785_white__False.png differ
diff --git a/test/testfile/lineart_6125785_white_black_True.png b/test/testfile/lineart_6125785_white_black_True.png
new file mode 100644
index 00000000000..9deae40d531
Binary files /dev/null and b/test/testfile/lineart_6125785_white_black_True.png differ
diff --git a/test/testfile/lineart_6125901_transparent_black_False.png b/test/testfile/lineart_6125901_transparent_black_False.png
new file mode 100644
index 00000000000..6cf7f3194a9
Binary files /dev/null and b/test/testfile/lineart_6125901_transparent_black_False.png differ
diff --git a/test/testfile/lineart_6125901_white__True.png b/test/testfile/lineart_6125901_white__True.png
new file mode 100644
index 00000000000..421f548918f
Binary files /dev/null and b/test/testfile/lineart_6125901_white__True.png differ
diff --git a/test/testfile/lineart_anime_6125785_transparent_.png b/test/testfile/lineart_anime_6125785_transparent_.png
new file mode 100644
index 00000000000..5b1984a44cd
Binary files /dev/null and b/test/testfile/lineart_anime_6125785_transparent_.png differ
diff --git a/test/testfile/lineart_anime_6125785_white_black.png b/test/testfile/lineart_anime_6125785_white_black.png
new file mode 100644
index 00000000000..119f58aca7e
Binary files /dev/null and b/test/testfile/lineart_anime_6125785_white_black.png differ
diff --git a/test/testfile/lineart_anime_6125901_transparent_black.png b/test/testfile/lineart_anime_6125901_transparent_black.png
new file mode 100644
index 00000000000..737f0a433dc
Binary files /dev/null and b/test/testfile/lineart_anime_6125901_transparent_black.png differ
diff --git a/test/testfile/lineart_anime_6125901_white_.png b/test/testfile/lineart_anime_6125901_white_.png
new file mode 100644
index 00000000000..eea94d4e00f
Binary files /dev/null and b/test/testfile/lineart_anime_6125901_white_.png differ
diff --git a/test/testfile/nian_green.png b/test/testfile/nian_green.png
new file mode 100644
index 00000000000..37fd09c3063
Binary files /dev/null and b/test/testfile/nian_green.png differ
diff --git a/test/testfile/surtr_underwear.png b/test/testfile/surtr_underwear.png
new file mode 100644
index 00000000000..fe65e32ce24
Binary files /dev/null and b/test/testfile/surtr_underwear.png differ
diff --git a/test/validate/test_monochrome.py b/test/validate/test_monochrome.py
index d93f16ef771..1101c5b9102 100644
--- a/test/validate/test_monochrome.py
+++ b/test/validate/test_monochrome.py
@@ -3,7 +3,15 @@
 import pytest
 from hbutils.testing import tmatrix
 
-from imgutils.validate.monochrome import get_monochrome_score, is_monochrome
+from imgutils.validate.monochrome import get_monochrome_score, is_monochrome, _monochrome_validate_model
+
+
+@pytest.fixture(scope='module', autouse=True)
+def _release_model_after_run():
+    try:
+        yield
+    finally:
+        _monochrome_validate_model.cache_clear()
 
 
 def get_samples():