Python: 色塗り処理の効率化実験（2024.12.19）

## はじめに
FEMで応力計算を行った際、応力値に応じて要素を塗りつぶした出力図を作成するのだが、この処理が非常に遅い。
このため、色塗り処理の効率化方法を試してきたのだが、現在私が採用している手法について紹介することにする。

採用しているのか、以下の２つの手法である。

1. ```PatchCollection```の利用 
2. ```ProcessPool```（並列処理）の利用

テストプログラム作成に際し、以下の記事を参考にさせていただいた。

[並列処理、並行処理、非同期処理をPythonで学ぶ](https://note.com/kuwaharu/n/n500722e821e3)
[Python, Pillowで画像を縦・横に連結（結合）](https://note.nkmk.me/python-pillow-concat-images/)

また自分の記事で恐縮だが、以下も参考に。
[matplotlib ポリゴン塗りつぶし高速化（2023.07.30投稿）](https://qiita.com/damyarou/items/be77ad4e49b3e7d07026)


当方の環境は以下の通り。

MacBook PRo 14" M1 Pro
macOS Sequoia 15.2
Pytho 3.13.0
matplotlib 3.9.3



## テストプログラムの概要
やっていることは、領域を正方形に分割して、指定した色で塗りつぶしていくものである。
作例を以下に示す。６枚の画像を作成し、これを結合したもの。時間計測を目的としたものなので、物理的な意味は特にない。下図は、正方形領域を 50 x 50 の領域に分割し、乱数を用いて色を指定し、塗りつぶし処理を行なったもの。

![fig_50x50.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129300/e7986ae9-a3a3-27d4-de95-6993888e5871.jpeg)

実験では、領域分割を、10x10, 20x20, 50x50, 100x100, 200x200, 500x500 としている。


## 用いた処理手法の概要
### 手法１
最も単純な方法。領域を１つづつ、```fill```で塗りつぶしていく。コードは比較的単純である。

```python
    for i in range(0,mm):
        for j in range(0,nn):
            icol=random.randrange(0,len(col),1)
            x=np.array([j*dx,(j+1)*dx,(j+1)*dx,j*dx])
            y=np.array([i*dy,i*dy,(i+1)*dy,(i+1)*dy])
            plt.fill(x,y,color=col[icol])
```


### 手法２
作図情報をリストにして溜め込み、```PatchCollection```を用いて一気に書き出す。```fill```を用いる方法に比べ、コードは少し面倒になるが、効率化の効果は大きい。

```python
    patches=[]
    lcol=[]
    for i in range(0,mm):
        for j in range(0,nn):
            icol=random.randrange(0,len(col),1)
            x1,x2,x3,x4=j*dx,(j+1)*dx,(j+1)*dx,j*dx
            y1,y2,y3,y4=i*dy,i*dy,(i+1)*dy,(i+1)*dy
            points=[[x1,y1],[x2,y2],[x3,y3],[x4,y4],[x1,y1]]            
            polygon=Polygon(xy=points,closed=True)
            patches.append(polygon)
            lcol.append(col[icol])
    p=PatchCollection(patches,facecolor=lcol,edgecolor='#aaaaaa',lw=0.5)
    plt.gca().add_collection(p)
```

### 手法３
```ProcessPoolExecutor```を用いた並列処理。６枚の図面描画を一気に投げ込んであとは機械に任せる。

```python
with ProcessPoolExecutor(max_workers=8) as executor:
    executor.submit(fig_b,1,nn,mm,'fig1',col1)
    executor.submit(fig_b,2,nn,mm,'fig2',col2)
    executor.submit(fig_b,3,nn,mm,'fig3',col3)
    executor.submit(fig_b,4,nn,mm,'fig4',col1)
    executor.submit(fig_b,5,nn,mm,'fig5',col2)
    executor.submit(fig_b,6,nn,mm,'fig6',col3)
```

## 実験結果

### ケースの説明
Case-1：（手法１）fillを用いた単純な方法。並列処理なし。
Case-2：（手法２）PatchCollectionを用いた方法。並列処理なし。
Case-3：（手法１＋手法３）fillを用いた単純な方法。並列処理あり。
Case-4：（手法２＋手法３）PatchCollectionを用いた方法。並列処理あり。
Case-5：（参考）画像結合だけの処理。


| Case | Description |
|---|---|
|Case-1 |fill                          |
|Case-2 |PatchCollection               |
|Case-3 |fill + ProcessPool            |
|Case-4 |PatchCollection + ProcessPool |
|Case-5 |Only combine                  |

### 処理時間の比較
実験結果を以下に示す。div は領域の分割数を示し、各ケースの数値は処理に要した時間（秒）を示す。
表より、以下のことがわかる。

+ ```PatchCollection```を用いる効果は、並列処理より大きい。
+ 処理量が増えると```PatchCollection```と並列処理の併用により大きな時間短縮効果を得ることができる。
+ 画像の結合に要する時間は、計算処理時間に比較して0.2秒程度と小さく、（私の場合）待ち時間としては許容できる。


|div|Case-1|Case-2|Case-3|Case-4|Case-5|
|---|---|---|---|---|---|
|10x10  |   0.589|   0.394|   0.795|   0.712|   0.105|
|20x20  |   0.988|   0.421|   0.868|   0.775|   0.116|
|50x50  |   4.108|   0.839|   1.504|   0.849|   0.131|
|100x100|  15.558|   2.057|   3.561|   1.096|   0.156|
|200x200|  63.498|   7.197|  12.396|   1.909|   0.190|
|500x500| 396.090|  44.006| 123.572|   8.395|   0.215|


## テストコード
関数```fig_a```は、```fill```を用いて領域を１つづつ塗りつぶす関数。
関数```fig_b```は、```PatchCollection```を用いて一気に書き出す関数。

```python
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
from matplotlib.collections import PatchCollection
import time
from PIL import Image
from concurrent.futures import ProcessPoolExecutor
import random
import os


def combine(fnameF):
    def get_concat_h(im1, im2):
        dst = Image.new('RGB', (im1.width + im2.width, im1.height))
        dst.paste(im1, (0, 0))
        dst.paste(im2, (im1.width, 0))
        return dst
    def get_concat_v(im1, im2):
        dst = Image.new('RGB', (im1.width, im1.height + im2.height))
        dst.paste(im1, (0, 0))
        dst.paste(im2, (0, im1.height))
        return dst
    im1 = Image.open('_fig1.jpg')
    im2 = Image.open('_fig2.jpg')
    im3 = Image.open('_fig3.jpg')
    im4 = Image.open('_fig4.jpg')
    im5 = Image.open('_fig5.jpg')
    im6 = Image.open('_fig6.jpg')
    temp1=get_concat_v(im1,im4)
    temp2=get_concat_v(im2,im5)
    temp3=get_concat_v(im3,im6)
    temp=get_concat_h(temp1,temp2)
    get_concat_h(temp,temp3).save(fnameF)


def figpre():
    fsz=14
    xmin,xmax=0,10
    ymin,ymax=0,10
    ga=np.array([xmin,xmax,ymin,ymax])
    iw=10
    ih=iw/(xmax-xmin)*(ymax-ymin)
    plt.figure(figsize=(iw,ih),facecolor='w')
    plt.rcParams['font.size']=fsz
    plt.rcParams['font.family']='sans-serif'
    plt.xlim([xmin,xmax])
    plt.ylim([ymin,ymax])
    plt.axis('off')
    plt.gca().set_aspect('equal',adjustable='box')
    return ga


def fig_a(icase,nn,mm,tstr,col):
    fnameF='_fig{0}.jpg'.format(icase)
    ga=figpre()
    xmin,xmax=ga[0],ga[1]
    ymin,ymax=ga[2],ga[3]
    plt.title(tstr,loc='left')
    dx,dy=(xmax-xmin)/nn,(ymax-ymin)/mm
    for i in range(0,mm):
        for j in range(0,nn):
            icol=random.randrange(0,len(col),1)
            x=np.array([j*dx,(j+1)*dx,(j+1)*dx,j*dx])
            y=np.array([i*dy,i*dy,(i+1)*dy,(i+1)*dy])
            plt.fill(x,y,color=col[icol])
    plt.tight_layout()
    plt.savefig(fnameF, dpi=200, bbox_inches="tight", pad_inches=0.1)
    plt.close()
 

def fig_b(icase,nn,mm,tstr,col):
    fnameF='_fig{0}.jpg'.format(icase)
    ga=figpre()
    xmin,xmax=ga[0],ga[1]
    ymin,ymax=ga[2],ga[3]
    plt.title(tstr,loc='left')
    dx,dy=(xmax-xmin)/nn,(ymax-ymin)/mm
    patches=[]
    lcol=[]
    for i in range(0,mm):
        for j in range(0,nn):
            icol=random.randrange(0,len(col),1)
            x1,x2,x3,x4=j*dx,(j+1)*dx,(j+1)*dx,j*dx
            y1,y2,y3,y4=i*dy,i*dy,(i+1)*dy,(i+1)*dy
            points=[[x1,y1],[x2,y2],[x3,y3],[x4,y4],[x1,y1]]            
            polygon=Polygon(xy=points,closed=True)
            patches.append(polygon)
            lcol.append(col[icol])
    p=PatchCollection(patches,facecolor=lcol,edgecolor='#aaaaaa',lw=0.5)
    plt.gca().add_collection(p)
    plt.tight_layout()
    plt.savefig(fnameF, dpi=200, bbox_inches="tight", pad_inches=0.1)
    plt.close()


def fig(nn,mm,num):
    col1=['#cc0000','#ff0066','#ff9966','#ffcc66','#ffffaa','#00ffff','#00ccff','#0099ff','#0000ff','#000088']
    col2=['#000000','#333333','#666666','#999999','#cccccc','#ffffff']
    col3=['#000000','#000080','#0000ff','#008000','#008080','#00ff00','#00ffff','#800000','#800080','#808000','#808080','#c0c0c0','#ff0000','#ff00ff','#ffff00','#ffffff']

    match num:
        case 1:
            fig_a(1,nn,mm,'fig1',col1)
            fig_a(2,nn,mm,'fig2',col2)
            fig_a(3,nn,mm,'fig3',col3)
            fig_a(4,nn,mm,'fig4',col1)
            fig_a(5,nn,mm,'fig5',col2)
            fig_a(6,nn,mm,'fig6',col3)
            fnameF='fig_test1.jpg'
        case 2:
            fig_b(1,nn,mm,'fig1',col1)
            fig_b(2,nn,mm,'fig2',col2)
            fig_b(3,nn,mm,'fig3',col3)
            fig_b(4,nn,mm,'fig4',col1)
            fig_b(5,nn,mm,'fig5',col2)
            fig_b(6,nn,mm,'fig6',col3)
            fnameF='fig_test2.jpg'
        case 3:
            with ProcessPoolExecutor(max_workers=8) as executor:
                executor.submit(fig_a,1,nn,mm,'fig1',col1)
                executor.submit(fig_a,2,nn,mm,'fig2',col2)
                executor.submit(fig_a,3,nn,mm,'fig3',col3)
                executor.submit(fig_a,4,nn,mm,'fig4',col1)
                executor.submit(fig_a,5,nn,mm,'fig5',col2)
                executor.submit(fig_a,6,nn,mm,'fig6',col3)
            fnameF='fig_test3.jpg'
        case 4:
            with ProcessPoolExecutor(max_workers=8) as executor:
                executor.submit(fig_b,1,nn,mm,'fig1',col1)
                executor.submit(fig_b,2,nn,mm,'fig2',col2)
                executor.submit(fig_b,3,nn,mm,'fig3',col3)
                executor.submit(fig_b,4,nn,mm,'fig4',col1)
                executor.submit(fig_b,5,nn,mm,'fig5',col2)
                executor.submit(fig_b,6,nn,mm,'fig6',col3)
            fnameF='fig_test4.jpg'
        case 5:
            fnameF='fig_test5.jpg'
    combine(fnameF)


def main():
    for ndiv in [10,20,50,100,200,500]:
        nn,mm=ndiv,ndiv
        fnameTF='fig_{0}x{1}.jpg'.format(nn,mm)
        ss=''
        for num in [1,2,3,4,5]:
            start=time.time()
            fig(nn,mm,num)
            ctime=time.time()-start
            ss=ss+'{0:8.3f}|'.format(ctime)
        print('#|{0}x{1}|{2}'.format(nn,mm,ss))
        os.rename('fig_test5.jpg',fnameTF)


#---------------
# Execute
#---------------
if __name__ == '__main__': main()
```

以　上