画像からクリップアートを切り抜き、ステンシル化する

前回のつづき。

手描き画像を取り込んで着色し、そこからクリップアートのデータを作ることにする。

まず、着色のルールを決めた。ひとつの作業用画像が複数のクリップアートを含むことができることにした。この際、背景以外の色が連続して隣接しているものをひとつのクリップアートとみなそうかと思ったが、そうすると、このようなクリップアートの
f:id:yamatt2:20140215101751p:plain

声の部分だけが別のまとまりとして認識されてしまうので、「切り抜き色」というのを決めて(この場合は濃い灰色)、これを含んで背景色以外のピクセルが隣接しているものをひとつのクリップアートとみなすことにした。

「切り抜き色」は、作業用画像全体の左上のピクセル色、背景色は右上のピクセル色を使うというルールにした。だからどの作業用画像も、左上にちょっとテスト塗りみたいなものが紛れている。
f:id:yamatt2:20140215102157p:plain

背景色のほうは、右上端には何も塗らない、というルールにしておけばよい。

さて、GIMPで塗り作業をしたあとで、pngに一旦落とした画像データを、切り抜き色をつかってバラバラにするというプログラムが必要になった。

pythonとPILモジュールを使って、まずクリップアートを切り抜く仕事をするスクリプトを書いた。不定形の範囲の塗りつぶしに相当する処理を書かないといけなかったので、少し長い。

import sys
from PIL import Image

class ImageClipper(object):

  def __init__(self, imagefile):
    self.load(imagefile)

  def load(self, imagefile):
    self.img = Image.open(imagefile).convert('RGB')
    self.imgbuf = self.img.load()
    self.bcolor = self.img.getpixel((self.img.size[0]-1,0))
    self.ccolor = self.img.getpixel((0,0))
    # dummy
    self.clip_one()

  def extend_sides(self, p):
    limit = self.img.size[0]-1
    lbound, rbound = p[0], p[0]
    while 1:
      if lbound == 0: break
      if self.imgbuf[lbound-1,p[1]] == self.bcolor: break
      lbound -= 1
    while 1:
      if rbound == limit: break
      if self.imgbuf[rbound+1,p[1]] == self.bcolor: break
      rbound += 1
    return (lbound, rbound)

  def scan_ajacent_lines(self, startx, endx, y):
    result = []
    x = startx
    while 1:
      if self.imgbuf[x,y] != self.bcolor:
        b = self.extend_sides((x,y))
        if (b[0], y) not in self.lines:
          result.append((y, b[0], b[1]))
          self.lines[(b[0], y)] = b[1]-b[0]+1
          x = b[1]+1
        else:
          x = b[0] + self.lines[b[0], y]
      else:
        x += 1
      if x > endx: break
    return result

  def fill_region_bcolor(self, r):
    for k in r:
      for i in xrange(r[k]):
        self.imgbuf[k[0]+i, k[1]] = self.bcolor

  def fittable_rect(self, r):
    left, top, right, bottom = (sys.maxint,sys.maxint,0,0)
    for i in r.keys():
      if top > i[1]: top = i[1]
      if bottom < i[1]: bottom = i[1]
      if left > i[0]: left = i[0]
      if right < i[0]+r[i]-1: right = i[0]+r[i]-1
    return (left, top, right, bottom)

  def region_lines(self, point):
    self.lines = dict()
    q = []

    b = self.extend_sides(point)
    q.append((point[1],) + (b[0], b[1]))
    self.lines[(b[0], point[1])] = b[1]-b[0]+1

    while q:
      (ty, lbound, rbound) = q.pop()
      if ty > 0:
        r = self.scan_ajacent_lines(lbound, rbound, ty-1)
        q.extend(r)
      if ty < (self.img.size[1]-1):
        r = self.scan_ajacent_lines(lbound, rbound, ty+1)
        q.extend(r)
    return self.lines

  def clip_image_region(self, r):
    box = self.fittable_rect(r)
    width = box[2]-box[0]+1
    height = box[3]-box[1]+1
    pp = Image.new("RGB", (width, height), self.bcolor)
    px = pp.load()
    for k in r:
      for i in xrange(r[k]):
        c = self.imgbuf[k[0]+i, k[1]]
        if c != self.ccolor:
          px[k[0]+i-box[0], k[1]-box[1]] = c
    return pp

  def clip_one(self):
    for x in xrange(self.img.size[0]):
      for y in xrange(self.img.size[1]):
        if self.imgbuf[x,y] == self.ccolor:
          r = self.region_lines((x, y))
          img = self.clip_image_region(r)
          self.fill_region_bcolor(r)
          return img
    return None

  def clip_generator(self):
    i = self.clip_one()
    while i:
      yield i
      i = self.clip_one()

このImageClipperクラスの使い方は下のよう。

ic = ImageClipper(ファイル名)
for i in ic.clip_generator():
  #iに画像ファイルが入ってくる…

切り抜き色を含んだ画像が、すべてループで取り出せる。一番最初に取り出されるものは、左上端の「切り抜き色指定用」範囲にあたるが、そこは自動的に捨てられる。

下のような画像などが、次々とループの値として得られる。切り抜き色はもう要らないので、背景色に置き換えられる。
f:id:yamatt2:20140215103040p:plain

さて、これをクリップアートとして利用する際には、背景色を透明色にしたいのはまずもちろんのことなのだけど、色の変更もできるようにしたいと思っていた。この場合は輪郭の「黒」と、頭部の「白」をそれぞれ好きな色に置き換えられたら面白い。

だから、できあがったこの画像を、色ごとの版木のようなものに変換して管理することにした。「ステンシル」と名づける。

できあがる予定のステンシルは、こんな感じ。
f:id:yamatt2:20140215103621p:plain

完全二値の画像にして、色ごとの版木データが横につながったものになる。このステンシルには対応する(デフォルトの)着色データと出来上がり予定の画像サイズデータも必要になる。この場合は、([黒、白]、352x303)といった感じ。

ここでは二色だけなのでステンシルが面白くないが、別の例で、下のような着色データは…
f:id:yamatt2:20140215104718p:plain

こんなステンシルデータになる。
f:id:yamatt2:20140215104554p:plain

着色は、境界をきれいにぼやかしてフワフワ塗ると、微妙に違う色が全部別のステンシルになってしまうので、きっかり同一色で塗らなくてはいけない。注意。

ステンシルデータを作るメソッドを、さきのImageClipperクラスに作り足すことにした。

class ImageClipper(object):

  #
  # …すでに書かれたいろいろのメソッド…
  #

  def stencilize(self, pimg):
    px = pimg.load()
    cdir = dict()
    for x in xrange(pimg.size[0]):
      for y in xrange(pimg.size[1]):
        c = px[x,y]
        if c != self.bcolor:
          if c not in cdir:
            cdir[c] = Image.new('1', pimg.size, 0)
          cdir[c].putpixel((x,y), 255)
    colors = cdir.keys()
    colors.sort()
    stencil = Image.new('1', (pimg.size[0]*len(colors), pimg.size[1]), 0)
    for (i, c) in enumerate(colors):
      stencil.paste(cdir[c], (pimg.size[0]*i, 0))
    return {'colors':colors, 'stencil':stencil}

このメソッドに画像データを放り込むと、ステンシル画像と、それの(デフォルトの)着色データを辞書の形式で返してくれる。

今まで書いたものをどうやってまとめるかは別に書かないが、これで「手描き画像」→「ステンシルデータ」までのデータ処理をするための一連のプログラム類を完成させた。

次はステンシルを実際のクリップアート画像に直す処理を書かなくてはいけないが、記事を分ける。