dothikoのカクカクワールド2D REBOOT

プログラミングとかLinux関連(特にOSSのグラフィックツール関連)とかレトロゲームとか色々。

potraceラッパースクリプト「mypotrace.py」

はじめに

potraceというプログラムがありまして、これは「ビットマップをsvgなどのベクタ線画画像に変換する」ものなのですが、応用として「鉛筆で描いた絵をペン画風に自動クリンナップする」ような用途にも使えます。

このpotraceは実に素晴らしいプログラムでして、既にinkscapeなどにも同じロジックのものが組み込まれていますが、コマンドラインで呼べることで色々自動化できたりもします。

しかし残念なことに、読み込む画像としてjpgやpngに対応していないなど若干古い制限が有ります。

そこで画像のjpg->ppmの変換をPythonにより自動化し、ImageMagickInkscapeなどと合わせて使うことで、あたかもpotraceが色々対応したかのように見えるスクリプトを作りました。

svgを吐くだけでなく、--renderオプションを使うとpngに自動で変換してくれます。

必要な環境

PythonとpotraceとImageMagickInkscapeが必要です。

Linux系OSでないとそのままでは使えないと思いますが、ソースを手直しすれば使えると思います。CONFIG_FILESの辺りとパス区切り文字、外部プログラム呼び出しなどを修正すれば使えそうかもです。

使い方・動作の説明

いきなり凡例で説明とさせていただきます。

./mypotrace.py --render=outimg.png -i15040302.JPG --scale-width=1440 --transform=turn-right

このようにすると

  • 15040302.JPGを読み込み、テンポラリフォルダでppmに変換
  • そのppmからsvgに変換。--transformで指定された回転はこの時実行される
  • svgを 1440ピクセル幅、高さは自動計算でoutimg.pngに変換

として、outimg.pngを得ることができます。

なお、--blacklevel等のpotrace用オプションはそのままpotrace呼び出し時に使われます。

デフォルト設定ファイルについて

たとえばスキャナの動作が、jpgはカラースキャン、tiffはモノクロスキャンなのでそれぞれ別のブラックレベルがふさわしい…という時の為に、デフォルトのpotraceオプションを設定ファイルに書いておけるようにしてみました。

CONFIG_FILESタプルに設定されたパス、たとえば$HOME/mypotrace.confが存在すれば、それを読み込みます。書式はjsonです。

{ 
  "blacklevel" : "0.79",
  ".jpg" : { "blacklevel" : "0.81" }
}

一番デフォルトのオプションはそのままオプション名をキーにした連想配列で、拡張子ごとの設定は「.拡張子」をキーとして、その中にさらにオプションを書いた連想配列を入れておくという仕組みです。

コード

汚くて長いコードですが勘弁してください。まぁバグバグの予感…

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import os
import subprocess
import glob
import shutil
import time
import random

CONFIG_NAME='mypotrace.conf'
CONFIG_FILES=(  
                "/usr/local/etc/%s" % CONFIG_NAME,
                "%s/.config/%s" % (os.environ["HOME"],CONFIG_NAME),
                "%s/%s" % (os.environ["HOME"],CONFIG_NAME)
                )

def load_configs(potarg):
    """
    config file (myportace.conf) is a json file
    basically,it contains a dictionary of default values of potrace arguments
    such as blacklevel,turdsize,etc.

    and,filetype(of input file) specific configuration can be described
    with exteision named key.

    for example,

    { 
      "blacklevel" : "0.80",
      "longcurve" : "",
      ".jpg" : { "blacklevel" : "0.83" },
      ".tiff" : { "blacklevel" : "0.75" }
    }

    To enable switch-options like longcurve,set empty string as value.
    To disable switch-options,just remove it from line or rename as wrong name. 
    Options which have wrong name for potrace are simply ignored.
    
    config file can be placed at CONFIG_FILE path,
    Only a file which is found first is processed,others are just ignored.
    """
    ftconf={}
    for cf in CONFIG_FILES:
        if os.path.exists(cf):
            import json
            with open(cf,'r') as ifp:
                print('[INFO] loading config file %s.' % cf)
                set_configs(potarg,json.load(ifp),ftconf)

    return ftconf

