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

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

mypaint 1.1のセーブを高速化してみる

githubをあたるとmypaint 1.2はものすごく進化しており全然違うので、この記事は適用できません。 あくまでubuntu 14.04デフォルトのmypaint 1.1についてであります。

さて、mypaintで悩みの種だったのがoraファイルのセーブの遅さ。以前から気になってはいましたが… 4000x3000ぐらいある画像でレイヤが10枚程度あると、セーブに4.5秒ほどかかってしまいます。

別にガマンできない速度ではないのですが、さほど描き込んでもいないのに、例えば何か描き忘れていて、ちょいと描点程度修正しただけの上書き保存で4.5秒はストレスが貯まる状況です。

そこで、いろいろ改造して激速化の目処がついてきたので、一応ここに記しておきます。

まぁ、どんなバグが潜んでいるかわからないので、お遊び以外でこの改造はすべきではないです…大事な絵がぶっ壊されてしまうかもしれません。

改造メモ

ここで採った高速化の方法は簡単。

mypaintでは、oraファイルを読み込む・書き込むときに一時フォルダを作成してそこにレイヤごとのpngを保存。そして読み込みはそれぞれレイヤとしてpngを読み込み、 保存時はそれをzipfileに渡して圧縮・リネームしています。その後、一時フォルダごと消去するので、保存ごとに毎回全てのpngファイルが生成されます。

ここでの案は、単にoraファイルを展開した時の画像を消去せずそのまま保持しておくのです。

そして「更新があったレイヤだけ」png化して保存するというわけです。

ここで問題になるのはora内部のpngファイル名が連番で保存されていること。 レイヤが消去されたり順番が変更された時、面倒なことになります。

そこで連番ではなく16bitのランダム値に変更しました。ケチらず32bitで良かった気がしてます…

ただ、この「更新があったレイヤ」というのが実に問題で、ソースをあたってみましたがどうも、「レイヤ内で描画されたタイルがいくつあるか」をpython側から取得する方法がないようなのですね。*1

それがわかれば、ペイントツール*2を実行された時にタイル数が>0ならばdirtyと言えるのですが。

もうひとつ問題なのは、mypaintでは何もしないマウスカーソル移動もストロークとして認識することです。ですから、厳密にやるならどうしてもタイル数の取得が必要なのですね。

libmypaintの方も調べた結果、TiledSurfaceの保有する実際のタイル処理オブジェクト、operation_queue自体はdirty_tilesという概念があります。

そして、mypaint_tiled_surface_end_atomic()の中でまさにdirtyなタイルを検出して描画反映を実行しています。 ですから、mypaint_tiled_surface_end_atomicの返り値がvoidなのをintにして、return tiles_n;するだけで、オイラの目的は果たせそうな感じなのですが…

いずれ試してみたく思います。

あとはundo/redoが実行されたらそれもdirtyと言えるでしょうか。undoスタックが切れたことを考えると、undoを全部さかのぼった時点でdirty解除はいかんですよね。まぁ、undoスタックが一度でもいっぱいになったらそのフラグを立てればいいのか…

ともかく、現時点では方法がないようなので簡易的にlayer.stroke_to()が呼び出された時点でそのレイヤにdirtyフラグを立て、dirtyフラグが立っていればpng保存を実行。立っていなければ以前のファイルを再利用。 保存・読み込み直後にdirtyフラグをクリア、という方法にしました。 まずこの状態で安全に動作するように改造したいところです。 dirtyの厳密な検出は次のステージと。*3

一時フォルダの消去とアプリケーション終了の検出

しかし、こうすると一時フォルダを消去するタイミングがありません。

「ドキュメントに新しいファイルが読み込まれた時点で、一時フォルダがあれば消す」ことはできますが、スクラッチパッドのdocumentと、セーブせずに破棄してそのまま終了する場合のdocumentは一時フォルダが残ってしまいます。

gtkのシグナルでメインウィンドウが破棄されるハンドラに追加すればいいのか?と思いましたが、よく分からなかったため苦肉の策でgui/application.pyにfinalizeというメソッドを追加。

そしてgui/drawwindow.pyの767行目あたり、quit_cbメソッドの中で終了確認ダイアログ処理が終了した時点でself.app.finalize() として、スクラッチパッドと現在のドキュメントの一時フォルダを消去させることにしました。

偶然見つけた、バグ(?)

「選択されているレイヤ」の保存処理が間違っており…? というか、改造の副作用なんでしょうか?とにかく、動作が変であることに気づきました。

