Morphological operations reshape the white foreground regions in a binary image mask. In OpenCV, they are useful after thresholding or color masking when small specks, thin gaps, or tiny holes would confuse contour detection, measurement, or later segmentation work.

OpenCV exposes cv2.erode() and cv2.dilate() for the primitive operations, while cv2.morphologyEx() applies combined operations such as opening and closing. Opening erodes and then dilates, which removes small white foreground noise. Closing dilates and then erodes, which fills small black gaps inside the foreground.

The script treats every nonzero input pixel as white foreground, applies one selected operation, writes a new mask, and prints foreground counts before and after the change. Start from a mask where the object or region to keep is white and the background is black; invert the threshold first when the source mask has the opposite polarity.

Steps to apply morphology to an image with OpenCV:

  1. Place the binary mask at input/mask.png.

    The mask should use 0 for background and 255 for foreground. Use cv2.THRESH_BINARY_INV or invert the mask first when the object is black on a white background.

  2. Create apply_morphology.py.
    apply_morphology.py
    #!/usr/bin/env python3
    import argparse
    from pathlib import Path
     
    import cv2
    import numpy as np
     
     
    def odd_positive(value: str) -> int:
        size = int(value)
        if size < 1 or size % 2 == 0:
            raise argparse.ArgumentTypeError("kernel size must be a positive odd integer")
        return size
     
     
    parser = argparse.ArgumentParser(description="Apply an OpenCV morphology operation to a binary mask.")
    parser.add_argument("input_mask", type=Path)
    parser.add_argument("output_mask", type=Path)
    parser.add_argument("--operation", choices=("erode", "dilate", "open", "close"), default="open")
    parser.add_argument("--kernel-size", type=odd_positive, default=5)
    parser.add_argument("--kernel-shape", choices=("rect", "ellipse", "cross"), default="ellipse")
    parser.add_argument("--iterations", type=int, default=1)
    args = parser.parse_args()
     
    gray = cv2.imread(str(args.input_mask), cv2.IMREAD_GRAYSCALE)
    if gray is None:
        raise SystemExit(f"could not read mask: {args.input_mask}")
     
    binary = np.where(gray > 0, 255, 0).astype(np.uint8)
     
    shape_map = {
        "rect": cv2.MORPH_RECT,
        "ellipse": cv2.MORPH_ELLIPSE,
        "cross": cv2.MORPH_CROSS,
    }
    kernel = cv2.getStructuringElement(
        shape_map[args.kernel_shape],
        (args.kernel_size, args.kernel_size),
    )
     
    if args.operation == "erode":
        result = cv2.erode(binary, kernel, iterations=args.iterations)
    elif args.operation == "dilate":
        result = cv2.dilate(binary, kernel, iterations=args.iterations)
    else:
        operation_map = {
            "open": cv2.MORPH_OPEN,
            "close": cv2.MORPH_CLOSE,
        }
        result = cv2.morphologyEx(
            binary,
            operation_map[args.operation],
            kernel,
            iterations=args.iterations,
        )
     
    args.output_mask.parent.mkdir(parents=True, exist_ok=True)
    if not cv2.imwrite(str(args.output_mask), result):
        raise SystemExit(f"could not write mask: {args.output_mask}")
     
    foreground_before = int(np.count_nonzero(binary == 255))
    foreground_after = int(np.count_nonzero(result == 255))
    changed_pixels = int(np.count_nonzero(binary != result))
    total_pixels = int(binary.size)
    unique_values = " ".join(str(int(value)) for value in np.unique(result))
     
    print(f"operation: {args.operation}")
    print(f"kernel: {args.kernel_shape} {args.kernel_size}x{args.kernel_size}")
    print(f"iterations: {args.iterations}")
    print(f"foreground before: {foreground_before} / {total_pixels} ({foreground_before / total_pixels * 100:.2f}%)")
    print(f"foreground after: {foreground_after} / {total_pixels} ({foreground_after / total_pixels * 100:.2f}%)")
    print(f"changed pixels: {changed_pixels}")
    print(f"unique values: {unique_values}")
    print(f"output: {args.output_mask}")
  3. Run an opening operation to remove small foreground specks.
    $ python3 apply_morphology.py input/mask.png output/mask-open.png --operation open --kernel-size 7
    operation: open
    kernel: ellipse 7x7
    iterations: 1
    foreground before: 69375 / 345600 (20.07%)
    foreground after: 69115 / 345600 (20.00%)
    changed pixels: 260
    unique values: 0 255
    output: output/mask-open.png

    Opening removes foreground components that are smaller than the structuring element. Increase --kernel-size only when the noise is larger than the details that should remain.

  4. Run a closing operation and verify that the saved mask remains binary.
    $ python3 apply_morphology.py input/mask.png output/mask-close.png --operation close --kernel-size 11
    operation: close
    kernel: ellipse 11x11
    iterations: 1
    foreground before: 69375 / 345600 (20.07%)
    foreground after: 69637 / 345600 (20.15%)
    changed pixels: 262
    unique values: 0 255
    output: output/mask-close.png

    Closing adds foreground pixels where dilation can bridge a gap and erosion can restore the outer boundary. Use a smaller kernel when separate objects start merging. Use --operation erode or --operation dilate only when shrinking or expanding the foreground is the intended result.