def set_configs(potarg,conf,ftconf=None):
    """
    to share same processing with filetype-config and
    default-setting-config
    """
    for ck in conf:
        if ck[0]=='.':
            # this must be filetype specific configuration.
            if ftconf!=None:
                ftconf[ck]=conf[ck]
        else:
            try:
                potarg.add(ck,conf[ck])
            except KeyError:
                pass
               #print('[ERROR] setting of %s is not potrace option,so ignored.' % ck)


def get_tempfilename(workdir,head,ext):

    while True:
        tfname="%s/%s-%08x-%08x.%s" % (workdir,head,random.randint(0,pow(2,32)),random.randint(0,pow(2,32)),ext)
        if not os.path.exists(tfname):
            return tfname
        else:
            print('[WARNING] the workfile %s already exists' % tfname)
            time.sleep(0.5)

def get_fnames(ifname):
    indir=os.path.dirname(ifname)
    if indir=='':
        indir=os.path.abspath(os.path.curdir)
    inbase,inext=os.path.splitext(os.path.basename(ifname))
    return (indir,inbase,inext.lower())

class ArgumentGenerator:


    def __init__(self):
        self.args={}
        self.argtable={
                "--blacklevel" : "-k",
                "--turdsize" : "-t",
                "--longcurve" : "-n",
                "--opttolerance" : "-O",
                "--unit" : "-u",
                "--rotate" : "-A"}

        # using argtable to initialize argtable itself
        # for abbr-form of options.
        for ck in self.argtable.keys():
            self.argtable[self.argtable[ck]]=self.argtable[ck]
        

    def get_arg(self):
        ret=[]
        for ck in self.args:

            ret.append(ck)
            if self.args[ck]!='':# current option may not be switch-option
                ret.append(str(self.args[ck]))

        return ret

    def add(self,arg,value):
        if arg[0]==arg[1]=='-':
            self.args[self.argtable[arg]]=value
        else:
            self.args[self.argtable["--%s" % arg]]=value

class DefaultConfigurator:

    def __init__(self):
        pass