このままだと最上位のレイヤを選択して作業を終えてora保存し、後日oraを読んだ時に最下位のレイヤが選択状態になるという状況だったのですが、仮にバグであったとしても、1.2では治っているというか全く違う構造になっていたので特にパッチを送る必要もないかなと…一応メモっておきます

lib/document.pyの711行目

            sel = (idx == self.layer_idx)

これは

            sel = (len(self.layers)-(1+idx) == self.layer_idx)

と言うふうにしたら、望み通りの挙動になりました。

ここではreversedでiterationしているので、レイヤは逆順に列挙されており、そこで通常のインデクス値をレイヤの選択状態チェックにそのまま使用したら、確かに問題があるはずなのですが…改造前どうだったか、あまり記憶にないのですね。

diff

以下にdiffをコピっておきます。trunk/がapt-get sourceしたオリジナルのubuntu 14.04のmypaint 1.1、develop/が改造した版です。

今思ったけどgitでオリジナル版保存しておけば、そのブランチに対してdiffを出力させればいいのか…

diff --git a/gui/application.py b/gui/application.py
index dbd8714..c41845d 100644
--- a/gui/application.py
+++ b/gui/application.py
@@ -194,6 +194,11 @@ class Application: # singleton
 
         gobject.idle_add(at_application_start)
 
+    def finalize(self):
+        """called before gtk.main_quit() called from drawwindow.py:Window.quit_cb()""" 
+        self.doc.model.clear_work_dir()
+        self.scratchpad_doc.model.clear_work_dir()
+
     def save_settings(self):
         """Saves the current settings to persistent storage."""
         def save_config():
diff --git a/gui/drawwindow.py b/gui/drawwindow.py
index 1039d2b..b5416d0 100644
--- a/gui/drawwindow.py
+++ b/gui/drawwindow.py
@@ -765,6 +765,7 @@ class Window (windowing.MainWindow, layout.MainWindow):
         if not self.app.filehandler.confirm_destructive_action(title=_('Quit'), question=_('Really Quit?')):
             return True
 
+        self.app.finalize()
         gtk.main_quit()
         return False
 
diff --git a/lib/document.py b/lib/document.py
index b96531b..67ebc88 100644
--- a/lib/document.py
+++ b/lib/document.py
@@ -27,6 +27,7 @@ LOAD_CHUNK_SIZE = 64*1024
 
 from layer import DEFAULT_COMPOSITE_OP, VALID_COMPOSITE_OPS
 
+import random
 
 class SaveLoadError(Exception):
     """Expected errors on loading or saving
@@ -75,6 +76,9 @@ class Document():
         self._frame = [0, 0, 0, 0]
         self._frame_enabled = False
 
+        self.workdir = None
+
+
     def move_current_layer(self, dx, dy):
         layer = self.layers[self.layer_idx]
         layer.translate(dx, dy)
@@ -533,10 +537,14 @@ class Document():
             presentable to the user.
 
         """
+
+
         if not os.path.isfile(filename):
             raise SaveLoadError, _('File does not exist: %s') % repr(filename)
         if not os.access(filename,os.R_OK):
             raise SaveLoadError, _('You do not have the necessary permissions to open file: %s') % repr(filename)
+
+        self.clear_work_dir()
         junk, ext = os.path.splitext(filename)
         ext = ext.lower().replace('.', '')
         load = getattr(self, 'load_' + ext, self._unsupported)
@@ -643,9 +651,12 @@ class Document():
     def save_ora(self, filename, options=None, **kwargs):
         print 'save_ora:'
         t0 = time.time()
-        tempdir = tempfile.mkdtemp('mypaint')
-        if not isinstance(tempdir, unicode):
-            tempdir = tempdir.decode(sys.getfilesystemencoding())
+       #tempdir = tempfile.mkdtemp('mypaint')
+       #if not isinstance(tempdir, unicode):
+       #    tempdir = tempdir.decode(sys.getfilesystemencoding())
+        if not self.workdir:
+            self.generate_temp_dir(filename)
+        tempdir = self.workdir
         # use .tmp extension, so we don't overwrite a valid file if there is an exception
         z = zipfile.ZipFile(filename + '.tmpsave', 'w', compression=zipfile.ZIP_STORED)
         # work around a permission bug in the zipfile library: http://bugs.python.org/issue3394
@@ -669,20 +680,32 @@ class Document():
             z.write(tmp, name)
             os.remove(tmp)
 
