Pythonの画像ライブラリ「pillow」を使って、画像のカラーハーフトーン処理を実装しました。
写真をアメコミ調に変換したいときに、スクリーントーンのような陰影を再現したいときなどに使います。
ハーフトーンは、アメコミのような画風にしたり、ポップなデザインによく使われる効果です。
カラーハーフトーンはCMYKに色分解し、角度をずらしたハーフトーンを重ねる処理です。
カラーの印刷物のような効果になります。
当初は簡単に実装できるだろうとタカをくくっていたのですが、意外と面倒な処理でした。日本語の技術資料はほとんどなかったため、共有&精度向上のためにメモしておきます。
ハーフトーン処理の概要
ハーフトーンは、画像をある角度で配置された単色ドットでグラデーションを擬似的に表現するものです。
角度45°で、pitchはドットの間隔、rはドットの最大半径とすると、下図のようになります。
ここで問題となるのは角度によるドットの配置です、具体的には元となる画像を回転しスキャンしていきます。
角度を45°とした時のスキャンを表したのが下図となります。
カラーハーフトーンの場合は、CMYKの各バンドに対して角度をずらしながら合成します、シアンを15°、イエローを30°、ブラックを45°、マゼンタを75°とすることが多いそうです。
pillowによる実装
Pythonの定番画像ライブラリである「pillow」でハーフトーンを実装します。
角度によるスキャン
まず、角度によるスキャンになりますが、これは座標のアフィン変換行列を使えば簡単です。
2次元の回転行列は以下のとおりです。
\begin{bmatrix}
cosθ & -sinθ \\
sinθ & cosθ
\end{bmatrix}
\begin{bmatrix}
x\\
y
\end{bmatrix}
Pythonには行列演算が標準で用意されていませんので、クロージャで行列演算関数を生成します。
このときドット間隔のpitchを受け取り、座標値が1増えると隣のpitchを指す座標系に変換する行列を作ります。
ついでに、pitch座標系から通常座標系に変換する逆行列も返すようにします。
def create_mat(_rot, _pitch):
""" pitch座標系にする行列と、通常座標系に戻す逆行列を生成 """
_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
更にスキャン時にxとyの多重ループになりソースが見難くなってしまうので、xとyをスキャンするジェネレータも作成します。
こうしておくと、多重ループがなくなりスッキリと書けるようになります。
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
画像をスキャンする関数は以下のとおりになります。
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.
# バウンディングボックスを計算
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)
# ガウシアンフィルターで平均化する
gmono = img.filter(ImageFilter.GaussianBlur(pitch / 2))
# スキャンを実行し、(x, y, color)の配列を生成する
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
ハーフトーン画像を生成する
スキャンした配列からハーフトーン画像を生成します。
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
それでは、モノクロのハーフトーン画像を生成してみましょう。
img = Image.open("sample.png").convert("L")
モノクロの変換は白い部分をドットで表現するのではなく、黒い部分をドットで表現したいので色反転します。
img = ImageOps.invert(img)
rot=45°, pitch=3, dot_radius=2.5でハーフトーン画像を生成します。
# rot=45°, pitch=3, dot_radius=2.5で変換
dots = halftone(img, 45, 3)
# バックが0xFF, ドットが0x00でハーフトーン画像生成
dot_image(img.size, dots, 2.5, 0xFF, 0x00)
汚い・・・
なぜこんなに汚いかというと、pillowのImageDrawはアンチエリアシングに対応していないためです、ドットバイドットでの描画ではエリアシングが発生します。
pillowでImageDrawのアンチエリアシングしたい場合は、8倍程度のスーパーサンプリングをします。
(出力画像より大きい画像でサンプリングして縮小しエリアシングを軽減する)
# 8倍にスーパーサンプリングして縮小する
dot_image(img.size, dots, 2.5, 0xFF, 0x00, scale=8.).resize(img.size, Image.LANCZOS)
少しモアレが見えますが、十分な品質になったかと思います。
Cairoを使って最高に綺麗に出力する
Cairoはベクター描画ライブラリです、ベクトルで描画するのでかなり綺麗に出力できます。
dot_image
関数を以下の関数に置き換えるだけで、Cairoを使用した出力ができます。
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)
# cairoを使用して出力
dot_image_by_cairo(img.size, dots, 2.5, [1]*3, [0]*3, scale=1.)
スーパーサンプリングより高品質になりますが、pycairoのインストールも大変ですので、個人的にはスーパーサンプリングで十分だと感じています。
カラーハーフトーンを生成する
基本的にモノクロの時と一緒です、画像をCMYKに分解して各バンドのハーフトーンを角度をずらして生成し、最後にCMYKとしてバンドをマージするだけです。
以下の例ではシアンを15°、イエローを30°、ブラックを45°、マゼンタを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")
CMYKについて補足
pillowを使用している上では知る必要はありませんが、スマホなどに移植する場合はRGB->CMYK、CMYK->RGBの変換を自身で計算する必要があります。
なお、RGBとCMYKの間には完全な変換式は存在しませんので、あくまで擬似的なものとなります。
以下、RGB->CMYKと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)
ハーフトーンを使った漫画風フィルター(2017-02-27追記)
ハーフトーンの生成方法だけというのも寂しいので、ハーフトーンを使用した漫画風フィルターも作ってみました。
サンプル画像のレナさんを漫画風にします。
img = Image.open("lenna.png")
モノクロに変換し、イコライズで明暗をハッキリとしてから、黒い部分を明るくします。
絵柄の好みによりますが、影の部分を暗くしすぎてしまうとディティールが失われてしまうので、黒い部分を明るくします。
この辺の調整は、完成図柄のイメージによって味付けをしてください。
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)
4色に減色してトーン部分を作ります、ディテールが細かいと写真っぽさが出てしまいますので、ModeFilterをかけて細かいディテールを潰します。
q_img = mono_img.quantize(4).convert("L")
q_img = q_img.filter(ImageFilter.ModeFilter(4))
これにハーフトーン処理をします。
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)
ハーフトーン画像とモノクロ画像をオーバーレイ合成します。
オーバーレイ合成のやり方は「PIL/Pillowで高速にPhotoShopなどの描画モードを実装する」を参照してください。
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)
あとは、このトーン画像と線画を合成すれば完成です。
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)
あとがき
カラーハーフトーンは、写真の陰影を漫画のスクリーントーンのように変換したり、アメコミのような印刷物の感触を出すときに使ったりと非常に使用範囲が広いフィルターです。
なお、ハーフトーン処理のコード全文はGistに上げています。