34
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

画像のカラーハーフトーン処理について

Last updated at Posted at 2017-02-26

Pythonの画像ライブラリ「pillow」を使って、画像のカラーハーフトーン処理を実装しました。
写真をアメコミ調に変換したいときに、スクリーントーンのような陰影を再現したいときなどに使います。

ハーフトーンは、アメコミのような画風にしたり、ポップなデザインによく使われる効果です。

grad.png  grad2.png

カラーハーフトーンはCMYKに色分解し、角度をずらしたハーフトーンを重ねる処理です。
カラーの印刷物のような効果になります。

rect4168.png

当初は簡単に実装できるだろうとタカをくくっていたのですが、意外と面倒な処理でした。日本語の技術資料はほとんどなかったため、共有&精度向上のためにメモしておきます。

ハーフトーン処理の概要

ハーフトーンは、画像をある角度で配置された単色ドットでグラデーションを擬似的に表現するものです。
角度45°で、pitchはドットの間隔、rはドットの最大半径とすると、下図のようになります。

範囲を選択_025.png

ここで問題となるのは角度によるドットの配置です、具体的には元となる画像を回転しスキャンしていきます。
角度を45°とした時のスキャンを表したのが下図となります。

scan2.png

カラーハーフトーンの場合は、CMYKの各バンドに対して角度をずらしながら合成します、シアンを15°、イエローを30°、ブラックを45°、マゼンタを75°とすることが多いそうです。

CMYK拡大.png

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")

ダウンロード (11).png

モノクロの変換は白い部分をドットで表現するのではなく、黒い部分をドットで表現したいので色反転します。

img = ImageOps.invert(img)

ダウンロード (13).png

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)

ダウンロード (14).png

汚い・・・

なぜこんなに汚いかというと、pillowのImageDrawはアンチエリアシングに対応していないためです、ドットバイドットでの描画ではエリアシングが発生します。
pillowでImageDrawのアンチエリアシングしたい場合は、8倍程度のスーパーサンプリングをします。
(出力画像より大きい画像でサンプリングして縮小しエリアシングを軽減する)

# 8倍にスーパーサンプリングして縮小する
dot_image(img.size, dots, 2.5, 0xFF, 0x00, scale=8.).resize(img.size, Image.LANCZOS)

ダウンロード (15).png

少しモアレが見えますが、十分な品質になったかと思います。

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.)

ダウンロード (16).png

スーパーサンプリングより高品質になりますが、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")

image22047.png

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")

ダウンロード (21).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)

ダウンロード (22).png

4色に減色してトーン部分を作ります、ディテールが細かいと写真っぽさが出てしまいますので、ModeFilterをかけて細かいディテールを潰します。

q_img = mono_img.quantize(4).convert("L")
q_img = q_img.filter(ImageFilter.ModeFilter(4))

ダウンロード (23).png

これにハーフトーン処理をします。

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)

ダウンロード (24).png

ハーフトーン画像とモノクロ画像をオーバーレイ合成します。
オーバーレイ合成のやり方は「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)

ダウンロード (25).png

あとは、このトーン画像と線画を合成すれば完成です。

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)

ダウンロード (27).png

あとがき

カラーハーフトーンは、写真の陰影を漫画のスクリーントーンのように変換したり、アメコミのような印刷物の感触を出すときに使ったりと非常に使用範囲が広いフィルターです。

なお、ハーフトーン処理のコード全文はGistに上げています。

34
30
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?