Repository: morphocut/morphocut (language: python)

Issue:
ImageCaption
In `pipeline/object_scale.py`, there is `ObjectScale`, the last remnant of the initial MorphoCut version. This needs to be implemented in the `image` module. Instead of `cv2.putText`, [PIL](https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html) should be used. (Because Pillow is already a dependency and OpenCV is not.)


Discussion / hints:
Hey, can I get this issue assigned to me??
Of cause! Just fork the project and create a pull request. Let me know if you run into any issues.
is this issue resolved ?

Not at all.

(Where is all the attention coming from all of a sudden?)

Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ba6aed1..1a0463c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -23,6 +23,8 @@ Added
 
 - Added support for Python 3.9 and 3.10 (#87).
 
+- Added `scalebar`: Append scalebars to images. (#113)
+
 Changed
 ~~~~~~~
 
diff --git a/docs/conf.py b/docs/conf.py
index a2c55da..999d61c 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -43,6 +43,10 @@
     "sphinx.ext.autosummary",
 ]
 
+# Mock modules so that they don't need to be available
+# just to generate the documentstion.
+autodoc_mock_imports = ["matplotlib"]
+
 napoleon_use_param = False
 napoleon_use_keyword = False
 napoleon_use_rtype = False
diff --git a/docs/index.rst b/docs/index.rst
index 6d53de5..88bf543 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -31,6 +31,7 @@ User Guide
    batch
    filters
    stat
+   scalebar
    parallel
    formats
    profiling
diff --git a/docs/scalebar.rst b/docs/scalebar.rst
new file mode 100644
index 0000000..d230606
--- /dev/null
+++ b/docs/scalebar.rst
@@ -0,0 +1,7 @@
+Scalebar
+========
+
+This module provides functionality for drawing and adding a scalebar to an image.
+
+.. automodule:: morphocut.scalebar
+    :members:
diff --git a/setup.py b/setup.py
index 0866d17..79dbadb 100644
--- a/setup.py
+++ b/setup.py
@@ -37,6 +37,7 @@
             "codecov",
             # Optional dependencies
             "parse",
+            "matplotlib",  # For FontManager in scalebar
         ],
         "docs": [
             "sphinx ~= 2.2",
diff --git a/src/morphocut/pipeline/__init__.py b/src/morphocut/pipeline/__init__.py
deleted file mode 100644
index b9f4203..0000000
--- a/src/morphocut/pipeline/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""
-Processing nodes are generators.
-"""
-
-from morphocut.pipeline.object_scale import ObjectScale
diff --git a/src/morphocut/pipeline/base.py b/src/morphocut/pipeline/base.py
deleted file mode 100644
index e26215c..0000000
--- a/src/morphocut/pipeline/base.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from abc import abstractmethod, ABC
-
-__all__ = ["NodeBase", "SimpleNodeBase"]
-
-
-class NodeBase(ABC):
-    """
-    Base class for all pipeline nodes.
-    """
-    @abstractmethod
-    def __call__(self, input=None):  # pragma: no cover
-        """
-        Process the input stream
-        """
-        while False:
-            yield None
-
-    # @abstractmethod
-    # def get_scheme(self):
-    #     """
-    #     Get the Node scheme
-    #     """
-    #     return {'fields': []}
-
-
-class SimpleNodeBase(NodeBase):
-    """
-    Base class for simple pipeline nodes that operate on each object individually.
-    """
-
-    def __init__(self, input_facet, output_facet):
-        self.input_facet = input_facet
-        self.output_facet = output_facet
-
-    def __call__(self, input=None):
-        for obj in input:
-            obj["facets"][self.output_facet] = self.process(
-                obj["facets"][self.input_facet])
-            yield obj
-
-    @abstractmethod
-    def process(self, facet):
-        """
-        Process the facet and return a new one.
-        """
-        pass
diff --git a/src/morphocut/pipeline/object_scale.py b/src/morphocut/pipeline/object_scale.py
deleted file mode 100644
index d1e1598..0000000
--- a/src/morphocut/pipeline/object_scale.py
+++ /dev/null
@@ -1,81 +0,0 @@
-import numpy as np
-import math
-import cv2
-
-from morphocut.pipeline import NodeBase
-
-
-class ObjectScale(NodeBase):
-    '''
-    Parameters
-    ----------
-    input_facets : list
-        The facets onto which a scale should be drawn.
-    output_facets : list
-        The facets where the outputs should be saved. This corresponds to the input_facets element-wise.
-    pixels_per_mm : int
-        The number of pixels per milimeter in the image.
-    scale_size : float
-        The size of the scale that should be drawn in milimeter.
-    '''
-
-    def __init__(self, input_facets, output_facets, pixels_per_mm=200, scale_size=0.1):
-        self.scale_size = scale_size
-        self.pixels_per_mm = pixels_per_mm
-        self.input_facets = input_facets
-        self.output_facets = output_facets
-        if not len(self.input_facets) == len(self.output_facets):
-            raise ValueError(
-                "The number of input facets and output facets must be equal.")
-
-    def __call__(self, input=None):
-        for obj in input:
-            for i in range(len(self.input_facets)):
-                input_facet = self.input_facets[i]
-                output_facet = self.output_facets[i]
-
-                obj["facets"][output_facet] = self.process(
-                    obj["facets"][input_facet])
-            yield obj
-
-    def process(self, facet):
-        image = facet["image"]
-
-        width = image.shape[1]
-        scale_x = 5
-
-        scale_width = int(self.pixels_per_mm * self.scale_size)
-        scale_y = -5
-
-        complete_width = max(width, scale_x + scale_width + 1)
-
-        # pad image if scale is too big
-        if (complete_width > width):
-            height = image.shape[0]
-            pad_width = math.ceil((complete_width - width) / 2)
-            pad_array = np.ones((height, pad_width, 3), dtype=np.int8) * 255
-            pad_array_copy = np.ones(
-                (height, pad_width, 3), dtype=np.int8) * 255
-            image = np.append(pad_array_copy, image, axis=1)
-            image = np.append(image, pad_array, axis=1)
-
-        width = image.shape[1]
-
-        pad_array = np.ones((31, width, 3), dtype=np.int8) * 255
-        image = np.append(image, pad_array, axis=0)
-
-        # draw scale line
-        image[scale_y, scale_x:scale_x
-              + scale_width] = np.zeros(3, dtype=np.int8)
-        # draw scale line hooks
-        image[scale_y - 3:scale_y, scale_x] = np.zeros(3, dtype=np.int8)
-        image[scale_y - 3:scale_y, scale_x
-              + (scale_width - 1)] = np.zeros(3, dtype=np.int8)
-        # print(image)
-
-        cv2.putText(image, '{}mm'.format(self.scale_size), (5, (image.shape[0] + scale_y) - 5), cv2.FONT_HERSHEY_SIMPLEX,
-                    0.25, (0, 0, 0), lineType=cv2.LINE_AA)
-
-        return {
-            "image": image
-        }
diff --git a/src/morphocut/scalebar.py b/src/morphocut/scalebar.py
new file mode 100644
index 0000000..f4d610b
--- /dev/null
+++ b/src/morphocut/scalebar.py
@@ -0,0 +1,146 @@
+from functools import lru_cache
+
+import matplotlib.font_manager
+import numpy as np
+import PIL.Image
+import PIL.ImageDraw
+import PIL.ImageFont
+
+from morphocut.core import Node, Output, RawOrVariable, ReturnOutputs
+
+
+@lru_cache(128)
+def draw_scalebar(
+    length_unit,
+    px_per_unit=1,
+    unit="px",
+    mode="L",
+    fg_color=0,
+    bg_color=255,
+    font_family="sans",
+    margin=10,
+):
+    """
+    Draw a scalebar image of specified length and units.
+
+    Args:
+        length_unit: The length of the scalebar in the specified unit.
+        px_per_unit: The ratio of pixels to units for the scalebar.
+        unit: The unit of length for the scalebar.
+        mode: The color mode to use for the PIL image.
+        fg_color: The color to use for the scalebar and text.
+        bg_color: The color to use for the background.
+        font_family: The font family to use for the scalebar text.
+        margin: The margin to use around the scalebar.
+
+    Returns:
+        A numpy array representing the image of the scalebar.
+    """
+
+    length_px = round(length_unit * px_per_unit)
+
+    h = 32
+    w = length_px + 2 * margin
+    img = PIL.Image.new(mode, (w, h), bg_color)
+
+    font_fn = matplotlib.font_manager.FontManager().findfont(font_family)
+    fnt = PIL.ImageFont.truetype(font_fn, 12)
+    d = PIL.ImageDraw.Draw(img)
+
+    d.text((10, 5), f"{length_unit:.0f}{unit}", font=fnt, fill=fg_color)
+
+    d.line(
+        [
+            (margin, 28),
+            (margin, 25),
+            (margin + length_px, 25),
+            (margin + length_px, 28),
+        ],
+        fill=fg_color,
+    )
+
+    return np.asarray(img)
+
+
+@ReturnOutputs
+@Output("image")
+class DrawScalebar(Node):
+    """
+    Append a scalebar to an image.
+
+    Args:
+        image: The image to append the scalebar to.
+        length_unit: The length of the scalebar in the specified unit.
+        px_per_unit: The ratio of pixels to units for the scalebar.
+        unit: The unit of length for the scalebar.
+        fg_color: The color to use for the scalebar and text.
+        bg_color: The color to use for the background.
+        font_family: The font family to use for the scalebar text.
+        margin: The margin to use around the scalebar.
+    """
+
+    def __init__(
+        self,
+        image: RawOrVariable[np.ndarray],
+        length_unit,
+        px_per_unit=1,
+        unit="px",
+        fg_color=0,
+        bg_color=255,
+        font_family="sans",
+        margin=10,
+    ):
+        super().__init__()
+
+        self.image = image
+
+        self.length_unit = length_unit
+        self.px_per_unit = px_per_unit
+        self.unit = unit
+        self.fg_color = fg_color
+        self.bg_color = bg_color
+        self.font_family = font_family
+        self.margin = margin
+
+    def transform(
+        self,
+        image: np.ndarray,
+        length_unit,
+        px_per_unit,
+        unit,
+        fg_color,
+        bg_color,
+        font_family,
+        margin,
+    ):
+        # TODO: Convert colors (see image.py)
+
+        # Calculate an alpha-mask for the scalebar
+        scalebar = draw_scalebar(
+            length_unit=length_unit,
+            px_per_unit=px_per_unit,
+            unit=unit,
+            mode="F",
+            fg_color=1,
+            bg_color=0,
+            font_family=font_family,
+            margin=margin,
+        )
+
+        # Construct canvas that can contain the image and the scalebar
+        cheight = image.shape[0] + scalebar.shape[0]
+        cwidth = max(image.shape[1], scalebar.shape[1])
+        canvas = np.full(
+            (cheight, cwidth) + image.shape[2:], bg_color, dtype=image.dtype
+        )
+
+        # Paste image (centered)
+        offs = (cwidth - image.shape[1]) // 2
+        canvas[: image.shape[0], offs : offs + image.shape[1]] = image
+
+        # Paste scalebar (aligned left)
+        canvas[
+            image.shape[0] : image.shape[0] + scalebar.shape[0], : scalebar.shape[1]
+        ] = (scalebar * fg_color) + (1 - scalebar) * bg_color
+
+        return canvas


Test patch:
diff --git a/tests/test_scalebar.py b/tests/test_scalebar.py
new file mode 100644
index 0000000..8602564
--- /dev/null
+++ b/tests/test_scalebar.py
@@ -0,0 +1,47 @@
+import numpy as np
+import pytest
+from morphocut import Node, Pipeline
+from morphocut.stream import Unpack
+from morphocut.scalebar import DrawScalebar, draw_scalebar
+
+
+@pytest.fixture
+def test_image():
+    return np.zeros((100, 100), dtype=np.uint8)  # Grayscale image
+
+
+@pytest.mark.parametrize("length_unit, px_per_unit", [
+    (100, 1),
+    (200, 2),
+    (50, 0.5),
+])
+def test_draw_scalebar_function(length_unit, px_per_unit):
+    result = draw_scalebar(length_unit, px_per_unit=px_per_unit)
+
+    expected_width = int((length_unit * px_per_unit) + 20)  # 10 margin on each side
+
+    assert isinstance(result, np.ndarray)
+    assert result.shape[0] == 32
+    assert result.shape[1] == expected_width
+
+
+def test_DrawScalebar_pipeline(test_image):
+    length_unit = 100
+
+    with Pipeline() as pipeline:
+        Unpack([test_image])
+        DrawScalebar(image=test_image, length_unit=length_unit)
+
+    # Execute the pipeline
+    objects = list(pipeline.transform_stream())
+
+    # Check if the output image has the expected properties
+    assert len(objects) == 1
+
+    # Access the output data
+    output_data = objects[0].data
+    print(output_data.keys())
+
+    # Retrieve one of the images (using the first key in the dict)
+    result_image = output_data[next(iter(output_data.keys()))]
+