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

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

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

![grad.png](https://qiita-image-store.s3.amazonaws.com/0/140075/e68e3255-9509-f8c7-2c7d-87eb73a87066.png)　<i class="fa fa-arrow-right" aria-hidden="true"></i>　![grad2.png](https://qiita-image-store.s3.amazonaws.com/0/140075/6aa2cca6-e854-3eb1-08c0-6d02012cd3d0.png)

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

![rect4168.png](https://qiita-image-store.s3.amazonaws.com/0/140075/185c01b3-8960-db52-a221-aeb7bbb3f790.png)

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

# ハーフトーン処理の概要

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

![範囲を選択_025.png](https://qiita-image-store.s3.amazonaws.com/0/140075/10bce776-5b20-9c35-13e8-da94b87402c5.png)

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

![scan2.png](https://qiita-image-store.s3.amazonaws.com/0/140075/15cc3a9f-5e43-dd08-ae61-2bce78f14da8.png)

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

![CMYK拡大.png](https://qiita-image-store.s3.amazonaws.com/0/140075/d9537587-45a7-4991-9bfa-f7bf4927c9e5.png)

# pillowによる実装

Pythonの定番画像ライブラリである「pillow」でハーフトーンを実装します。

## 角度によるスキャン

まず、角度によるスキャンになりますが、これは座標のアフィン変換行列を使えば簡単です。
2次元の回転行列は以下のとおりです。

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

Pythonには行列演算が標準で用意されていませんので、クロージャで行列演算関数を生成します。
このときドット間隔のpitchを受け取り、座標値が1増えると隣のpitchを指す座標系に変換する行列を作ります。
ついでに、pitch座標系から通常座標系に変換する逆行列も返すようにします。

```python
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をスキャンするジェネレータも作成します。
こうしておくと、多重ループがなくなりスッキリと書けるようになります。

```python
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
```

画像をスキャンする関数は以下のとおりになります。

```python
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
```

## ハーフトーン画像を生成する

スキャンした配列からハーフトーン画像を生成します。

```python
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
```

それでは、モノクロのハーフトーン画像を生成してみましょう。

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

![ダウンロード (11).png](https://qiita-image-store.s3.amazonaws.com/0/140075/3a28d979-85ad-a5a3-ce3f-7c0d73473e8b.png)

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

```python
img = ImageOps.invert(img)
```

![ダウンロード (13).png](https://qiita-image-store.s3.amazonaws.com/0/140075/027d5c7f-84a9-cf6d-67c8-7242a84d91cb.png)

rot=45°, pitch=3, dot_radius=2.5でハーフトーン画像を生成します。

```python
# 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](https://qiita-image-store.s3.amazonaws.com/0/140075/8ef9d72d-507b-17f1-f314-5fd80342f8d1.png)

汚い・・・

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

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

![ダウンロード (15).png](https://qiita-image-store.s3.amazonaws.com/0/140075/607fe92c-883d-1969-fb67-a36c708de7a4.png)

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


### <i class="fa fa-pencil-square-o" aria-hidden="true"></i> Cairoを使って最高に綺麗に出力する

[Cairo](https://cairographics.org/pycairo/)はベクター描画ライブラリです、ベクトルで描画するのでかなり綺麗に出力できます。
`dot_image`関数を以下の関数に置き換えるだけで、Cairoを使用した出力ができます。

```python
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)
```

```python
# cairoを使用して出力
dot_image_by_cairo(img.size, dots, 2.5, [1]*3, [0]*3, scale=1.)
```
![ダウンロード (16).png](https://qiita-image-store.s3.amazonaws.com/0/140075/4bc3a5d8-0f4a-49a4-7e63-03eb1772bb03.png)

スーパーサンプリングより高品質になりますが、[pycairoのインストールも大変](https://cairographics.org/download/)ですので、個人的にはスーパーサンプリングで十分だと感じています。

## カラーハーフトーンを生成する

基本的にモノクロの時と一緒です、画像をCMYKに分解して各バンドのハーフトーンを角度をずらして生成し、最後にCMYKとしてバンドをマージするだけです。

以下の例ではシアンを15°、イエローを30°、ブラックを45°、マゼンタを75°として出力しています。

```python
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](https://qiita-image-store.s3.amazonaws.com/0/140075/fb9cf9e7-7014-076a-5f84-265a92b9e5b5.png)

### <i class="fa fa-pencil-square-o" aria-hidden="true"></i> CMYKについて補足

pillowを使用している上では知る必要はありませんが、スマホなどに移植する場合はRGB->CMYK、CMYK->RGBの変換を自身で計算する必要があります。
なお、RGBとCMYKの間には完全な変換式は存在しませんので、あくまで擬似的なものとなります。
以下、RGB->CMYKとCMYK->の一般的な変換式を記載します。

#### RGB->CMYK

```math
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

```math
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追記）

ハーフトーンの生成方法だけというのも寂しいので、ハーフトーンを使用した漫画風フィルターも作ってみました。
サンプル画像のレナさんを漫画風にします。

```python
img = Image.open("lenna.png")
```

![ダウンロード (21).png](https://qiita-image-store.s3.amazonaws.com/0/140075/5e8c6150-88a2-a164-4a55-bc8b23559cc1.png)

モノクロに変換し、イコライズで明暗をハッキリとしてから、黒い部分を明るくします。
絵柄の好みによりますが、影の部分を暗くしすぎてしまうとディティールが失われてしまうので、黒い部分を明るくします。
この辺の調整は、完成図柄のイメージによって味付けをしてください。

```python
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](https://qiita-image-store.s3.amazonaws.com/0/140075/1409217f-cd4b-217a-4ef8-9c548f8fb531.png)

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

```python
q_img = mono_img.quantize(4).convert("L")
q_img = q_img.filter(ImageFilter.ModeFilter(4))
```
![ダウンロード (23).png](https://qiita-image-store.s3.amazonaws.com/0/140075/9bd13254-a6e1-4227-2411-f80b7dbd6359.png)

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

```python
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](https://qiita-image-store.s3.amazonaws.com/0/140075/ceb73d8c-9bf4-9c70-60e9-722cf75f7897.png)

ハーフトーン画像とモノクロ画像をオーバーレイ合成します。
オーバーレイ合成のやり方は[「PIL/Pillowで高速にPhotoShopなどの描画モードを実装する」](http://qiita.com/pashango2/items/3c99489ebccd468ab454)を参照してください。

```python
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](https://qiita-image-store.s3.amazonaws.com/0/140075/c30a49e9-29dc-7f9f-5957-1c512d8457ab.png)

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

```python
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](https://qiita-image-store.s3.amazonaws.com/0/140075/27a1767a-f8f4-fc24-df83-66078cf26ad3.png)

# あとがき

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

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

https://gist.github.com/pashango2/487e5147015655a7fd6db3cbc1c7c833