[PYTHON] About color halftone processing of images

I implemented the color halftone processing of the image using the image library "pillow" of Python. Use this when you want to convert a photo to an American comic style, or when you want to reproduce shadows such as screen tones.

Halftone is an effect that is often used for comics-like styles and pop designs.

grad.png <iclass="fafa-arrow-right"aria-hidden="true"> grad2.png

Color halftone is a process that separates colors into CMYK and superimposes halftones with different angles. The effect is similar to color printed matter.

rect4168.png

At first, I was hoping that it would be easy to implement, but it was a surprisingly troublesome process. There were few technical documents in Japanese, so I will make a note for sharing and improving accuracy.

Overview of halftone processing

Halftone is a pseudo-gradation representation of an image with monochromatic dots arranged at an angle. Assuming that the angle is 45 °, pitch is the dot spacing, and r is the maximum radius of the dots, the result is as shown in the figure below.

範囲を選択_025.png

The problem here is the arrangement of dots according to the angle. Specifically, the original image is rotated and scanned. The figure below shows the scan when the angle is 45 °.

scan2.png

In the case of color halftone, it is synthesized while shifting the angle for each band of CMYK. Cyan is often set to 15 °, yellow is set to 30 °, black is set to 45 °, and magenta is set to 75 °.

CMYK拡大.png

Implementation by pillow

Implement halftone with "pillow" which is a standard image library of Python.

Scan by angle

The first is an angle scan, which is easy with the affine transformation matrix of coordinates. The two-dimensional rotation matrix is as follows.

\begin{bmatrix}
cosθ & -sinθ \\
sinθ & cosθ
\end{bmatrix}
\begin{bmatrix}
x\\
y
\end{bmatrix}

Matrix operations are not provided as standard in Python, so use closures to generate matrix operations functions. At this time, the pitch of the dot interval is received, and when the coordinate value increases by 1, a matrix is created that converts it to the coordinate system pointing to the adjacent pitch. By the way, it also returns the inverse matrix that converts from the pitch coordinate system to the normal coordinate system.

def create_mat(_rot, _pitch):
    """Generate a matrix to be in the pitch coordinate system and an inverse matrix to return to the normal coordinate system"""
    _pi = math.pi * (_rot / 180.)
    _scale = 1.0 / _pitch

    def _mul(x, y):
        return (
            (x * math.cos(_pi) - y * math.sin(_pi)) * _scale,
            (x * math.sin(_pi) + y * math.cos(_pi)) * _scale,
        )

    def _imul(x, y):
        return (
            (x * math.cos(_pi) + y * math.sin(_pi)) * _pitch,
            (x * -math.sin(_pi) + y * math.cos(_pi)) * _pitch,
        )

    return _mul, _imul

In addition, since the source becomes difficult to see due to the multiple loop of x and y at the time of scanning, we will also create a generator that scans x and y. By doing this, multiple loops will be eliminated and you will be able to write clearly.

def x_y_iter(w, h, sx, ex, sy, ey):
    fw, fh = float(w), float(h)
    for y in range(h + 1):
        ty = y / fh
        yy = (1. - ty) * sy + ty * ey
        for x in range(w + 1):
            tx = x / fw
            xx = (1. - tx) * sx + tx * ex
            yield xx, yy

The function to scan the image is as follows.

def halftone(img, rot, pitch):
    mat, imat = create_mat(rot, pitch)
    
    w_half = img.size[0] // 2
    h_half = img.size[1] // 2
    pitch_2 = pitch / 2.
    
    #Calculate bounding box
    bounding_rect = [
        (-w_half - pitch_2, -h_half - pitch_2),
        (-w_half - pitch_2, h_half + pitch_2),
    ]
    x, y = zip(*[mat(x, y) for x, y in bounding_rect])
    w, h = max(abs(t) for t in x), max(abs(t) for t in y)
    
    #Average with Gaussian filter
    gmono = img.filter(ImageFilter.GaussianBlur(pitch / 2))
    
    #Run a scan,(x, y, color)Generate an array of
    dots = []
    for x, y in x_y_iter(int(w * 2) + 1, int(h * 2) - 1, -w, w, -h + 1., h - 1.):
        x, y = imat(x, y)
        x += w_half
        y += h_half
        if -pitch_2 < x < img.size[0] + pitch_2 and -pitch_2 < y < img.size[1] + pitch_2:
            color = gmono.getpixel((
                min(max(x, 0), img.size[0]-1),
                min(max(y, 0), img.size[1]-1)
            ))
            t = pitch_2 * (1.0 - (color / 255))
            dots.append((x, y, color))
    return dots

Generate a halftone image

Generates a halftone image from the scanned array.

def dot_image(size, dots, dot_radius, base_color=0, dot_color=0xFF, scale=1.0):
    img = Image.new("L", tuple(int(x * scale) for x in size), base_color)
    draw = ImageDraw.Draw(img)

    for x, y, color in dots:
        t = dot_radius * (color / 255) * scale
        x *= scale
        y *= scale
        draw.ellipse((x - t, y - t, x + t, y + t), dot_color)
    
    return img

Now let's generate a monochrome halftone image.

img = Image.open("sample.png ").convert("L")

ダウンロード (11).png

In monochrome conversion, we want to express the black part with dots instead of expressing the white part with dots, so the color is inverted.

img = ImageOps.invert(img)

ダウンロード (13).png

Generate a halftone image with rot = 45 °, pitch = 3, dot_radius = 2.5.

# rot=45°, pitch=3, dot_radius=2.Convert with 5
dots = halftone(img, 45, 3)

#Back is 0xFF,Halftone image generation with 0x00 dots
dot_image(img.size, dots, 2.5, 0xFF, 0x00)

ダウンロード (14).png

dirty···

The reason why it is so dirty is that pillow's ImageDraw does not support anti-aliasing, so dot-by-dot drawing causes aliasing. If you want to anti-aliasing ImageDraw with pillow, do supersampling about 8 times. (Sampling an image larger than the output image and reducing it to reduce aliasing)

#Supersample 8 times and reduce
dot_image(img.size, dots, 2.5, 0xFF, 0x00, scale=8.).resize(img.size, Image.LANCZOS)

ダウンロード (15).png

You can see a little moire, but I think the quality is sufficient.