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.
<iclass="fafa-arrow-right"aria-hidden="true">
Color halftone is a process that separates colors into CMYK and superimposes halftones with different angles. The effect is similar to color printed matter.
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.
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.
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 °.
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 °.
Implement halftone with "pillow" which is a standard image library of Python.
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
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")
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)
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)
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)
You can see a little moire, but I think the quality is sufficient.
Cairo is a vector drawing library, it draws with a vector, so it can output quite beautifully.
You can output using Cairo just by replacing the dot_image
function with the following function.
def dot_image_by_cairo(size, dots, dot_radius, base_color=(0, 0, 0), dot_color=(1., 1., 1.), scale=1.0):
import cairo
w, h = tuple(int(x * scale) for x in img.size)
surface = cairo.ImageSurface(cairo.FORMAT_RGB24, w, h)
ctx = cairo.Context(surface)
ctx.set_source_rgb(*base_color)
ctx.rectangle(0, 0, w, h)
ctx.fill()
for x, y, color in dots:
fcolor = color / 255.
t = dot_radius * fcolor * scale
ctx.set_source_rgb(*dot_color)
ctx.arc(x, y, t, 0, 2 * math.pi)
ctx.fill()
return Image.frombuffer("RGBA", img.size, surface.get_data(), "raw", "RGBA", 0, 1)
#Output using cairo
dot_image_by_cairo(img.size, dots, 2.5, [1]*3, [0]*3, scale=1.)
It's better than supersampling, but pycairo is hard to install, so I personally feel that supersampling is enough.
It's basically the same as in monochrome, just decompose the image into CMYK, generate halftones for each band at different angles, and finally merge the bands as CMYK.
In the example below, cyan is output as 15 °, yellow as 30 °, black as 45 °, and magenta as 75 °.
cmyk = img.convert("CMYK")
c, m, y, k = cmyk.split()
cdots = halftone(c, 15, 3)
mdots = halftone(m, 75, 3)
ydots = halftone(y, 35, 3)
kdots = halftone(k, 45, 3)
nc = dot_image(img.size, cdots, 2.5, scale=3.)
nm = dot_image(img.size, mdots, 2.5, scale=3.)
ny = dot_image(img.size, ydots, 2.5, scale=3.)
nk = dot_image(img.size, kdots, 2.5, scale=3.)
Image.merge("CMYK", [nc, nm, ny, nk]).convert("RGB")
You don't need to know when using pillow, but when porting to a smartphone etc., you need to calculate the RGB-> CMYK, CMYK-> RGB conversion by yourself. Note that there is no perfect conversion formula between RGB and CMYK, so it is just a pseudo one. The following is a general conversion formula for RGB-> CMYK and CMYK->.
RGB->CMYK
C=(1-R-K)/(1-K)\\
M=(1-G-K)/(1-K)\\
Y=(1-B-K)/(1-K)\\
K=min(1-R,1-G,1-B)
CMYK->RGB
R=1-min(1,C*(1-K)+K)\\
G=1-min(1,M*(1-K)+K)\\
B=1-min(1,Y*(1-K)+K)
It's lonely to just generate halftones, so I made a cartoon-style filter that uses halftones. Make Lena in the sample image look like a cartoon.
img = Image.open("lenna.png ")
Convert to monochrome, make the light and dark clear by equalizing, and then brighten the black part. Depending on the taste of the pattern, if you make the shadow part too dark, the details will be lost, so make the black part brighter. Please season the adjustment of this area according to the image of the completed design.
mono_img = img.convert("L")
mono_img = ImageOps.equalize(mono_img)
mono_img = mono_img.point(lambda x: x + 30 if x < 100 else x)
Colors are reduced to 4 colors to create a tone part. If the details are fine, it will look like a photo, so apply ModeFilter to crush the fine details.
q_img = mono_img.quantize(4).convert("L")
q_img = q_img.filter(ImageFilter.ModeFilter(4))
Halftone processing is applied to this.
dots = halftone(ImageOps.invert(q_img), 45, 4)
dot_img = dot_image(q_img.size, dots, 2, 0xFF, 0x00, scale=8).resize(q_img.size, Image.LANCZOS)
Overlay composites of halftone and monochrome images. Please refer to "Implementing drawing modes such as PhotoShop at high speed with PIL / Pillow" for the method of overlay composition.
from PIL import ImageMath
def _over_lay(a, b):
_cl = 2 * a * b / 255
_ch = 2 * (a + b - a * b / 255) - 255
return _cl * (a < 128) + _ch * (a >= 128)
def over_lay(img1, img2):
eval_str = "func(float(a), float(b))"
return ImageMath.eval(eval_str, func=_over_lay, a=img1, b=img2).convert("L")
tone_img = over_lay(q_img, dot_img)
The rest is completed by synthesizing this tone image and line art.
gray = img.convert("L")
gray2 = gray.filter(ImageFilter.MaxFilter(5))
line_inv = ImageChops.difference(gray, gray2)
line_img = ImageOps.invert(line_inv)
ImageChops.multiply(tone_img, line_img)
Color halftone is a very versatile filter that can be used to convert the shadows of a photo like a cartoon screen tone, or to give the feel of printed matter such as American comics.
The full code for halftone processing is posted on Gist.
https://gist.github.com/pashango2/487e5147015655a7fd6db3cbc1c7c833
Recommended Posts