-        def store_surface(surface, name, rect=[]):
-            tmp = join(tempdir, 'tmp.png')
+        def store_surface(surface, filename, rect=[]):
+            print('to save %s' % filename)
+            name=self.get_filename_at_workdir(filename)
             t1 = time.time()
-            surface.save_as_png(tmp, *rect, **kwargs)
+            surface.save_as_png(filename, *rect, **kwargs)
             print '  %.3fs surface saving %s' % (time.time() - t1, name)
-            z.write(tmp, name)
-            os.remove(tmp)
+            z.write(filename, name)
+            return name
+           #os.remove(tmp)
 
-        def add_layer(x, y, opac, surface, name, layer_name, visible=True,
+        def add_layer(src_layer,x, y, opac, surface, layer_name,visible=True,
                       locked=False, selected=False,
                       compositeop=DEFAULT_COMPOSITE_OP, rect=[]):
             layer = ET.Element('layer')
             stack.append(layer)
-            store_surface(surface, name, rect)
+            if src_layer==None or src_layer.dirty or (not os.path.exists(src_layer.filename)):
+                if src_layer and src_layer.dirty:
+                    print('dirty found.%s' % layer_name)
+                name=store_surface(surface, self.generate_layer_filename(), rect)
+                print('saved.%s' % name)
+                if src_layer:
+                    src_layer.filename=join(self.workdir,name)
+            else:
+                name=self.get_filename_at_workdir(src_layer.filename)
+                z.write(src_layer.filename,name)
+
             a = layer.attrib
             if layer_name:
                 a['name'] = layer_name
@@ -708,10 +731,12 @@ class Document():
                 continue
             opac = l.opacity
             x, y, w, h = l.get_bbox()
-            sel = (idx == self.layer_idx)
-            el = add_layer(x-x0, y-y0, opac, l._surface,
-                           'data/layer%03d.png' % idx, l.name, l.visible,
-                           locked=l.locked, selected=sel,
+           #sel = (idx == self.layer_idx) # THIS CODE WRONG BECAUSE LAYER IS REVERSED
+            sel = (len(self.layers)-(1+idx) == self.layer_idx)
+            el = add_layer(l, x-x0, y-y0, opac, l._surface,
+                           l.name,l.visible,
+                          #'data/layer%03d.png' % idx, l.name, l.visible,
+                           locked=l.locked, selected=sel, 
                            compositeop=l.compositeop, rect=(x, y, w, h))
             # strokemap
             sio = StringIO()
@@ -720,18 +745,19 @@ class Document():
             name = 'data/layer%03d_strokemap.dat' % idx
             el.attrib['mypaint_strokemap_v2'] = name
             write_file_str(name, data)
+            l.dirty=False
 
         # save background as layer (solid color or tiled)
         bg = self.background
         # save as fully rendered layer
         x, y, w, h = self.get_bbox()
-        l = add_layer(x-x0, y-y0, 1.0, bg, 'data/background.png', 'background',
-                      locked=True, selected=False,
+        l = add_layer(None, x-x0, y-y0, 1.0, bg,'background', 
+                      locked=True, selected=False, 
                       compositeop=DEFAULT_COMPOSITE_OP,
                       rect=(x,y,w,h))
         x, y, w, h = bg.get_pattern_bbox()
         # save as single pattern (with corrected origin)
-        store_surface(bg, 'data/background_tile.png', rect=(x+x0, y+y0, w, h))
+        store_surface(bg, join(self.workdir,'data/background_tile.png'), rect=(x+x0, y+y0, w, h))
         l.attrib['background_tile'] = 'data/background_tile.png'
 
         # preview (256x256)
@@ -747,9 +773,9 @@ class Document():
 
         write_file_str('stack.xml', xml)
         z.close()
-        os.rmdir(tempdir)
-        if os.path.exists(filename):
-            os.remove(filename) # windows needs that
+       #os.rmdir(tempdir)
+       #if os.path.exists(filename):
+       #    os.remove(filename) # windows needs that
         os.rename(filename + '.tmpsave', filename)
 
         print '%.3fs save_ora total' % (time.time() - t0)
@@ -766,7 +792,8 @@ class Document():
         """Loads from an OpenRaster file"""
         print 'load_ora:'
         t0 = time.time()
-        tempdir = tempfile.mkdtemp('mypaint')
+       #tempdir = tempfile.mkdtemp('mypaint')
+        tempdir = self.generate_temp_dir(filename)
         if not isinstance(tempdir, unicode):
             tempdir = tempdir.decode(sys.getfilesystemencoding())
         z = zipfile.ZipFile(filename)
@@ -851,15 +878,18 @@ class Document():
             z.extract(src, tempdir)
             tmp_filename = join(tempdir, src)
             self.load_layer_from_png(tmp_filename, x, y, feedback_cb)
-            os.remove(tmp_filename)
+
+           #os.remove(tmp_filename)
 
             layer = self.layers[0]
+            layer.filename=tmp_filename
 
             self.set_layer_opacity(helpers.clamp(opac, 0.0, 1.0), layer)
             self.set_layer_compositeop(compositeop, layer)
             self.set_layer_visibility(visible, layer)
             self.set_layer_locked(locked, layer)
             if selected:
+                print("found selected layer %s" % tmp_filename)
                 selected_layer = layer
             print '  %.3fs loading and converting layer png' % (time.time() - t1)
             # strokemap
@@ -885,15 +915,71 @@ class Document():
         if selected_layer is not None:
             for i, layer in zip(range(len(self.layers)), self.layers):
                 if layer is selected_layer:
+                    print("selected layer %s" % layer.filename)
                     self.select_layer(i)
                     break
 
         z.close()
 
         # remove empty directories created by zipfile's extract()
-        for root, dirs, files in os.walk(tempdir, topdown=False):
-            for name in dirs:
-                os.rmdir(os.path.join(root, name))
-        os.rmdir(tempdir)
+       #for root, dirs, files in os.walk(tempdir, topdown=False):
+       #    for name in dirs:
+       #        os.rmdir(os.path.join(root, name))
+       #os.rmdir(tempdir)
 
         print '%.3fs load_ora total' % (time.time() - t0)
+
+    def generate_temp_dir(self,oraname):
+        """
+        generate temporary directory name.
+
+        Arguments:
+        oraname - ora file name
+
+        Returns:
+        generated temporary directory name,which used to png cache
+        """
+        self.workdir = tempfile.mkdtemp('mypaint_%s' % os.path.basename(oraname))
+        os.mkdir(join(self.workdir,'data'))
+        os.mkdir(join(self.workdir,'Thumbnails'))
+        return self.workdir
+
+    def clear_work_dir(self):
+        if self.workdir and os.path.exists(self.workdir):
+            def del_dir_auto(dirname):
+                for root, dirs, files in os.walk(dirname, topdown=False):
+                    for name in dirs:
+                        dirname=join(root,name)
+
+    def get_filename_at_workdir(self,filename):
+        """
+        get relative filename at inside work directory.
+
+        Arguments:
+        filename -- the filename,it MUST be fullpath.
+
+        Returns:
+        relative path of filename
+        """
+        return filename[len(self.workdir)+1:]
+
+    def generate_layer_filename(self):
+        while True:
+            idx=int(random.random()*65536)
+            layer_filename=join(self.workdir,"data%slayer_%04x.png" % (os.path.sep,idx))
+            if not os.path.exists(layer_filename):
+                return layer_filename
+                 
diff --git a/lib/layer.py b/lib/layer.py
index b13312f..9982fe2 100644
--- a/lib/layer.py
+++ b/lib/layer.py
@@ -108,6 +108,7 @@ class Layer:
 
     def save_as_png(self, filename, *args, **kwargs):
         self._surface.save_as_png(filename, *args, **kwargs)
+        self.dirty=False
 
     def stroke_to(self, brush, x, y, pressure, xtilt, ytilt, dtime):
         """Render a part of a stroke."""
@@ -115,15 +116,18 @@ class Layer:
         split = brush.stroke_to(self._surface, x, y,
                                     pressure, xtilt, ytilt, dtime)
         self._surface.end_atomic()
+        self.dirty=True
         return split
 
     def clear(self):
         self.strokes = [] # contains StrokeShape instances (not stroke.Stroke)
         self._surface.clear()
+        self.dirty=False
 
     def load_from_surface(self, surface):
         self.strokes = []
         self._surface.load_from_surface(surface)
+        self.dirty=False
 
     def render_as_pixbuf(self, *rect, **kwargs):
         return self._surface.render_as_pixbuf(*rect, **kwargs)
@@ -231,6 +235,7 @@ class Layer:
                 opacity=self.effective_opacity,
                 mode=self.compositeop)
         dst.opacity = 1.0
+        dst.dirty=True
 
     def convert_to_normal_mode(self, get_bg):
         """

*1:mypaintのサーフェスはいまどき普通ではありますが、タイルベース

*2:mypaintではストロークしかない

*3:というか、むしろ1.2に対応したい…