class Potconv:

    def __init__(self,workdir,ifname,ofname):
        self.workdir=workdir
        self.ifname=ifname
        self.ofname=ofname

    def process_potrace(self,arg_src,ifname,ofname):

        args=['potrace']
        args+=arg_src.get_arg()
        args.append('-s')
        args.append(ifname)
       #print(args)
        subprocess.check_call(args)
        if ofname!=None:
            indir,inbase,inext=get_fnames(ifname)
            tout="%s/%s.svg" % (indir,inbase)
            print("output vector file %s" % ofname) 
            shutil.move(tout,ofname)





    def execute(self,args):


        indir,inbase,inext=get_fnames(self.ifname)
        if self.ofname==None:
            self.ofname="%s/%s.svg" % (indir,inbase)
       #workfile="%s/%s.bmp" % (workdir,inbase)
        workfile="%s/%s.ppm" % (self.workdir,inbase) # use ppm, to avoid imagemagick bug.


        if os.path.exists(self.ifname):

            if inext == '.bmp':
                self.process_potrace(args,self.ifname,self.ofname)
            else:
                subprocess.check_call(['convert',self.ifname,workfile])

                if inext == '.tif':
                    wbase=os.path.basename(workfile)
                    wbase,wext=os.path.splitext(wbase)
                    lst=glob.glob("%s/%s-*.bmp" % (workdir,wbase))
                    if len(lst)>0:
                       #print lst
                        idx=0
                        for cf in lst:
                            ofname='%s/%s-%d.svg' % (indir,inbase,idx)
                            self.process_potrace(args,cf,self.ofname)
                            idx+=1
                    else:
                        self.process_potrace(args,workfile,self.ofname)

                else:
                    self.process_potrace(args,workfile,self.ofname)

        else:
            print("ERROR:file %s does not exist." % self.ifname)


    def render(self,postprocess_output,scale_factor):#,delete_workfile):
        """
        render a svg file as png with inkscape
        """
        tfname=get_tempfilename(self.workdir,'mptc','png')
       #cmdlst=['inkscape',self.ofname,'-e',postprocess_output]
        cmdlst=['inkscape',self.ofname,'-e',tfname]

        if scale_factor:
            # these lines  needs to place here
            # because dpi scaling needs to assign Inkscape,not Imagemagick
            dpi,scale_width,scale_height,ratio = scale_factor

            if dpi:
                cmdlst.append('-d')
                cmdlst.append(str(dpi))
            elif ratio:
                ratio=float(ratio)
               # we cannot call identify command 
               # because we have not called Inkscape yet
               # so we does not have rendered image yet,
               # no way to know the dimension of rendered image.
            else:
                pass

        subprocess.check_call(cmdlst)

        if scale_factor: 
            # scale_factor check and scale_width/height check can be done at one line,
            # but I split them into two if-conditional lines for furture expansion.

            if scale_width or scale_height: 
                # there is no need to analyse src image file 
                # when using Imagemagick for scaling.
                # so only set width or height to geometry option.
            
                if scale_width:
                    geometry="%sx" % scale_width
                elif scale_height:
                    geometry="x%s" % scale_height

            elif ratio:
                cl=subprocess.check_output( ('identify',tfname) ).split()
                src_width,src_height=[int(x) for x in cl[2].split('x')]
                scale_width=int(src_width*ratio)
                scale_height=int(src_height*ratio)
                geometry="%sx" % scale_width
                print("[INFO] scaling ratio %.4f assigned, so scaled to %dx%d (source image is %dx%d)" % (ratio,scale_width,scale_height,src_width,src_height))
            else:
                print('[ERROR] scale_width and scale_height are missing')
                return False

            # below this line,common processing.
            subprocess.check_call( 
                                     ('convert' ,
                                     tfname,
                                     '-adaptive-resize', geometry,
                                     '-colorspace','rgb','-type','truecolormatte',
                                     postprocess_output
                                     )
                                  )

            # Imagemagick 'convert' command does not delete source(temporary) image
            # so we need delete it now. 
            os.unlink(tfname)

        else:
            # otherwise,simply move(rename) final rendered image.
            shutil.move(tfname,postprocess_output)


if __name__ == '__main__':


    import getopt,sys
    ofname=None
    try:
        shortcmd="hk:o:w:z:t:a:O:u:ni:Ar:d:"
        longcmd=["help",
                 "output=",
                 "input=",
                 "workdir=",
                 "blacklevel=",
                 "turnpolicy=",
                 "turdsize=",
                 "alphamax=",
                 "opttolerance=",
                 "unit=",
                 "longcurve",
                 "rotate=",
                 "transform=",
                 "render=",
                 "scale-dpi",
                 "scale-ratio=",
                 "scale-width=",
                 "scale-height=",]
        opts,args=getopt.getopt(sys.argv[1:],shortcmd,longcmd)
        if len(opts)==0 and len(args)>1:
            firstarg=args[0]
            opts,args=getopt.getopt(sys.argv[2:],shortcmd,longcmd)
            args.append(firstarg)

        if len(args) > 0:
            try:
                ifname=args[0]
                ofname=args[1]
            except IndexError:
                pass

    except getopt.GetoptError,e:
        # something getopterror happen
        print(str(e))
        sys.exit(1)
    except IndexError:
        # no argument assigned 
        pass

    a=ArgumentGenerator()
    postprocess_output=None

    # scale factor is a tuple, which contains
    # (dpi,width to scale,height to scale,ratio of scale)
    # assigning None to this means 'disable scaling'
    scale_factor=None

    workdir='/tmp'
    a.add('--blacklevel',0.81) # setting default blacklevel for potrace
    # NOTE: the higher blacklevel value set,lines become much darker. 

    # after hard-coded default setting,load user config file
    ftconf=load_configs(a) 

    # pre-processing of commandline options
    for cmd,cmdarg in opts:
        if cmd in ('-i','--input'):
            # set source-filetype based configuration
            # prior to commandline option decoding
            fb,ext=os.path.splitext(cmdarg)
            ext=ext.lower()
            if ext in ftconf:
                print("[INFO] filetype %s specific setting is found and applied." % ext)
                set_configs(a,ftconf[ext])

    # and then,commandline option process to overwrite above settings.
    for cmd,cmdarg in opts:

        if cmd=='-h' or cmd=='--help':
           #print(open(sys.argv[0],'r').read())
            import re,os
            ro=re.compile('^\s*#-+#\s*$')

            # getting script file itself filename,
            # to dump some part of sourcecode as help document
            ofname=os.path.realpath(__file__)
            if ofname[-1]=='c':
                ofname=ofname[:-1] # adjust filename when .pyc compiled file executed

            with open(ofname,'rt') as fp:
                cnt=0
                for cl in fp.readlines():
                    if cnt==1:
                        print cl.rstrip()
                    m=ro.search(cl)
                    if m:
                        cnt+=1
            sys.exit(0)
        else:
            try:
                # as a default, incoming arguments are treated as potrace argument.
                # if it is not for potrace,KeyError exception should be raised.
                a.add(cmd,cmdarg)
            except KeyError:
                # Incoming arguments are not for potrace,
                # but it may be mypotrace argument.
    #-----#
    # sample:
    # mypotrace.py --infile=hoge.jpg --render=hoge_1.png --transform=turn-left --scale-width=1280
    
                if cmd=='-o' or cmd in ('--output','--outfile'):
                    # output filename of SVG. 
                    # when missing this option,filename should be generated automatically. 
                    ofname=cmdarg
                elif cmd=='-i' or cmd in ('-i','--input'):
                    # ** NEEDED,MOST IMPORTANT OPTION ** 
                    # source of bitmap file to convert to SVG.
                    ifname=cmdarg
                elif cmd=='-w' or cmd=='--workdir':
                    # you can assign work directory to generate temporary files.
                    # for example,to save SSD usage or use ramdisk for faster operation.
                    workdir=cmdarg
                elif cmd=='--transform':
                    # transform argument to assign mainly rotation,for now. 
                    # the format is 'verb''hyphen''direction/form'
                    # such as --transform=turn-left
                    move,dir=cmdarg.split('-')
                    if move in ('turn','rotate'):
                        if dir == 'left':
                            a.add('--rotate','90')
                        elif dir == 'right':
                            a.add('--rotate','-90')
                        elif dir in ('over','around'):
                            a.add('--rotate','180')
                elif cmd in ('-r','--render'):
                    # rendering output filename of PNG
                    # scanned bitmap -> SVG -> SVG rendered bitmap of PNG
                    # this argument needs Inkscape to be installed.

                    postprocess_output=cmdarg
                    ofname=get_tempfilename(workdir,'mpt','svg')

                elif cmd in ('-d','--scale-dpi'):
                    # -d and --scale-dpi option assigns the scaling size by dpi(dot per inch) 
                    # first of all,scanning dpi considered as 300 dpi in this script.
                    # so,for example,--scale-dpi=75 means
                    # 'scale final output image as 75/300=0.25=quarter size of original'

                    scale_factor=(cmdarg,None,None,None)

                elif cmd == '--scale-width':
                    # --scale-width and --scale-height 
                    # these option needs --render option.
                    # to scale(resize) final rendered bitmap.
                    # scale dimension culculated automatically,
                    # so you can assign only width or height.

                    scale_factor=(None,cmdarg,None,None)

                elif cmd == '--scale-height':
                    scale_factor=(None,None,cmdarg,None)
                elif cmd == '--scale-ratio':
                    # --scale-ratio option assigns the scaling ratio of 
                    # final rendered PNG.
                    # for example,--scale-ratio=0.5 means
                    # scale output PNG as half size(quarter square) of original.
                    scale_factor=(None,None,None,cmdarg)

                    
    #-----#

    p=Potconv(workdir,ifname,ofname)
    p.execute(a)

    if postprocess_output:
        p.render(postprocess_output,scale_factor)